chore(仓库对齐): 文档库结构治理 + IM/插件线落地
build / build-and-scan (push) Waiting to run

文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
This commit is contained in:
admin committed 2026-09-24 07:25:16 +08:00
1 parent 3d8f50e366
commit e6207aa691
239 files changed
+34477 -14633

No files matched your search

+11 -1
View File
@@ -17,7 +17,8 @@
*/
import { AGENT_TOKEN_HEADER } from '../worker/agent.js'
import type { DbAdapter } from '../db/adapter.js'
import type { Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
import type { PluginApplyItem } from './plugin-assembly.js'
import type { ApplyPluginsReport, Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
import { InstanceLease, type LeaseOptions } from './lease.js'
/** 归属被别人持有时抛出 —— 调用方应**退让**(等待/报告),**不得接管**(R9)。 */
@@ -275,6 +276,15 @@ export class LeasedSpawner implements Spawner {
return this.inner.restartAndProbe(userId, settleMs)
}
/**
* 插件投放与分库线 ① · S3:**纯透传** —— 装配落在哪台机由 `inner`(`RemoteSpawner` /
* `LocalSpawner`)自己的 `hostFor` 决定,租约层不参与(装配不改变归属,
* ⛔ 因此这里**不**碰 `this.lease`:抢一次归属会让"用户只是勾了个插件"变成一次迁移候选)。
*/
async applyPlugins(userId: string, items: readonly PluginApplyItem[]): Promise<ApplyPluginsReport> {
return this.inner.applyPlugins(userId, items)
}
touch(userId: string): void {
this.inner.touch(userId)
}
+203 -5
View File
@@ -26,6 +26,7 @@ import { connect } from 'node:net'
import { dirname, join } from 'node:path'
import type { ServerConfig } from '../config.js'
import { handoffPath, homeRoot, userRoot, workspaceRoot } from '../fs/workspace.js'
import type { ImInstanceTokenRegistry } from '../im/instance-token.js'
import {
breakerActive,
breakerCooldownMs,
@@ -38,10 +39,19 @@ import {
type CrashPolicyConfig,
} from './crash-policy.js'
import { createPortGuard, type PortGuard } from './firewall.js'
import {
applyProfileChanges,
profileDirOf,
sharedDirOf,
uidOfUserRoot,
wsDirOf,
type PluginApplyItem,
} from './plugin-assembly.js'
import { findInstancePort, scrubEnv } from './spawn.js'
import {
AlreadyRunningError,
CrashBreakerOpenError,
type ApplyPluginsReport,
type Endpoint,
type Instance,
type InstanceRole,
@@ -63,6 +73,32 @@ export {
/** 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.'
/**
* `LocalSpawner` 的可选外挂(序47 · S4)。
*
* 为什么是**注入的钩子**而不是在这里 import 覆盖网络模块:`LocalSpawner` 也被
* **worker 侧的 agent** 构造(`worker/agent.ts`),而 worker **没有控制面库**,
* 也不该去写用户的实例凭据(那要经 Manager 的 `UserFs` 按归属路由)。
* 注入 ⇒ 同一个类在两种部署下行为一致:Manager 传钩子、worker 不传(缺省 = 无操作)。
*/
export interface LocalSpawnerHooks {
/**
* **实例(role=main)启动前**调用一次。
*
* ⚠️ 契约:**实现不得抛异常**(抛了也会被 `notifyInstanceStart` 吞掉并具名记日志);
* 语义是"多一条能力",⛔ **不是**"起实例的前置条件"。
*/
onInstanceStart?: (userId: string) => Promise<void>
/**
* 🔴 C 单:IM 实例凭据登记簿 —— **发放侧与校验侧必须是同一份**。
*
* `web/server.ts` 建 `app.imInstanceTokens` 后交到这里;编排器起实例时调 `issue()`
* 拿 token 注入 env,而 `web/routes/im.ts` 与 `im/ws.ts` 校验时读**同一个**登记簿。
* ⛔ 未注入 ⇒ 不签发(env 里没有 token)⇒ 插件不启动,实例照起(fail-closed)。
*/
imInstanceTokens?: ImInstanceTokenRegistry
}
/* ─────────────────────────────────────────────────────────────────────────────
* 实例内存:**基础 MIN、最多浮动到 MAX**(2026-09-14 用户要求 —— 与「插件开关」解耦)
*
@@ -365,12 +401,32 @@ export class LocalSpawner implements Spawner {
/** 序 ㊽:本轮 `schedule` 已走完(不会再产生新探活)。`true` + `pendingProbes === 0` ⇒ 落定。 */
private rehydrateScheduled = false
/**
* 🔴 C 单 · 集群形态:**由 Manager 投递过来**的 IM 实例凭据(`userId → token`)。
*
* 为什么需要这张表:集群形态下实例由 Worker 起,但凭据**只在 Manager 侧签发与校验**
* (登记簿只有 Manager 有)⇒ Worker 拿到的是"别人发的 token",只能原样注入 env。
* ⛔ Worker 不解释、不校验、不持久化它(与 `apiKeys` 的纪律同向:凭据只活在实例生命周期内)。
*/
private readonly externalImTokens = new Map<string, string>()
/**
* 记下 Manager 投递来的 IM 实例凭据(仅集群形态调用)。
* ⛔ 注释里不打印 token;`userIds` 维度与 `apiKeys` 一致。
*/
setExternalImToken(userId: string, token: string | null | undefined): void {
if (token === undefined || token === null || token === '') this.externalImTokens.delete(userId)
else this.externalImTokens.set(userId, token)
}
constructor(
private readonly config: ServerConfig,
/** Resolve the user's own API key (decrypted); null = user has none. */
private readonly resolveApiKey: (userId: string) => Promise<string | null>,
/** Resolve the user's assigned Linux uid (falls back to hash when unset). */
private readonly resolveUid: (userId: string) => Promise<number>,
/** 序47 · S4:可选外挂(缺省 `{}` = 无操作 —— worker 侧就是不传)。 */
private readonly hooks: LocalSpawnerHooks = {},
) {
this.portGuard = createPortGuard(config.portGuard)
// 覆盖网络线 序 ㉕:原为「档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)」。
@@ -545,6 +601,40 @@ export class LocalSpawner implements Spawner {
return { ok: false, reason: lastReason !== "" ? lastReason : "实例启动超时(未产出 launch token)" }
}
/**
* 插件投放与分库线 ① · S3:**本机那条腿** —— 该用户的实例恰好就在这台机上时的装配。
*
* 🔴 为什么本机形态也必须走 `Spawner` 方法(而不是让路由层自己改文件):
* 装配逻辑**只能有一份**(`plugin-assembly.ts`)。集群形态下 Manager 把请求转给 worker,
* 而 worker 的 agent 是**复用 `LocalSpawner`** 的 ⇒ 走同一条实现路径,"两处各写一份判据"
* 这件事才从根上不成立(这正是 §五 S3-4 要的"抽公用模块")。
*
* ⚠️ **不重启、不探活**(与 `RemoteSpawner` 的契约一致):重启探活由调用方编排。
*/
async applyPlugins(userId: string, items: readonly PluginApplyItem[]): Promise<ApplyPluginsReport> {
const root = userRoot(this.config.dataRoot, userId)
// profile 目录必须先存在(`reconcileBundles` / `syncWebProviderPatch` 都要读它)
mkdirSync(profileDirOf(root), { recursive: true })
// 属主对齐:目录由 root 侧创建 ⇒ 以该用户身份跑 pnpm 前要保证这棵树是他能写的
// (`uidOfUserRoot` 读的就是 `<root>/home` 的属主,编排器建号时已 chown)
const uid = uidOfUserRoot(root)
for (const p of [join(root, 'home'), join(root, 'home', 'profiles'), profileDirOf(root), wsDirOf(root)]) {
try {
if (existsSync(p)) execFileSync('chown', [`${uid}:${uid}`, p], { stdio: 'pipe' })
} catch {
/* 尽力而为:chown 失败会在 pnpm 那一步以 EACCES 显形,那才是该报的错 */
}
}
return applyProfileChanges(items, {
userRoot: root,
bundledPluginDir: this.config.bundledPluginDir,
resolveBundleDir: (id) => {
const dir = sharedDirOf(this.config.bundledPluginDir, id)
return existsSync(join(dir, 'package.json')) ? dir : undefined
},
})
}
/** Restart every running main so a swapped global API key takes effect. */
async restartAllMains(): Promise<void> {
for (const userId of [...this.mains.keys()]) {
@@ -707,7 +797,7 @@ export class LocalSpawner implements Spawner {
return path
}
private async baseEnv(userId: string): Promise<Record<string, string>> {
private async baseEnv(userId: string, imToken: string | null = null): Promise<Record<string, string>> {
// 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160)
const memMb = instanceMaxMb()
const root = userRoot(this.config.dataRoot, userId)
@@ -724,6 +814,10 @@ export class LocalSpawner implements Spawner {
// 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 } : {}),
// 插件投放与分库线 ① · S1:共享只读**插件包库**(节点一份、版本目录不可变)。
// 与上面的技能层同族:实例侧只引用不复制(D2),装配清单由平台在正确那台机上写。
// 光注入 env 不够 —— 目录必须真的挂进命名空间(同 :1039 的教训)。
...(this.config.bundledPluginDir !== '' ? { DSH_BUNDLED_PLUGIN_DIR: this.config.bundledPluginDir } : {}),
// 档案 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
@@ -774,11 +868,79 @@ export class LocalSpawner implements Spawner {
// 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 } : {}),
// ── C 单(`交接单/IM群组-C-Agent接入插件.md §三`):agent 接入插件的两项 env ──
//
// 为什么走 env 而不是 profile:插件是**候选池 → 实例「功能管理」启用**那条路发下去的
// (§二-4),而它一旦启用就需要知道"往哪拨、用什么身份拨"。这两件事只有平台知道
// (URL 是本区平台端点;token 是 per-instance 短期凭据)⇒ 只能由编排器注入。
//
// 🔴 **天然开关**:两项**任一缺失** ⇒ 插件 `apply()` 首行 return、日志无连接尝试
// (§三「不注入则插件不启动」)。⇒ 平台侧回滚 = 删掉这里的注入,无需改插件。
// 🔴 **只拨出**(E3):URL 是**平台侧**端点,实例侧永远 connect 不 listen。
...(process.env.DSH_PLATFORM_IM_URL === undefined || process.env.DSH_PLATFORM_IM_URL === ''
? {}
: { DSH_PLATFORM_IM_URL: process.env.DSH_PLATFORM_IM_URL }),
// token 是**per-instance 短期凭据**(C 单 §四-5,我定的可推翻):平台生成 → 注入 env
// → 实例重启即换。⚠️ 这条由 `issueImInstanceToken()` 在**实例启动前**写入台账由
// 下面 `pendingImTokens` 取用;取不到 ⇒ 不注入 ⇒ 插件不启动(fail-closed)。
...(imToken !== null ? { DSH_IM_INSTANCE_TOKEN: imToken } : {}),
...(process.env.DSH_IM_AGENT_REF === undefined ? {} : { DSH_IM_AGENT_REF: process.env.DSH_IM_AGENT_REF }),
}
}
private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise<Instance> {
const isMain = role === 'main'
/**
* 序47 · S4:调一次"实例启动前"钩子(当前唯一用途 = 实例设备凭据的平台侧代签 + 投递)。
*
* 🔴 **本方法吞掉一切异常,是有意的**:钩子是"多一条能力",⛔ **不是**"起实例的前置条件"。
* 让覆盖网络分支性的故障(签名者私钥没配 / 台账写不进 / worker 上的 home 写不进)把
* **平台主功能(起实例)**打成故障,是净变差(R11)。⇒ 失败**具名**打进日志,实例照起;
* "这条能力有没有落地"由 `ensureInstanceCredential` 的读数 + 下面这行日志负责可见。
*/
private async notifyInstanceStart(userId: string): Promise<void> {
const hook = this.hooks.onInstanceStart
if (hook === undefined) return
try {
await hook(userId)
} catch (err) {
console.error(
`[instance-start-hook] ⛔ ${userId} 实例启动前钩子抛异常(实例照常启动):${err instanceof Error ? err.message : String(err)}`,
)
}
}
/**
* 🔴 C 单(`交接单/IM群组-C-Agent接入插件.md §四-5`):给本实例签发一次**短期凭据**。
*
* 本方法的全部异常都**吞掉**(返回 `null`)—— 与 `notifyInstanceStart` 同一条判据:
* IM agent 桥是"多一条能力",⛔ **不是**起实例的前置条件。`null` ⇒ env 里没有 token
* ⇒ 插件 `apply()` 首行 return(§三「不注入则不启动」)⇒ 实例照起,只是没有 agent 桥。
*
* ⚠️ 登记簿是"发放侧与校验侧必须是同一份"的那个对象 ⇒ 由 `SpawnerHooks` 注入
* (`web/server.ts` 建 `app.imInstanceTokens` 后一并交给编排器)。未注入 ⇒ 返回 `null`。
*
* @returns token **原文**(注入 env);⛔ 绝不写进任何日志。
*/
private issueImInstanceToken(userId: string): string | null {
const registry = this.hooks.imInstanceTokens
if (registry === undefined) return null
try {
// 同用户重复起实例 ⇒ 先撤旧的("实例重启即换 token",§四-5)。
registry.revokeByInstance(userId)
const { token } = registry.issue({
instanceId: userId,
userId,
region: process.env.DSH_IM_REGION ?? '',
})
return token
} catch (err) {
console.error(
`[im-instance-token] ⛔ ${userId} 实例凭据签发失败(实例照常启动、agent 桥不启动):${err instanceof Error ? err.message : String(err)}`,
)
return null
}
}
private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise<Instance> { const isMain = role === 'main'
// 覆盖网络 S3:端口来自**本机专属区间**(未配 env ⇒ 退回旧的 `listen(0)`)。
// 为什么不能用 `listen(0)`:跨机实例的隧道落点全挤在 Manager 的 `127.0.0.1`,
// 两台 worker 各自随机取端口就会撞号,而 `-R` 失败是**静默**的 ⇒ 会拨到别人的实例。
@@ -818,8 +980,25 @@ export class LocalSpawner implements Spawner {
const memMb = instanceMaxMb()
/**
* 🔴 C 单(§四-5):**per-instance 短期凭据**在 env 组装**之前**签发。
*
* 为什么在这里(而不是 baseEnv 内部):签发要写进**与校验侧同一个登记簿**,而登记簿
* 由 `web/server.ts` 持有 ⇒ 只有编排器拿得到。签发点是"spawn 之前",与
* `notifyInstanceStart` 同一时机(实例内的插件随时可能在启动后立刻拨出)。
*
* ⚠️ 只在 **main** 上签:watchdog 是一次性修复进程,⛔ 没有网络身份(与 S4 同判据)。
* 🔴 `imToken === null` ⇒ 不注入 ⇒ 插件不启动(fail-closed),⛔ 不阻断实例启动 ——
* IM agent 桥是"多一条能力",不是"起实例的前置条件"(与 S4 的异常处理同向)。
*/
const imToken = isMain ? this.issueImInstanceToken(userId) : null
// 🔴 C 单 · 集群形态:Worker 上**没有登记簿**(它签不了)⇒ 凭据由 Manager 随 launch
// 投递,Worker 侧经 `setExternalImToken()` 落进这张表;本机形态直接走上面的签发。
const tokenFromManager = this.externalImTokens.get(userId) ?? null
const effectiveImToken = imToken ?? (isMain ? tokenFromManager : null)
const env: Record<string, string> = {
...(await this.baseEnv(userId)),
...(await this.baseEnv(userId, effectiveImToken)),
DSHS_ROLE: role,
DSHS_HANDOFF_PATH: this.handoffPath(userId), // both roles: main writes, watchdog reads
}
@@ -827,6 +1006,16 @@ export class LocalSpawner implements Spawner {
env.DSHS_PORT = String(port)
}
/**
* 序47 · S4:**实例设备凭据**在子进程起来之前落好(投递面 = 实例 home,走 `UserFs`)。
*
* 为什么放在"spawn 之前、`cleanStaleScopes` 之前":凭据要**先于**实例存在 —— 实例内的
* 拨号客户端随时可能在启动后立刻读它;而 `cleanStaleScopes` 之后紧接着就是 spawn。
* ⚠️ 只对 **main** 做:watchdog 是一次性修复进程,⛔ 没有自己的网络身份。
* 🔴 **不阻断启动**(见 `notifyInstanceStart` 的注释)。
*/
if (isMain) await this.notifyInstanceStart(userId)
// 档案 30:单实例保证升到 OS 层 —— spawn 前清掉该 uid 名下未纳管的残留 scope,
// 防止 portal 重启后旧 scope 变孤儿、与新实例并存(共享 profile → 会话/settings 冲突)。
if (this.config.isolationMode === 'account') {
@@ -976,7 +1165,9 @@ export class LocalSpawner implements Spawner {
// 权限不扩大:这些目录里只有随后绑定的白名单内容。
...(() => {
const dirs = new Set<string>()
for (const dest of [root, this.config.bundledSkillDir].filter((d) => d !== '')) {
for (const dest of [root, this.config.bundledSkillDir, this.config.bundledPluginDir].filter(
(d) => d !== '',
)) {
for (const d of mountParentDirList(dest, '/')) dirs.add(d)
}
return [...dirs]
@@ -1002,6 +1193,13 @@ export class LocalSpawner implements Spawner {
...(this.config.bundledSkillDir !== ''
? (['--ro-bind-try', this.config.bundledSkillDir, this.config.bundledSkillDir] as string[])
: []),
// 插件投放与分库线 ① · S1:共享只读插件包库,逐字照上面技能层那条。
// 位置必须在 `--tmpfs /var/lib/dshs*` 与 `--bind root root` **之后**(bwrap 后写覆盖前写)
// —— `--tmpfs /var/lib/dshs` 会把此前挂的内容整个遮掉(见 :1019 的中间目录注释)。
// 权限不扩大:仍是**精确叶子路径 + 只读**,实例内 touch = EROFS。
...(this.config.bundledPluginDir !== ''
? (['--ro-bind-try', this.config.bundledPluginDir, this.config.bundledPluginDir] as string[])
: []),
// 2026-09-11(档案 18 v3 收尾 / 档案 17 §P1):平台策略文件在实例内**只读**。
// 威胁模型:用户可把 <userRoot>/home/profiles/web 加为工作区,随后用 bash 直接改写
// cordis.patch.yml(去掉平台段 → 恢复全盘 picker)或 package.json(挂任意 bundle)→
+488
View File
@@ -0,0 +1,488 @@
/**
* **插件装配** —— profile 侧的"把哪些包装进某个用户实例"这件事的**唯一一份实现**。
*
* ## 为什么必须有这个模块(S3 的第 4 条:抽公用模块)
*
* 装配这件事**会在两台机上被触发**:
* - **Manager 本机**:该用户的实例恰好就在 Manager 这台(单机形态 / `hostIdFor` 返回本机);
* - **worker**:实例在别的机器上(集群形态,占绝大多数)—— 只有那台机看得见
* `home/profiles/web` 这棵树,Manager 隔着网络改不了(写了自己盘上的一个不存在路径,
* 静默空操作,见 `user-fs.ts` 的 seam 注释)。
*
* 两条路径**判据必须是同一份**:`depRefOf` 引用怎么拼、bundles 怎么 reconcile、web provider
* 托管段怎么重算、属主怎么自愈 —— 任何一条在两处各写一遍,就一定会漂移,而漂移的表现是
* "某台机上装出来的实例和别处不一样",排查成本极高(这也正是本仓反复出现的"同一语义多份实现"坑)。
*
* ⇒ 本模块**不含任何 Manager / worker 独有的东西**:不碰 `app.db`、不碰 Fastify、
* 不碰 `UserFs`。它只认三样输入 —— **用户根目录**、**包名 → 绝对路径**、**uid**。
* 谁调它(Manager 路由 / worker agent)自己解决这三样怎么来。
*
* ## 与共享只读包库(D2)的关系
*
* 装配动作 = 往 profile 的 `package.json` 写
* `"<pkg>": "link:/var/lib/dshs/bundled-plugins/<flat>"`(**软链依赖**,`§4.1 D-h` 修订版;
* 原 `file:` 写法 2026-09-23 被真机实测推翻 —— 见 {@link depRefOf}),
* 再让 pnpm 去把 `node_modules/<pkg>` 建成指向那个目录的链接。**共享层里一份实体、
* 每个用户一条链接** ⇒ 满足 D2「⛔ 不给每个用户复制一份」。
*
* ⚠️ 共享层路径**不带版本号**(`§4.1 D-i` 拍板 B 档:同名只留最新一版、直接替换目录)。
* 把版本号写进路径会让老用户 profile 里已写死的引用**悬空**。
*
* @module dshs/supervisor/plugin-assembly
*/
import { execFileSync } from 'node:child_process'
import { existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
/** 一个依赖是否带 `dsh.bundle.patch`(= 是 bundle,对齐 dsh 的 `isBundle` 判定)。 */
export function isBundle(dir: string, dep: string): boolean {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8')) as {
dsh?: { bundle?: { patch?: unknown } }
}
return pkg.dsh?.bundle?.patch !== undefined
} catch {
return false
}
}
export interface ProfileManifest {
bundles: string[]
deps: Record<string, string>
}
/** 读 profile 的 `package.json`(读不出 ⇒ 空清单,**不抛**:首次装配就该从"没有"开始)。 */
export function readProfileManifest(dir: string): ProfileManifest {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
dsh?: { profile?: { bundles?: string[] } }
dependencies?: Record<string, string>
}
return { bundles: pkg.dsh?.profile?.bundles ?? [], deps: pkg.dependencies ?? {} }
} catch {
return { bundles: [], deps: {} }
}
}
/** 写回 profile 的 `package.json`(只动 `dsh.profile.bundles` 与 `dependencies` 两处)。 */
export function writeProfileManifest(dir: string, bundles: string[], deps: Record<string, string>): void {
const path = join(dir, 'package.json')
let pkg: Record<string, unknown>
try {
pkg = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
} catch {
pkg = { name: 'dsh-profile-web', private: true }
}
const dsh = (pkg.dsh ?? {}) as Record<string, unknown>
const profile = (dsh.profile ?? {}) as Record<string, unknown>
profile.bundles = bundles
dsh.profile = profile
pkg.dsh = dsh
pkg.dependencies = deps
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
}
/**
* 对齐 dsh `plugin add` 的 reconcile:`dependencies` 里带 `dsh.bundle.patch` 的进 bundles,
* 非 `@deepseek-ai/` 模板且已不在 `dependencies` 的从 bundles 移除。返回最终 bundles。
*
* ⚠️ 顺序依赖 `node_modules` 里**已经**有该包(`isBundle` 读的是装好之后的目录)⇒
* **必须在 pnpm 装完之后调**,⛔ 不能提前。
*/
export function reconcileBundles(dir: string): string[] {
const path = join(dir, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8')) as {
dependencies?: Record<string, string>
dsh?: { profile?: { bundles?: string[] } }
}
const deps = Object.keys(pkg.dependencies ?? {})
const bundles = pkg.dsh?.profile?.bundles ?? []
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
for (const dep of deps) {
if (!kept.includes(dep) && isBundle(dir, dep)) kept.push(dep)
}
pkg.dsh = pkg.dsh ?? {}
pkg.dsh.profile = pkg.dsh.profile ?? {}
pkg.dsh.profile.bundles = kept
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
return kept
}
// ── web provider 联动(2026-09-12 · 档案 65)──────────────────────────────
// 为什么需要:dsh 的 web provider 选择是**单选** —— 显式配置即硬绑定(provider 一旦不在就报
// CONFIGURED_MISSING 且**不回落**),未配置时只有「恰好一个可用」才自动选,多个可用直接
// AMBIGUOUS 报错。于是「两个 provider 共存 + 用户可任意启停」必然出现坏状态。
// 解法:平台在插件启停后**按当前 bundles 重算**这一段 ——
// 插件在 → 写死它声明的 searchProvider;插件不在 → 整段删除(回到「只有一个可用」时自动选)。
// 平台策略:`fetchProvider` 恒为本地 `http`(保留 SSRF 防护),**忽略**插件对 fetch 的声明。
//
// ⚠️ 常量**从本模块导出**(原来是 `business-plugins.ts` 的模块级导出):验证脚本按字符串
// 找托管段,⛔ 改名会让它们静默失效 —— 保持字面不变。
export const WEB_PROVIDER_OPEN = '# >>> platform: web-provider (managed by business-plugins)'
export const WEB_PROVIDER_CLOSE = '# <<< platform: web-provider'
/** 历史托管段(`scripts/ensure-anysearch-admin.cjs` 早期直铺时写的),同步时一并清掉。 */
export const WEB_PROVIDER_LEGACY: ReadonlyArray<readonly [string, string]> = [
['# >>> platform: anysearch-search', '# <<< platform: anysearch-search'],
]
/** 扫描已启用 bundle 自带的 `cordis.patch.yml`,收集它们对 `dsh-web` 的 provider 声明。 */
export function collectWebProviderClaims(dir: string, bundles: readonly string[]): { search: string | null } {
let search: string | null = null
for (const b of bundles) {
if (b.startsWith('@deepseek-ai/')) continue
let text: string
try {
text = readFileSync(join(dir, 'node_modules', b, 'cordis.patch.yml'), 'utf8')
} catch {
continue
}
let inWeb = false
let inConfig = false
for (const raw of text.split('\n')) {
const line = raw.replace(/\s+$/, '')
const idm = /^-\s+id:\s*(\S+)/.exec(line)
if (idm !== null) {
inWeb = idm[1] === 'web'
inConfig = false
continue
}
if (!inWeb) continue
if (/^\s+config:\s*$/.test(line)) {
inConfig = true
continue
}
if (!inConfig) continue
const sm = /^\s+searchProvider:\s*(\S+)/.exec(line)
if (sm !== null && search === null) search = sm[1]
}
}
return { search }
}
/** 按当前 bundles 重算 profile 的 web provider 托管段(幂等:先摘旧段,再按需追加)。 */
export function syncWebProviderPatch(dir: string): void {
const path = join(dir, 'cordis.patch.yml')
let text = existsSync(path) ? readFileSync(path, 'utf8') : ''
const blocks: ReadonlyArray<readonly [string, string]> = [
[WEB_PROVIDER_OPEN, WEB_PROVIDER_CLOSE],
...WEB_PROVIDER_LEGACY,
]
for (const [open, close] of blocks) {
const s = text.indexOf(open)
const e = text.indexOf(close)
if (s < 0 || e <= s) continue
const lineStart = text.lastIndexOf('\n', s)
text = text.slice(0, lineStart < 0 ? s : lineStart) + text.slice(e + close.length)
}
const { search } = collectWebProviderClaims(dir, readProfileManifest(dir).bundles)
if (search !== null) {
text = text.replace(/\s*$/, '\n') + [
'',
WEB_PROVIDER_OPEN,
'# 由业务插件的启用/禁用自动维护,请勿手改。策略:只换 search,fetch 恒为本地 http(保留 SSRF 防护)。',
'- id: web',
' config:',
` searchProvider: ${search}`,
' fetchProvider: http',
WEB_PROVIDER_CLOSE,
'',
].join('\n')
}
writeFileSync(path, text.replace(/^\s*\n/, ''))
}
// ── 快照与还原(档案 34:插件启用失败时回滚用)────────────────────────────
// 只快照我们会改动的文件;node_modules 不回滚(无害,重装很快,回滚反而慢且易碎)。
export const PROFILE_SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'] as const
export function snapshotProfile(dir: string): Record<string, string | null> {
const snap: Record<string, string | null> = {}
for (const f of PROFILE_SNAPSHOT_FILES) {
const p = join(dir, f)
snap[f] = existsSync(p) ? readFileSync(p, 'utf8') : null
}
return snap
}
export function restoreProfile(dir: string, snap: Record<string, string | null>): void {
for (const [f, content] of Object.entries(snap)) {
const p = join(dir, f)
if (content === null) {
rmSync(p, { force: true })
} else {
writeFileSync(p, content)
}
}
}
// ── 属主与降权执行 ────────────────────────────────────────────────────────
/**
* `pnpm install` 的参数(**唯一一份**,两台机共用)。
*
* 🔴 用 `-w`(`--workspace-root`),**⛔ 不能用 `--ignore-workspace-root-check`**。
* profile 目录里有 `pnpm-workspace.yaml`(`packages: [.]`)⇒ pnpm 视其为 workspace root,
* 往根上装依赖需要显式放行;**pnpm 9 只把 `ignore-workspace-root-check` 当 config 项**,
* ⛔ 不是 `install` 的 CLI 选项 —— 裸写会被判 `Unknown option` 并**直接失败**
* (106 · pnpm 9.15.9 实测:裸写报错退出;`-w` 与 `--config.<key>=true` 两种写法均通过)。
* ✅ 取 `-w`:与仓内 `scripts/ensure-biz-plugins.cjs` 的既有正确姿势**逐字一致**
* (那处写 `args.push('-w')`),避免同一仓库里出现两种"放行 workspace root"的写法。
* 历史:旧代码在 `business-plugins.ts` 里一直裸写,2026-09-13 07:43:58 就是撞在这上面
* (当时只加了 try/catch 兜住进程,⛔ 没修参数本身)⇒ 本轮抽公用模块时一并修正。
*
* ⚠️ `--config.auto-install-peers=false`(`pnpm-workspace.yaml` 里已有 `autoInstallPeers: false`):
* dsh 插件把 `@deepseek-ai/dsh-client-*` 声明为 **optional peerDeps**,公网 registry 上没有
* ⇒ 一旦自动装 peer 必失败。该开关同样由 profile 内的 workspace 文件承载,⛔ 别在命令行重复传。
*/
export const PNPM_INSTALL_ARGS: readonly string[] = [
'install', '-w', '--reporter', 'silent',
]
/**
* **以该用户身份**跑 pnpm(`setpriv` 降权)。
*
* 为什么必须有:这条路(门户候选池启停)早期直接**以平台进程身份**跑 pnpm,装出来的文件属主是
* root —— 此后该用户自己跑任何 `pnpm add/remove` 都会撞 `EACCES`,插件彻底装不上
* (2026-09-12 实测:guest profile 下积了 561 个 root 属主项,升级直接失败)。
*
* ⚠️ `HOME` 必须是该用户的 `ws`(不是 `/root`):pnpm 的 store 位置由 `HOME` 决定,
* 平台跑 pnpm 必须设成与实例内那次**同一个** `HOME`,否则报 `ERR_PNPM_UNEXPECTED_STORE`。
*/
export function runPnpmAs(uid: number, homeDir: string, args: readonly string[], cwd: string): void {
execFileSync(
'setpriv',
['--reuid', String(uid), '--regid', String(uid), '--clear-groups', 'env', `HOME=${homeDir}`, 'pnpm', ...args],
{ cwd, timeout: 180000, stdio: 'pipe' },
)
}
/**
* 🔻 **pnpm 执行缝**(可跳过的外部进程调用)。
*
* 为什么需要:`runPnpmAs` 走 `setpriv`(**Linux 专有**),Windows 开发机上必然 `ENOENT`
* ⇒ 任何"装配到底往 profile 里写了什么引用"的断言都跑不起来 —— 而那正是 D2 / `link:` 协议
* 最该被钉住的地方(**协议写错只表现为"磁盘被悄悄复制",跑起来不报错**,真机上才发现)。
*
* ⚠️ 生效条件 = **`setpriv` 不存在** 且 **`DSHS_TEST_NO_PNPM === '1'`** —— 两个条件缺一不可:
* · 前者保证**生产路径(Linux,setpriv 存在)永远走真调用**,不会静默跳过安装;
* · 后者保证即便在 Linux 上跑测试,也**必须显式声明**才跳过(⛔ 不会意外绕过)。
* ✅ 跳过时**只省掉外部进程**,写 `package.json` 的那段(协议本体)照常执行 ⇒ 断言测的是真产物。
*/
export function pnpmExecDisabled(): boolean {
if (process.env.DSHS_TEST_NO_PNPM !== '1') return false
try {
// 有 setpriv ⇒ 真机形态 ⇒ 一律走真调用
execFileSync('setpriv', ['--help'], { stdio: 'ignore', timeout: 5000 })
return false
} catch (e) {
// 只有"找不到这个可执行文件"才算"这台机器上没有 setpriv";
// 其余(权限、超时)一律当"存在"处理,⛔ 不放行跳过。
return (e as { code?: string })?.code === 'ENOENT'
}
}
/** 内层真实调用点:测试开关打开 ⇒ 跳过外部进程,其余行为不变。 */
function runPnpm(uid: number, homeDir: string, args: readonly string[], cwd: string): void {
if (pnpmExecDisabled()) return
runPnpmAs(uid, homeDir, args, cwd)
}
/**
* 属主自愈:把 profile 下 **root 属主**的残留项还给该用户(历史根因留下的存量)。
*
* 每次改插件前跑一次 —— 这是「修根因 + 防复发」里的防复发那一半:即便将来**别的**写入路径
* 又污染了属主,用户下次启停插件时也会被自动清理,不必再人工排查。
* 用 `find -exec chown +` 而不是一次传全部路径,避免路径过多撞命令行长度上限。
*
* @returns 修复的条目数(0 = 本来就干净)。
*/
export function healOwnership(dir: string, uid: number): number {
let count = 0
try {
const out = execFileSync('find', [dir, '-user', 'root'], { encoding: 'utf8', timeout: 60000 })
count = out.split('\n').filter((line) => line.trim() !== '').length
if (count === 0) return 0
} catch {
return 0
}
try {
// `-h`:`.bin/*` 是指向别处的符号链接,要改链接本身而不是它的目标。
execFileSync('find', [dir, '-user', 'root', '-exec', 'chown', '-h', `${uid}:${uid}`, '{}', '+'], {
timeout: 180000,
})
} catch {
return 0
}
return count
}
/**
* profile 目录(该用户实例读的那棵树的**平台侧绝对路径**)。
*
* ⚠️ 与"实例内可见路径"是**同一个绝对路径**(bwrap `--bind <userRoot> <userRoot>` 同路径绑定,
* 全程不重映射)—— 所以这个函数在两台机上都算得出正确结果,只要 `userRoot` 是**那台机上**的。
*/
export function profileDirOf(userRoot: string): string {
return join(userRoot, 'home', 'profiles', 'web')
}
/**
* 包名 → 共享只读包库里的**扁平目录名**。
*
* `sanitizeFileName` 的口径(`[^a-zA-Z0-9._-] → _`)在**两台机上都必须一样**,
* 否则 Manager 算出的共享层路径与 worker 上实际铺出来的目录名对不上
* ⇒ `pnpm install` 报 `ENOENT`,而症状只是"某个用户装不上插件"。
* ⚠️ 与 `business-plugins.ts` 的 `sanitizeFileName` **逐字同实现**(那条是共享层铺盘时用的)。
*/
export function sharedDirNameOf(pkgName: string): string {
return pkgName.replace(/[^a-zA-Z0-9._-]/g, '_')
}
/** 该包在共享只读包库里的**绝对目录**(= 写进 profile 依赖引用的那个值)。 */
export function sharedDirOf(bundledPluginDir: string, pkgName: string): string {
return join(bundledPluginDir, sharedDirNameOf(pkgName))
}
/**
* **写进 profile `package.json` 的依赖引用**(`§4.1 D-h` 修订版 · 2026-09-23 用户拍板换方案)。
*
* ## 为什么是 `link:` 而不是 `file:`(原 D-h 写法已被实测推翻)
*
* 🔴 **`file:`(目录型)在 pnpm 9 下走"复制"而不是"硬链"** —— 第 6 棒真机实测:
* profile 里每个包被**整份复制**进该用户 `.pnpm/`(**6.5 MiB / 用户 / 包**、116 文件、
* `links=1`、inode 与源不同)⇒ **不满足 D2「⛔ 不给每个用户复制一份」**,
* 用户数一涨磁盘线性膨胀。
* ✅ **`link:` 实测 = 纯软链直达共享层**(**≈ 4.0 KiB** 一条链),且**抗 reconcile**
* (加依赖 / `remove` 后软链仍在 —— 这正是 D-h 当初选 `file:` 的唯一理由),
* 该性质已由回归测试钉住。
*
* ## ⚠️ 跨机前提(换方案后新增的唯一约束)
*
* 软链的**目标路径必须两台机完全一致**(现在都是 `/var/lib/dshs/bundled-plugins/<flat>`)。
* `file:` 时这个约束**不存在**(复制把路径固化进副本)⇒ 若将来 47 与 106 的共享层根
* 出现分叉,`link:` 会比 `file:` **更脆**(写在 Manager 盘上的目标在 worker 上不存在)。
* ⇒ 改部署根 / 改 `bundledPluginDir` 前,先确认**两台机同值**。
*
* ⚠️ 共享层路径**不带版本号**(`§4.1 D-i` 拍板 B 档:同名只留最新一版、直接替换目录)。
* 把版本号写进路径会让老用户 profile 里已写死的引用**悬空**。
*/
export function depRefOf(target: string): string {
return `link:${target}`
}
/** 该用户的 `<root>/ws` —— pnpm 的 `HOME`(store / cache 落在这里)。 */
export function wsDirOf(userRoot: string): string {
return join(userRoot, 'ws')
}
/**
* 读该用户的 Linux uid/gid —— 取 `<root>/home` 的属主(编排器创建时就 chown 给了该用户)。
* ⛔ 不查 DB:uid 列与本目录属主由同一次创建写入,读目录少一处耦合。
* 目录不存在 ⇒ 抛(调用方必须先 `mkdir -p` / `initUserRoot`)。
*/
export function uidOfUserRoot(userRoot: string): number {
return lstatSync(join(userRoot, 'home')).uid
}
// ── 装配清单 → profile 变更(两台机共用的那一段)──────────────────────────
/** 装配项:`enable` 只做"声明要装",实际落盘由 {@link applyProfileChanges} 统一做。 */
export interface PluginApplyItem {
/** 包真名(`@scope/name` 原样或裸名)。 */
id: string
/**
* 期望的包版本(`business_plugins.version` 的记录值)。
*
* ⚠️ **只用于台账与对账告警,⛔ 不参与路径计算** —— 共享层路径**不带版本号**
* (`§4.1 D-i` 拍板 B 档:同名只留最新一版、新版本直接替换目录)。真值一律以磁盘为准
* (`04-144 §十` 的教训:记录字段会与磁盘不符,实测 `0.3.9` vs 磁盘 `0.3.13`)。
*/
version?: string | null
enabled: boolean
/** `bundled` = 走共享只读包库(`link:` 指向它);`file` = 显式给绝对路径。 */
src?: 'bundled' | 'file'
}
export interface ApplyProfileOptions {
/** 该用户根的**本机**绝对路径。 */
userRoot: string
/** 共享只读包库根(`config.bundledPluginDir`)—— 算不出/为空 ⇒ 拒绝装配。 */
bundledPluginDir: string
/** 包名 → 是否在共享层里存在(由调用方查:Manager 查 manifest,worker 查目录)。 */
resolveBundleDir: (id: string) => string | undefined
}
export interface ApplyProfileResult {
enabled: string[]
disabled: string[]
bundles: string[]
}
/**
* **把一份启用清单落到 profile 上**(装 / 卸 + reconcile + web provider 重算)。
*
* 本函数**只碰文件与 pnpm**,⛔ 不重启实例、⛔ 不探活 —— 那两件事由调用方按自己的形态做
* (Manager 走 `supervisor.restartAndProbe`,worker 走本机 `spawner.restartAndProbe`)。
*
* 🔴 **失败语义**:`resolveBundleDir` 返回 `undefined` ⇒ **抛错并指出是哪一项**,
* ⛔ 不静默跳过(S3-E ⑤ 反证项要的正是"报错可读、实例不崩")。
*/
export function applyProfileChanges(items: readonly PluginApplyItem[], opts: ApplyProfileOptions): ApplyProfileResult {
const dir = profileDirOf(opts.userRoot)
const uid = uidOfUserRoot(opts.userRoot)
const home = wsDirOf(opts.userRoot)
const before = readProfileManifest(dir)
const enabledSet = new Set(before.bundles)
const deps = { ...before.deps }
const enabled: string[] = []
const disabled: string[] = []
// ① 禁用项先做(remove 不引入新代码,永远安全)。
for (const item of items) {
if (item.enabled) continue
if (!enabledSet.has(item.id)) continue
// 属主自愈(防复发那一半):残留的 root 属主项会让 pnpm 连旧文件都覆盖不了。
// ⚠️ 计数由调用方写审计 —— 本模块 ⛔ 不碰 `app.db`(这是它能在 worker 上跑的前提)。
healOwnership(dir, uid)
runPnpm(uid, home, ['remove', item.id, '-w', '--reporter', 'silent'], dir)
delete deps[item.id]
disabled.push(item.id)
}
// ② 启用项:**先解析全部包路径(失败即中止、不落盘)**,再写依赖引用,最后一次性 pnpm install。
const toEnable = items.filter((i) => i.enabled)
const planned: Array<{ id: string; target: string }> = []
for (const item of toEnable) {
const target = opts.resolveBundleDir(item.id)
if (target === undefined) {
// ⛔ 不静默跳过:包不在共享层 ⇒ 立刻抛,且**此时 profile 尚未被改动**(原子性)。
throw Object.assign(new Error(`共享层里没有该插件的包:${item.id}`), { code: 'plugin_bundle_missing' })
}
planned.push({ id: item.id, target })
deps[item.id] = depRefOf(target)
}
if (toEnable.length > 0) {
healOwnership(dir, uid)
// 🔴 **失败即回滚**:pnpm 若中途失败(版本不兼容 / store 损坏 / 磁盘满),
// 必须把 `package.json` 恢复到调用前的样子 —— 否则会留下「依赖已声明、实际未安装」
// 的半装状态,下次实例冷启动直接崩,且用户看到的是"装过了"。
try {
writeProfileManifest(dir, before.bundles, deps)
runPnpm(uid, home, PNPM_INSTALL_ARGS, dir)
} catch (e) {
const keep = { ...deps }
for (const p of planned) delete keep[p.id]
try { writeProfileManifest(dir, before.bundles, keep) } catch { /* 尽力还原 */ }
throw e
}
for (const item of toEnable) enabled.push(item.id)
}
const bundles = reconcileBundles(dir)
syncWebProviderPatch(dir)
return { enabled, disabled, bundles }
}
+30
View File
@@ -15,6 +15,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Agent, request as httpRequest, type ClientRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http'
import { connect } from 'node:net'
import type { Duplex } from 'node:stream'
import { createHash } from 'node:crypto'
import { hashSessionToken, parseCookie } from '../web/auth.js'
import { requireAuth } from '../web/middleware/authn.js'
@@ -621,6 +622,32 @@ function proxyHttp(
request.raw.on('error', () => reply.raw.destroy())
}
/**
* 🔴 upgrade 的**委托钩子**(IM 线 B 单 · `交接单/IM群组-B-平台API与用户端IM-WS通道.md` §四-5)。
*
* ## 为什么要有它
* 平台全仓在 `app.server` 上只注册**一个** `upgrade` 回调(本文件的子域隧道)。
* 新增第二个 WS 端点时,**不能**再挂一个 `app.server.on('upgrade')` —— Node 会把
* 同一次 upgrade 事件**并发投给两个监听器**,于是 IM 路径也会被子域判定判失败并
* `socket.destroy()`(静默半死)。所以走"**先分流、后委托**":IM 模块注册本钩子,
* 回调第一行问它"要不要接管";答 `true` ⇒ 本回调立即返回,**行为一字不变**地跳过
* 既有隧道逻辑;答 `false`(未注册 = `undefined`)⇒ 走原路径。
*
* ⛔ **纪律**:钩子只能"接管或不接管",**不得修改**既有判定顺序 / 失败语义
* (`access === null || 'error' in access ⇒ socket.destroy()` 保持原样)。
*/
export type UpgradeDispatch = (req: IncomingMessage, socket: Duplex, head: Buffer) => boolean
let upgradeDispatch: UpgradeDispatch | undefined
/**
* 注册 upgrade 分流的接管者(同进程只有一个 ⇒ 后注册覆盖前者)。
* ⛔ 未注册时,upgrade 行为与本钩子引入前**逐字相同**。
*/
export function setUpgradeDispatch(dispatch: UpgradeDispatch | undefined): void {
upgradeDispatch = dispatch
}
export async function registerDshProxy(app: FastifyInstance): Promise<void> {
// Legacy authenticated subpath proxy.
app.all('/u/:slug/dsh/*', { preHandler: requireAuth }, async (request, reply) => {
@@ -757,6 +784,9 @@ export async function registerDshProxy(app: FastifyInstance): Promise<void> {
// Per-user subdomain: WebSocket upgrade tunnel. Auth is async (DB lookup), so
// the raw `upgrade` callback defers to an async IIFE before deciding to tunnel.
app.server.on('upgrade', (req, socket, head) => {
// 🆕 IM 线 B 单:**先分流** —— 接管者只认 `/api/im/` 前缀;⛔ 未命中时下面
// 的子域隧道逻辑与本次改动前**逐字相同**(§六-5 硬回归判据靠这条守住)。
if (upgradeDispatch !== undefined && upgradeDispatch(req, socket, head)) return
void (async () => {
const access = await resolveSubdomainAccess(app, clientHost(req.headers), req.headers.cookie)
if (access === null || 'error' in access) {
+102 -4
View File
@@ -19,8 +19,10 @@
*/
import { randomUUID } from 'node:crypto'
import { agentBaseUrlOf, type Reachability } from '../net/reachability.js'
import type { ImInstanceTokenRegistry } from '../im/instance-token.js'
import { AGENT_TOKEN_HEADER } from '../worker/agent.js'
import type { Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
import type { PluginApplyItem } from './plugin-assembly.js'
import type { ApplyPluginsReport, Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
/**
* 一台 worker 的接入信息。
@@ -73,6 +75,25 @@ export interface RemoteSpawnerOptions {
resolveUid?: (userId: string) => Promise<number>
/** 默认 host 的 id(不提供 `hosts` 时的单机形态用它)。 */
defaultHostId?: string
/**
* 序47 · S4:**实例启动前**调一次(当前唯一用途 = 实例设备凭据的平台侧代签 + 投递)。
*
* 为什么集群形态也得有这个挂点:Manager 侧的 `launch` 走的**不是** `LocalSpawner`
* (那台机上的实例由 worker 的 agent 起)⇒ 若只在本地 spawner 挂,集群部署下
* "实例凭据"会**一条都不产生** —— 而这种缺省失效长得跟"功能没做"一样。
*
* ⚠️ 契约与 `LocalSpawnerHooks.onInstanceStart` 相同:**实现不得抛异常**
* (抛了也会被吞掉并具名记日志),语义是"多一条能力",⛔ 不是"起实例的前置条件"。
*/
onInstanceStart?: (userId: string) => Promise<void>
/**
* 🔴 C 单:IM 实例凭据登记簿 —— 与 `LocalSpawnerHooks.imInstanceTokens` 同一条。
*
* 集群形态下实例由 Worker 的 agent 起,但**凭据必须由 Manager 签发**(只有 Manager
* 持有登记簿,且校验也在 Manager 侧)⇒ 这里签发、随 launch 投递给 agent 注入 env。
* ⛔ 未注入 ⇒ 不签发 ⇒ 实例 env 里没有 token ⇒ 插件不启动(fail-closed)。
*/
imInstanceTokens?: ImInstanceTokenRegistry
/** **多机(S6)**:除默认 host 外的其它 worker。给了就按 `hostId` 路由。 */
hosts?: ClusterHost[]
/** **多机(S6)**:`userId` → 它实例所在的 `hostId`(上层查 `dsh_instances.host_id` 注入)。 */
@@ -111,6 +132,9 @@ export class RemoteSpawner implements Spawner {
private readonly hostsProvider?: () => Promise<ClusterHost[]>
private readonly directoryTtlMs: number
private readonly translateEndpoint?: (hostId: string, endpoint: Endpoint) => Endpoint | undefined
private readonly onInstanceStart?: (userId: string) => Promise<void>
/** 🔴 C 单:IM 实例凭据登记簿(见 `RemoteSpawnerOptions.imInstanceTokens`)。 */
private readonly imInstanceTokens?: ImInstanceTokenRegistry
private directoryLoadedAt = 0
constructor(options: RemoteSpawnerOptions) {
@@ -139,6 +163,8 @@ export class RemoteSpawner implements Spawner {
// 而平台上**一行错误日志都没有**。这正是覆盖网络线要消灭的那类静默失败。
// 判据:`test/remote-spawner.test.mjs` 第 1 例(构造时就断言 `translateEndpoint` 生效)。
this.translateEndpoint = options.translateEndpoint
this.onInstanceStart = options.onInstanceStart
this.imInstanceTokens = options.imInstanceTokens
}
/**
@@ -235,10 +261,25 @@ export class RemoteSpawner implements Spawner {
})
if (res.ok) return (await res.json()) as T
const text = await res.text()
// 4xx 是"协议/参数错",重试没意义;5xx 与网络错才重试。
if (res.status < 500) throw new Error(`agent ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`)
/**
* 4xx 是"协议/参数错"(包名非法、共享层没这个包、鉴权失败…)—— **重试没意义**:
* 同样的请求再发三次只会得到同样四个 4xx,而调用方要等 200ms+1s+3s 才拿到错误。
*
* 🔴 **2026-09-23 修**(本仓旧缺陷,S3 的测试当场抓到):原写法把这条 `throw` 放在
* **`try` 块内** ⇒ 立刻被下面的 `catch (err) { lastErr = err }` 接住 ⇒ 循环照旧跑满 4 次
* ⇒ 注释说的"重试没意义"**从未真的生效**(实测 `calls === 4`,不是 1)。
* 判据 = `test/plugin-assembly.test.mjs`「agent 回 4xx ⇒ 不被重试吞掉」。
* 修法 = 用**带标记的 Error** 穿透 catch,在 catch 里按标记**立刻抛**(⛔ 不改重试次数与
* 退避参数 —— 那两条是覆盖网络线 R4 的既有口径,⛔ 本单不动)。
*/
if (res.status < 500) {
const err = new Error(`agent ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`)
;(err as Error & { noRetry?: boolean }).noRetry = true
throw err
}
lastErr = new Error(`agent ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`)
} catch (err) {
if ((err as { noRetry?: boolean }).noRetry === true) throw err // 4xx:确定性错误,不再重试
lastErr = err
}
}
@@ -251,6 +292,44 @@ export class RemoteSpawner implements Spawner {
patch?: string,
opts?: { force?: boolean; epoch?: number; hostId?: string },
): Promise<Instance> {
/**
* 序47 · S4:实例设备凭据**先落好再下发 launch**(投递面 = 实例 home,走 Manager 的
* `UserFs` —— 它的归属钉法与 `hostFor` 是同一份,故"凭据写到 A、实例起在 B"不成立)。
* 🔴 **失败不阻断**(与 `LocalSpawner.notifyInstanceStart` 同一条理由):凭据是"多一条
* 能力",⛔ 不是实例的前置条件。
*/
if (this.onInstanceStart !== undefined) {
try {
await this.onInstanceStart(userId)
} catch (err) {
console.error(
`[instance-start-hook] ⛔ ${userId} 实例启动前钩子抛异常(继续下发 launch):${err instanceof Error ? err.message : String(err)}`,
)
}
}
/**
* 🔴 C 单:**签发 IM 实例凭据**并随 launch 投递给 agent(由它注入实例 env)。
*
* 为什么签发在 Manager:登记簿只有 Manager 有(`web/server.ts` 建),且**校验也在
* Manager 侧**(`imRoutes` / `im/ws.ts`)—— 若让 Worker 自己发,Manager 永远认不出。
* 🔴 **失败不阻断**(与上面同一条理由):拿不到 token ⇒ 实例照起,只是没有 agent 桥。
* ⚠️ 与 `LocalSpawner` 同一判据:**实例重启即换 token**(先 revoke 同实例的旧凭据)。
*/
let imInstanceToken: string | null = null
if (this.imInstanceTokens !== undefined) {
try {
this.imInstanceTokens.revokeByInstance(userId)
imInstanceToken = this.imInstanceTokens.issue({
instanceId: userId,
userId,
region: process.env.DSH_IM_REGION ?? '',
}).token
} catch (err) {
console.error(
`[im-instance-token] ⛔ ${userId} 集群形态签发失败(继续下发 launch、agent 桥不启动):${err instanceof Error ? err.message : String(err)}`,
)
}
}
const host = await this.hostFor(userId, opts?.hostId)
// 同一个 operationId 贯穿这次调用的所有重试 ⇒ agent 侧幂等回放(不会起两个实例)。
const operationId = randomUUID()
@@ -260,7 +339,7 @@ export class RemoteSpawner implements Spawner {
host,
'POST',
'/launch',
{ userId, folder, patch, apiKey, uid, epoch: opts?.epoch },
{ userId, folder, patch, apiKey, uid, epoch: opts?.epoch, imInstanceToken },
operationId,
)
return res.instance
@@ -367,6 +446,25 @@ export class RemoteSpawner implements Spawner {
)
}
/**
* 插件投放与分库线 ① · S3:**跨机那条腿** —— 把装配清单发到实例所在那台 worker 上去落盘。
*
* 为什么必须发过去:profile 树(`<userRoot>/home/profiles/web`)在**那台机**上,
* Manager 直接改只会操作自己盘上一个不存在的路径(静默空操作)。
* 落点与 `restartAndProbe` **同一套路由**(`hostFor` ⇒ `dsh_instances.host_id`),
* ⛔ 不另起一套选机判据 —— 两套判据必然漂移出"装在 A、实例在 B"。
*
* ⚠️ 与 `launch` 不同,这里**不需要** `operationId` 幂等键:装配本身幂等
* (同清单重跑 = 空操作),且它不改实例生命周期 ⇒ 重试不会起出第二个实例。
*/
async applyPlugins(userId: string, items: readonly PluginApplyItem[]): Promise<ApplyPluginsReport> {
const host = await this.hostFor(userId)
return this.call<ApplyPluginsReport>(host, 'POST', '/plugins/apply', {
userId,
items: items.map((i) => ({ id: i.id, version: i.version ?? null, enabled: i.enabled, src: i.src ?? 'bundled' })),
})
}
/** 活动信号:转发给**实例所在那台** agent,让它自己的 idle-reap 不误杀(fire-and-forget)。 */
touch(userId: string): void {
void this.hostFor(userId)
+33
View File
@@ -6,10 +6,26 @@
* no backend owns them.
* @module dshs/supervisor/spawner
*/
import type { PluginApplyItem } from './plugin-assembly.js'
export type InstanceStatus = 'starting' | 'running' | 'crashed' | 'stopped' | 'failed'
export type InstanceRole = 'main' | 'watchdog'
/**
* 插件装配的结果(`Spawner.applyPlugins`)。
*
* `bundles` = 装配后 profile 的最终 bundles 清单(**以磁盘为准**,不是以请求为准)——
* 调用方的台账与任务状态都该拿它当真值,⛔ 不要回显请求里的 items。
*/
export interface ApplyPluginsReport {
/** 本次真的装上的(幂等重复请求 ⇒ 空数组)。 */
enabled: string[]
/** 本次真的卸掉的。 */
disabled: string[]
/** 装配后 profile 的最终 bundles(磁盘真值)。 */
bundles: string[]
}
/** A tracked DSH instance (main or watchdog). `port`/`pid` are local-only. */
export interface Instance {
id: string
@@ -139,6 +155,23 @@ export interface Spawner {
* `ok:false` 时调用方必须回滚/隔离该插件(否则会把实例拖进崩溃循环,档案 25 教训)。
*/
restartAndProbe(userId: string, settleMs?: number): Promise<{ ok: boolean; reason: string }>
/**
* 插件投放与分库线 ① · S3:**把一份启用清单装配到该用户实例所在的那台机上**。
*
* 🔴 为什么必须是 `Spawner` 的方法、而不是路由层直接改文件:profile 树
* (`<userRoot>/home/profiles/web`)**长在实例所在的那台机上**。集群形态下实例在 worker,
* 而 Manager 直接 `readFileSync/execFileSync` 只会操作**自己盘上一个不存在的路径**
* (静默空操作 —— `user-fs.ts` 的 seam 注释里记着这个教训)⇒ 装配必须**按 host 路由过去**,
* 与 `status` / `stop` / `restartAndProbe` 走同一套 `hostIdFor`。
*
* ⚠️ **本方法只做"装 / 卸 + reconcile + web provider 重算",⛔ 不重启实例、⛔ 不探活** ——
* 重启探活是调用方的编排动作(`restartAndProbe` 单独调),这样"装配"与"判定装坏了没有"
* 两件事各自可单独重试,也便于 §五 S3-E 的逐项取证。
*
* @param items 启用清单(谁要开 / 谁要关)。`src='bundled'` ⇒ 指向节点共享只读包库(D2)。
*/
applyPlugins(userId: string, items: readonly PluginApplyItem[]): Promise<ApplyPluginsReport>
/** Record user activity (proxied traffic / entering the workspace) so idle
* reaping keeps warm instances that are genuinely in use. Local mode tracks
* this in memory; k8s mode relies on its own session-based reconcile. */