jishushell 0.4.24 → 0.5.15

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.
Files changed (281) hide show
  1. package/INSTALL-NOTICE +11 -0
  2. package/apps/anythingllm-container.yaml +287 -0
  3. package/apps/browserless-chromium-container.yaml +90 -0
  4. package/apps/filebrowser-container.yaml +163 -0
  5. package/apps/hermes-container.yaml +36 -2
  6. package/apps/ollama-binary.yaml +91 -90
  7. package/apps/ollama-cpu-container.yaml +8 -1
  8. package/apps/ollama-with-hollama-binary.yaml +91 -90
  9. package/apps/openclaw-binary.yaml +38 -1
  10. package/apps/openclaw-container.yaml +45 -2
  11. package/apps/openclaw-with-ollama-container.yaml +11 -2
  12. package/apps/openclaw-with-searxng-container.yaml +26 -2
  13. package/apps/openwebui-container.yaml +45 -1
  14. package/apps/playwright-container.yaml +7 -1
  15. package/apps/searxng-container.yaml +58 -7
  16. package/apps/weknora-container.yaml +471 -0
  17. package/dist/cli/app.js +79 -9
  18. package/dist/cli/app.js.map +1 -1
  19. package/dist/cli/doctor.d.ts +12 -12
  20. package/dist/cli/doctor.js +242 -55
  21. package/dist/cli/doctor.js.map +1 -1
  22. package/dist/cli/llm.d.ts +4 -3
  23. package/dist/cli/llm.js +4 -3
  24. package/dist/cli/llm.js.map +1 -1
  25. package/dist/cli/panel.d.ts +6 -5
  26. package/dist/cli/panel.js +10 -9
  27. package/dist/cli/panel.js.map +1 -1
  28. package/dist/config.d.ts +19 -0
  29. package/dist/config.js +99 -1
  30. package/dist/config.js.map +1 -1
  31. package/dist/control.d.ts +7 -6
  32. package/dist/control.js +7 -6
  33. package/dist/control.js.map +1 -1
  34. package/dist/install.js +3 -3
  35. package/dist/install.js.map +1 -1
  36. package/dist/routes/agent-apps.d.ts +1 -1
  37. package/dist/routes/agent-apps.js +1 -1
  38. package/dist/routes/apps.js +44 -11
  39. package/dist/routes/apps.js.map +1 -1
  40. package/dist/routes/auth.js +5 -2
  41. package/dist/routes/auth.js.map +1 -1
  42. package/dist/routes/backup.js +64 -11
  43. package/dist/routes/backup.js.map +1 -1
  44. package/dist/routes/external-mounts.d.ts +17 -0
  45. package/dist/routes/external-mounts.js +73 -0
  46. package/dist/routes/external-mounts.js.map +1 -0
  47. package/dist/routes/file-mounts.d.ts +13 -0
  48. package/dist/routes/file-mounts.js +90 -0
  49. package/dist/routes/file-mounts.js.map +1 -0
  50. package/dist/routes/files-organize.d.ts +28 -0
  51. package/dist/routes/files-organize.js +167 -0
  52. package/dist/routes/files-organize.js.map +1 -0
  53. package/dist/routes/files.d.ts +31 -0
  54. package/dist/routes/files.js +321 -0
  55. package/dist/routes/files.js.map +1 -0
  56. package/dist/routes/instances.js +826 -17
  57. package/dist/routes/instances.js.map +1 -1
  58. package/dist/routes/internal.d.ts +2 -0
  59. package/dist/routes/internal.js +59 -0
  60. package/dist/routes/internal.js.map +1 -0
  61. package/dist/routes/llm.js +24 -35
  62. package/dist/routes/llm.js.map +1 -1
  63. package/dist/routes/setup.js +10 -10
  64. package/dist/routes/setup.js.map +1 -1
  65. package/dist/routes/system.js +1 -1
  66. package/dist/routes/system.js.map +1 -1
  67. package/dist/routes/webdav.d.ts +17 -0
  68. package/dist/routes/webdav.js +114 -0
  69. package/dist/routes/webdav.js.map +1 -0
  70. package/dist/server.d.ts +9 -0
  71. package/dist/server.js +751 -20
  72. package/dist/server.js.map +1 -1
  73. package/dist/services/agent-apps/catalog.js +4 -3
  74. package/dist/services/agent-apps/catalog.js.map +1 -1
  75. package/dist/services/agent-apps/index.d.ts +1 -1
  76. package/dist/services/agent-apps/index.js +1 -1
  77. package/dist/services/agent-apps/installers/adapter.d.ts +1 -1
  78. package/dist/services/agent-apps/installers/adapter.js +1 -1
  79. package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
  80. package/dist/services/agent-apps/installers/shell-script.js +3 -3
  81. package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
  82. package/dist/services/agent-apps/types.d.ts +2 -2
  83. package/dist/services/agent-apps/types.js +1 -1
  84. package/dist/services/app/app-compiler.d.ts +1 -1
  85. package/dist/services/app/app-compiler.js +5 -5
  86. package/dist/services/app/app-compiler.js.map +1 -1
  87. package/dist/services/app/app-manager.d.ts +25 -1
  88. package/dist/services/app/app-manager.js +829 -150
  89. package/dist/services/app/app-manager.js.map +1 -1
  90. package/dist/services/app/custom-manager.js.map +1 -1
  91. package/dist/services/app/hermes-agent-manager.js +7 -4
  92. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  93. package/dist/services/app/ollama-manager.js +1 -1
  94. package/dist/services/app/ollama-manager.js.map +1 -1
  95. package/dist/services/app/openclaw-manager.js +20 -3
  96. package/dist/services/app/openclaw-manager.js.map +1 -1
  97. package/dist/services/app/platform-transform.d.ts +32 -0
  98. package/dist/services/app/platform-transform.js +65 -0
  99. package/dist/services/app/platform-transform.js.map +1 -0
  100. package/dist/services/app/provide-resolver.d.ts +29 -0
  101. package/dist/services/app/provide-resolver.js +112 -0
  102. package/dist/services/app/provide-resolver.js.map +1 -0
  103. package/dist/services/app-passwords.d.ts +61 -0
  104. package/dist/services/app-passwords.js +173 -0
  105. package/dist/services/app-passwords.js.map +1 -0
  106. package/dist/services/backup-manager.d.ts +11 -0
  107. package/dist/services/backup-manager.js +177 -4
  108. package/dist/services/backup-manager.js.map +1 -1
  109. package/dist/services/capability-endpoint-validator.d.ts +41 -0
  110. package/dist/services/capability-endpoint-validator.js +104 -0
  111. package/dist/services/capability-endpoint-validator.js.map +1 -0
  112. package/dist/services/capability-health.d.ts +16 -0
  113. package/dist/services/capability-health.js +121 -0
  114. package/dist/services/capability-health.js.map +1 -0
  115. package/dist/services/capability-registry.d.ts +106 -0
  116. package/dist/services/capability-registry.js +313 -0
  117. package/dist/services/capability-registry.js.map +1 -0
  118. package/dist/services/connection-apply.d.ts +91 -0
  119. package/dist/services/connection-apply.js +475 -0
  120. package/dist/services/connection-apply.js.map +1 -0
  121. package/dist/services/connection-resolver.d.ts +65 -0
  122. package/dist/services/connection-resolver.js +281 -0
  123. package/dist/services/connection-resolver.js.map +1 -0
  124. package/dist/services/connection-transactor.d.ts +39 -0
  125. package/dist/services/connection-transactor.js +351 -0
  126. package/dist/services/connection-transactor.js.map +1 -0
  127. package/dist/services/external-mounts.d.ts +40 -0
  128. package/dist/services/external-mounts.js +187 -0
  129. package/dist/services/external-mounts.js.map +1 -0
  130. package/dist/services/files-manager.d.ts +252 -0
  131. package/dist/services/files-manager.js +1075 -0
  132. package/dist/services/files-manager.js.map +1 -0
  133. package/dist/services/files-mounts.d.ts +42 -0
  134. package/dist/services/files-mounts.js +207 -0
  135. package/dist/services/files-mounts.js.map +1 -0
  136. package/dist/services/instance-manager.d.ts +13 -0
  137. package/dist/services/instance-manager.js +138 -46
  138. package/dist/services/instance-manager.js.map +1 -1
  139. package/dist/services/llm-proxy/index.d.ts +16 -2
  140. package/dist/services/llm-proxy/index.js +48 -44
  141. package/dist/services/llm-proxy/index.js.map +1 -1
  142. package/dist/services/llm-proxy/probe.d.ts +6 -0
  143. package/dist/services/llm-proxy/probe.js +85 -0
  144. package/dist/services/llm-proxy/probe.js.map +1 -0
  145. package/dist/services/llm-proxy/ssrf.d.ts +1 -0
  146. package/dist/services/llm-proxy/ssrf.js +24 -9
  147. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  148. package/dist/services/nomad-manager.d.ts +4 -0
  149. package/dist/services/nomad-manager.js +428 -35
  150. package/dist/services/nomad-manager.js.map +1 -1
  151. package/dist/services/organize/applier.d.ts +46 -0
  152. package/dist/services/organize/applier.js +218 -0
  153. package/dist/services/organize/applier.js.map +1 -0
  154. package/dist/services/organize/rules.d.ts +57 -0
  155. package/dist/services/organize/rules.js +286 -0
  156. package/dist/services/organize/rules.js.map +1 -0
  157. package/dist/services/organize/scanner.d.ts +50 -0
  158. package/dist/services/organize/scanner.js +366 -0
  159. package/dist/services/organize/scanner.js.map +1 -0
  160. package/dist/services/organize/store.d.ts +14 -0
  161. package/dist/services/organize/store.js +82 -0
  162. package/dist/services/organize/store.js.map +1 -0
  163. package/dist/services/panel-manager.js +20 -1
  164. package/dist/services/panel-manager.js.map +1 -1
  165. package/dist/services/process-manager.js +4 -3
  166. package/dist/services/process-manager.js.map +1 -1
  167. package/dist/services/runtime/adapters/hermes.d.ts +30 -1
  168. package/dist/services/runtime/adapters/hermes.js +219 -6
  169. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  170. package/dist/services/runtime/adapters/openclaw-mcporter.d.ts +45 -0
  171. package/dist/services/runtime/adapters/openclaw-mcporter.js +108 -0
  172. package/dist/services/runtime/adapters/openclaw-mcporter.js.map +1 -0
  173. package/dist/services/runtime/adapters/openclaw-routes.d.ts +8 -2
  174. package/dist/services/runtime/adapters/openclaw-routes.js +68 -0
  175. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -1
  176. package/dist/services/runtime/adapters/openclaw.d.ts +177 -0
  177. package/dist/services/runtime/adapters/openclaw.js +1171 -11
  178. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  179. package/dist/services/runtime/instance.d.ts +1 -1
  180. package/dist/services/runtime/instance.js +1 -1
  181. package/dist/services/runtime/instance.js.map +1 -1
  182. package/dist/services/runtime/mcp-shims/anythingllm-shim.d.ts +46 -0
  183. package/dist/services/runtime/mcp-shims/anythingllm-shim.js +281 -0
  184. package/dist/services/runtime/mcp-shims/anythingllm-shim.js.map +1 -0
  185. package/dist/services/runtime/mcp-shims/drive-shim.d.ts +54 -0
  186. package/dist/services/runtime/mcp-shims/drive-shim.js +489 -0
  187. package/dist/services/runtime/mcp-shims/drive-shim.js.map +1 -0
  188. package/dist/services/runtime/mcp-shims/firewall.d.ts +26 -0
  189. package/dist/services/runtime/mcp-shims/firewall.js +129 -0
  190. package/dist/services/runtime/mcp-shims/firewall.js.map +1 -0
  191. package/dist/services/runtime/mcp-shims/searxng-shim.d.ts +27 -0
  192. package/dist/services/runtime/mcp-shims/searxng-shim.js +125 -0
  193. package/dist/services/runtime/mcp-shims/searxng-shim.js.map +1 -0
  194. package/dist/services/runtime/mcp-shims/write-mcp-entry.d.ts +83 -0
  195. package/dist/services/runtime/mcp-shims/write-mcp-entry.js +127 -0
  196. package/dist/services/runtime/mcp-shims/write-mcp-entry.js.map +1 -0
  197. package/dist/services/runtime/migrations.d.ts +8 -0
  198. package/dist/services/runtime/migrations.js +100 -0
  199. package/dist/services/runtime/migrations.js.map +1 -1
  200. package/dist/services/runtime/types.d.ts +46 -0
  201. package/dist/services/setup-manager.js +99 -24
  202. package/dist/services/setup-manager.js.map +1 -1
  203. package/dist/services/suggestions.d.ts +27 -0
  204. package/dist/services/suggestions.js +133 -0
  205. package/dist/services/suggestions.js.map +1 -0
  206. package/dist/services/task-registry.js +4 -2
  207. package/dist/services/task-registry.js.map +1 -1
  208. package/dist/services/telemetry/device-fingerprint.d.ts +1 -1
  209. package/dist/services/telemetry/device-fingerprint.js +1 -1
  210. package/dist/services/types-shim.d.ts +16 -0
  211. package/dist/services/types-shim.js +2 -0
  212. package/dist/services/types-shim.js.map +1 -0
  213. package/dist/services/webdav/server.d.ts +24 -0
  214. package/dist/services/webdav/server.js +420 -0
  215. package/dist/services/webdav/server.js.map +1 -0
  216. package/dist/services/webdav/xml-builder.d.ts +73 -0
  217. package/dist/services/webdav/xml-builder.js +156 -0
  218. package/dist/services/webdav/xml-builder.js.map +1 -0
  219. package/dist/services/workspace-builder.d.ts +29 -0
  220. package/dist/services/workspace-builder.js +188 -0
  221. package/dist/services/workspace-builder.js.map +1 -0
  222. package/dist/types.d.ts +231 -1
  223. package/dist/utils/instance-lock.d.ts +22 -0
  224. package/dist/utils/instance-lock.js +48 -0
  225. package/dist/utils/instance-lock.js.map +1 -0
  226. package/dist/utils/path-locks.d.ts +30 -0
  227. package/dist/utils/path-locks.js +63 -0
  228. package/dist/utils/path-locks.js.map +1 -0
  229. package/dist/utils/path-safety.d.ts +41 -0
  230. package/dist/utils/path-safety.js +119 -0
  231. package/dist/utils/path-safety.js.map +1 -0
  232. package/dist/utils/safe-json.js +55 -22
  233. package/dist/utils/safe-json.js.map +1 -1
  234. package/dist/utils/safe-write.d.ts +24 -0
  235. package/dist/utils/safe-write.js +82 -0
  236. package/dist/utils/safe-write.js.map +1 -0
  237. package/install/jishu-install.sh +323 -27
  238. package/install/jishu-uninstall.sh +353 -20
  239. package/package.json +18 -1
  240. package/public/assets/Dashboard-BdWPtroF.js +1 -0
  241. package/public/assets/{HermesChatPanel-mFSureyc.js → HermesChatPanel-B_2HlVBQ.js} +1 -1
  242. package/public/assets/HermesConfigForm-DVlhg3WV.js +4 -0
  243. package/public/assets/{InitPassword-CVA8wQA6.js → InitPassword-D7glTExX.js} +1 -1
  244. package/public/assets/InstanceDetail-CxSy2cpe.js +92 -0
  245. package/public/assets/{Login-BWsZH2mu.js → Login-Cfr5c2sv.js} +1 -1
  246. package/public/assets/NewInstance-BIYDmJis.js +1 -0
  247. package/public/assets/ProviderRecommendations-BuRnvRcI.js +1 -0
  248. package/public/assets/Settings-Cc-tYBil.js +1 -0
  249. package/public/assets/Setup-lGZEk5jq.js +1 -0
  250. package/public/assets/{WeixinLoginPanel-CnjR8xMu.js → WeixinLoginPanel-CoGqzxeV.js} +2 -2
  251. package/public/assets/index-87IJXG-w.css +1 -0
  252. package/public/assets/index-BZc5zH7u.js +19 -0
  253. package/public/assets/providers-DtNXh9JD.js +1 -0
  254. package/public/assets/registry-BWnkJgZ1.js +2 -0
  255. package/public/assets/{usePolling-Do5Erqm_.js → usePolling-CwwT9KrC.js} +1 -1
  256. package/public/assets/{vendor-i18n-ucpM0OR0.js → vendor-i18n-y9V7Sfuu.js} +1 -1
  257. package/public/assets/{vendor-react-Bk1hRGiY.js → vendor-react-BWrEVJVb.js} +6 -6
  258. package/public/index.html +4 -4
  259. package/scripts/check-app-spec.mjs +457 -0
  260. package/scripts/check-i18n.mjs +154 -0
  261. package/scripts/check-new-file-tests.mjs +230 -0
  262. package/scripts/check-quarantine-expiry.mjs +105 -0
  263. package/scripts/perf/README.md +49 -0
  264. package/scripts/perf/auth.js +99 -0
  265. package/scripts/perf/config.js +63 -0
  266. package/scripts/perf/instances.js +143 -0
  267. package/scripts/perf/proxy.js +96 -0
  268. package/scripts/run.sh +4 -4
  269. package/scripts/smoke/files-w1.sh +142 -0
  270. package/scripts/smoke-backend.mjs +122 -0
  271. package/scripts/smoke-post-publish.mjs +346 -0
  272. package/public/assets/Dashboard-B-JoOjBQ.js +0 -1
  273. package/public/assets/HermesConfigForm-DvR05LK1.js +0 -4
  274. package/public/assets/InstanceDetail-DcZW2QGO.js +0 -91
  275. package/public/assets/NewInstance-BCIrAd86.js +0 -1
  276. package/public/assets/Settings-xkDcduFz.js +0 -1
  277. package/public/assets/Setup-Cfuwj4gV.js +0 -1
  278. package/public/assets/index-CPhVFEsx.css +0 -1
  279. package/public/assets/index-DQsM6Joa.js +0 -19
  280. package/public/assets/providers-V-vwrExZ.js +0 -1
  281. package/public/assets/registry-B4UFJdpA.js +0 -2
@@ -1,17 +1,48 @@
1
1
  import { createHash } from "crypto";
2
- import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync, } from "fs";
2
+ import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync, chownSync, lstatSync, } from "fs";
3
3
  import { homedir, tmpdir } from "os";
4
4
  import { basename, extname, join, dirname } from "path";
5
- import { spawn } from "child_process";
5
+ import { spawn, spawnSync } from "child_process";
6
6
  import { fileURLToPath } from "url";
7
7
  import { parse, stringify } from "yaml";
8
8
  import * as config from "../../config.js";
9
9
  import { ensureDirHost } from "../../utils/fs.js";
10
10
  import { safeReadJson, safeWriteJson } from "../../utils/safe-json.js";
11
11
  import * as legacyInstanceManager from "../instance-manager.js";
12
+ import { withInstanceLock } from "../../utils/instance-lock.js";
12
13
  import { createTask, emitTask, getRunningTasks, getTask } from "../task-registry.js";
13
14
  import { compileTaskRuntime } from "./app-compiler.js";
14
- const DEFAULT_LIFECYCLE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
15
+ import * as capabilityRegistry from "../capability-registry.js";
16
+ import { resolveProvideEndpoint } from "./provide-resolver.js";
17
+ import { platformTransformSpec } from "./platform-transform.js";
18
+ const DEFAULT_LIFECYCLE_PATH_ENTRIES = [
19
+ "/opt/homebrew/bin",
20
+ "/opt/homebrew/sbin",
21
+ "/usr/local/bin",
22
+ "/usr/local/sbin",
23
+ "/usr/bin",
24
+ "/bin",
25
+ "/usr/sbin",
26
+ "/sbin",
27
+ ];
28
+ const DEFAULT_LIFECYCLE_PATH = DEFAULT_LIFECYCLE_PATH_ENTRIES.join(":");
29
+ const MACOS_LIFECYCLE_PATH_PROBES = [
30
+ "/Applications/Docker.app/Contents/Resources/bin",
31
+ ];
32
+ const ANONYMOUS_DOWNLOAD_IMAGE_ALLOWLIST = new Set([
33
+ "filebrowser/filebrowser:latest",
34
+ "ghcr.io/browserless/chromium:latest",
35
+ "ghcr.io/fmaclen/hollama:latest",
36
+ "ghcr.io/open-webui/open-webui:main",
37
+ "mcr.microsoft.com/playwright:v1.55.0-noble",
38
+ "mintplexlabs/anythingllm:latest",
39
+ "paradedb/paradedb:v0.22.2-pg17",
40
+ "redis:7.0-alpine",
41
+ "searxng/searxng:latest",
42
+ "wechatopenai/weknora-app:latest",
43
+ "wechatopenai/weknora-docreader:latest",
44
+ "wechatopenai/weknora-ui:latest",
45
+ ]);
15
46
  function getConfigValue(name) {
16
47
  return name in config ? config[name] : undefined;
17
48
  }
@@ -78,7 +109,6 @@ function parseBuiltinTemplate(fileName, yamlText) {
78
109
  const runtime = typeof task?.runtime === "string" ? task.runtime.trim() : "";
79
110
  return runtime === "container" || runtime === "process";
80
111
  })
81
- && (tasks.length === 1 || isOllamaTemplate)
82
112
  && (serviceRuntime === "container" || serviceRuntime === "process"),
83
113
  suggestedAppType,
84
114
  yaml: yamlText,
@@ -121,7 +151,11 @@ export function updateInstance(instanceId, name, description) {
121
151
  return legacyInstanceManager.getInstance(instanceId) ?? updatedMeta;
122
152
  }
123
153
  function parseComparableVersion(version, label) {
124
- const normalized = version.trim().replace(/^>=\s*/, "").replace(/^v/i, "");
154
+ const normalized = version
155
+ .trim()
156
+ .replace(/^>=\s*/, "")
157
+ .replace(/^v/i, "")
158
+ .replace(/[-+].*$/, "");
125
159
  const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
126
160
  if (!match) {
127
161
  throw new Error(`${label} '${version}' 格式无效,应为 x.y.z`);
@@ -162,17 +196,154 @@ function expandPath(p) {
162
196
  }
163
197
  return p.replace(/^~(?=\/|$)/, process.env.HOME ?? homedir());
164
198
  }
165
- function buildLifecycleEnv() {
166
- const mergedPath = `${process.env.PATH ?? ""}:${DEFAULT_LIFECYCLE_PATH}`
167
- .split(":")
199
+ function buildDeterministicPath(basePath, extraPaths = []) {
200
+ return [basePath ?? "", DEFAULT_LIFECYCLE_PATH, dirname(process.execPath), ...extraPaths]
201
+ .flatMap((entry) => entry.split(":"))
168
202
  .map((entry) => entry.trim())
169
- .filter(Boolean);
203
+ .filter(Boolean)
204
+ .filter((entry, index, entries) => entries.indexOf(entry) === index)
205
+ .join(":");
206
+ }
207
+ function buildLifecycleEnv() {
208
+ const extraPaths = process.platform === "darwin"
209
+ ? MACOS_LIFECYCLE_PATH_PROBES.filter((entry) => existsSync(entry))
210
+ : [];
211
+ const mergedPath = buildDeterministicPath(process.env.PATH, extraPaths);
212
+ // Surface panel callback hooks to lifecycle scripts. post_start can curl
213
+ // ${JISHUSHELL_PANEL_URL}/api/internal/* with the internal token to read
214
+ // panel-managed state (default provider creds etc.) and self-configure
215
+ // without going through user JWT. Best-effort: if the token file or
216
+ // port lookup fails we just omit the vars and the script gets a clean
217
+ // "missing env" failure path.
218
+ const panelHooks = {};
219
+ try {
220
+ panelHooks.JISHUSHELL_PANEL_URL = `http://127.0.0.1:${config.getPanelPort()}`;
221
+ panelHooks.JISHUSHELL_INTERNAL_TOKEN = config.getInternalMcpToken();
222
+ // LAN host that *other* services (and the post_start script's curls into
223
+ // its own service) can reach. AnythingLLM binds eth0 via Nomad's
224
+ // `external` host_network, so the panel-host loopback (127.0.0.1) won't
225
+ // reach 18097 — post_start needs to hit the LAN IP to check its own
226
+ // health. getPanelLanHost() returns the same IP Nomad publishes ports on.
227
+ panelHooks.JISHUSHELL_LAN_HOST = config.getPanelLanHost();
228
+ }
229
+ catch {
230
+ // tolerate — only post_start cares
231
+ }
170
232
  return {
171
233
  ...process.env,
172
234
  HOME: process.env.HOME ?? homedir(),
173
- PATH: [...new Set(mergedPath)].join(":"),
235
+ PATH: mergedPath,
236
+ ...panelHooks,
237
+ };
238
+ }
239
+ const SUDO_PASSTHROUGH_ENV_KEYS = ["HOME", "PATH", "TMPDIR", "TMP", "TEMP", "XDG_RUNTIME_DIR"];
240
+ function isSudoAuthenticationError(message) {
241
+ return /incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message);
242
+ }
243
+ function isSudoNoNewPrivilegesError(message) {
244
+ return /no new privileges/i.test(message);
245
+ }
246
+ function isSudoPasswordRequiredError(message) {
247
+ return /password is required|a password is required/i.test(message);
248
+ }
249
+ function buildSudoWrappedCommand(cmd, args, env, execOptions) {
250
+ const sudoArgs = execOptions?.sudoPassword ? ["-k", "-A"] : ["-n"];
251
+ const envArgs = SUDO_PASSTHROUGH_ENV_KEYS.flatMap((key) => {
252
+ const value = env[key];
253
+ return typeof value === "string" && value.length > 0 ? [`${key}=${value}`] : [];
254
+ });
255
+ return {
256
+ command: "sudo",
257
+ args: [...sudoArgs, "--", "env", ...envArgs, cmd, ...args],
174
258
  };
175
259
  }
260
+ function createLifecycleSudoError(stderr, fallbackDisplay, hasPassword) {
261
+ const message = sanitizeTaskLine(stderr).trim();
262
+ if (isSudoNoNewPrivilegesError(message)) {
263
+ return createNoNewPrivilegesSudoError();
264
+ }
265
+ if (isSudoAuthenticationError(message)) {
266
+ const err = new Error("sudo 密码错误,请重新输入。");
267
+ err.code = "INVALID_SUDO_PASSWORD";
268
+ return err;
269
+ }
270
+ if (!hasPassword && isSudoPasswordRequiredError(message)) {
271
+ return new Error("该生命周期步骤需要 sudo 密码;请在页面弹窗中输入后重试。");
272
+ }
273
+ if (message) {
274
+ return new Error(message);
275
+ }
276
+ return new Error(`lifecycle sudo step failed: ${fallbackDisplay}`);
277
+ }
278
+ function panelSystemdServicePath() {
279
+ const override = process.env.JISHUSHELL_PANEL_SYSTEMD_SERVICE_PATH?.trim();
280
+ return override || "/etc/systemd/system/jishushell.service";
281
+ }
282
+ function isLikelySystemdServiceProcess() {
283
+ return Boolean(process.env.INVOCATION_ID
284
+ || process.env.JOURNAL_STREAM
285
+ || process.env.NOTIFY_SOCKET
286
+ || process.env.JISHUSHELL_PANEL_SYSTEMD_SERVICE_PATH?.trim());
287
+ }
288
+ function maybeRepairPanelAutostartNoNewPrivileges() {
289
+ if (!isLikelySystemdServiceProcess())
290
+ return null;
291
+ const servicePath = panelSystemdServicePath();
292
+ if (!existsSync(servicePath))
293
+ return null;
294
+ let unitText = "";
295
+ try {
296
+ unitText = readFileSync(servicePath, "utf-8");
297
+ }
298
+ catch {
299
+ return { servicePath, detected: true, updated: false };
300
+ }
301
+ if (!/^\s*NoNewPrivileges\s*=\s*true\s*$/mi.test(unitText)) {
302
+ return null;
303
+ }
304
+ const nextText = unitText.replace(/^\s*NoNewPrivileges\s*=\s*true\s*\n?/gim, "");
305
+ if (nextText === unitText) {
306
+ return { servicePath, detected: true, updated: false };
307
+ }
308
+ try {
309
+ writeFileSync(servicePath, nextText);
310
+ return { servicePath, detected: true, updated: true };
311
+ }
312
+ catch {
313
+ return { servicePath, detected: true, updated: false };
314
+ }
315
+ }
316
+ function manualInstallCommandForSpec(spec) {
317
+ if (spec.id === "ollama-binary") {
318
+ return "jishushell app install ollama";
319
+ }
320
+ const builtin = listBuiltinAppSpecs().find((entry) => entry.id === spec.id);
321
+ if (!builtin)
322
+ return null;
323
+ return `jishushell app install ${spec.id}`;
324
+ }
325
+ function createNoNewPrivilegesSudoError(manualInstallCommand) {
326
+ const repair = maybeRepairPanelAutostartNoNewPrivileges();
327
+ const restartCommand = "sudo systemctl daemon-reload && sudo systemctl restart jishushell";
328
+ const parts = ["当前运行环境禁止 sudo 提权(no new privileges),面板内无法继续后续安装。"];
329
+ if (repair?.updated) {
330
+ parts.push(`已从自启文件 ${repair.servicePath} 移除 NoNewPrivileges=true。请在系统终端执行以下命令后重试:\n${restartCommand}`);
331
+ }
332
+ else if (repair?.detected) {
333
+ parts.push(`检测到自启文件 ${repair.servicePath} 仍包含 NoNewPrivileges=true。请在系统终端删除该行后执行:\n${restartCommand}`);
334
+ }
335
+ if (manualInstallCommand) {
336
+ parts.push(`当前安装已停止。你也可以在系统终端手动执行 ${manualInstallCommand}。`);
337
+ }
338
+ return new Error(parts.join("\n"));
339
+ }
340
+ function decorateInstallError(error, spec) {
341
+ const original = error instanceof Error ? error : new Error(String(error));
342
+ if (!isSudoNoNewPrivilegesError(original.message) && !/NoNewPrivileges=true/i.test(original.message)) {
343
+ return original;
344
+ }
345
+ return createNoNewPrivilegesSudoError(manualInstallCommandForSpec(spec) ?? undefined);
346
+ }
176
347
  export async function validateSudoPassword(sudoPassword) {
177
348
  if (!sudoPassword) {
178
349
  throw new Error("请输入 sudo 密码");
@@ -198,13 +369,19 @@ export async function validateSudoPassword(sudoPassword) {
198
369
  return;
199
370
  }
200
371
  const message = sanitizeTaskLine(stderr).trim();
372
+ if (isSudoNoNewPrivilegesError(message)) {
373
+ reject(createNoNewPrivilegesSudoError());
374
+ return;
375
+ }
201
376
  if (/incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message)) {
202
- reject(new Error("sudo 密码错误,请重新输入。"));
377
+ const err = new Error("sudo 密码错误,请重新输入。");
378
+ err.code = "INVALID_SUDO_PASSWORD";
379
+ reject(err);
203
380
  return;
204
381
  }
205
382
  resolve();
206
383
  });
207
- child.on("error", (error) => {
384
+ child.on("error", (_error) => {
208
385
  resolve();
209
386
  });
210
387
  });
@@ -213,8 +390,11 @@ export async function validateSudoPassword(sudoPassword) {
213
390
  preparedEnv.cleanup();
214
391
  }
215
392
  }
216
- function prepareLifecycleExecEnv(execOptions) {
217
- const env = buildLifecycleEnv();
393
+ function prepareLifecycleExecEnv(execOptions, envOverrides) {
394
+ const env = {
395
+ ...buildLifecycleEnv(),
396
+ ...(envOverrides ?? {}),
397
+ };
218
398
  const sudoPassword = execOptions?.sudoPassword;
219
399
  if (!sudoPassword) {
220
400
  return { env, cleanup: () => undefined };
@@ -243,6 +423,26 @@ function prepareLifecycleExecEnv(execOptions) {
243
423
  },
244
424
  };
245
425
  }
426
+ function shouldBypassDockerCredentialHelperForDownloadImage(image) {
427
+ return ANONYMOUS_DOWNLOAD_IMAGE_ALLOWLIST.has(image.trim());
428
+ }
429
+ function createAnonymousDockerConfig() {
430
+ const dockerConfigDir = mkdtempSync(join(tmpdir(), "jishushell-docker-config-"));
431
+ writeFileSync(join(dockerConfigDir, "config.json"), `${JSON.stringify({ auths: {} }, null, 2)}\n`, { mode: 0o600 });
432
+ return {
433
+ envOverrides: {
434
+ DOCKER_CONFIG: dockerConfigDir,
435
+ },
436
+ cleanup: () => {
437
+ try {
438
+ rmSync(dockerConfigDir, { recursive: true, force: true });
439
+ }
440
+ catch {
441
+ // best effort cleanup for one-shot anonymous docker config files
442
+ }
443
+ },
444
+ };
445
+ }
246
446
  const ANSI_ESCAPE_RE = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
247
447
  function sanitizeTaskLine(line) {
248
448
  return line
@@ -276,24 +476,88 @@ function normalizePortVisibility(visibility) {
276
476
  throw new Error(`port visibility '${visibility}' 仅支持 external 或 internal`);
277
477
  }
278
478
  function normalizeAppSpec(spec) {
479
+ const normalizedProvides = (spec.provides ?? []).map((provide) => {
480
+ if (spec.id === "browserless-chromium-container"
481
+ && provide.capability === BROWSERLESS_DEBUGGER_CAPABILITY
482
+ && (provide.path === "/" || provide.path === "/debugger")) {
483
+ return { ...provide, path: "/debugger/" };
484
+ }
485
+ if (spec.id === "browserless-chromium-container"
486
+ && provide.capability === BROWSERLESS_DOCS_CAPABILITY
487
+ && provide.path === "/docs") {
488
+ return { ...provide, path: "/docs/" };
489
+ }
490
+ if (spec.id === "browserless-chromium-container"
491
+ && provide.capability === BROWSERLESS_API_CAPABILITY
492
+ && provide.path !== "/") {
493
+ return { ...provide, path: "/" };
494
+ }
495
+ return provide;
496
+ });
497
+ // Inject browserless-api capability for legacy installed specs that lack it.
498
+ if (spec.id === "browserless-chromium-container"
499
+ && normalizedProvides.length > 0
500
+ && !normalizedProvides.some((p) => p.capability === BROWSERLESS_API_CAPABILITY)) {
501
+ const debugger_ = normalizedProvides.find((p) => p.capability === BROWSERLESS_DEBUGGER_CAPABILITY);
502
+ if (debugger_) {
503
+ normalizedProvides.push({
504
+ capability: BROWSERLESS_API_CAPABILITY,
505
+ port: debugger_.port ?? 3000,
506
+ path: "/",
507
+ protocol: "http",
508
+ description: "Browserless 根 API(供调试器页面连接 ws 与 sessions 接口)",
509
+ });
510
+ }
511
+ }
512
+ const normalizedTasks = (spec.tasks ?? []).map((task) => {
513
+ const rawTask = { ...task };
514
+ if (!rawTask.role) {
515
+ rawTask.role = "service";
516
+ }
517
+ if (!rawTask.command && rawTask.binary) {
518
+ rawTask.command = rawTask.binary;
519
+ }
520
+ if (Array.isArray(rawTask.ports)) {
521
+ rawTask.ports = rawTask.ports.map((port) => ({
522
+ ...port,
523
+ visibility: normalizePortVisibility(port.visibility),
524
+ }));
525
+ }
526
+ return rawTask;
527
+ });
528
+ let normalizedLifecycle = spec.lifecycle ? { ...spec.lifecycle } : undefined;
529
+ if (spec.id === "browserless-chromium-container") {
530
+ const browserlessDataDir = `~/.jishushell/apps/${spec.app_id || spec.id}/data`;
531
+ const browserlessDataDirTemplate = "~/.jishushell/apps/${app_id}/data";
532
+ const browserlessTask = normalizedTasks.find((task) => task.name === "browserless");
533
+ if (browserlessTask) {
534
+ const dataDirTarget = "/tmp/browserless-data";
535
+ const existingVolumes = Array.isArray(browserlessTask.volumes) ? [...browserlessTask.volumes] : [];
536
+ const hasDataDirVolume = existingVolumes.some((volume) => typeof volume === "object" && volume !== null && volume.target === dataDirTarget);
537
+ if (!hasDataDirVolume) {
538
+ existingVolumes.push({ source: browserlessDataDir, target: dataDirTarget });
539
+ }
540
+ browserlessTask.volumes = existingVolumes;
541
+ }
542
+ const install = [...(normalizedLifecycle?.install ?? [])];
543
+ if (!install.some((step) => "mkdir" in step && (step.mkdir === browserlessDataDir || step.mkdir === browserlessDataDirTemplate))) {
544
+ install.push({ mkdir: browserlessDataDir });
545
+ }
546
+ const preStart = [...(normalizedLifecycle?.pre_start ?? [])];
547
+ if (!preStart.some((step) => "mkdir" in step && (step.mkdir === browserlessDataDir || step.mkdir === browserlessDataDirTemplate))) {
548
+ preStart.push({ mkdir: browserlessDataDir });
549
+ }
550
+ normalizedLifecycle = {
551
+ ...(normalizedLifecycle ?? {}),
552
+ install,
553
+ pre_start: preStart,
554
+ };
555
+ }
279
556
  return {
280
557
  ...spec,
281
- tasks: (spec.tasks ?? []).map((task) => {
282
- const rawTask = { ...task };
283
- if (!rawTask.role) {
284
- rawTask.role = "service";
285
- }
286
- if (!rawTask.command && rawTask.binary) {
287
- rawTask.command = rawTask.binary;
288
- }
289
- if (Array.isArray(rawTask.ports)) {
290
- rawTask.ports = rawTask.ports.map((port) => ({
291
- ...port,
292
- visibility: normalizePortVisibility(port.visibility),
293
- }));
294
- }
295
- return rawTask;
296
- }),
558
+ ...(spec.provides ? { provides: normalizedProvides } : {}),
559
+ tasks: normalizedTasks,
560
+ ...(normalizedLifecycle ? { lifecycle: normalizedLifecycle } : {}),
297
561
  };
298
562
  }
299
563
  function imageReferencedByOtherInstalledApps(currentAppId, imagePath) {
@@ -388,7 +652,8 @@ function getProvidePort(spec, provide) {
388
652
  const firstPort = spec.tasks.find((task) => task.role === "service")?.ports?.[0];
389
653
  if (!firstPort)
390
654
  return null;
391
- return firstPort.host_port ?? firstPort.port;
655
+ const p = firstPort.host_port ?? firstPort.port;
656
+ return typeof p === "number" && p > 0 ? p : null;
392
657
  }
393
658
  function getProvideUrl(provide) {
394
659
  const raw = typeof provide.url === "string" ? provide.url.trim() : "";
@@ -406,7 +671,7 @@ function getProvideUrl(provide) {
406
671
  }
407
672
  }
408
673
  function buildCapabilityAddress(port, path) {
409
- const host = legacyInstanceManager.getAdvertisedHostForPort(port);
674
+ const host = port > 0 ? legacyInstanceManager.getAdvertisedHostForPort(port) : "127.0.0.1";
410
675
  if (!path) {
411
676
  return `${host}:${port}`;
412
677
  }
@@ -501,11 +766,16 @@ function materializeInstalledSpec(spec, appId, offset) {
501
766
  const portShiftMap = buildPortShiftMap(spec, offset);
502
767
  const rewritten = rewriteInstalledSpecValue(spec, spec.id, appId, portShiftMap);
503
768
  const derivedName = deriveInstalledDisplayName(spec, appId);
504
- return normalizeAppSpec({
769
+ const normalized = normalizeAppSpec({
505
770
  ...rewritten,
506
771
  id: spec.id,
507
772
  ...(derivedName ? { name: derivedName } : {}),
508
773
  });
774
+ // Final step: drop spec fields that work on the spec author's Linux
775
+ // baseline but break on the host platform (e.g. host_network:
776
+ // docker_bridge / user: "host" on darwin). Identity pass-through on
777
+ // Linux — see platform-transform.ts for the rule list.
778
+ return platformTransformSpec(normalized);
509
779
  }
510
780
  function deriveInstalledDisplayName(spec, appId) {
511
781
  const baseName = typeof spec.name === "string" && spec.name.trim() ? spec.name.trim() : spec.id;
@@ -615,7 +885,15 @@ async function resolveInstallTarget(spec, originalSpecYaml, requestedAppId) {
615
885
  return {
616
886
  appId,
617
887
  installedSpec,
618
- installedSpecYaml: offset === 0 ? originalSpecYaml : stringify(installedSpec),
888
+ // Always re-serialize from `installedSpec` so the cached yaml
889
+ // reflects every transform `materializeInstalledSpec` ran —
890
+ // including the platform pass that strips host_network/user fields
891
+ // on darwin. Using `originalSpecYaml` for offset=0 (the previous
892
+ // behavior) bypassed the transformer for multi-instance apps'
893
+ // first install slot and re-introduced the Linux-only fields on
894
+ // disk; subsequent panel restarts then re-read the raw source via
895
+ // `loadInstalledAppSpec` and broke macOS placement again.
896
+ installedSpecYaml: stringify(installedSpec),
619
897
  };
620
898
  }
621
899
  throw new Error(`App '${baseId}' 没有可用安装槽位,目录名或端口已全部占用`);
@@ -630,30 +908,60 @@ const DOCKER_PULL_RETRY_ATTEMPTS = 3;
630
908
  // extracts in 5 min on a Raspberry Pi. 30 min clears both with headroom while
631
909
  // still capping runaway failures (total retry budget 90 min).
632
910
  const DOCKER_PULL_TIMEOUT_MS = 1_800_000;
911
+ // Separate from the total timeout above: if docker pull stops producing any
912
+ // stdout/stderr for long enough, treat it as stalled and retry rather than
913
+ // waiting the full 30 minutes.
914
+ //
915
+ // Why 600s (not 180s): on slow links (especially Docker Hub from China to
916
+ // edge devices), large single layers — AnythingLLM ~500MB, Playwright/Hermes
917
+ // ~2.3GB — can spend 5-8 minutes between progress lines without TTY. 180s
918
+ // idle was killing pulls that were actually making progress, then rolling
919
+ // back the partially-completed image. Layer cache is preserved across
920
+ // retries (docker dedupes by layer sha), so a higher idle ceiling lets each
921
+ // big layer finish without throwing away the layers already on disk.
922
+ const DOCKER_PULL_IDLE_TIMEOUT_MS = 600_000;
633
923
  async function pullDockerImageStep(label, image, display, task, timeoutMs = DOCKER_PULL_TIMEOUT_MS) {
924
+ if (await dockerImageExists(image)) {
925
+ const skipMessage = `[lifecycle:${label}] docker image '${image}' already exists locally; skipping pull`;
926
+ process.stdout.write(` ${skipMessage}\n`);
927
+ emitInstallTaskLog(task, skipMessage);
928
+ return;
929
+ }
930
+ const anonymousDockerConfig = shouldBypassDockerCredentialHelperForDownloadImage(image)
931
+ ? createAnonymousDockerConfig()
932
+ : null;
634
933
  let lastError;
635
- for (let attempt = 1; attempt <= DOCKER_PULL_RETRY_ATTEMPTS; attempt++) {
636
- try {
637
- await spawnStepWithTimeout(label, display, display, "docker", ["pull", image], timeoutMs, task);
638
- return;
639
- }
640
- catch (error) {
641
- if (await dockerImageExists(image)) {
642
- const recoveredMessage = `[lifecycle:${label}] docker image '${image}' is present locally after pull failure/timeout; treating step as successful`;
643
- process.stdout.write(` ${recoveredMessage}\n`);
644
- emitInstallTaskLog(task, recoveredMessage);
934
+ try {
935
+ for (let attempt = 1; attempt <= DOCKER_PULL_RETRY_ATTEMPTS; attempt++) {
936
+ try {
937
+ await spawnStepWithTimeout(label, display, display, "docker", ["pull", image], timeoutMs, task, undefined, undefined, {
938
+ idleTimeoutMs: Math.min(DOCKER_PULL_IDLE_TIMEOUT_MS, timeoutMs),
939
+ envOverrides: anonymousDockerConfig?.envOverrides,
940
+ stallMessageHint: "Docker credential resolution (for example docker-credential-desktop) may be involved.",
941
+ });
645
942
  return;
646
943
  }
647
- lastError = error;
648
- if (attempt === DOCKER_PULL_RETRY_ATTEMPTS) {
649
- break;
944
+ catch (error) {
945
+ if (await dockerImageExists(image)) {
946
+ const recoveredMessage = `[lifecycle:${label}] docker image '${image}' is present locally after pull failure/timeout; treating step as successful`;
947
+ process.stdout.write(` ${recoveredMessage}\n`);
948
+ emitInstallTaskLog(task, recoveredMessage);
949
+ return;
950
+ }
951
+ lastError = error;
952
+ if (attempt === DOCKER_PULL_RETRY_ATTEMPTS) {
953
+ break;
954
+ }
955
+ const reason = error instanceof Error ? error.message : String(error);
956
+ const retryMessage = `[lifecycle:${label}] docker pull failed for ${image} (attempt ${attempt}/${DOCKER_PULL_RETRY_ATTEMPTS}): ${reason}; retrying`;
957
+ process.stdout.write(` ${retryMessage}\n`);
958
+ emitInstallTaskLog(task, retryMessage);
650
959
  }
651
- const reason = error instanceof Error ? error.message : String(error);
652
- const retryMessage = `[lifecycle:${label}] docker pull failed for ${image} (attempt ${attempt}/${DOCKER_PULL_RETRY_ATTEMPTS}): ${reason}; retrying`;
653
- process.stdout.write(` ${retryMessage}\n`);
654
- emitInstallTaskLog(task, retryMessage);
655
960
  }
656
961
  }
962
+ finally {
963
+ anonymousDockerConfig?.cleanup();
964
+ }
657
965
  throw (lastError instanceof Error ? lastError : new Error(String(lastError)));
658
966
  }
659
967
  async function dockerImageExists(image) {
@@ -666,13 +974,18 @@ async function dockerImageExists(image) {
666
974
  child.on("error", () => resolve(false));
667
975
  });
668
976
  }
669
- function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions) {
977
+ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions, sudo, runOptions) {
670
978
  process.stdout.write(` [lifecycle:${label}] ${display}\n`);
671
979
  emitInstallTaskLog(task, `[lifecycle:${label}] ${taskDisplay}`);
672
980
  return new Promise((resolve, reject) => {
673
- const preparedEnv = prepareLifecycleExecEnv(execOptions);
981
+ const preparedEnv = prepareLifecycleExecEnv(sudo ? execOptions : undefined, runOptions?.envOverrides);
674
982
  let cleaned = false;
675
983
  let heartbeatTimer = null;
984
+ let idleTimer = null;
985
+ let stdoutPending = "";
986
+ let stderrPending = "";
987
+ let capturedStderr = "";
988
+ let forcedError = null;
676
989
  const cleanupPreparedEnv = () => {
677
990
  if (cleaned)
678
991
  return;
@@ -681,22 +994,58 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
681
994
  clearInterval(heartbeatTimer);
682
995
  heartbeatTimer = null;
683
996
  }
997
+ if (idleTimer) {
998
+ clearTimeout(idleTimer);
999
+ idleTimer = null;
1000
+ }
684
1001
  preparedEnv.cleanup();
685
1002
  };
686
- const child = spawn(cmd, args, {
687
- stdio: task ? ["ignore", "pipe", "pipe"] : "inherit",
1003
+ const spawnTarget = sudo
1004
+ ? buildSudoWrappedCommand(cmd, args, preparedEnv.env, execOptions)
1005
+ : { command: cmd, args };
1006
+ const captureOutput = Boolean(task) || Boolean(sudo) || Boolean(runOptions?.idleTimeoutMs);
1007
+ const child = spawn(spawnTarget.command, spawnTarget.args, {
1008
+ stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
688
1009
  timeout: timeoutMs,
689
1010
  env: preparedEnv.env,
690
1011
  });
691
- if (task) {
692
- let stdoutPending = "";
693
- let stderrPending = "";
1012
+ const resetIdleTimer = () => {
1013
+ if (!runOptions?.idleTimeoutMs)
1014
+ return;
1015
+ if (idleTimer)
1016
+ clearTimeout(idleTimer);
1017
+ idleTimer = setTimeout(() => {
1018
+ const idleSeconds = Math.max(1, Math.round(runOptions.idleTimeoutMs / 1000));
1019
+ const stallMessageSuffix = runOptions.stallMessageHint ? ` ${runOptions.stallMessageHint}` : "";
1020
+ const stallMessage = `[lifecycle:${label}] no output for ${idleSeconds}s; terminating stalled step: ${taskDisplay}${stallMessageSuffix}`;
1021
+ process.stdout.write(` ${stallMessage}\n`);
1022
+ emitInstallTaskLog(task, stallMessage);
1023
+ forcedError = new Error(`lifecycle '${label}' step stalled after ${idleSeconds}s with no output: ${display}${stallMessageSuffix}`);
1024
+ child.kill("SIGTERM");
1025
+ }, runOptions.idleTimeoutMs);
1026
+ idleTimer.unref?.();
1027
+ };
1028
+ resetIdleTimer();
1029
+ if (captureOutput) {
694
1030
  const startedAt = Date.now();
695
1031
  const flushPendingLine = (line) => {
1032
+ if (!task)
1033
+ return;
696
1034
  emitInstallTaskLog(task, line);
697
1035
  };
698
1036
  const handleChunk = (chunk, stream) => {
1037
+ resetIdleTimer();
699
1038
  const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
1039
+ if (stream === "stderr") {
1040
+ capturedStderr += text;
1041
+ }
1042
+ if (!task) {
1043
+ if (stream === "stdout")
1044
+ process.stdout.write(text);
1045
+ else
1046
+ process.stderr.write(text);
1047
+ return;
1048
+ }
700
1049
  const normalized = `${stream === "stdout" ? stdoutPending : stderrPending}${text}`
701
1050
  .replace(/\r\n/g, "\n")
702
1051
  .replace(/\r/g, "\n");
@@ -712,24 +1061,38 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
712
1061
  };
713
1062
  child.stdout?.on("data", (data) => handleChunk(data, "stdout"));
714
1063
  child.stderr?.on("data", (data) => handleChunk(data, "stderr"));
715
- heartbeatTimer = setInterval(() => {
716
- const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
717
- emitInstallTaskLog(task, `[lifecycle:${label}] still running (${elapsedSeconds}s): ${taskDisplay}`);
718
- }, 10_000);
719
- child.on("close", () => {
720
- flushPendingLine(stdoutPending);
721
- flushPendingLine(stderrPending);
722
- });
1064
+ if (task) {
1065
+ heartbeatTimer = setInterval(() => {
1066
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
1067
+ emitInstallTaskLog(task, `[lifecycle:${label}] still running (${elapsedSeconds}s): ${taskDisplay}`);
1068
+ }, 10_000);
1069
+ child.on("close", () => {
1070
+ flushPendingLine(stdoutPending);
1071
+ flushPendingLine(stderrPending);
1072
+ });
1073
+ }
723
1074
  }
724
1075
  child.on("close", (code) => {
725
1076
  cleanupPreparedEnv();
726
- if (code === 0)
1077
+ if (forcedError)
1078
+ reject(forcedError);
1079
+ else if (code === 0)
727
1080
  resolve();
1081
+ else if (sudo)
1082
+ reject(createLifecycleSudoError(capturedStderr, display, Boolean(execOptions?.sudoPassword)));
728
1083
  else
729
1084
  reject(new Error(`lifecycle '${label}' step failed (exit ${code ?? 1}): ${display}`));
730
1085
  });
731
1086
  child.on("error", (err) => {
732
1087
  cleanupPreparedEnv();
1088
+ if (forcedError) {
1089
+ reject(forcedError);
1090
+ return;
1091
+ }
1092
+ if (sudo && err.code === "ENOENT") {
1093
+ reject(new Error("当前环境未检测到 sudo,无法执行需要 sudo 的生命周期步骤。请以 root 身份重试。"));
1094
+ return;
1095
+ }
733
1096
  reject(new Error(`lifecycle '${label}' step error: ${err.message}`));
734
1097
  });
735
1098
  });
@@ -742,7 +1105,8 @@ function lifecycleRunStepDisplay(label, index) {
742
1105
  }
743
1106
  async function commandExists(command) {
744
1107
  return new Promise((resolve) => {
745
- const child = spawn("sh", ["-c", `command -v '${command}' > /dev/null 2>&1`], {
1108
+ const quoted = command.replace(/'/g, "'\\''");
1109
+ const child = spawn("sh", ["-c", `command -v '${quoted}' > /dev/null 2>&1`], {
746
1110
  stdio: "ignore",
747
1111
  env: buildLifecycleEnv(),
748
1112
  });
@@ -750,6 +1114,64 @@ async function commandExists(command) {
750
1114
  child.on("error", () => resolve(false));
751
1115
  });
752
1116
  }
1117
+ // Recursively chown a path. Owner format is "uid:gid" (numeric only, e.g.
1118
+ // "0:0" or "1000:1000"). Used by container apps whose images run as a
1119
+ // different uid than the panel user — without this, bind-mounted data
1120
+ // dirs end up unwritable for the in-container process and fail with
1121
+ // SQLite "readonly database" or chroma init errors.
1122
+ function parseOwnerSpec(owner) {
1123
+ const m = /^(\d+):(\d+)$/.exec(owner);
1124
+ if (!m)
1125
+ throw new Error(`chown owner must be "uid:gid" (numeric), got "${owner}"`);
1126
+ return { uid: Number(m[1]), gid: Number(m[2]) };
1127
+ }
1128
+ function chownRecursive(path, uid, gid) {
1129
+ chownSync(path, uid, gid);
1130
+ let stat;
1131
+ try {
1132
+ stat = lstatSync(path);
1133
+ }
1134
+ catch {
1135
+ return;
1136
+ }
1137
+ if (!stat.isDirectory())
1138
+ return;
1139
+ for (const entry of readdirSync(path)) {
1140
+ chownRecursive(join(path, entry), uid, gid);
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Try chowning via direct fs syscall first; on EPERM (panel runs as a
1145
+ * non-root user with no CAP_CHOWN) fall back to `sudo -n chown`. The
1146
+ * fallback only succeeds where passwordless sudo is configured for the
1147
+ * panel user (the canonical Pi setup); on other hosts the original
1148
+ * EPERM bubbles up as a clear error.
1149
+ */
1150
+ function chownWithSudoFallback(path, uid, gid, recursive) {
1151
+ try {
1152
+ if (recursive)
1153
+ chownRecursive(path, uid, gid);
1154
+ else
1155
+ chownSync(path, uid, gid);
1156
+ return;
1157
+ }
1158
+ catch (e) {
1159
+ if (e?.code !== "EPERM" && e?.code !== "EACCES")
1160
+ throw e;
1161
+ }
1162
+ const args = [
1163
+ "-n",
1164
+ "chown",
1165
+ ...(recursive ? ["-R"] : []),
1166
+ `${uid}:${gid}`,
1167
+ path,
1168
+ ];
1169
+ const r = spawnSync("sudo", args, { stdio: ["ignore", "ignore", "pipe"] });
1170
+ if (r.status !== 0) {
1171
+ const stderr = r.stderr ? r.stderr.toString().trim() : "";
1172
+ throw new Error(`chown ${recursive ? "-R " : ""}${uid}:${gid} ${path} failed: panel user lacks CAP_CHOWN and passwordless sudo also failed${stderr ? `: ${stderr}` : ""}`);
1173
+ }
1174
+ }
753
1175
  async function downloadBinaryStep(label, url, dest, chmod, task) {
754
1176
  const expanded = expandPath(dest);
755
1177
  process.stdout.write(` [lifecycle:${label}] downloadBinary: ${url} → ${expanded}\n`);
@@ -770,13 +1192,16 @@ async function runLifecycleSteps(steps, label, artifacts, task, execOptions) {
770
1192
  return;
771
1193
  for (const [index, step] of steps.entries()) {
772
1194
  if ("run" in step) {
1195
+ if (step.ifFileExists && !existsSync(expandPath(step.ifFileExists))) {
1196
+ continue;
1197
+ }
773
1198
  const timeoutMs = step.timeout_ms ?? 300_000;
774
1199
  const display = label === "pre_install"
775
1200
  ? lifecycleRunStepDisplay(label, index)
776
1201
  : `${lifecycleRunStepDisplay(label, index)} ${step.run}`;
777
1202
  const taskDisplay = `run step ${index + 1}`;
778
1203
  try {
779
- await spawnStepWithTimeout(label, display, taskDisplay, "sh", ["-c", step.run], timeoutMs, task, execOptions);
1204
+ await spawnStepWithTimeout(label, display, taskDisplay, "sh", ["-c", step.run], timeoutMs, task, execOptions, step.sudo === true);
780
1205
  }
781
1206
  catch (error) {
782
1207
  if (step.successIfCommandExists && await commandExists(step.successIfCommandExists)) {
@@ -829,6 +1254,36 @@ async function runLifecycleSteps(steps, label, artifacts, task, execOptions) {
829
1254
  mkdirSync(p, { recursive: true });
830
1255
  artifacts?.push({ type: "dir", path: p });
831
1256
  }
1257
+ else if ("chown" in step) {
1258
+ const p = expandPath(step.chown.path);
1259
+ const { uid, gid } = parseOwnerSpec(step.chown.owner);
1260
+ const recursive = step.chown.recursive !== false;
1261
+ const tag = recursive ? "chown -R" : "chown";
1262
+ process.stdout.write(` [lifecycle:${label}] ${tag} ${uid}:${gid} ${p}\n`);
1263
+ emitInstallTaskLog(task, `[lifecycle:${label}] ${tag} ${uid}:${gid} ${p}`);
1264
+ if (!existsSync(p)) {
1265
+ // chown only makes sense if the target exists; surface a clear
1266
+ // error rather than letting fs throw an opaque ENOENT later.
1267
+ throw new Error(`chown target does not exist: ${p}`);
1268
+ }
1269
+ try {
1270
+ chownWithSudoFallback(p, uid, gid, recursive);
1271
+ }
1272
+ catch (chownErr) {
1273
+ // In pre_start, chown failures are non-fatal: on macOS Docker/Colima
1274
+ // the VM handles UID mapping for bind-mounts, so the container can
1275
+ // write even without matching ownership. Failing fatally here blocks
1276
+ // any non-root panel user from starting the app.
1277
+ if (label === "pre_start") {
1278
+ const msg = `[lifecycle:${label}] ${tag} ${uid}:${gid} ${p} failed (non-fatal): ${chownErr.message}`;
1279
+ process.stdout.write(` ${msg}\n`);
1280
+ emitInstallTaskLog(task, msg);
1281
+ }
1282
+ else {
1283
+ throw chownErr;
1284
+ }
1285
+ }
1286
+ }
832
1287
  else if ("deleteDir" in step) {
833
1288
  const p = expandPath(step.deleteDir);
834
1289
  process.stdout.write(` [lifecycle:${label}] deleteDir: ${p}\n`);
@@ -866,7 +1321,7 @@ function cleanupArtifacts(artifacts, task) {
866
1321
  }
867
1322
  const DOCKER_IMAGE_RE = /^[a-zA-Z0-9][a-zA-Z0-9\-_.:/@]*$/;
868
1323
  const APP_ID_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
869
- const REGISTRY_PATH = join(APPS_DIR, "capability-registry.json");
1324
+ const _REGISTRY_PATH = join(APPS_DIR, "capability-registry.json");
870
1325
  const INSTALL_LOCK_FILENAME = "install.lock";
871
1326
  // ── Directory helpers ─────────────────────────────────────────────────────
872
1327
  function appDirForId(appId) {
@@ -995,6 +1450,7 @@ function startAppLifecycleTask(appId, kind, startMessage, doneMessage, action) {
995
1450
  return {
996
1451
  ok: false,
997
1452
  error: `App '${appId}' 正在执行 ${currentTask.kind} 操作,请等待完成后再试`,
1453
+ code: "TASK_BUSY",
998
1454
  kind,
999
1455
  };
1000
1456
  }
@@ -1214,7 +1670,22 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
1214
1670
  return null;
1215
1671
  const { appId, installedSpec, } = await resolveInstallTarget(spec, specYaml, requestedAppId);
1216
1672
  let instanceSpec = rewriteInstanceScopedPaths(installedSpec, appId);
1217
- const resolvedRequires = resolveRequires(installedSpec);
1673
+ // PR 3 sub-step 3c: switch from legacy `resolveRequires(spec)` to the new
1674
+ // resolveConnections in preCreate mode. Legacy single-candidate fallback
1675
+ // still materializes its env (so meta-apps stay "open the box and it
1676
+ // works"); category-prefix requires + missing required producers fall
1677
+ // into `pending` for UI display via the install task event (PR 4 wires
1678
+ // task.event.affectedConsumers etc.). install never fails here — even if
1679
+ // a required capability is unavailable; start time will surface the
1680
+ // error. The previous `ensureRequiredCapabilitiesAvailable` block has
1681
+ // been removed for the same reason.
1682
+ const { resolveConnections, resolvedToLegacyEnv } = await import("../connection-resolver.js");
1683
+ const { resolved, pending } = resolveConnections(installedSpec, { connections: {} }, "preCreate");
1684
+ if (pending.length > 0) {
1685
+ console.log(`[install] ${appId}: ${pending.length} pending connection(s): ` +
1686
+ pending.map((p) => `${p.slot} (${p.capability}, ${p.reason})`).join(", "));
1687
+ }
1688
+ const resolvedRequires = resolvedToLegacyEnv(resolved);
1218
1689
  if (Object.keys(resolvedRequires).length > 0) {
1219
1690
  instanceSpec = {
1220
1691
  ...installedSpec,
@@ -1262,6 +1733,19 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
1262
1733
  renameSync(yamlTmp, yamlPath);
1263
1734
  safeWriteJson(join(instanceDir, "manifest.json"), manifest, true);
1264
1735
  await runLifecycleSteps(instanceSpec.lifecycle?.pre_install, "pre_install", artifacts, options.task, options.exec);
1736
+ }
1737
+ catch (e) {
1738
+ cleanupArtifacts(artifacts, options.task);
1739
+ try {
1740
+ const instanceManager = await import("../instance-manager.js");
1741
+ await instanceManager.deleteInstance(appId);
1742
+ }
1743
+ catch {
1744
+ rmSync(instanceDir, { recursive: true, force: true });
1745
+ }
1746
+ throw decorateInstallError(e, instanceSpec);
1747
+ }
1748
+ try {
1265
1749
  await runLifecycleSteps(instanceSpec.lifecycle?.install, "install", artifacts, options.task, options.exec);
1266
1750
  const pulledImages = new Set(artifacts.filter((artifact) => artifact.type === "image").map((artifact) => artifact.path));
1267
1751
  const imagesToPull = [...new Set(instanceSpec.tasks.filter((task) => task.image).map((task) => task.image))];
@@ -1288,7 +1772,7 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
1288
1772
  catch {
1289
1773
  rmSync(instanceDir, { recursive: true, force: true });
1290
1774
  }
1291
- throw e;
1775
+ throw decorateInstallError(e, instanceSpec);
1292
1776
  }
1293
1777
  if (artifacts.length > 0) {
1294
1778
  manifest.artifacts = artifacts;
@@ -1339,43 +1823,33 @@ export function getAppInstallState(appId) {
1339
1823
  return null;
1340
1824
  return hasInstallLock(location.dir) ? "installing" : "installed";
1341
1825
  }
1826
+ const BROWSERLESS_DEBUGGER_CAPABILITY = "browserless-debugger";
1827
+ const BROWSERLESS_API_CAPABILITY = "browserless-api";
1828
+ const BROWSERLESS_DOCS_CAPABILITY = "browserless-docs";
1829
+ /**
1830
+ * Compat-view registry reader. Returns the legacy `{ capabilities: {} }`
1831
+ * shape that older call sites expect. The new `capability-registry.ts`
1832
+ * module migrates dual-shape entries on every read so the legacy view is
1833
+ * always populated. PR 3 sub-step 3f deletes this shim.
1834
+ */
1342
1835
  function readRegistry() {
1343
- const reg = safeReadJson(REGISTRY_PATH, "capability-registry");
1344
- return reg ?? { capabilities: {} };
1345
- }
1346
- function writeRegistry(reg) {
1347
- ensureDirHost(APPS_DIR);
1348
- safeWriteJson(REGISTRY_PATH, reg, true);
1349
- }
1350
- function installedProvidersForCapability(capability) {
1351
- return listApps()
1352
- .filter((app) => app.spec.provides?.some((provide) => provide.capability === capability))
1353
- .map((app) => app.manifest.id);
1354
- }
1355
- function ensureRequiredCapabilitiesAvailable(spec) {
1356
- if (!spec.requires?.length)
1357
- return;
1358
- const reg = readRegistry();
1359
- const missing = spec.requires
1360
- .filter((req) => req.required !== false && !reg.capabilities[req.capability])
1361
- .map((req) => {
1362
- const installedProviders = installedProvidersForCapability(req.capability);
1363
- const providerHint = installedProviders.length > 0
1364
- ? `;已安装但未注册的 provider: ${installedProviders.join(", ")}`
1365
- : "";
1366
- return `- ${req.capability} -> ${req.inject_as}${providerHint}`;
1367
- });
1368
- if (missing.length === 0)
1369
- return;
1370
- throw new Error(`App '${spec.id}' 缺少必需能力,已跳过安装:\n${missing.join("\n")}\n请先启动对应 provider,再执行 jishushell app provides 查看当前可用能力。`);
1836
+ const file = capabilityRegistry.readRegistry();
1837
+ return { capabilities: file.capabilities ?? {}, providersByCapability: file.providersByCapability };
1371
1838
  }
1839
+ // `ensureRequiredCapabilitiesAvailable` was removed in PR 3 sub-step 3c.
1840
+ // install never blocks on missing required providers any more —
1841
+ // resolveConnections(..., "preCreate") collects them into the `pending`
1842
+ // list and the UI surfaces them after install completes.
1372
1843
  export function listProvidedCapabilities() {
1373
1844
  const reg = readRegistry();
1374
1845
  return listApps().flatMap((app) => (app.spec.provides ?? []).map((provide) => {
1375
1846
  const url = getProvideUrl(provide) ?? undefined;
1376
1847
  const port = getProvidePort(app.spec, provide) ?? undefined;
1377
1848
  const address = !url && typeof port === "number" ? buildCapabilityAddress(port, provide.path) : undefined;
1378
- const registered = reg.capabilities[provide.capability];
1849
+ const providers = reg.providersByCapability?.[provide.capability] ?? [];
1850
+ const registered = providers.find((e) => e.instanceId === app.manifest.id)
1851
+ ?? providers.find((e) => e.status === "running")
1852
+ ?? providers[0];
1379
1853
  const protocol = resolveProvideProtocol(provide);
1380
1854
  return {
1381
1855
  appId: app.manifest.id,
@@ -1387,6 +1861,7 @@ export function listProvidedCapabilities() {
1387
1861
  ...(provide.visibility ? { visibility: provide.visibility } : {}),
1388
1862
  ...(provide.description ? { description: provide.description } : {}),
1389
1863
  ...(provide.terminal ? { terminal: provide.terminal } : {}),
1864
+ ...(provide.embedded ? { embedded: provide.embedded } : {}),
1390
1865
  ...(address ? { address } : {}),
1391
1866
  registered: Boolean(registered),
1392
1867
  ...(registered?.address ? { registeredAddress: registered.address } : {}),
@@ -1401,7 +1876,22 @@ export function getEmbeddedUiHintForApp(appId) {
1401
1876
  const provides = getProvidedCapabilitiesForApp(appId);
1402
1877
  if (!provides.length)
1403
1878
  return null;
1404
- for (const provide of provides) {
1879
+ // Selection priority for which provide becomes the embedded UI:
1880
+ // 1. browserless-debugger (special — dev console, not the API)
1881
+ // 2. any capability whose name ends in "-ui" (the canonical Web UI slot,
1882
+ // by convention served at "/"). Apps like AnythingLLM provide both an
1883
+ // API capability (`knowledge-anythingllm`, path `/api/v1`) AND a UI
1884
+ // capability (`anythingllm-ui`, path `/`). Without this preference
1885
+ // the first-iterated provide wins and the iframe ends up pointing
1886
+ // at `/api/v1` → 404.
1887
+ // 3. fall back to natural order for legacy single-capability apps.
1888
+ const browserlessPreferred = provides.find((provide) => provide.capability === BROWSERLESS_DEBUGGER_CAPABILITY);
1889
+ const uiPreferred = provides.find((provide) => provide.capability.endsWith("-ui"));
1890
+ const preferred = browserlessPreferred ?? uiPreferred ?? null;
1891
+ const orderedProvides = preferred
1892
+ ? [preferred, ...provides.filter((provide) => provide !== preferred)]
1893
+ : provides;
1894
+ for (const provide of orderedProvides) {
1405
1895
  const protocol = normalizeProvideProtocol(provide.protocol);
1406
1896
  if (provide.visibility === "internal")
1407
1897
  continue;
@@ -1417,14 +1907,54 @@ export function getEmbeddedUiHintForApp(appId) {
1417
1907
  }
1418
1908
  if (typeof provide.port !== "number" || provide.port < 1)
1419
1909
  continue;
1420
- const address = typeof provide.address === "string" && provide.address.trim()
1421
- ? provide.address.trim()
1422
- : buildCapabilityAddress(provide.port, provide.path);
1910
+ // Honor explicit `embedded` opt-in/out on the provide before the
1911
+ // auto-detection logic runs. `"proxy"` short-circuits to the
1912
+ // same-origin proxy path (needed when upstream is firewall-blocked,
1913
+ // emits X-Frame-Options, or otherwise can't be reached by the
1914
+ // browser directly). `"direct"` forces the direct URL even when
1915
+ // listening only on loopback — caller asserts they know what
1916
+ // they're doing.
1917
+ const embeddedMode = provide.embedded ?? "auto";
1918
+ // Prefer a direct upstream URL when the container port is published to
1919
+ // a LAN-reachable address (Pi with host_network "external", etc.). The
1920
+ // same-origin reverse-proxy path is necessary only when the container
1921
+ // is bound to 127.0.0.1 (macOS+Colima, dev laptops without LAN
1922
+ // exposure) — there it's the only way for a remote browser to reach
1923
+ // the iframe content. Going through the proxy when the upstream is
1924
+ // already public causes path-collision bugs for apps that fetch
1925
+ // absolute URLs starting with `/api/...` (e.g. OpenWebUI), because
1926
+ // those calls bypass `<base href>` and hit the panel API instead.
1927
+ const listeningHost = legacyInstanceManager.getListeningHostForPort(provide.port);
1928
+ const isLoopback = listeningHost === "127.0.0.1" || listeningHost === "::1";
1929
+ const directlyReachable = embeddedMode !== "proxy"
1930
+ && listeningHost
1931
+ && (!isLoopback || embeddedMode === "direct");
1932
+ if (directlyReachable) {
1933
+ const advertised = legacyInstanceManager.getAdvertisedHostForPort(provide.port);
1934
+ const directUrl = `${protocol}://${advertised}:${provide.port}${provide.path ?? ""}`;
1935
+ return {
1936
+ capability: provide.capability,
1937
+ protocol,
1938
+ port: provide.port,
1939
+ url: directUrl,
1940
+ };
1941
+ }
1942
+ // Use same-origin reverse-proxy path so the frontend iframe works for
1943
+ // remote browsers and macOS+Colima environments where the container
1944
+ // port is only published to 127.0.0.1.
1945
+ // Root-path UIs (path omitted) still need a trailing slash so the
1946
+ // browser treats the iframe src as a directory URL. Without it, some
1947
+ // SPA runtimes compute relative URLs from `/.../provides/<capability>`
1948
+ // as if `<capability>` were a file segment, which breaks boot under the
1949
+ // proxy even when the HTML/base rewrite succeeded.
1950
+ const normalizedProvidePath = typeof provide.path === "string" ? provide.path.trim() : "";
1951
+ const needsTrailingSlash = !normalizedProvidePath || normalizedProvidePath.endsWith("/");
1952
+ const proxyPath = `/api/instances/${encodeURIComponent(appId)}/provides/${encodeURIComponent(provide.capability)}${needsTrailingSlash ? "/" : ""}`;
1423
1953
  return {
1424
1954
  capability: provide.capability,
1425
1955
  protocol,
1426
1956
  port: provide.port,
1427
- url: `${protocol}://${address}`,
1957
+ url: proxyPath,
1428
1958
  };
1429
1959
  }
1430
1960
  return null;
@@ -1462,7 +1992,11 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
1462
1992
  throw new Error(`task '${task.name}' 的 image '${task.image}' 格式无效`);
1463
1993
  }
1464
1994
  }
1465
- ensureRequiredCapabilitiesAvailable(spec);
1995
+ // PR 3 sub-step 3c: removed the legacy `ensureRequiredCapabilitiesAvailable`
1996
+ // hard-stop. install now never blocks on missing required providers —
1997
+ // resolveConnections(..., "preCreate") records them as `pending` on the
1998
+ // install task event so the UI can prompt the user; start time surfaces
1999
+ // the error if still unresolved.
1466
2000
  const instanceBackedInstall = await installIntoInstanceDir(spec, specYaml, requestedAppId, options);
1467
2001
  if (instanceBackedInstall) {
1468
2002
  return instanceBackedInstall;
@@ -1486,6 +2020,13 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
1486
2020
  const artifacts = [];
1487
2021
  try {
1488
2022
  await runLifecycleSteps(installedSpec.lifecycle?.pre_install, "pre_install", artifacts, options.task, options.exec);
2023
+ }
2024
+ catch (e) {
2025
+ cleanupArtifacts(artifacts, options.task);
2026
+ rmSync(appDir, { recursive: true, force: true });
2027
+ throw decorateInstallError(e, installedSpec);
2028
+ }
2029
+ try {
1489
2030
  await runLifecycleSteps(installedSpec.lifecycle?.install, "install", artifacts, options.task, options.exec);
1490
2031
  // Auto-pull docker images declared in tasks (deduplicated, skip already-pulled by lifecycle steps)
1491
2032
  const pulledImages = new Set(artifacts.filter(a => a.type === "image").map(a => a.path));
@@ -1511,7 +2052,7 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
1511
2052
  }
1512
2053
  cleanupArtifacts(artifacts, options.task);
1513
2054
  rmSync(appDir, { recursive: true, force: true });
1514
- throw e;
2055
+ throw decorateInstallError(e, installedSpec);
1515
2056
  }
1516
2057
  if (artifacts.length > 0) {
1517
2058
  manifest.artifacts = artifacts;
@@ -1686,44 +2227,91 @@ export function uninstallAppTask(id, exec) {
1686
2227
  export async function runPostStartSteps(spec) {
1687
2228
  await runLifecycleSteps(spec.lifecycle?.post_start, "post_start");
1688
2229
  }
2230
+ /**
2231
+ * Register all `provides` for an instance. PR 1 routes through the new
2232
+ * `capability-registry.ts` module (which dual-writes the legacy
2233
+ * `capabilities` map for compat). `portOverride` is preserved as a
2234
+ * temporary parameter for the existing server.ts startup-rebuild path
2235
+ * (`server.ts:266-282`); PR 1 step 0 of `resolveProvideEndpoint` reads
2236
+ * the actual allocated port from instance runtime when available, so
2237
+ * once PR 3 lands `portOverride` becomes redundant and gets removed.
2238
+ */
1689
2239
  export function registerCapabilities(instanceId, spec, portOverride) {
1690
2240
  if (!spec.provides || spec.provides.length === 0)
1691
2241
  return;
1692
- const reg = readRegistry();
1693
2242
  const now = new Date().toISOString();
2243
+ const appName = spec.name ?? spec.id;
1694
2244
  for (const provide of spec.provides) {
1695
- const hostPort = typeof portOverride === "number" && portOverride > 0
1696
- ? portOverride
1697
- : getProvidePort(spec, provide);
1698
- if (hostPort == null) {
2245
+ // url-only provide out of capability registry scope (§5.1 boundary).
2246
+ if (typeof provide.url === "string" && provide.url.trim())
1699
2247
  continue;
2248
+ let host = "127.0.0.1";
2249
+ let hostPort;
2250
+ if (typeof portOverride === "number" && portOverride > 0) {
2251
+ // Legacy startup-rebuild path: server.ts already resolved the actual
2252
+ // listening port via `instanceManager.getGatewayPort()`. Honor it for
2253
+ // the gateway-port provide (typically `provides[0]`); other provides
2254
+ // fall through to the spec-derived port.
2255
+ hostPort = portOverride;
2256
+ host = legacyInstanceManager.getAdvertisedHostForPort(portOverride);
2257
+ }
2258
+ if (typeof hostPort !== "number") {
2259
+ const resolved = resolveProvideEndpoint(instanceId, spec, provide);
2260
+ if (resolved) {
2261
+ host = resolved.host;
2262
+ hostPort = resolved.hostPort;
2263
+ }
2264
+ else {
2265
+ const declared = getProvidePort(spec, provide);
2266
+ if (declared == null)
2267
+ continue;
2268
+ hostPort = declared;
2269
+ host = legacyInstanceManager.getAdvertisedHostForPort(declared);
2270
+ }
1700
2271
  }
1701
- reg.capabilities[provide.capability] = {
2272
+ const protocol = resolveProvideProtocol(provide);
2273
+ const entry = {
1702
2274
  instanceId,
2275
+ name: appName,
2276
+ capability: provide.capability,
2277
+ host,
1703
2278
  hostPort,
2279
+ ...(provide.path ? { path: provide.path } : {}),
1704
2280
  address: buildCapabilityAddress(hostPort, provide.path),
1705
- path: provide.path,
1706
- registered_at: now,
2281
+ protocol,
2282
+ ...(provide.visibility ? { visibility: String(provide.visibility) } : {}),
2283
+ status: "running",
2284
+ lastSeenRunningAt: now,
2285
+ registeredAt: now,
2286
+ // §17 (PR 8) — carry MCP firewall canonical schema through so
2287
+ // adapters can resolve it during applyConnectionEnv without
2288
+ // reading the spec a second time.
2289
+ ...(provide.tool_schema ? { toolSchema: provide.tool_schema } : {}),
2290
+ // §6 (PR B) — carry auth config through so apply hooks can resolve
2291
+ // tokens at apply/runtime time without re-reading the spec.
2292
+ ...(provide.auth ? { auth: provide.auth } : {}),
1707
2293
  };
2294
+ capabilityRegistry.registerProvider(entry);
1708
2295
  }
1709
- writeRegistry(reg);
1710
2296
  }
2297
+ /**
2298
+ * Mark an instance's providers as `stopped` (preferred for stop) or
2299
+ * remove them entirely (uninstall / delete). Defaults to remove for
2300
+ * back-compat with existing call sites; new code should prefer
2301
+ * `markCapabilitiesStopped` to keep entries visible in the Connections UI.
2302
+ */
1711
2303
  export function unregisterCapabilities(instanceId) {
1712
- const reg = readRegistry();
1713
- for (const key of Object.keys(reg.capabilities)) {
1714
- if (reg.capabilities[key].instanceId === instanceId) {
1715
- delete reg.capabilities[key];
1716
- }
1717
- }
1718
- writeRegistry(reg);
2304
+ capabilityRegistry.unregisterProviders(instanceId);
2305
+ }
2306
+ export function markCapabilitiesStopped(instanceId) {
2307
+ capabilityRegistry.setProviderStatus(instanceId, "stopped");
1719
2308
  }
1720
2309
  export function resolveRequires(spec) {
1721
2310
  if (!spec.requires || spec.requires.length === 0)
1722
2311
  return {};
1723
- const reg = readRegistry();
1724
2312
  const result = {};
1725
2313
  for (const req of spec.requires) {
1726
- const entry = reg.capabilities[req.capability];
2314
+ const entry = capabilityRegistry.getCapabilityEntry(req.capability);
1727
2315
  if (entry) {
1728
2316
  result[req.inject_as] = entry.address;
1729
2317
  }
@@ -1734,7 +2322,49 @@ export function resolveRequires(spec) {
1734
2322
  return result;
1735
2323
  }
1736
2324
  // ── App Lifecycle (delegates to nomad-manager) ───────────────────
2325
+ /**
2326
+ * Read `instance.json` for a generic container app. Returns the parsed
2327
+ * record or `null` if missing/unreadable. Adapter-managed consumers
2328
+ * (OpenClaw, Hermes) keep their state elsewhere; this is the generic
2329
+ * app-dir layout under `~/.jishushell/apps/<appId>/`.
2330
+ */
2331
+ function readAppInstanceJson(appId) {
2332
+ try {
2333
+ const path = join(APPS_DIR, appId, "instance.json");
2334
+ return safeReadJson(path, `app-instance:${appId}`) ?? null;
2335
+ }
2336
+ catch (e) {
2337
+ console.warn(`[app-instance] read failed for ${appId}: ${e?.message ?? e}`);
2338
+ return null;
2339
+ }
2340
+ }
2341
+ /**
2342
+ * Read `instance.json["connections-env"]` for a generic container app and
2343
+ * return a copy of the persisted env vars. Adapter-managed consumers
2344
+ * (OpenClaw, Hermes) route connections through `applyConnectionEnv` and
2345
+ * don't write to this field. Best-effort — failures return empty object
2346
+ * so they never block startup.
2347
+ */
2348
+ function loadConnectionsEnv(appId) {
2349
+ const inst = readAppInstanceJson(appId);
2350
+ const env = inst?.["connections-env"];
2351
+ if (env && typeof env === "object" && !Array.isArray(env)) {
2352
+ const out = {};
2353
+ for (const [k, v] of Object.entries(env)) {
2354
+ if (typeof v === "string")
2355
+ out[k] = v;
2356
+ }
2357
+ return out;
2358
+ }
2359
+ return {};
2360
+ }
1737
2361
  export async function startApp(appId) {
2362
+ // Serialize against PUT /connections, stopApp and concurrent startApp on
2363
+ // the same instance — see `utils/instance-lock.ts` and §10.3 of the
2364
+ // app-interconnect design.
2365
+ return withInstanceLock(appId, () => startAppImpl(appId));
2366
+ }
2367
+ async function startAppImpl(appId) {
1738
2368
  const appData = getApp(appId);
1739
2369
  if (!appData) {
1740
2370
  return { ok: false, error: `App '${appId}' not found` };
@@ -1743,6 +2373,9 @@ export async function startApp(appId) {
1743
2373
  return { ok: false, error: `App '${appId}' is still installing` };
1744
2374
  }
1745
2375
  if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
2376
+ if (appData.spec.lifecycle?.pre_start?.length) {
2377
+ await runLifecycleSteps(appData.spec.lifecycle.pre_start, "pre_start");
2378
+ }
1746
2379
  const { startNomadJobInstance } = await import("../nomad-manager.js");
1747
2380
  const result = await startNomadJobInstance(appId);
1748
2381
  if (!result.ok)
@@ -1755,18 +2388,47 @@ export async function startApp(appId) {
1755
2388
  }
1756
2389
  return result;
1757
2390
  }
2391
+ // Resolve requires through the v3 connection-resolver in runtime mode so
2392
+ // missing-required / ambiguous-prefix / invalid-binding all surface with
2393
+ // the structured 412/409/400 codes (§6.4 Phase 4 of the app-interconnect
2394
+ // design). The legacy `resolveRequires` path threw a bare Error which the
2395
+ // route handler could only forward as a generic 400 — losing the bind-vs-
2396
+ // start-vs-pick distinction the UI needs.
2397
+ const instJson = readAppInstanceJson(appId);
2398
+ const persistedEnv = loadConnectionsEnv(appId);
1758
2399
  let extraEnv = {};
1759
2400
  try {
1760
- extraEnv = resolveRequires(appData.spec);
2401
+ const { resolveConnections, resolvedToLegacyEnv } = await import("../connection-resolver.js");
2402
+ const { renderRuntimeConnectionsEnv } = await import("../connection-apply.js");
2403
+ const { resolved } = resolveConnections(appData.spec, { connections: instJson?.connections ?? {} }, "runtime");
2404
+ const runtimeEnv = await renderRuntimeConnectionsEnv(appData.spec, {
2405
+ id: appId,
2406
+ connections: instJson?.connections,
2407
+ });
2408
+ // Frozen `connections-env` is the lowest-priority fallback for legacy
2409
+ // apps that pre-date resolveConnections. Runtime-rendered env wins so
2410
+ // provider port/IP changes propagate without re-binding.
2411
+ extraEnv = { ...persistedEnv, ...resolvedToLegacyEnv(resolved), ...runtimeEnv };
1761
2412
  }
1762
2413
  catch (e) {
1763
- return { ok: false, error: e.message };
2414
+ return {
2415
+ ok: false,
2416
+ error: e.message,
2417
+ ...(e.code ? { code: e.code } : {}),
2418
+ ...(typeof e.statusCode === "number" ? { statusCode: e.statusCode } : {}),
2419
+ };
1764
2420
  }
1765
2421
  const { startAppJob: nomadStart, checkDependencies, waitForRunning } = await import("../nomad-manager.js");
1766
2422
  const depCheck = await checkDependencies(appData.spec);
1767
2423
  if (!depCheck.ok) {
1768
2424
  return { ok: false, error: depCheck.errors.join("; ") };
1769
2425
  }
2426
+ // Run pre_start steps right before submitting the Nomad job — gives
2427
+ // apps a place to enforce per-start invariants (e.g. chown the
2428
+ // bind-mount source so the container's runtime uid can write).
2429
+ if (appData.spec.lifecycle?.pre_start?.length) {
2430
+ await runLifecycleSteps(appData.spec.lifecycle.pre_start, "pre_start");
2431
+ }
1770
2432
  const result = await nomadStart(appData.spec, appId, extraEnv);
1771
2433
  if (!result.ok) {
1772
2434
  return result;
@@ -1794,6 +2456,9 @@ export function startAppTask(appId) {
1794
2456
  });
1795
2457
  }
1796
2458
  export async function stopApp(appId, purge = false) {
2459
+ return withInstanceLock(appId, () => stopAppImpl(appId, purge));
2460
+ }
2461
+ async function stopAppImpl(appId, purge) {
1797
2462
  const appData = getApp(appId);
1798
2463
  if (appData?.install_state === "installing") {
1799
2464
  return { ok: false, error: `App '${appId}' is still installing` };
@@ -1802,14 +2467,22 @@ export async function stopApp(appId, purge = false) {
1802
2467
  const { stopNomadJobInstance } = await import("../nomad-manager.js");
1803
2468
  const result = await stopNomadJobInstance(appId, purge);
1804
2469
  if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
1805
- unregisterCapabilities(appId);
2470
+ // Stop = mark stopped (keep entry visible in Connections UI as a
2471
+ // greyed candidate); only purge / uninstall fully unregisters.
2472
+ if (purge)
2473
+ unregisterCapabilities(appId);
2474
+ else
2475
+ markCapabilitiesStopped(appId);
1806
2476
  }
1807
2477
  return result;
1808
2478
  }
1809
2479
  const { stopAppJob } = await import("../nomad-manager.js");
1810
2480
  const result = await stopAppJob(appId, purge);
1811
2481
  if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
1812
- unregisterCapabilities(appId);
2482
+ if (purge)
2483
+ unregisterCapabilities(appId);
2484
+ else
2485
+ markCapabilitiesStopped(appId);
1813
2486
  }
1814
2487
  return result;
1815
2488
  }
@@ -1825,28 +2498,34 @@ export function stopAppTask(appId, purge = false) {
1825
2498
  });
1826
2499
  }
1827
2500
  export async function restartApp(appId) {
1828
- const appData = getApp(appId);
1829
- if (appData?.install_state === "installing") {
1830
- return { ok: false, error: `App '${appId}' is still installing` };
1831
- }
1832
- if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
1833
- const { restartNomadJobInstance } = await import("../nomad-manager.js");
1834
- const result = await restartNomadJobInstance(appId);
1835
- if (!result.ok)
2501
+ // Hold the instance lock for the entire restart sequence so a concurrent
2502
+ // PUT /connections / startApp / stopApp on the same id can't observe a
2503
+ // half-restarted state. Inner stop/start calls reuse the same lock id;
2504
+ // we route them through the *Impl helpers to avoid re-acquiring.
2505
+ return withInstanceLock(appId, async () => {
2506
+ const appData = getApp(appId);
2507
+ if (appData?.install_state === "installing") {
2508
+ return { ok: false, error: `App '${appId}' is still installing` };
2509
+ }
2510
+ if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
2511
+ const { restartNomadJobInstance } = await import("../nomad-manager.js");
2512
+ const result = await restartNomadJobInstance(appId);
2513
+ if (!result.ok)
2514
+ return result;
2515
+ if (appData?.spec.provides?.length) {
2516
+ registerCapabilities(appId, appData.spec);
2517
+ }
2518
+ if (appData?.spec.lifecycle?.post_start?.length) {
2519
+ await runPostStartSteps(appData.spec);
2520
+ }
1836
2521
  return result;
1837
- if (appData?.spec.provides?.length) {
1838
- registerCapabilities(appId, appData.spec);
1839
2522
  }
1840
- if (appData?.spec.lifecycle?.post_start?.length) {
1841
- await runPostStartSteps(appData.spec);
2523
+ const stopResult = await stopAppImpl(appId, true);
2524
+ if (!stopResult.ok && !stopResult.error?.includes("not found")) {
2525
+ return stopResult;
1842
2526
  }
1843
- return result;
1844
- }
1845
- const stopResult = await stopApp(appId, true);
1846
- if (!stopResult.ok && !stopResult.error?.includes("not found")) {
1847
- return stopResult;
1848
- }
1849
- return startApp(appId);
2527
+ return startAppImpl(appId);
2528
+ });
1850
2529
  }
1851
2530
  export function restartAppTask(appId) {
1852
2531
  if (!getApp(appId)) {
@@ -1984,5 +2663,5 @@ export async function copyApp(sourceId) {
1984
2663
  // executes the install lifecycle in the new app dir.
1985
2664
  return installApp(baseYaml);
1986
2665
  }
1987
- export { onConfigChange, notifyConfigChange, instanceDir, instanceMetaPath, defaultModelEnvFile, normalizePath, extractGatewayPort, isPortInUse, allocateGatewayPort, releasePendingPort, getResolvedOpenclawBin, resolveServiceUser, chownToServiceUser, parseEnvFile, updateEnvFile, inferProviderApiKeyEnvName, listInstances, getInstance, updateInstanceMeta, deleteInstance, getConfig, getStoredConfig, saveConfig, CHANNEL_PLUGIN_MAP, isChannelPluginInstalled, createInstance, getOpenclawHome, saveFeishuCredentials, saveWeixinCredentials, getWeixinAccounts, getOpenclawConfigPath, getLegacyOpenclawConfigPath, getInstanceRuntime, getRuntimeEnvFiles, getGatewayPort, getGatewayHost, getListeningHostForPort, urlHost, findInstancesSharingOpenclawHome, reallocateGatewayPort, findInstancesSharingGatewayPort, getRuntimeEnv, defaultGatewayPort, releasePort, } from "../instance-manager.js";
2666
+ export { onConfigChange, notifyConfigChange, instanceDir, instanceMetaPath, defaultModelEnvFile, normalizePath, extractGatewayPort, isPortInUse, allocateGatewayPort, releasePendingPort, getResolvedOpenclawBin, resolveServiceUser, chownToServiceUser, parseEnvFile, updateEnvFile, inferProviderApiKeyEnvName, listInstances, getInstance, updateInstanceMeta, deleteInstance, getConfig, getStoredConfig, saveConfig, CHANNEL_PLUGIN_MAP, isChannelPluginInstalled, createInstance, getOpenclawHome, saveFeishuCredentials, saveWeixinCredentials, getWeixinAccounts, getOpenclawConfigPath, getLegacyOpenclawConfigPath, getInstanceRuntime, getRuntimeEnvFiles, getGatewayPort, getGatewayHost, getListeningHostForPort, getHostForAppPort, urlHost, findInstancesSharingOpenclawHome, reallocateGatewayPort, findInstancesSharingGatewayPort, getRuntimeEnv, defaultGatewayPort, releasePort, } from "../instance-manager.js";
1988
2667
  //# sourceMappingURL=app-manager.js.map