@gehennawu/dsh-service 0.15.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -14,19 +14,22 @@ The Settings panel "Service Control" page has six top-level tabs: **Overview, No
14
14
 
15
15
  - Displays current DSH and plugin versions with links to GitHub Releases
16
16
  - Automatically checks npm registry for stable and preview releases; when a new version exists, the right-side status text (chevron + `New version`) is clickable to expand/collapse, showing the current/latest versions and both dist-tag versions inline, each with npmjs and npmmirror links (npmjs.com is blocked on some networks; npmmirror serves as the mirror entry)
17
- - One-click plugin upgrade with automatic restart after completion
17
+ - One-click plugin upgrade with automatic restart after completion; when no process manager is detected (for example, a manual launch from a Windows terminal), the upgrade first confirms the consequences, then keeps the process running and shows manual-restart instructions
18
18
 
19
19
  ### Safe restart
20
20
 
21
21
  - Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
22
22
  - `/restart` command also available in conversations; automatically refuses when active work is detected
23
23
  - Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
24
+ - When a manual terminal launch is suspected, the restart confirmation flow warns that nothing will bring the process back, and Health diagnostics marks it with a yellow inline caution
24
25
  - Optional `Restart` entry at the bottom of the settings left navigation, enabled by a switch in the Restart tab (off by default), sharing the exact same confirmation flow as the Restart tab
25
26
 
26
27
  ### Health diagnostics
27
28
 
28
29
  - Shows uptime, memory, session count, active agents, and background jobs
29
- - Full diagnostics check session storage, workspace registry, backup storage, tar availability, and file permissions
30
+ - The "Process and runtime" card shows platform, architecture, and Node version
31
+ - Full diagnostics check session storage, workspace registry, backup storage, tar availability, file permissions, runtime environment, and Node runtime version; a manual launch is marked with a yellow inline caution (no restart assurance) that does not trigger the health alert banner, the service-control reminder, or the tab ⚠; an unrecognized environment and an empty backup list are informational only — all can be declared explicitly via `DSH_SERVICE_RUNTIME_ENV`
32
+ - Having no backups is an informational note, not a warning, and does not light the Health tab ⚠
30
33
  - File-permission deep scan and repair: checks whether the Agent can read/write DSH_HOME and workspaces; repair requires two-step confirmation
31
34
 
32
35
  ### Model statistics
@@ -90,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-service
90
93
 
91
94
  The plugin only sends an exit signal; it does not start the process again. Without a process manager, selecting restart stops DSH Web.
92
95
 
96
+ The plugin passively detects whether a process manager is present (environment variables, `/.dockerenv`, `/proc/1/cgroup`, terminal TTY): with Docker/systemd/pm2/supervisord/Kubernetes detected it restarts as usual; when nothing is detected and stdin/stdout are an interactive terminal, it treats the environment as a likely manual launch, flags it in Health diagnostics with a yellow caution, and switches one-click upgrade to keep the process running with manual-restart instructions. Heuristics cannot cover redirected output or wrappers like NSSM/WinSW; set `DSH_SERVICE_RUNTIME_ENV=managed|manual` to declare it explicitly.
97
+
93
98
  ### Docker Compose
94
99
 
95
100
  ```yaml
@@ -122,6 +127,8 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
122
127
  | macOS / Windows + pm2 or similar | Not blocked by the code | Managed externally | Not tested |
123
128
  | Direct `dsh web` execution | Supported | Not supported | Expected behavior |
124
129
 
130
+ When launched directly from a terminal (PowerShell/CMD/bash), the panel labels the environment as a likely manual launch and the one-click upgrade no longer exits the process automatically.
131
+
125
132
  Requirements: Node.js `>=22`, and a DSH Web installation capable of loading both Host and Client plugin halves. Update checks require access to `registry.npmjs.org`; network failures do not affect other features.
126
133
 
127
134
  ## Security design
package/README.md CHANGED
@@ -14,19 +14,22 @@
14
14
 
15
15
  - 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
16
16
  - 自动检查 npm registry 的正式版和预览版;有新版本时右侧「小三角 + 有新版本」状态文本可点击展开/收起,行内展示当前/最新版本与正式版/预览版双 tag,版本号分别带 npmjs 与 npmmirror 链接(部分网络会被 npmjs.com 拦截,npmmirror 作为镜像入口)
17
- - 一键升级插件,升级后自动重启
17
+ - 一键升级插件,升级后自动重启;未检测到进程管理器(如在 Windows 终端手动启动)时,升级前先确认后果,安装完成后保持运行并提示手动重启
18
18
 
19
19
  ### 安全重启
20
20
 
21
21
  - 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
22
22
  - 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
23
23
  - 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
24
+ - 疑似终端手动启动时,重启确认流程会提示「退出后不会自动拉起」,健康诊断中以黄色行内警示标注
24
25
  - 可在「重启」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「重启」快捷入口,与「重启」标签共用同一套确认流程
25
26
 
26
27
  ### 健康诊断
27
28
 
28
29
  - 显示运行时间、内存、会话数、活跃 Agent 和后台任务
29
- - 完整诊断检查会话存储、工作区注册表、备份目录、tar 可用性和文件权限
30
+ - 「进程与运行环境」卡显示平台、架构和 Node 版本
31
+ - 完整诊断检查会话存储、工作区注册表、备份目录、tar 可用性、文件权限、运行环境和 Node 运行时版本;手动启动环境以黄色行内警示标注重启无保障(不触发健康提醒横幅、服务控制提醒和标签 ⚠),未识别环境与空备份为信息级提示(不算警告),均可通过 `DSH_SERVICE_RUNTIME_ENV` 显式声明
32
+ - 没有备份属于信息级提示,不算警告,也不点亮健康诊断标签的 ⚠
30
33
  - 文件权限深检与修复:检查 Agent 是否能读写 DSH_HOME 和工作区,修复需两段式确认
31
34
 
32
35
  ### 模型统计
@@ -90,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-service
90
93
 
91
94
  插件只发送退出信号,不负责重新启动进程。没有进程管理器时,点击重启会直接停止 DSH Web。
92
95
 
96
+ 插件会用被动信号(环境变量、`/.dockerenv`、`/proc/1/cgroup`、终端 TTY)判断当前是否由进程管理器拉起:检测到 Docker/systemd/pm2/supervisord/Kubernetes 时照常自动重启;都没有且 stdin/stdout 是交互终端时视为「疑似手动启动」,健康诊断会以黄色警示标注,一键升级改为「不自动退出 + 提示手动重启」。启发式无法覆盖输出重定向、NSSM/WinSW 等场景,可用环境变量 `DSH_SERVICE_RUNTIME_ENV=managed|manual` 显式声明。
97
+
93
98
  ### Docker Compose
94
99
 
95
100
  ```yaml
@@ -122,6 +127,8 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
122
127
  | macOS / Windows + pm2 等 | 代码未限制 | 由进程管理器负责 | 未验证 |
123
128
  | 直接运行 `dsh web` | 支持 | 不支持 | 预期行为 |
124
129
 
130
+ 直接在终端运行(PowerShell/CMD/bash)时,面板会标注「疑似终端手动启动」,一键升级不再自动退出进程。
131
+
125
132
  运行要求:Node.js `>=22`,DSH Web 能加载 Host 和 Client 两半插件。检查更新需要访问 `registry.npmjs.org`;网络失败不影响其他功能。
126
133
 
127
134
  ## 安全设计
package/client.js CHANGED
@@ -20,6 +20,8 @@ window.__ModuleLoader__.load({
20
20
  'health.title': '运行状况',
21
21
  'health.uptime': '运行时间',
22
22
  'health.rss': '内存 RSS',
23
+ 'health.platform': '平台',
24
+ 'health.nodeVersion': 'Node 版本',
23
25
  'health.liveSessions': '存活会话',
24
26
  'health.persistedSessions': '持久化会话',
25
27
  'health.activeAgents': '活跃 Agent',
@@ -43,6 +45,14 @@ window.__ModuleLoader__.load({
43
45
  'health.check.backup-storage': '备份存储',
44
46
  'health.check.tar': 'tar',
45
47
  'health.check.permissions': '文件权限',
48
+ 'health.check.runtime-env': '运行环境',
49
+ 'health.check.node-version': 'Node 运行时',
50
+ 'health.detail.runtime-env.managed': '由{kind}管理,重启后会自动拉起',
51
+ 'health.detail.runtime-env.declared': '已通过 DSH_SERVICE_RUNTIME_ENV 声明由外部进程管理器管理',
52
+ 'health.detail.runtime-env.manual': '疑似终端手动启动,重启后不会自动拉起;一键升级已改为不自动退出',
53
+ 'health.detail.runtime-env.unknown': '未检测到进程管理器,无法确认重启后是否自动拉起;如实际有,可设置 DSH_SERVICE_RUNTIME_ENV=managed 声明',
54
+ 'health.detail.node-version.ok': '{version},满足 ≥{required} 要求',
55
+ 'health.detail.node-version.warning': '{version} 低于插件要求的 {required}.x,建议升级 Node',
46
56
  'health.detail.session-storage.ok': '可用,共 {count} 个会话快照',
47
57
  'health.detail.workspace-registry.ok': '可用,共 {count} 个工作区',
48
58
  'health.detail.dsh-home.ok': '目录可访问,权限模式 {mode}',
@@ -56,7 +66,7 @@ window.__ModuleLoader__.load({
56
66
  'tabs.notify': '通知',
57
67
  'tabs.health': '健康诊断',
58
68
  'tabs.usage': '模型统计',
59
- 'overview.container': '容器信息',
69
+ 'overview.container': '进程与运行环境',
60
70
  'overview.errors': '报错信息',
61
71
  'tabs.backup': '备份维护',
62
72
  'tabs.restart': '重启',
@@ -141,6 +151,18 @@ window.__ModuleLoader__.load({
141
151
  'update.failAddingToRoot': 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)',
142
152
  'update.failNotWorkspace': 'profile 不是 pnpm workspace 却传入了 -w',
143
153
  'update.failIgnoredBuilds': '依赖构建脚本被 pnpm 默认拦截,无法完成升级',
154
+ 'env.kind.docker': 'Docker 容器',
155
+ 'env.kind.container': '容器(containerd/Kubernetes)',
156
+ 'env.kind.systemd': 'systemd 服务',
157
+ 'env.kind.pm2': 'pm2',
158
+ 'env.kind.supervisord': 'supervisord',
159
+ 'env.kind.kubernetes': 'Kubernetes',
160
+ 'env.kind.declared': '外部进程管理器(手动指定)',
161
+ 'update.manualConfirmTitle': '升级前请确认:当前疑似手动启动环境',
162
+ 'update.manualConfirm': '未检测到 Docker/systemd/pm2 等进程管理器。升级完成后 DSH 不会自动重启——新版本写入磁盘,但当前进程继续运行旧版本;你需要手动关闭并重新运行 dsh 才能启用新版本。',
163
+ 'update.manualProceed': '仍要升级',
164
+ 'update.manualRestartTitle': '升级完成,需要手动重启',
165
+ 'update.manualRestartBody': '新版本已安装,但当前进程仍在运行旧版本。请在运行 dsh 的终端窗口按 Ctrl+C(或直接关闭窗口),然后重新启动 dsh。',
144
166
  'restart.title': '服务重启',
145
167
  'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
146
168
  'restart.button': '重启 dsh web',
@@ -151,6 +173,8 @@ window.__ModuleLoader__.load({
151
173
  'restart.sent': '重启指令已发出。',
152
174
  'restart.sentHint': '页面连接即将断开,服务恢复后将自动刷新。',
153
175
  'restart.idleHint': '当前没有检测到运行中的工作。确认后将断开连接,等待服务自动重启。',
176
+ 'restart.manualWarn': '当前疑似终端手动启动环境:确认后进程将退出且不会自动拉起,需要你手动重新运行 dsh。',
177
+ 'restart.sentManualHint': '当前为手动启动环境,服务不会自动拉起。请在原终端重新运行 dsh;服务起来后刷新此页面即可恢复。',
154
178
  'restart.navToggle': '设置页左列显示「重启」入口',
155
179
  'restart.navToggleHint': '默认关闭;开启后在设置页左侧标签列底部显示快捷重启入口',
156
180
  'activity.agent': 'Agent',
@@ -228,6 +252,8 @@ window.__ModuleLoader__.load({
228
252
  'health.title': 'Health',
229
253
  'health.uptime': 'Uptime',
230
254
  'health.rss': 'Memory RSS',
255
+ 'health.platform': 'Platform',
256
+ 'health.nodeVersion': 'Node version',
231
257
  'health.liveSessions': 'Live sessions',
232
258
  'health.persistedSessions': 'Persisted sessions',
233
259
  'health.activeAgents': 'Active agents',
@@ -251,6 +277,14 @@ window.__ModuleLoader__.load({
251
277
  'health.check.backup-storage': 'Backup storage',
252
278
  'health.check.tar': 'tar',
253
279
  'health.check.permissions': 'File permissions',
280
+ 'health.check.runtime-env': 'Runtime environment',
281
+ 'health.check.node-version': 'Node runtime',
282
+ 'health.detail.runtime-env.managed': 'Managed by {kind}; the process restarts automatically after exit',
283
+ 'health.detail.runtime-env.declared': 'Declared externally managed via DSH_SERVICE_RUNTIME_ENV',
284
+ 'health.detail.runtime-env.manual': 'Likely a manual terminal launch — nothing restarts the process after exit; one-click upgrade already keeps it running',
285
+ 'health.detail.runtime-env.unknown': 'No process manager detected, so automatic restart cannot be confirmed; set DSH_SERVICE_RUNTIME_ENV=managed if one exists',
286
+ 'health.detail.node-version.ok': '{version}, meets the >={required} requirement',
287
+ 'health.detail.node-version.warning': '{version} is below the required {required}.x; upgrading Node is recommended',
254
288
  'health.detail.session-storage.ok': 'Available, with {count} session snapshots',
255
289
  'health.detail.workspace-registry.ok': 'Available, with {count} workspaces',
256
290
  'health.detail.dsh-home.ok': 'Directory is accessible with mode {mode}',
@@ -264,7 +298,7 @@ window.__ModuleLoader__.load({
264
298
  'tabs.notify': 'Notifications',
265
299
  'tabs.health': 'Health',
266
300
  'tabs.usage': 'Models',
267
- 'overview.container': 'Container info',
301
+ 'overview.container': 'Process and runtime',
268
302
  'overview.errors': 'Errors',
269
303
  'tabs.backup': 'Backup',
270
304
  'tabs.restart': 'Restart',
@@ -349,6 +383,18 @@ window.__ModuleLoader__.load({
349
383
  'update.failAddingToRoot': 'pnpm refused to add at the workspace root (missing -w)',
350
384
  'update.failNotWorkspace': '-w was passed but the profile is not a pnpm workspace',
351
385
  'update.failIgnoredBuilds': 'Dependency build scripts are blocked by pnpm by default, so the upgrade could not finish',
386
+ 'env.kind.docker': 'Docker container',
387
+ 'env.kind.container': 'Container (containerd/Kubernetes)',
388
+ 'env.kind.systemd': 'systemd service',
389
+ 'env.kind.pm2': 'pm2',
390
+ 'env.kind.supervisord': 'supervisord',
391
+ 'env.kind.kubernetes': 'Kubernetes',
392
+ 'env.kind.declared': 'External process manager (declared manually)',
393
+ 'update.manualConfirmTitle': 'Confirm before upgrading: manual launch suspected',
394
+ 'update.manualConfirm': 'No process manager such as Docker/systemd/pm2 was detected. After the upgrade DSH will NOT restart automatically — the new version is written to disk while the current process keeps running the old one; close and rerun dsh manually to activate it.',
395
+ 'update.manualProceed': 'Upgrade anyway',
396
+ 'update.manualRestartTitle': 'Upgrade finished — manual restart required',
397
+ 'update.manualRestartBody': 'The new version is installed, but the current process still runs the old one. Press Ctrl+C in the terminal running dsh (or simply close the window), then start dsh again.',
352
398
  'restart.title': 'Service restart',
353
399
  'restart.description': 'Restart the dsh web process. Active work will be interrupted; persisted sessions can be resumed. You can also type /restart in a conversation.',
354
400
  'restart.button': 'Restart dsh web',
@@ -359,6 +405,8 @@ window.__ModuleLoader__.load({
359
405
  'restart.sent': 'Restart request sent.',
360
406
  'restart.sentHint': 'The connection will close shortly. The page will reload automatically after recovery.',
361
407
  'restart.idleHint': 'No active work was detected. Confirm to disconnect and wait for the service to restart.',
408
+ 'restart.manualWarn': 'This looks like a manual terminal launch: once confirmed the process exits and nothing restarts it — you must run dsh again yourself.',
409
+ 'restart.sentManualHint': 'This is a manual-launch environment, so the service will not come back on its own. Run dsh again in the original terminal; refresh this page once the service is up.',
362
410
  'restart.navToggle': 'Show "Restart" entry in settings left nav',
363
411
  'restart.navToggleHint': 'Off by default; when enabled, a quick-restart entry appears at the bottom of the settings left navigation',
364
412
  'activity.agent': 'Agent',
@@ -607,6 +655,53 @@ window.__ModuleLoader__.load({
607
655
  }, [])
608
656
  return snapshot
609
657
  }
658
+
659
+ // 运行环境(version RPC 随 instanceId 返回):概览展示、升级前置确认与重启警告共用。
660
+ // null = 宿主未返回该字段(旧版本)或尚未拉取,一切行为静默退回现状。
661
+ const runtimeEnvListeners = new Set()
662
+ let runtimeEnvState = null
663
+ const setRuntimeEnvState = (next) => {
664
+ runtimeEnvState = next
665
+ for (const listener of runtimeEnvListeners) listener(next)
666
+ }
667
+ const useRuntimeEnv = () => {
668
+ const [snapshot, setSnapshot] = useState(runtimeEnvState)
669
+ useEffect(() => {
670
+ runtimeEnvListeners.add(setSnapshot)
671
+ setSnapshot(runtimeEnvState)
672
+ return () => runtimeEnvListeners.delete(setSnapshot)
673
+ }, [])
674
+ return snapshot
675
+ }
676
+ // 宿主字段按形状校验后才入库:manualStartLikely 必须 boolean、supervisorKind 缺省或 string,
677
+ // 形状不对按「旧宿主无该字段」降级,绝不让坏值悄悄关掉确认门。
678
+ const applyVersionRuntimeEnv = (value) => {
679
+ const env = value ? value.runtimeEnv : undefined
680
+ if (env === null || typeof env !== 'object') return
681
+ if (typeof env.manualStartLikely !== 'boolean') return
682
+ if (env.supervisorKind !== undefined && env.supervisorKind !== null && typeof env.supervisorKind !== 'string') return
683
+ setRuntimeEnvState({ platform: typeof env.platform === 'string' ? env.platform : '', supervisorKind: env.supervisorKind === undefined || env.supervisorKind === null ? null : env.supervisorKind, manualStartLikely: env.manualStartLikely })
684
+ }
685
+ // version 快照全插件只取一次:ServicePanel 与左列入口无论谁先挂载都共享同一请求,
686
+ // 升级前取 instanceId/运行环境也复用它。失败清缓存,下一个消费者重试。
687
+ let versionSnapshotPromise = null
688
+ const fetchVersionSnapshot = () => {
689
+ if (versionSnapshotPromise === null) {
690
+ versionSnapshotPromise = ctx.connection.rpc.call('/dsh-service', 'version', {})
691
+ .then((res) => {
692
+ if (res && res.ok) applyVersionRuntimeEnv(res.value)
693
+ return res
694
+ })
695
+ .catch(() => {
696
+ versionSnapshotPromise = null
697
+ return null
698
+ })
699
+ }
700
+ return versionSnapshotPromise
701
+ }
702
+ // 升级执行中标志放在 factory 作用域:闭包状态挡不住同一 tick 的重入,跨渲染的新闭包
703
+ // 也各自持有独立的 false,只有插件级可变标志能同时覆盖两种情况。
704
+ let upgradeInFlight = false
610
705
  const checkRestart = async () => {
611
706
  setRestartFlow({ ...restartFlow, busy: true, error: null })
612
707
  try {
@@ -692,6 +787,7 @@ window.__ModuleLoader__.load({
692
787
  recoveryGeneration += 1
693
788
  recoveryListeners.clear()
694
789
  restartFlowListeners.clear()
790
+ runtimeEnvListeners.clear()
695
791
  updateListeners.clear()
696
792
  }, 'dsh-service recovery')
697
793
 
@@ -797,6 +893,11 @@ window.__ModuleLoader__.load({
797
893
  function RestartSection({ showNavToggle }) {
798
894
  const translate = useTranslation()
799
895
  const flow = useRestartFlow()
896
+ const runtimeEnv = useRuntimeEnv()
897
+ // 左列专属入口可能先于「服务控制」面板挂载:走共享 version 快照(与面板同一次请求)。
898
+ useEffect(() => {
899
+ fetchVersionSnapshot()
900
+ }, [])
800
901
  const [navEnabled, setNavEnabled] = useRestartNavEnabled()
801
902
  const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
802
903
  const danger = { ...btn, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', borderColor: 'var(--dsw-alias-state-error-primary)' }
@@ -807,12 +908,12 @@ window.__ModuleLoader__.load({
807
908
  const displaySurface = { background: 'var(--dsh-svc-surface-bg)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', padding: '10px' }
808
909
  const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
809
910
 
810
- // 重启后提示
911
+ // 重启后提示:手动启动环境不会自动拉起,等待文案换成手动指引。
811
912
  if (flow.stage === 2) {
812
913
  return React.createElement('div', { 'data-testid': 'restart-card', style: card },
813
914
  React.createElement('div', { style: sectionTitle }, translate('restart.title')),
814
915
  React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
815
- React.createElement('p', { style: hint }, translate('restart.sentHint')))
916
+ React.createElement('p', { style: hint }, translate(runtimeEnv !== null && runtimeEnv.manualStartLikely === true ? 'restart.sentManualHint' : 'restart.sentHint')))
816
917
  }
817
918
 
818
919
  const activityLabels = {
@@ -821,6 +922,10 @@ window.__ModuleLoader__.load({
821
922
  terminal: translate('activity.terminal'),
822
923
  }
823
924
  const activityItems = flow.activity && Array.isArray(flow.activity.items) ? flow.activity.items : []
925
+ // 疑似终端手动启动:确认前就把「退出后无人拉起」讲清楚,两段式确认的后果清单。
926
+ const manualWarn = runtimeEnv !== null && runtimeEnv.manualStartLikely === true
927
+ ? React.createElement('p', { 'data-testid': 'restart-manual-warn', style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-warn-primary)', margin: '6px 0 0' }) }, translate('restart.manualWarn'))
928
+ : null
824
929
  const activityWarning = flow.stage === 3
825
930
  ? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
826
931
  React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
@@ -840,6 +945,7 @@ window.__ModuleLoader__.load({
840
945
  React.createElement('div', { style: sectionTitle }, translate('restart.title')),
841
946
  React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
842
947
  React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
948
+ manualWarn,
843
949
  activityWarning,
844
950
  React.createElement('div', { style: row },
845
951
  flow.stage === 0
@@ -906,6 +1012,9 @@ window.__ModuleLoader__.load({
906
1012
  const [usageError, setUsageError] = useState(null)
907
1013
  const [upgradeBusy, setUpgradeBusy] = useState(false)
908
1014
  const [upgradeError, setUpgradeError] = useState(null)
1015
+ // 疑似手动启动环境的升级两段式:确认后果 → 仍要升级;成功后不自动退出,改示指引。
1016
+ const [upgradeManualConfirm, setUpgradeManualConfirm] = useState(false)
1017
+ const [upgradeManualPending, setUpgradeManualPending] = useState(false)
909
1018
  const [hoveredUsageSegment, setHoveredUsageSegment] = useState(null)
910
1019
  const [usageProject, setUsageProject] = useState('all')
911
1020
  const [modelErrorsOpen, setModelErrorsOpen] = useState(false)
@@ -916,16 +1025,18 @@ window.__ModuleLoader__.load({
916
1025
  const [channelOpen, setChannelOpen] = useState(false)
917
1026
  // 重启流程状态来自共享流(与设置页左列底部的专属入口同源)
918
1027
  const restartFlowState = useRestartFlow()
1028
+ const runtimeEnv = useRuntimeEnv()
919
1029
  const usageRequestPayload = { timezoneOffsetMinutes: new Date().getTimezoneOffset() }
920
1030
 
921
1031
  // 进入面板时拉取当前版本和健康快照;健康数据每 5 秒刷新,卸载即停止。
1032
+ // version 走全插件共享的缓存快照:无论哪个挂载点先到,都只有一次请求。
922
1033
  useEffect(() => {
923
- ctx.connection.rpc.call('/dsh-service', 'version', {}).then((res) => {
1034
+ fetchVersionSnapshot().then((res) => {
924
1035
  if (res && res.ok) {
925
1036
  setVersion(res.value.current)
926
1037
  setPluginVersion(res.value.pluginVersion || null)
927
1038
  }
928
- }).catch(() => {})
1039
+ })
929
1040
  }, [])
930
1041
  useEffect(() => {
931
1042
  let active = true
@@ -1173,10 +1284,25 @@ window.__ModuleLoader__.load({
1173
1284
  'ignored-builds': 'update.failIgnoredBuilds',
1174
1285
  }
1175
1286
  const upgradePlugin = async () => {
1176
- setUpgradeBusy(true)
1177
- setUpgradeError(null)
1287
+ if (upgradeInFlight) return
1288
+ upgradeInFlight = true
1178
1289
  try {
1179
- const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
1290
+ // check-update 先于 version 返回时按钮可能先出现:env 未知就等共享快照落地再判。
1291
+ let env = runtimeEnv
1292
+ if (env === null) {
1293
+ await fetchVersionSnapshot()
1294
+ env = runtimeEnvState
1295
+ }
1296
+ // 疑似终端手动启动:升级成功不会自动重启,先两段式确认后果再动手(安全教义)。
1297
+ if (!upgradeManualConfirm && env !== null && env.manualStartLikely === true) {
1298
+ setUpgradeError(null)
1299
+ setUpgradeManualConfirm(true)
1300
+ return
1301
+ }
1302
+ setUpgradeManualConfirm(false)
1303
+ setUpgradeBusy(true)
1304
+ setUpgradeError(null)
1305
+ const versionRes = await fetchVersionSnapshot()
1180
1306
  const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
1181
1307
  const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
1182
1308
  if (!res || res.ok === false) {
@@ -1190,6 +1316,11 @@ window.__ModuleLoader__.load({
1190
1316
  }
1191
1317
  return
1192
1318
  }
1319
+ if (res.value && res.value.requiresManualRestart === true) {
1320
+ // 宿主保持运行(没有 exit,就不会有新实例):不启动恢复轮询,改示手动重启指引。
1321
+ setUpgradeManualPending(true)
1322
+ return
1323
+ }
1193
1324
  if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
1194
1325
  startRecovery(previousInstanceId).catch(() => {})
1195
1326
  }
@@ -1198,6 +1329,7 @@ window.__ModuleLoader__.load({
1198
1329
  console.error('dsh-service: upgrade failed', detail || err)
1199
1330
  setUpgradeError(detail ? translate('update.upgradeErrorDetail', { detail }) : translate('update.upgradeError'))
1200
1331
  } finally {
1332
+ upgradeInFlight = false
1201
1333
  setUpgradeBusy(false)
1202
1334
  }
1203
1335
  }
@@ -1338,6 +1470,17 @@ window.__ModuleLoader__.load({
1338
1470
  }
1339
1471
  if (check.id === 'tar' && check.status === 'ok') return translate('health.detail.tar.ok')
1340
1472
  if (check.id === 'permissions') return translate(check.status === 'ok' ? 'health.detail.permissions.ok' : 'health.detail.permissions.warning', { count: detail || '0' })
1473
+ if (check.id === 'runtime-env') {
1474
+ if (detail === 'manual') return translate('health.detail.runtime-env.manual')
1475
+ if (detail === 'declared') return translate('health.detail.runtime-env.declared')
1476
+ if (detail === 'unknown') return translate('health.detail.runtime-env.unknown')
1477
+ // 已识别的管理器 kind:复用概览运行环境的标签词典。
1478
+ return translate('health.detail.runtime-env.managed', { kind: translate(`env.kind.${detail}`) })
1479
+ }
1480
+ if (check.id === 'node-version') {
1481
+ const [version = '', required = ''] = detail.split(':')
1482
+ return translate(check.status === 'ok' ? 'health.detail.node-version.ok' : 'health.detail.node-version.warning', { version, required })
1483
+ }
1341
1484
  return translate('health.detail.generic', { status: translate(`health.status.${check.status}`) })
1342
1485
  }
1343
1486
  const summaryItems = (totals) => [
@@ -1440,10 +1583,17 @@ window.__ModuleLoader__.load({
1440
1583
  : React.createElement('p', { style: hint }, usageError || translate('usage.empty')),
1441
1584
  React.createElement('div', { style: row }, React.createElement('button', { style: neutral, 'data-variant': 'neutral', onClick: refreshUsage, disabled: usageBusy }, translate(usageBusy ? 'usage.refreshing' : 'usage.refresh'))))
1442
1585
 
1586
+ // 平台标签:process.platform 映射为常见系统名,arch 跟在后面(均为专有名词,不走词典)。
1587
+ const platformNames = { win32: 'Windows', darwin: 'macOS', linux: 'Linux', freebsd: 'FreeBSD', openbsd: 'OpenBSD' }
1588
+ const platformLabel = (health && typeof health.platform === 'string' && health.platform)
1589
+ ? `${platformNames[health.platform] || health.platform}${typeof health.arch === 'string' && health.arch ? ` · ${health.arch}` : ''}`
1590
+ : '—'
1443
1591
  const containerInfoBlock = React.createElement('div', { key: 'container-info', style: { marginTop: '18px' } },
1444
1592
  React.createElement('div', { style: sectionTitle }, translate('overview.container')),
1445
1593
  health
1446
1594
  ? React.createElement('div', { 'data-testid': 'health-display', style: Object.assign({}, displaySurface, { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '8px' }) },
1595
+ metric('health.platform', platformLabel),
1596
+ metric('health.nodeVersion', typeof health.nodeVersion === 'string' && health.nodeVersion ? health.nodeVersion : '—'),
1447
1597
  metric('health.uptime', formatUptime(health.uptimeSeconds)),
1448
1598
  metric('health.rss', formatBytes(health.rssBytes)),
1449
1599
  metric('health.liveSessions', String(health.liveSessions)),
@@ -1473,7 +1623,7 @@ window.__ModuleLoader__.load({
1473
1623
  ? React.createElement('div', { 'data-testid': 'health-check-list', style: Object.assign({}, displaySurface, { marginTop: '10px', padding: '8px 10px' }) },
1474
1624
  diagnostics.checks.map((check, index) => React.createElement('div', { key: check.id, style: { display: 'flex', justifyContent: 'space-between', gap: '14px', fontSize: '12px', padding: '9px 2px', borderTop: index === 0 ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1475
1625
  React.createElement('span', null, translate(`health.check.${check.id}`)),
1476
- React.createElement('span', { style: { color: check.status === 'ok' ? 'var(--dsw-alias-state-success-primary)' : check.status === 'warning' ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)', textAlign: 'right' } }, diagnosticDetail(check)))))
1626
+ React.createElement('span', { style: { color: check.status === 'ok' ? 'var(--dsw-alias-state-success-primary)' : check.status === 'warning' ? 'var(--dsw-alias-state-warn-primary)' : check.status === 'info' ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-state-error-primary)', textAlign: 'right' } }, diagnosticDetail(check)))))
1477
1627
  : null)
1478
1628
 
1479
1629
  const permissionAbnormal = permissions && permissions.supported === true
@@ -1602,9 +1752,11 @@ window.__ModuleLoader__.load({
1602
1752
  const dshUpdate = updateInfo?.dsh
1603
1753
  const dshExpandable = dshUpdate && dshUpdate.status !== 'unpublished' && dshUpdate.status !== 'unavailable' && !dshUpdate.upToDate
1604
1754
  const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
1605
- const pluginAction = pluginUpdate
1755
+ // 确认后果或已装好待手动重启期间收起升级按钮,避免重复触发或撞 no-newer-version 守卫。
1756
+ const pluginAction = pluginUpdate && !upgradeManualConfirm && !upgradeManualPending
1606
1757
  ? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
1607
1758
  : null
1759
+ // 版本卡只放版本与升级:运行环境信息在健康诊断检查项与重启确认提示中呈现(用户复核口径)。
1608
1760
  const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
1609
1761
  React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
1610
1762
  React.createElement('div', { style: displaySurface },
@@ -1616,6 +1768,19 @@ window.__ModuleLoader__.load({
1616
1768
  React.createElement('div', null, translate('update.details.latest', { version: dshUpdate?.latest || '—' })),
1617
1769
  channelLines(translate, dshUpdate?.tags))
1618
1770
  : null,
1771
+ upgradeManualConfirm
1772
+ ? React.createElement('div', { 'data-testid': 'upgrade-manual-confirm', style: { marginTop: '10px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.08)', border: '1px solid rgba(211,51,51,0.3)' } },
1773
+ React.createElement('p', { style: { margin: '0 0 6px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } }, translate('update.manualConfirmTitle')),
1774
+ React.createElement('p', { style: Object.assign({}, hint, { margin: '0 0 8px' }) }, translate('update.manualConfirm')),
1775
+ React.createElement('div', { style: row },
1776
+ React.createElement('button', { style: dangerGhost, 'data-variant': 'danger', disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.manualProceed')),
1777
+ React.createElement('button', { style: ghost, disabled: upgradeBusy, onClick: () => setUpgradeManualConfirm(false) }, translate('restart.cancel'))))
1778
+ : null,
1779
+ upgradeManualPending
1780
+ ? React.createElement('div', { 'data-testid': 'upgrade-manual-pending', style: { marginTop: '10px', padding: '10px 12px', borderRadius: '6px', border: '1px solid var(--dsw-alias-state-warn-primary)', background: 'var(--dsw-alias-bg-layer-2)' } },
1781
+ React.createElement('p', { style: { margin: '0 0 4px', color: 'var(--dsw-alias-state-warn-primary)', fontSize: '13px', fontWeight: 650 } }, translate('update.manualRestartTitle')),
1782
+ React.createElement('p', { style: Object.assign({}, hint, { margin: 0 }) }, translate('update.manualRestartBody')))
1783
+ : null,
1619
1784
  upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
1620
1785
 
1621
1786
  // 重启区块复用共享组件(「重启」标签还承载设置页左列入口的显示开关;左侧入口默认关闭)
@@ -1659,7 +1824,8 @@ window.__ModuleLoader__.load({
1659
1824
  // 任务通知独立成顶部标签(v0.14 起不再混在概览里)
1660
1825
  const notifyBlock = React.createElement('div', null, notificationBlock)
1661
1826
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1662
- const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
1827
+ // advisory 警告(如手动启动环境的黄色提示)只做行内呈现,不点亮标签 与顶部服务控制提醒。
1828
+ const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && check.advisory !== true)) === true
1663
1829
  const tabWarnings = {
1664
1830
  overview: false,
1665
1831
  notify: false,
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 「服务控制」:版本信息 + 检查更新 + 一键重启 dsh web
3
3
  import { createHmac, randomBytes, randomUUID } from 'node:crypto'
4
4
  import { Buffer } from 'node:buffer'
5
- import { constants as fsConstants } from 'node:fs'
5
+ import { constants as fsConstants, existsSync, readFileSync } from 'node:fs'
6
6
  import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
7
7
  import { homedir } from 'node:os'
8
8
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
@@ -44,6 +44,13 @@ try {
44
44
  } catch (__) {}
45
45
  }
46
46
 
47
+ // Node 最低要求只读 package.json engines 一处(与版本号同源),启动时解析一次。
48
+ let requiredNodeMajor = 22
49
+ try {
50
+ const enginesNode = require('./package.json').engines?.node || ''
51
+ requiredNodeMajor = parseInt(enginesNode.replace(/[^0-9].*$/, ''), 10) || 22
52
+ } catch (_) {}
53
+
47
54
  function isSemverIdentifier(value) {
48
55
  if (value.length === 0) return false
49
56
  return [...value].every((char) => (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '-')
@@ -794,7 +801,8 @@ function classifyUpgradeFailure(output) {
794
801
  // 一键升级:走「dsh plugin --profile <p> add <pkg>@<版本>」,落在 DSH 真正读取的 profile
795
802
  // node_modules,替代旧的无升级语义的 npm install -g。命令、包名、版本全部宿主白名单常量。
796
803
  // 安全教义守卫:活动工作拒用、link/file 拒绝、latest 不高于当前版本拒绝(防降级)一律先于网络。
797
- async function upgradePlugin(ctx, dshHome) {
804
+ // runtimeEnv 来自 apply() 的 detectRuntimeEnv():疑似终端手动启动时安装成功不调度 exit(42)。
805
+ async function upgradePlugin(ctx, dshHome, runtimeEnv) {
798
806
  const activity = collectActiveWork(ctx)
799
807
  if (activity.hasActive) throw new Error('active-work')
800
808
 
@@ -849,6 +857,12 @@ async function upgradePlugin(ctx, dshHome) {
849
857
  : installed !== pluginVersion
850
858
  if (!advanced) throw new Error('upgrade-stale')
851
859
 
860
+ // 手动终端启动环境没有进程管理器拉起 exit(42):保持旧版本继续服务,把重启时机交给用户。
861
+ // requiresManualRestart 让客户端改走「手动重启指引」而不是恢复轮询(不会有新实例)。
862
+ if (runtimeEnv !== undefined && runtimeEnv !== null && runtimeEnv.manualStartLikely === true) {
863
+ return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed, requiresManualRestart: true }
864
+ }
865
+
852
866
  scheduleRestart(ctx)
853
867
  return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed }
854
868
  }
@@ -999,7 +1013,20 @@ async function repairPermissions(ctx, dshHome, plans, planId) {
999
1013
  return permissionSnapshot(ctx, dshHome, plans)
1000
1014
  }
1001
1015
 
1002
- async function collectDiagnostics(ctx, dshHome) {
1016
+ // 运行环境检查(v0.17):managed/declared ok;疑似手动启动 → warning 但带 advisory 标记
1017
+ //(黄色行内提示,不参与 overall 聚合、不点亮标签 ⚠ 与顶部提醒——用户复核口径);unknown →
1018
+ // info。detail 是客户端映射词典的令牌。
1019
+ function runtimeEnvCheck(runtimeEnv) {
1020
+ if (runtimeEnv === undefined || runtimeEnv === null) return null
1021
+ if (runtimeEnv.manualStartLikely === true) return { id: 'runtime-env', status: 'warning', detail: 'manual', advisory: true }
1022
+ if (runtimeEnv.supervisorKind === 'declared') return { id: 'runtime-env', status: 'ok', detail: 'declared' }
1023
+ if (typeof runtimeEnv.supervisorKind === 'string' && runtimeEnv.supervisorKind.length > 0) {
1024
+ return { id: 'runtime-env', status: 'ok', detail: runtimeEnv.supervisorKind }
1025
+ }
1026
+ return { id: 'runtime-env', status: 'info', detail: 'unknown' }
1027
+ }
1028
+
1029
+ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
1003
1030
  const checks = []
1004
1031
  const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
1005
1032
 
@@ -1027,7 +1054,8 @@ async function collectDiagnostics(ctx, dshHome) {
1027
1054
 
1028
1055
  try {
1029
1056
  const backups = await listBackups(dshHome)
1030
- add('backup-storage', backups.items.length === 0 ? 'warning' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
1057
+ // 没有备份不算警告(v0.14 口径的落地):空列表是信息级提示,不点亮标签 ⚠、不影响 overall。
1058
+ add('backup-storage', backups.items.length === 0 ? 'info' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
1031
1059
  } catch (error) { add('backup-storage', 'error', error?.code || error?.message || error) }
1032
1060
 
1033
1061
  const subprocess = ctx.get('subprocess')
@@ -1044,9 +1072,18 @@ async function collectDiagnostics(ctx, dshHome) {
1044
1072
  } catch (error) { add('permissions', 'warning', error?.message || error) }
1045
1073
  }
1046
1074
 
1075
+ const runtimeCheck = runtimeEnvCheck(runtimeEnv)
1076
+ if (runtimeCheck !== null) checks.push(runtimeCheck)
1077
+
1078
+ // Node 运行时版本检查:低于 package.json engines 的最低 major 时告警。
1079
+ // detail 形如 "v22.14.0:22"(当前版本:要求 major),客户端按 backup-storage 同款分隔符解析。
1080
+ const nodeMajor = parseInt(process.versions.node, 10)
1081
+ add('node-version', Number.isFinite(nodeMajor) && nodeMajor >= requiredNodeMajor ? 'ok' : 'warning', `${process.version}:${requiredNodeMajor}`)
1082
+
1047
1083
  let status = 'ok'
1084
+ // advisory 警告(手动启动环境的黄色提示)只做行内呈现:不把 overall 拉成 warning。
1048
1085
  if (checks.some((check) => check.status === 'error')) status = 'error'
1049
- else if (checks.some((check) => check.status === 'warning')) status = 'warning'
1086
+ else if (checks.some((check) => check.status === 'warning' && check.advisory !== true)) status = 'warning'
1050
1087
  return { status, checkedAt: Date.now(), checks }
1051
1088
  }
1052
1089
 
@@ -1060,6 +1097,9 @@ async function collectHealth(ctx) {
1060
1097
  return {
1061
1098
  uptimeSeconds: process.uptime(),
1062
1099
  rssBytes: memory.rss,
1100
+ platform: process.platform,
1101
+ arch: process.arch,
1102
+ nodeVersion: process.version,
1063
1103
  liveSessions: sessionsService === undefined ? 0 : sessionsService.list().length,
1064
1104
  persistedSessions: sessionRecords.filter((record) => record.persisted === true).length,
1065
1105
  activeAgents: activity.items.filter((item) => item.type === 'agent').length,
@@ -1127,6 +1167,50 @@ function collectActiveWork(ctx) {
1127
1167
  return { hasActive: items.length > 0, items }
1128
1168
  }
1129
1169
 
1170
+ // ---- 运行环境探测(v0.16:Windows 终端手动启动场景,用户点名)----
1171
+ // 重启 = process.exit(42),依赖外层进程管理器拉起(见 AGENTS.md);PowerShell/CMD 手动
1172
+ // 启动时没有任何东西会把它拉起来。这里只用跨平台被动信号(env + 固定路径 fs + Node 自带
1173
+ // isTTY,零 spawn、浏览器零输入)把环境分成三态:
1174
+ // supervisorKind 非 null —— 检测到管理器,exit 后会被自动拉起,维持自动重启;
1175
+ // manualStartLikely=true —— 无管理器且 stdin/stdout 双 TTY(终端直启特征),升级后宿主
1176
+ // 不自动退出,客户端改示手动重启指引;
1177
+ // 其余 —— unknown(输出重定向、NSSM/WinSW 等包装器),维持现状不折腾。
1178
+ // 探测不到的包装器可用 DSH_SERVICE_RUNTIME_ENV=managed|manual 由用户显式声明。
1179
+ const RUNTIME_SUPERVISOR_ENV = [
1180
+ ['pm2', ['pm_id', 'PM2_HOME', 'pm_uptime']],
1181
+ ['systemd', ['INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET']],
1182
+ ['supervisord', ['SUPERVISOR_ENABLED', 'SUPERVISOR_PROCESS_NAME']],
1183
+ ['kubernetes', ['KUBERNETES_SERVICE_HOST']],
1184
+ ]
1185
+
1186
+ function detectRuntimeEnv(options = {}) {
1187
+ const env = options.env ?? process.env
1188
+ const platform = options.platform ?? process.platform
1189
+ const stdinIsTTY = options.stdinIsTTY ?? (process.stdin ? process.stdin.isTTY === true : false)
1190
+ const stdoutIsTTY = options.stdoutIsTTY ?? (process.stdout ? process.stdout.isTTY === true : false)
1191
+ // 默认探测器只在未被注入时执行;win32 直接短路(/.dockerenv 与 /proc 不存在),单测全部显式传原语。
1192
+ const dockerEnvExists = options.dockerEnvExists ?? (platform === 'win32' ? false : (() => { try { return existsSync('/.dockerenv') } catch (_) { return false } })())
1193
+ const cgroupText = options.cgroupText ?? (platform === 'win32' ? '' : (() => { try { return readFileSync('/proc/1/cgroup', 'utf8') } catch (_) { return '' } })())
1194
+
1195
+ const forced = typeof env.DSH_SERVICE_RUNTIME_ENV === 'string' ? env.DSH_SERVICE_RUNTIME_ENV.trim().toLowerCase() : ''
1196
+ if (forced === 'manual') return { platform, supervisorKind: null, manualStartLikely: true }
1197
+ if (forced === 'managed') return { platform, supervisorKind: 'declared', manualStartLikely: false }
1198
+
1199
+ for (const [kind, keys] of RUNTIME_SUPERVISOR_ENV) {
1200
+ if (keys.some((key) => env[key] !== undefined && env[key] !== '')) {
1201
+ return { platform, supervisorKind: kind, manualStartLikely: false }
1202
+ }
1203
+ }
1204
+ // /.dockerenv 与 /proc 只在 POSIX 内核上存在;win32 原生进程不做这两个探测。
1205
+ if (platform !== 'win32') {
1206
+ if (dockerEnvExists === true) return { platform, supervisorKind: 'docker', manualStartLikely: false }
1207
+ if (/docker/i.test(cgroupText)) return { platform, supervisorKind: 'docker', manualStartLikely: false }
1208
+ if (/containerd|kubepods|lxc|podman/i.test(cgroupText)) return { platform, supervisorKind: 'container', manualStartLikely: false }
1209
+ }
1210
+ if (stdinIsTTY === true && stdoutIsTTY === true) return { platform, supervisorKind: null, manualStartLikely: true }
1211
+ return { platform, supervisorKind: null, manualStartLikely: false }
1212
+ }
1213
+
1130
1214
  function scheduleRestart(ctx) {
1131
1215
  const doExit = () => {
1132
1216
  try {
@@ -1143,6 +1227,8 @@ function scheduleRestart(ctx) {
1143
1227
 
1144
1228
  function apply(ctx) {
1145
1229
  const dshHome = resolveDshHome()
1230
+ // 进程运行环境在生命周期内不变:挂载时探测一次,version RPC 与升级分支共用。
1231
+ const runtimeEnv = detectRuntimeEnv()
1146
1232
  const permissionPlans = new Map()
1147
1233
  const downloadTokens = new Map()
1148
1234
  let usageIndexPromise = loadUsageIndex(dshHome)
@@ -1205,7 +1291,9 @@ function apply(ctx) {
1205
1291
  // 合法示例:channel=/dsh-service,endpoint=version/check-update/activity/web。
1206
1292
  ctx.connection.rpc.handle('/dsh-service', async (endpoint, payload) => {
1207
1293
  if (endpoint === 'version') {
1208
- return { ok: true, value: { current: dshVersion, pluginVersion, instanceId } }
1294
+ // runtimeEnv 随进程身份(instanceId)一起返回:概览展示、升级前置确认与重启警告共用,
1295
+ // 客户端对缺字段的老宿主静默降级。
1296
+ return { ok: true, value: { current: dshVersion, pluginVersion, instanceId, runtimeEnv } }
1209
1297
  }
1210
1298
 
1211
1299
  if (endpoint === 'check-update') {
@@ -1243,7 +1331,7 @@ function apply(ctx) {
1243
1331
 
1244
1332
  if (endpoint === 'upgrade') {
1245
1333
  try {
1246
- return { ok: true, value: await upgradePlugin(ctx, dshHome) }
1334
+ return { ok: true, value: await upgradePlugin(ctx, dshHome, runtimeEnv) }
1247
1335
  } catch (error) {
1248
1336
  return { ok: false, error: error?.message || String(error) }
1249
1337
  }
@@ -1263,7 +1351,7 @@ function apply(ctx) {
1263
1351
 
1264
1352
  if (endpoint === 'diagnostics') {
1265
1353
  try {
1266
- return { ok: true, value: await collectDiagnostics(ctx, dshHome) }
1354
+ return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
1267
1355
  } catch (error) {
1268
1356
  return { ok: false, error: error?.message || String(error) }
1269
1357
  }
@@ -1393,5 +1481,5 @@ function apply(ctx) {
1393
1481
  }, { authority: 'loopback' })
1394
1482
  }
1395
1483
 
1396
- export { apply, inject, name }
1397
- export default { apply, inject, name }
1484
+ export { apply, detectRuntimeEnv, inject, name, runtimeEnvCheck }
1485
+ export default { apply, detectRuntimeEnv, inject, name, runtimeEnvCheck }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.15.0",
3
+ "version": "0.17.1",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {