jishushell 0.4.24 → 0.4.30

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 (167) hide show
  1. package/INSTALL-NOTICE +11 -0
  2. package/apps/browserless-chromium-container.yaml +78 -0
  3. package/apps/hermes-container.yaml +36 -2
  4. package/apps/ollama-binary.yaml +91 -90
  5. package/apps/ollama-cpu-container.yaml +8 -1
  6. package/apps/ollama-with-hollama-binary.yaml +91 -90
  7. package/apps/openclaw-binary.yaml +30 -1
  8. package/apps/openclaw-container.yaml +37 -2
  9. package/apps/openclaw-with-ollama-container.yaml +11 -2
  10. package/apps/openclaw-with-searxng-container.yaml +22 -2
  11. package/apps/openwebui-container.yaml +45 -1
  12. package/apps/playwright-container.yaml +7 -1
  13. package/apps/searxng-container.yaml +54 -4
  14. package/dist/cli/app.js +79 -9
  15. package/dist/cli/app.js.map +1 -1
  16. package/dist/cli/doctor.d.ts +12 -12
  17. package/dist/cli/doctor.js +242 -55
  18. package/dist/cli/doctor.js.map +1 -1
  19. package/dist/cli/llm.d.ts +4 -3
  20. package/dist/cli/llm.js +4 -3
  21. package/dist/cli/llm.js.map +1 -1
  22. package/dist/cli/panel.d.ts +6 -5
  23. package/dist/cli/panel.js +10 -9
  24. package/dist/cli/panel.js.map +1 -1
  25. package/dist/control.d.ts +7 -6
  26. package/dist/control.js +7 -6
  27. package/dist/control.js.map +1 -1
  28. package/dist/routes/agent-apps.d.ts +1 -1
  29. package/dist/routes/agent-apps.js +1 -1
  30. package/dist/routes/apps.js +44 -11
  31. package/dist/routes/apps.js.map +1 -1
  32. package/dist/routes/auth.js +3 -0
  33. package/dist/routes/auth.js.map +1 -1
  34. package/dist/routes/instances.js +787 -16
  35. package/dist/routes/instances.js.map +1 -1
  36. package/dist/routes/llm.js +24 -35
  37. package/dist/routes/llm.js.map +1 -1
  38. package/dist/routes/setup.js +1 -1
  39. package/dist/routes/setup.js.map +1 -1
  40. package/dist/server.d.ts +9 -0
  41. package/dist/server.js +410 -17
  42. package/dist/server.js.map +1 -1
  43. package/dist/services/agent-apps/catalog.js +4 -3
  44. package/dist/services/agent-apps/catalog.js.map +1 -1
  45. package/dist/services/agent-apps/index.d.ts +1 -1
  46. package/dist/services/agent-apps/index.js +1 -1
  47. package/dist/services/agent-apps/installers/adapter.d.ts +1 -1
  48. package/dist/services/agent-apps/installers/adapter.js +1 -1
  49. package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
  50. package/dist/services/agent-apps/installers/shell-script.js +3 -3
  51. package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
  52. package/dist/services/agent-apps/types.d.ts +2 -2
  53. package/dist/services/agent-apps/types.js +1 -1
  54. package/dist/services/app/app-manager.d.ts +24 -1
  55. package/dist/services/app/app-manager.js +664 -116
  56. package/dist/services/app/app-manager.js.map +1 -1
  57. package/dist/services/app/hermes-agent-manager.js +6 -4
  58. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  59. package/dist/services/app/provide-resolver.d.ts +29 -0
  60. package/dist/services/app/provide-resolver.js +112 -0
  61. package/dist/services/app/provide-resolver.js.map +1 -0
  62. package/dist/services/capability-endpoint-validator.d.ts +41 -0
  63. package/dist/services/capability-endpoint-validator.js +104 -0
  64. package/dist/services/capability-endpoint-validator.js.map +1 -0
  65. package/dist/services/capability-health.d.ts +16 -0
  66. package/dist/services/capability-health.js +121 -0
  67. package/dist/services/capability-health.js.map +1 -0
  68. package/dist/services/capability-registry.d.ts +106 -0
  69. package/dist/services/capability-registry.js +313 -0
  70. package/dist/services/capability-registry.js.map +1 -0
  71. package/dist/services/connection-apply.d.ts +89 -0
  72. package/dist/services/connection-apply.js +421 -0
  73. package/dist/services/connection-apply.js.map +1 -0
  74. package/dist/services/connection-resolver.d.ts +65 -0
  75. package/dist/services/connection-resolver.js +281 -0
  76. package/dist/services/connection-resolver.js.map +1 -0
  77. package/dist/services/connection-transactor.d.ts +37 -0
  78. package/dist/services/connection-transactor.js +341 -0
  79. package/dist/services/connection-transactor.js.map +1 -0
  80. package/dist/services/instance-manager.d.ts +13 -0
  81. package/dist/services/instance-manager.js +137 -23
  82. package/dist/services/instance-manager.js.map +1 -1
  83. package/dist/services/llm-proxy/index.d.ts +16 -2
  84. package/dist/services/llm-proxy/index.js +48 -44
  85. package/dist/services/llm-proxy/index.js.map +1 -1
  86. package/dist/services/llm-proxy/probe.d.ts +6 -0
  87. package/dist/services/llm-proxy/probe.js +85 -0
  88. package/dist/services/llm-proxy/probe.js.map +1 -0
  89. package/dist/services/llm-proxy/ssrf.d.ts +1 -0
  90. package/dist/services/llm-proxy/ssrf.js +18 -7
  91. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  92. package/dist/services/nomad-manager.js +375 -16
  93. package/dist/services/nomad-manager.js.map +1 -1
  94. package/dist/services/process-manager.js +1 -1
  95. package/dist/services/process-manager.js.map +1 -1
  96. package/dist/services/runtime/adapters/hermes.d.ts +30 -1
  97. package/dist/services/runtime/adapters/hermes.js +218 -5
  98. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  99. package/dist/services/runtime/adapters/openclaw-mcporter.d.ts +45 -0
  100. package/dist/services/runtime/adapters/openclaw-mcporter.js +108 -0
  101. package/dist/services/runtime/adapters/openclaw-mcporter.js.map +1 -0
  102. package/dist/services/runtime/adapters/openclaw.d.ts +87 -0
  103. package/dist/services/runtime/adapters/openclaw.js +250 -2
  104. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  105. package/dist/services/runtime/mcp-shims/firewall.d.ts +26 -0
  106. package/dist/services/runtime/mcp-shims/firewall.js +129 -0
  107. package/dist/services/runtime/mcp-shims/firewall.js.map +1 -0
  108. package/dist/services/runtime/mcp-shims/searxng-shim.d.ts +27 -0
  109. package/dist/services/runtime/mcp-shims/searxng-shim.js +125 -0
  110. package/dist/services/runtime/mcp-shims/searxng-shim.js.map +1 -0
  111. package/dist/services/runtime/mcp-shims/write-mcp-entry.d.ts +83 -0
  112. package/dist/services/runtime/mcp-shims/write-mcp-entry.js +127 -0
  113. package/dist/services/runtime/mcp-shims/write-mcp-entry.js.map +1 -0
  114. package/dist/services/runtime/migrations.d.ts +8 -0
  115. package/dist/services/runtime/migrations.js +100 -0
  116. package/dist/services/runtime/migrations.js.map +1 -1
  117. package/dist/services/runtime/types.d.ts +15 -0
  118. package/dist/services/setup-manager.js +6 -6
  119. package/dist/services/setup-manager.js.map +1 -1
  120. package/dist/services/suggestions.d.ts +27 -0
  121. package/dist/services/suggestions.js +133 -0
  122. package/dist/services/suggestions.js.map +1 -0
  123. package/dist/services/task-registry.js +4 -2
  124. package/dist/services/task-registry.js.map +1 -1
  125. package/dist/services/telemetry/device-fingerprint.d.ts +1 -1
  126. package/dist/services/telemetry/device-fingerprint.js +1 -1
  127. package/dist/services/types-shim.d.ts +16 -0
  128. package/dist/services/types-shim.js +2 -0
  129. package/dist/services/types-shim.js.map +1 -0
  130. package/dist/types.d.ts +171 -1
  131. package/dist/utils/instance-lock.d.ts +22 -0
  132. package/dist/utils/instance-lock.js +48 -0
  133. package/dist/utils/instance-lock.js.map +1 -0
  134. package/dist/utils/safe-json.js +55 -22
  135. package/dist/utils/safe-json.js.map +1 -1
  136. package/install/jishu-install.sh +323 -27
  137. package/install/jishu-uninstall.sh +353 -20
  138. package/package.json +3 -1
  139. package/public/assets/Dashboard-rkWp-CXd.js +1 -0
  140. package/public/assets/{HermesChatPanel-mFSureyc.js → HermesChatPanel-_GHoklgo.js} +1 -1
  141. package/public/assets/HermesConfigForm-anDnwUp_.js +4 -0
  142. package/public/assets/{InitPassword-CVA8wQA6.js → InitPassword-ZU9_-hDr.js} +1 -1
  143. package/public/assets/InstanceDetail-CN0FH1aw.js +92 -0
  144. package/public/assets/{Login-BWsZH2mu.js → Login-BItXqYAJ.js} +1 -1
  145. package/public/assets/NewInstance-BousE6kY.js +1 -0
  146. package/public/assets/ProviderRecommendations-DFYj7Fb6.js +1 -0
  147. package/public/assets/Settings-Bttc6QmM.js +1 -0
  148. package/public/assets/Setup-Bsxx1zgj.js +1 -0
  149. package/public/assets/{WeixinLoginPanel-CnjR8xMu.js → WeixinLoginPanel-DPZpAKgO.js} +2 -2
  150. package/public/assets/index-8xZy1z5k.css +1 -0
  151. package/public/assets/index-Dw3HhUYE.js +19 -0
  152. package/public/assets/providers-DtNXh9JD.js +1 -0
  153. package/public/assets/registry-5s2UB6is.js +2 -0
  154. package/public/index.html +2 -2
  155. package/scripts/check-app-spec.mjs +443 -0
  156. package/scripts/check-i18n.mjs +154 -0
  157. package/scripts/run.sh +4 -4
  158. package/public/assets/Dashboard-B-JoOjBQ.js +0 -1
  159. package/public/assets/HermesConfigForm-DvR05LK1.js +0 -4
  160. package/public/assets/InstanceDetail-DcZW2QGO.js +0 -91
  161. package/public/assets/NewInstance-BCIrAd86.js +0 -1
  162. package/public/assets/Settings-xkDcduFz.js +0 -1
  163. package/public/assets/Setup-Cfuwj4gV.js +0 -1
  164. package/public/assets/index-CPhVFEsx.css +0 -1
  165. package/public/assets/index-DQsM6Joa.js +0 -19
  166. package/public/assets/providers-V-vwrExZ.js +0 -1
  167. package/public/assets/registry-B4UFJdpA.js +0 -2
@@ -1,16 +1,19 @@
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";
15
+ import * as capabilityRegistry from "../capability-registry.js";
16
+ import { resolveProvideEndpoint } from "./provide-resolver.js";
14
17
  const DEFAULT_LIFECYCLE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
15
18
  function getConfigValue(name) {
16
19
  return name in config ? config[name] : undefined;
@@ -121,7 +124,11 @@ export function updateInstance(instanceId, name, description) {
121
124
  return legacyInstanceManager.getInstance(instanceId) ?? updatedMeta;
122
125
  }
123
126
  function parseComparableVersion(version, label) {
124
- const normalized = version.trim().replace(/^>=\s*/, "").replace(/^v/i, "");
127
+ const normalized = version
128
+ .trim()
129
+ .replace(/^>=\s*/, "")
130
+ .replace(/^v/i, "")
131
+ .replace(/[-+].*$/, "");
125
132
  const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
126
133
  if (!match) {
127
134
  throw new Error(`${label} '${version}' 格式无效,应为 x.y.z`);
@@ -173,6 +180,114 @@ function buildLifecycleEnv() {
173
180
  PATH: [...new Set(mergedPath)].join(":"),
174
181
  };
175
182
  }
183
+ const SUDO_PASSTHROUGH_ENV_KEYS = ["HOME", "PATH", "TMPDIR", "TMP", "TEMP", "XDG_RUNTIME_DIR"];
184
+ function isSudoAuthenticationError(message) {
185
+ return /incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message);
186
+ }
187
+ function isSudoNoNewPrivilegesError(message) {
188
+ return /no new privileges/i.test(message);
189
+ }
190
+ function isSudoPasswordRequiredError(message) {
191
+ return /password is required|a password is required/i.test(message);
192
+ }
193
+ function buildSudoWrappedCommand(cmd, args, env, execOptions) {
194
+ const sudoArgs = execOptions?.sudoPassword ? ["-k", "-A"] : ["-n"];
195
+ const envArgs = SUDO_PASSTHROUGH_ENV_KEYS.flatMap((key) => {
196
+ const value = env[key];
197
+ return typeof value === "string" && value.length > 0 ? [`${key}=${value}`] : [];
198
+ });
199
+ return {
200
+ command: "sudo",
201
+ args: [...sudoArgs, "--", "env", ...envArgs, cmd, ...args],
202
+ };
203
+ }
204
+ function createLifecycleSudoError(stderr, fallbackDisplay, hasPassword) {
205
+ const message = sanitizeTaskLine(stderr).trim();
206
+ if (isSudoNoNewPrivilegesError(message)) {
207
+ return createNoNewPrivilegesSudoError();
208
+ }
209
+ if (isSudoAuthenticationError(message)) {
210
+ const err = new Error("sudo 密码错误,请重新输入。");
211
+ err.code = "INVALID_SUDO_PASSWORD";
212
+ return err;
213
+ }
214
+ if (!hasPassword && isSudoPasswordRequiredError(message)) {
215
+ return new Error("该生命周期步骤需要 sudo 密码;请在页面弹窗中输入后重试。");
216
+ }
217
+ if (message) {
218
+ return new Error(message);
219
+ }
220
+ return new Error(`lifecycle sudo step failed: ${fallbackDisplay}`);
221
+ }
222
+ function panelSystemdServicePath() {
223
+ const override = process.env.JISHUSHELL_PANEL_SYSTEMD_SERVICE_PATH?.trim();
224
+ return override || "/etc/systemd/system/jishushell.service";
225
+ }
226
+ function isLikelySystemdServiceProcess() {
227
+ return Boolean(process.env.INVOCATION_ID
228
+ || process.env.JOURNAL_STREAM
229
+ || process.env.NOTIFY_SOCKET
230
+ || process.env.JISHUSHELL_PANEL_SYSTEMD_SERVICE_PATH?.trim());
231
+ }
232
+ function maybeRepairPanelAutostartNoNewPrivileges() {
233
+ if (!isLikelySystemdServiceProcess())
234
+ return null;
235
+ const servicePath = panelSystemdServicePath();
236
+ if (!existsSync(servicePath))
237
+ return null;
238
+ let unitText = "";
239
+ try {
240
+ unitText = readFileSync(servicePath, "utf-8");
241
+ }
242
+ catch {
243
+ return { servicePath, detected: true, updated: false };
244
+ }
245
+ if (!/^\s*NoNewPrivileges\s*=\s*true\s*$/mi.test(unitText)) {
246
+ return null;
247
+ }
248
+ const nextText = unitText.replace(/^\s*NoNewPrivileges\s*=\s*true\s*\n?/gim, "");
249
+ if (nextText === unitText) {
250
+ return { servicePath, detected: true, updated: false };
251
+ }
252
+ try {
253
+ writeFileSync(servicePath, nextText);
254
+ return { servicePath, detected: true, updated: true };
255
+ }
256
+ catch {
257
+ return { servicePath, detected: true, updated: false };
258
+ }
259
+ }
260
+ function manualInstallCommandForSpec(spec) {
261
+ if (spec.id === "ollama-binary") {
262
+ return "jishushell app install ollama";
263
+ }
264
+ const builtin = listBuiltinAppSpecs().find((entry) => entry.id === spec.id);
265
+ if (!builtin)
266
+ return null;
267
+ return `jishushell app install ${spec.id}`;
268
+ }
269
+ function createNoNewPrivilegesSudoError(manualInstallCommand) {
270
+ const repair = maybeRepairPanelAutostartNoNewPrivileges();
271
+ const restartCommand = "sudo systemctl daemon-reload && sudo systemctl restart jishushell";
272
+ const parts = ["当前运行环境禁止 sudo 提权(no new privileges),面板内无法继续后续安装。"];
273
+ if (repair?.updated) {
274
+ parts.push(`已从自启文件 ${repair.servicePath} 移除 NoNewPrivileges=true。请在系统终端执行以下命令后重试:\n${restartCommand}`);
275
+ }
276
+ else if (repair?.detected) {
277
+ parts.push(`检测到自启文件 ${repair.servicePath} 仍包含 NoNewPrivileges=true。请在系统终端删除该行后执行:\n${restartCommand}`);
278
+ }
279
+ if (manualInstallCommand) {
280
+ parts.push(`当前安装已停止。你也可以在系统终端手动执行 ${manualInstallCommand}。`);
281
+ }
282
+ return new Error(parts.join("\n"));
283
+ }
284
+ function decorateInstallError(error, spec) {
285
+ const original = error instanceof Error ? error : new Error(String(error));
286
+ if (!isSudoNoNewPrivilegesError(original.message) && !/NoNewPrivileges=true/i.test(original.message)) {
287
+ return original;
288
+ }
289
+ return createNoNewPrivilegesSudoError(manualInstallCommandForSpec(spec) ?? undefined);
290
+ }
176
291
  export async function validateSudoPassword(sudoPassword) {
177
292
  if (!sudoPassword) {
178
293
  throw new Error("请输入 sudo 密码");
@@ -198,8 +313,14 @@ export async function validateSudoPassword(sudoPassword) {
198
313
  return;
199
314
  }
200
315
  const message = sanitizeTaskLine(stderr).trim();
316
+ if (isSudoNoNewPrivilegesError(message)) {
317
+ reject(createNoNewPrivilegesSudoError());
318
+ return;
319
+ }
201
320
  if (/incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message)) {
202
- reject(new Error("sudo 密码错误,请重新输入。"));
321
+ const err = new Error("sudo 密码错误,请重新输入。");
322
+ err.code = "INVALID_SUDO_PASSWORD";
323
+ reject(err);
203
324
  return;
204
325
  }
205
326
  resolve();
@@ -276,24 +397,88 @@ function normalizePortVisibility(visibility) {
276
397
  throw new Error(`port visibility '${visibility}' 仅支持 external 或 internal`);
277
398
  }
278
399
  function normalizeAppSpec(spec) {
400
+ const normalizedProvides = (spec.provides ?? []).map((provide) => {
401
+ if (spec.id === "browserless-chromium-container"
402
+ && provide.capability === BROWSERLESS_DEBUGGER_CAPABILITY
403
+ && (provide.path === "/" || provide.path === "/debugger")) {
404
+ return { ...provide, path: "/debugger/" };
405
+ }
406
+ if (spec.id === "browserless-chromium-container"
407
+ && provide.capability === BROWSERLESS_DOCS_CAPABILITY
408
+ && provide.path === "/docs") {
409
+ return { ...provide, path: "/docs/" };
410
+ }
411
+ if (spec.id === "browserless-chromium-container"
412
+ && provide.capability === BROWSERLESS_API_CAPABILITY
413
+ && provide.path !== "/") {
414
+ return { ...provide, path: "/" };
415
+ }
416
+ return provide;
417
+ });
418
+ // Inject browserless-api capability for legacy installed specs that lack it.
419
+ if (spec.id === "browserless-chromium-container"
420
+ && normalizedProvides.length > 0
421
+ && !normalizedProvides.some((p) => p.capability === BROWSERLESS_API_CAPABILITY)) {
422
+ const debugger_ = normalizedProvides.find((p) => p.capability === BROWSERLESS_DEBUGGER_CAPABILITY);
423
+ if (debugger_) {
424
+ normalizedProvides.push({
425
+ capability: BROWSERLESS_API_CAPABILITY,
426
+ port: debugger_.port ?? 3000,
427
+ path: "/",
428
+ protocol: "http",
429
+ description: "Browserless 根 API(供调试器页面连接 ws 与 sessions 接口)",
430
+ });
431
+ }
432
+ }
433
+ const normalizedTasks = (spec.tasks ?? []).map((task) => {
434
+ const rawTask = { ...task };
435
+ if (!rawTask.role) {
436
+ rawTask.role = "service";
437
+ }
438
+ if (!rawTask.command && rawTask.binary) {
439
+ rawTask.command = rawTask.binary;
440
+ }
441
+ if (Array.isArray(rawTask.ports)) {
442
+ rawTask.ports = rawTask.ports.map((port) => ({
443
+ ...port,
444
+ visibility: normalizePortVisibility(port.visibility),
445
+ }));
446
+ }
447
+ return rawTask;
448
+ });
449
+ let normalizedLifecycle = spec.lifecycle ? { ...spec.lifecycle } : undefined;
450
+ if (spec.id === "browserless-chromium-container") {
451
+ const browserlessDataDir = `~/.jishushell/apps/${spec.app_id || spec.id}/data`;
452
+ const browserlessDataDirTemplate = "~/.jishushell/apps/${app_id}/data";
453
+ const browserlessTask = normalizedTasks.find((task) => task.name === "browserless");
454
+ if (browserlessTask) {
455
+ const dataDirTarget = "/tmp/browserless-data";
456
+ const existingVolumes = Array.isArray(browserlessTask.volumes) ? [...browserlessTask.volumes] : [];
457
+ const hasDataDirVolume = existingVolumes.some((volume) => typeof volume === "object" && volume !== null && volume.target === dataDirTarget);
458
+ if (!hasDataDirVolume) {
459
+ existingVolumes.push({ source: browserlessDataDir, target: dataDirTarget });
460
+ }
461
+ browserlessTask.volumes = existingVolumes;
462
+ }
463
+ const install = [...(normalizedLifecycle?.install ?? [])];
464
+ if (!install.some((step) => "mkdir" in step && (step.mkdir === browserlessDataDir || step.mkdir === browserlessDataDirTemplate))) {
465
+ install.push({ mkdir: browserlessDataDir });
466
+ }
467
+ const preStart = [...(normalizedLifecycle?.pre_start ?? [])];
468
+ if (!preStart.some((step) => "mkdir" in step && (step.mkdir === browserlessDataDir || step.mkdir === browserlessDataDirTemplate))) {
469
+ preStart.push({ mkdir: browserlessDataDir });
470
+ }
471
+ normalizedLifecycle = {
472
+ ...(normalizedLifecycle ?? {}),
473
+ install,
474
+ pre_start: preStart,
475
+ };
476
+ }
279
477
  return {
280
478
  ...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
- }),
479
+ ...(spec.provides ? { provides: normalizedProvides } : {}),
480
+ tasks: normalizedTasks,
481
+ ...(normalizedLifecycle ? { lifecycle: normalizedLifecycle } : {}),
297
482
  };
298
483
  }
299
484
  function imageReferencedByOtherInstalledApps(currentAppId, imagePath) {
@@ -388,7 +573,8 @@ function getProvidePort(spec, provide) {
388
573
  const firstPort = spec.tasks.find((task) => task.role === "service")?.ports?.[0];
389
574
  if (!firstPort)
390
575
  return null;
391
- return firstPort.host_port ?? firstPort.port;
576
+ const p = firstPort.host_port ?? firstPort.port;
577
+ return typeof p === "number" && p > 0 ? p : null;
392
578
  }
393
579
  function getProvideUrl(provide) {
394
580
  const raw = typeof provide.url === "string" ? provide.url.trim() : "";
@@ -406,7 +592,7 @@ function getProvideUrl(provide) {
406
592
  }
407
593
  }
408
594
  function buildCapabilityAddress(port, path) {
409
- const host = legacyInstanceManager.getAdvertisedHostForPort(port);
595
+ const host = port > 0 ? legacyInstanceManager.getAdvertisedHostForPort(port) : "127.0.0.1";
410
596
  if (!path) {
411
597
  return `${host}:${port}`;
412
598
  }
@@ -630,11 +816,21 @@ const DOCKER_PULL_RETRY_ATTEMPTS = 3;
630
816
  // extracts in 5 min on a Raspberry Pi. 30 min clears both with headroom while
631
817
  // still capping runaway failures (total retry budget 90 min).
632
818
  const DOCKER_PULL_TIMEOUT_MS = 1_800_000;
819
+ // Separate from the total timeout above: if docker pull stops producing any
820
+ // stdout/stderr for long enough, treat it as stalled and retry rather than
821
+ // waiting the full 30 minutes.
822
+ const DOCKER_PULL_IDLE_TIMEOUT_MS = 180_000;
633
823
  async function pullDockerImageStep(label, image, display, task, timeoutMs = DOCKER_PULL_TIMEOUT_MS) {
824
+ if (await dockerImageExists(image)) {
825
+ const skipMessage = `[lifecycle:${label}] docker image '${image}' already exists locally; skipping pull`;
826
+ process.stdout.write(` ${skipMessage}\n`);
827
+ emitInstallTaskLog(task, skipMessage);
828
+ return;
829
+ }
634
830
  let lastError;
635
831
  for (let attempt = 1; attempt <= DOCKER_PULL_RETRY_ATTEMPTS; attempt++) {
636
832
  try {
637
- await spawnStepWithTimeout(label, display, display, "docker", ["pull", image], timeoutMs, task);
833
+ await spawnStepWithTimeout(label, display, display, "docker", ["pull", image], timeoutMs, task, undefined, undefined, { idleTimeoutMs: Math.min(DOCKER_PULL_IDLE_TIMEOUT_MS, timeoutMs) });
638
834
  return;
639
835
  }
640
836
  catch (error) {
@@ -666,13 +862,18 @@ async function dockerImageExists(image) {
666
862
  child.on("error", () => resolve(false));
667
863
  });
668
864
  }
669
- function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions) {
865
+ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions, sudo, runOptions) {
670
866
  process.stdout.write(` [lifecycle:${label}] ${display}\n`);
671
867
  emitInstallTaskLog(task, `[lifecycle:${label}] ${taskDisplay}`);
672
868
  return new Promise((resolve, reject) => {
673
- const preparedEnv = prepareLifecycleExecEnv(execOptions);
869
+ const preparedEnv = prepareLifecycleExecEnv(sudo ? execOptions : undefined);
674
870
  let cleaned = false;
675
871
  let heartbeatTimer = null;
872
+ let idleTimer = null;
873
+ let stdoutPending = "";
874
+ let stderrPending = "";
875
+ let capturedStderr = "";
876
+ let forcedError = null;
676
877
  const cleanupPreparedEnv = () => {
677
878
  if (cleaned)
678
879
  return;
@@ -681,22 +882,57 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
681
882
  clearInterval(heartbeatTimer);
682
883
  heartbeatTimer = null;
683
884
  }
885
+ if (idleTimer) {
886
+ clearTimeout(idleTimer);
887
+ idleTimer = null;
888
+ }
684
889
  preparedEnv.cleanup();
685
890
  };
686
- const child = spawn(cmd, args, {
687
- stdio: task ? ["ignore", "pipe", "pipe"] : "inherit",
891
+ const spawnTarget = sudo
892
+ ? buildSudoWrappedCommand(cmd, args, preparedEnv.env, execOptions)
893
+ : { command: cmd, args };
894
+ const captureOutput = Boolean(task) || Boolean(sudo) || Boolean(runOptions?.idleTimeoutMs);
895
+ const child = spawn(spawnTarget.command, spawnTarget.args, {
896
+ stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
688
897
  timeout: timeoutMs,
689
898
  env: preparedEnv.env,
690
899
  });
691
- if (task) {
692
- let stdoutPending = "";
693
- let stderrPending = "";
900
+ const resetIdleTimer = () => {
901
+ if (!runOptions?.idleTimeoutMs)
902
+ return;
903
+ if (idleTimer)
904
+ clearTimeout(idleTimer);
905
+ idleTimer = setTimeout(() => {
906
+ const idleSeconds = Math.max(1, Math.round(runOptions.idleTimeoutMs / 1000));
907
+ const stallMessage = `[lifecycle:${label}] no output for ${idleSeconds}s; terminating stalled step: ${taskDisplay}`;
908
+ process.stdout.write(` ${stallMessage}\n`);
909
+ emitInstallTaskLog(task, stallMessage);
910
+ forcedError = new Error(`lifecycle '${label}' step stalled after ${idleSeconds}s with no output: ${display}`);
911
+ child.kill("SIGTERM");
912
+ }, runOptions.idleTimeoutMs);
913
+ idleTimer.unref?.();
914
+ };
915
+ resetIdleTimer();
916
+ if (captureOutput) {
694
917
  const startedAt = Date.now();
695
918
  const flushPendingLine = (line) => {
919
+ if (!task)
920
+ return;
696
921
  emitInstallTaskLog(task, line);
697
922
  };
698
923
  const handleChunk = (chunk, stream) => {
924
+ resetIdleTimer();
699
925
  const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
926
+ if (stream === "stderr") {
927
+ capturedStderr += text;
928
+ }
929
+ if (!task) {
930
+ if (stream === "stdout")
931
+ process.stdout.write(text);
932
+ else
933
+ process.stderr.write(text);
934
+ return;
935
+ }
700
936
  const normalized = `${stream === "stdout" ? stdoutPending : stderrPending}${text}`
701
937
  .replace(/\r\n/g, "\n")
702
938
  .replace(/\r/g, "\n");
@@ -712,24 +948,38 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
712
948
  };
713
949
  child.stdout?.on("data", (data) => handleChunk(data, "stdout"));
714
950
  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
- });
951
+ if (task) {
952
+ heartbeatTimer = setInterval(() => {
953
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
954
+ emitInstallTaskLog(task, `[lifecycle:${label}] still running (${elapsedSeconds}s): ${taskDisplay}`);
955
+ }, 10_000);
956
+ child.on("close", () => {
957
+ flushPendingLine(stdoutPending);
958
+ flushPendingLine(stderrPending);
959
+ });
960
+ }
723
961
  }
724
962
  child.on("close", (code) => {
725
963
  cleanupPreparedEnv();
726
- if (code === 0)
964
+ if (forcedError)
965
+ reject(forcedError);
966
+ else if (code === 0)
727
967
  resolve();
968
+ else if (sudo)
969
+ reject(createLifecycleSudoError(capturedStderr, display, Boolean(execOptions?.sudoPassword)));
728
970
  else
729
971
  reject(new Error(`lifecycle '${label}' step failed (exit ${code ?? 1}): ${display}`));
730
972
  });
731
973
  child.on("error", (err) => {
732
974
  cleanupPreparedEnv();
975
+ if (forcedError) {
976
+ reject(forcedError);
977
+ return;
978
+ }
979
+ if (sudo && err.code === "ENOENT") {
980
+ reject(new Error("当前环境未检测到 sudo,无法执行需要 sudo 的生命周期步骤。请以 root 身份重试。"));
981
+ return;
982
+ }
733
983
  reject(new Error(`lifecycle '${label}' step error: ${err.message}`));
734
984
  });
735
985
  });
@@ -742,7 +992,8 @@ function lifecycleRunStepDisplay(label, index) {
742
992
  }
743
993
  async function commandExists(command) {
744
994
  return new Promise((resolve) => {
745
- const child = spawn("sh", ["-c", `command -v '${command}' > /dev/null 2>&1`], {
995
+ const quoted = command.replace(/'/g, "'\\''");
996
+ const child = spawn("sh", ["-c", `command -v '${quoted}' > /dev/null 2>&1`], {
746
997
  stdio: "ignore",
747
998
  env: buildLifecycleEnv(),
748
999
  });
@@ -750,6 +1001,64 @@ async function commandExists(command) {
750
1001
  child.on("error", () => resolve(false));
751
1002
  });
752
1003
  }
1004
+ // Recursively chown a path. Owner format is "uid:gid" (numeric only, e.g.
1005
+ // "0:0" or "1000:1000"). Used by container apps whose images run as a
1006
+ // different uid than the panel user — without this, bind-mounted data
1007
+ // dirs end up unwritable for the in-container process and fail with
1008
+ // SQLite "readonly database" or chroma init errors.
1009
+ function parseOwnerSpec(owner) {
1010
+ const m = /^(\d+):(\d+)$/.exec(owner);
1011
+ if (!m)
1012
+ throw new Error(`chown owner must be "uid:gid" (numeric), got "${owner}"`);
1013
+ return { uid: Number(m[1]), gid: Number(m[2]) };
1014
+ }
1015
+ function chownRecursive(path, uid, gid) {
1016
+ chownSync(path, uid, gid);
1017
+ let stat;
1018
+ try {
1019
+ stat = lstatSync(path);
1020
+ }
1021
+ catch {
1022
+ return;
1023
+ }
1024
+ if (!stat.isDirectory())
1025
+ return;
1026
+ for (const entry of readdirSync(path)) {
1027
+ chownRecursive(join(path, entry), uid, gid);
1028
+ }
1029
+ }
1030
+ /**
1031
+ * Try chowning via direct fs syscall first; on EPERM (panel runs as a
1032
+ * non-root user with no CAP_CHOWN) fall back to `sudo -n chown`. The
1033
+ * fallback only succeeds where passwordless sudo is configured for the
1034
+ * panel user (the canonical Pi setup); on other hosts the original
1035
+ * EPERM bubbles up as a clear error.
1036
+ */
1037
+ function chownWithSudoFallback(path, uid, gid, recursive) {
1038
+ try {
1039
+ if (recursive)
1040
+ chownRecursive(path, uid, gid);
1041
+ else
1042
+ chownSync(path, uid, gid);
1043
+ return;
1044
+ }
1045
+ catch (e) {
1046
+ if (e?.code !== "EPERM" && e?.code !== "EACCES")
1047
+ throw e;
1048
+ }
1049
+ const args = [
1050
+ "-n",
1051
+ "chown",
1052
+ ...(recursive ? ["-R"] : []),
1053
+ `${uid}:${gid}`,
1054
+ path,
1055
+ ];
1056
+ const r = spawnSync("sudo", args, { stdio: ["ignore", "ignore", "pipe"] });
1057
+ if (r.status !== 0) {
1058
+ const stderr = r.stderr ? r.stderr.toString().trim() : "";
1059
+ throw new Error(`chown ${recursive ? "-R " : ""}${uid}:${gid} ${path} failed: panel user lacks CAP_CHOWN and passwordless sudo also failed${stderr ? `: ${stderr}` : ""}`);
1060
+ }
1061
+ }
753
1062
  async function downloadBinaryStep(label, url, dest, chmod, task) {
754
1063
  const expanded = expandPath(dest);
755
1064
  process.stdout.write(` [lifecycle:${label}] downloadBinary: ${url} → ${expanded}\n`);
@@ -770,13 +1079,16 @@ async function runLifecycleSteps(steps, label, artifacts, task, execOptions) {
770
1079
  return;
771
1080
  for (const [index, step] of steps.entries()) {
772
1081
  if ("run" in step) {
1082
+ if (step.ifFileExists && !existsSync(expandPath(step.ifFileExists))) {
1083
+ continue;
1084
+ }
773
1085
  const timeoutMs = step.timeout_ms ?? 300_000;
774
1086
  const display = label === "pre_install"
775
1087
  ? lifecycleRunStepDisplay(label, index)
776
1088
  : `${lifecycleRunStepDisplay(label, index)} ${step.run}`;
777
1089
  const taskDisplay = `run step ${index + 1}`;
778
1090
  try {
779
- await spawnStepWithTimeout(label, display, taskDisplay, "sh", ["-c", step.run], timeoutMs, task, execOptions);
1091
+ await spawnStepWithTimeout(label, display, taskDisplay, "sh", ["-c", step.run], timeoutMs, task, execOptions, step.sudo === true);
780
1092
  }
781
1093
  catch (error) {
782
1094
  if (step.successIfCommandExists && await commandExists(step.successIfCommandExists)) {
@@ -829,6 +1141,36 @@ async function runLifecycleSteps(steps, label, artifacts, task, execOptions) {
829
1141
  mkdirSync(p, { recursive: true });
830
1142
  artifacts?.push({ type: "dir", path: p });
831
1143
  }
1144
+ else if ("chown" in step) {
1145
+ const p = expandPath(step.chown.path);
1146
+ const { uid, gid } = parseOwnerSpec(step.chown.owner);
1147
+ const recursive = step.chown.recursive !== false;
1148
+ const tag = recursive ? "chown -R" : "chown";
1149
+ process.stdout.write(` [lifecycle:${label}] ${tag} ${uid}:${gid} ${p}\n`);
1150
+ emitInstallTaskLog(task, `[lifecycle:${label}] ${tag} ${uid}:${gid} ${p}`);
1151
+ if (!existsSync(p)) {
1152
+ // chown only makes sense if the target exists; surface a clear
1153
+ // error rather than letting fs throw an opaque ENOENT later.
1154
+ throw new Error(`chown target does not exist: ${p}`);
1155
+ }
1156
+ try {
1157
+ chownWithSudoFallback(p, uid, gid, recursive);
1158
+ }
1159
+ catch (chownErr) {
1160
+ // In pre_start, chown failures are non-fatal: on macOS Docker/Colima
1161
+ // the VM handles UID mapping for bind-mounts, so the container can
1162
+ // write even without matching ownership. Failing fatally here blocks
1163
+ // any non-root panel user from starting the app.
1164
+ if (label === "pre_start") {
1165
+ const msg = `[lifecycle:${label}] ${tag} ${uid}:${gid} ${p} failed (non-fatal): ${chownErr.message}`;
1166
+ process.stdout.write(` ${msg}\n`);
1167
+ emitInstallTaskLog(task, msg);
1168
+ }
1169
+ else {
1170
+ throw chownErr;
1171
+ }
1172
+ }
1173
+ }
832
1174
  else if ("deleteDir" in step) {
833
1175
  const p = expandPath(step.deleteDir);
834
1176
  process.stdout.write(` [lifecycle:${label}] deleteDir: ${p}\n`);
@@ -995,6 +1337,7 @@ function startAppLifecycleTask(appId, kind, startMessage, doneMessage, action) {
995
1337
  return {
996
1338
  ok: false,
997
1339
  error: `App '${appId}' 正在执行 ${currentTask.kind} 操作,请等待完成后再试`,
1340
+ code: "TASK_BUSY",
998
1341
  kind,
999
1342
  };
1000
1343
  }
@@ -1214,7 +1557,22 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
1214
1557
  return null;
1215
1558
  const { appId, installedSpec, } = await resolveInstallTarget(spec, specYaml, requestedAppId);
1216
1559
  let instanceSpec = rewriteInstanceScopedPaths(installedSpec, appId);
1217
- const resolvedRequires = resolveRequires(installedSpec);
1560
+ // PR 3 sub-step 3c: switch from legacy `resolveRequires(spec)` to the new
1561
+ // resolveConnections in preCreate mode. Legacy single-candidate fallback
1562
+ // still materializes its env (so meta-apps stay "open the box and it
1563
+ // works"); category-prefix requires + missing required producers fall
1564
+ // into `pending` for UI display via the install task event (PR 4 wires
1565
+ // task.event.affectedConsumers etc.). install never fails here — even if
1566
+ // a required capability is unavailable; start time will surface the
1567
+ // error. The previous `ensureRequiredCapabilitiesAvailable` block has
1568
+ // been removed for the same reason.
1569
+ const { resolveConnections, resolvedToLegacyEnv } = await import("../connection-resolver.js");
1570
+ const { resolved, pending } = resolveConnections(installedSpec, { connections: {} }, "preCreate");
1571
+ if (pending.length > 0) {
1572
+ console.log(`[install] ${appId}: ${pending.length} pending connection(s): ` +
1573
+ pending.map((p) => `${p.slot} (${p.capability}, ${p.reason})`).join(", "));
1574
+ }
1575
+ const resolvedRequires = resolvedToLegacyEnv(resolved);
1218
1576
  if (Object.keys(resolvedRequires).length > 0) {
1219
1577
  instanceSpec = {
1220
1578
  ...installedSpec,
@@ -1262,6 +1620,19 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
1262
1620
  renameSync(yamlTmp, yamlPath);
1263
1621
  safeWriteJson(join(instanceDir, "manifest.json"), manifest, true);
1264
1622
  await runLifecycleSteps(instanceSpec.lifecycle?.pre_install, "pre_install", artifacts, options.task, options.exec);
1623
+ }
1624
+ catch (e) {
1625
+ cleanupArtifacts(artifacts, options.task);
1626
+ try {
1627
+ const instanceManager = await import("../instance-manager.js");
1628
+ await instanceManager.deleteInstance(appId);
1629
+ }
1630
+ catch {
1631
+ rmSync(instanceDir, { recursive: true, force: true });
1632
+ }
1633
+ throw decorateInstallError(e, instanceSpec);
1634
+ }
1635
+ try {
1265
1636
  await runLifecycleSteps(instanceSpec.lifecycle?.install, "install", artifacts, options.task, options.exec);
1266
1637
  const pulledImages = new Set(artifacts.filter((artifact) => artifact.type === "image").map((artifact) => artifact.path));
1267
1638
  const imagesToPull = [...new Set(instanceSpec.tasks.filter((task) => task.image).map((task) => task.image))];
@@ -1288,7 +1659,7 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
1288
1659
  catch {
1289
1660
  rmSync(instanceDir, { recursive: true, force: true });
1290
1661
  }
1291
- throw e;
1662
+ throw decorateInstallError(e, instanceSpec);
1292
1663
  }
1293
1664
  if (artifacts.length > 0) {
1294
1665
  manifest.artifacts = artifacts;
@@ -1339,43 +1710,38 @@ export function getAppInstallState(appId) {
1339
1710
  return null;
1340
1711
  return hasInstallLock(location.dir) ? "installing" : "installed";
1341
1712
  }
1713
+ const BROWSERLESS_DEBUGGER_CAPABILITY = "browserless-debugger";
1714
+ const BROWSERLESS_API_CAPABILITY = "browserless-api";
1715
+ const BROWSERLESS_DOCS_CAPABILITY = "browserless-docs";
1716
+ /**
1717
+ * Compat-view registry reader. Returns the legacy `{ capabilities: {} }`
1718
+ * shape that older call sites expect. The new `capability-registry.ts`
1719
+ * module migrates dual-shape entries on every read so the legacy view is
1720
+ * always populated. PR 3 sub-step 3f deletes this shim.
1721
+ */
1342
1722
  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);
1723
+ const file = capabilityRegistry.readRegistry();
1724
+ return { capabilities: file.capabilities ?? {}, providersByCapability: file.providersByCapability };
1349
1725
  }
1350
1726
  function installedProvidersForCapability(capability) {
1351
1727
  return listApps()
1352
1728
  .filter((app) => app.spec.provides?.some((provide) => provide.capability === capability))
1353
1729
  .map((app) => app.manifest.id);
1354
1730
  }
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 查看当前可用能力。`);
1371
- }
1731
+ // `ensureRequiredCapabilitiesAvailable` was removed in PR 3 sub-step 3c.
1732
+ // install never blocks on missing required providers any more —
1733
+ // resolveConnections(..., "preCreate") collects them into the `pending`
1734
+ // list and the UI surfaces them after install completes.
1372
1735
  export function listProvidedCapabilities() {
1373
1736
  const reg = readRegistry();
1374
1737
  return listApps().flatMap((app) => (app.spec.provides ?? []).map((provide) => {
1375
1738
  const url = getProvideUrl(provide) ?? undefined;
1376
1739
  const port = getProvidePort(app.spec, provide) ?? undefined;
1377
1740
  const address = !url && typeof port === "number" ? buildCapabilityAddress(port, provide.path) : undefined;
1378
- const registered = reg.capabilities[provide.capability];
1741
+ const providers = reg.providersByCapability?.[provide.capability] ?? [];
1742
+ const registered = providers.find((e) => e.instanceId === app.manifest.id)
1743
+ ?? providers.find((e) => e.status === "running")
1744
+ ?? providers[0];
1379
1745
  const protocol = resolveProvideProtocol(provide);
1380
1746
  return {
1381
1747
  appId: app.manifest.id,
@@ -1401,7 +1767,11 @@ export function getEmbeddedUiHintForApp(appId) {
1401
1767
  const provides = getProvidedCapabilitiesForApp(appId);
1402
1768
  if (!provides.length)
1403
1769
  return null;
1404
- for (const provide of provides) {
1770
+ const preferred = provides.find((provide) => provide.capability === BROWSERLESS_DEBUGGER_CAPABILITY);
1771
+ const orderedProvides = preferred
1772
+ ? [preferred, ...provides.filter((provide) => provide !== preferred)]
1773
+ : provides;
1774
+ for (const provide of orderedProvides) {
1405
1775
  const protocol = normalizeProvideProtocol(provide.protocol);
1406
1776
  if (provide.visibility === "internal")
1407
1777
  continue;
@@ -1417,14 +1787,43 @@ export function getEmbeddedUiHintForApp(appId) {
1417
1787
  }
1418
1788
  if (typeof provide.port !== "number" || provide.port < 1)
1419
1789
  continue;
1420
- const address = typeof provide.address === "string" && provide.address.trim()
1421
- ? provide.address.trim()
1422
- : buildCapabilityAddress(provide.port, provide.path);
1790
+ // Prefer a direct upstream URL when the container port is published to
1791
+ // a LAN-reachable address (Pi with host_network "external", etc.). The
1792
+ // same-origin reverse-proxy path is necessary only when the container
1793
+ // is bound to 127.0.0.1 (macOS+Colima, dev laptops without LAN
1794
+ // exposure) — there it's the only way for a remote browser to reach
1795
+ // the iframe content. Going through the proxy when the upstream is
1796
+ // already public causes path-collision bugs for apps that fetch
1797
+ // absolute URLs starting with `/api/...` (e.g. OpenWebUI), because
1798
+ // those calls bypass `<base href>` and hit the panel API instead.
1799
+ const listeningHost = legacyInstanceManager.getListeningHostForPort(provide.port);
1800
+ const directlyReachable = listeningHost && listeningHost !== "127.0.0.1" && listeningHost !== "::1";
1801
+ if (directlyReachable) {
1802
+ const advertised = legacyInstanceManager.getAdvertisedHostForPort(provide.port);
1803
+ const directUrl = `${protocol}://${advertised}:${provide.port}${provide.path ?? ""}`;
1804
+ return {
1805
+ capability: provide.capability,
1806
+ protocol,
1807
+ port: provide.port,
1808
+ url: directUrl,
1809
+ };
1810
+ }
1811
+ // Use same-origin reverse-proxy path so the frontend iframe works for
1812
+ // remote browsers and macOS+Colima environments where the container
1813
+ // port is only published to 127.0.0.1.
1814
+ // Root-path UIs (path omitted) still need a trailing slash so the
1815
+ // browser treats the iframe src as a directory URL. Without it, some
1816
+ // SPA runtimes compute relative URLs from `/.../provides/<capability>`
1817
+ // as if `<capability>` were a file segment, which breaks boot under the
1818
+ // proxy even when the HTML/base rewrite succeeded.
1819
+ const normalizedProvidePath = typeof provide.path === "string" ? provide.path.trim() : "";
1820
+ const needsTrailingSlash = !normalizedProvidePath || normalizedProvidePath.endsWith("/");
1821
+ const proxyPath = `/api/instances/${encodeURIComponent(appId)}/provides/${encodeURIComponent(provide.capability)}${needsTrailingSlash ? "/" : ""}`;
1423
1822
  return {
1424
1823
  capability: provide.capability,
1425
1824
  protocol,
1426
1825
  port: provide.port,
1427
- url: `${protocol}://${address}`,
1826
+ url: proxyPath,
1428
1827
  };
1429
1828
  }
1430
1829
  return null;
@@ -1462,7 +1861,11 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
1462
1861
  throw new Error(`task '${task.name}' 的 image '${task.image}' 格式无效`);
1463
1862
  }
1464
1863
  }
1465
- ensureRequiredCapabilitiesAvailable(spec);
1864
+ // PR 3 sub-step 3c: removed the legacy `ensureRequiredCapabilitiesAvailable`
1865
+ // hard-stop. install now never blocks on missing required providers —
1866
+ // resolveConnections(..., "preCreate") records them as `pending` on the
1867
+ // install task event so the UI can prompt the user; start time surfaces
1868
+ // the error if still unresolved.
1466
1869
  const instanceBackedInstall = await installIntoInstanceDir(spec, specYaml, requestedAppId, options);
1467
1870
  if (instanceBackedInstall) {
1468
1871
  return instanceBackedInstall;
@@ -1486,6 +1889,13 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
1486
1889
  const artifacts = [];
1487
1890
  try {
1488
1891
  await runLifecycleSteps(installedSpec.lifecycle?.pre_install, "pre_install", artifacts, options.task, options.exec);
1892
+ }
1893
+ catch (e) {
1894
+ cleanupArtifacts(artifacts, options.task);
1895
+ rmSync(appDir, { recursive: true, force: true });
1896
+ throw decorateInstallError(e, installedSpec);
1897
+ }
1898
+ try {
1489
1899
  await runLifecycleSteps(installedSpec.lifecycle?.install, "install", artifacts, options.task, options.exec);
1490
1900
  // Auto-pull docker images declared in tasks (deduplicated, skip already-pulled by lifecycle steps)
1491
1901
  const pulledImages = new Set(artifacts.filter(a => a.type === "image").map(a => a.path));
@@ -1511,7 +1921,7 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
1511
1921
  }
1512
1922
  cleanupArtifacts(artifacts, options.task);
1513
1923
  rmSync(appDir, { recursive: true, force: true });
1514
- throw e;
1924
+ throw decorateInstallError(e, installedSpec);
1515
1925
  }
1516
1926
  if (artifacts.length > 0) {
1517
1927
  manifest.artifacts = artifacts;
@@ -1686,44 +2096,91 @@ export function uninstallAppTask(id, exec) {
1686
2096
  export async function runPostStartSteps(spec) {
1687
2097
  await runLifecycleSteps(spec.lifecycle?.post_start, "post_start");
1688
2098
  }
2099
+ /**
2100
+ * Register all `provides` for an instance. PR 1 routes through the new
2101
+ * `capability-registry.ts` module (which dual-writes the legacy
2102
+ * `capabilities` map for compat). `portOverride` is preserved as a
2103
+ * temporary parameter for the existing server.ts startup-rebuild path
2104
+ * (`server.ts:266-282`); PR 1 step 0 of `resolveProvideEndpoint` reads
2105
+ * the actual allocated port from instance runtime when available, so
2106
+ * once PR 3 lands `portOverride` becomes redundant and gets removed.
2107
+ */
1689
2108
  export function registerCapabilities(instanceId, spec, portOverride) {
1690
2109
  if (!spec.provides || spec.provides.length === 0)
1691
2110
  return;
1692
- const reg = readRegistry();
1693
2111
  const now = new Date().toISOString();
2112
+ const appName = spec.name ?? spec.id;
1694
2113
  for (const provide of spec.provides) {
1695
- const hostPort = typeof portOverride === "number" && portOverride > 0
1696
- ? portOverride
1697
- : getProvidePort(spec, provide);
1698
- if (hostPort == null) {
2114
+ // url-only provide out of capability registry scope (§5.1 boundary).
2115
+ if (typeof provide.url === "string" && provide.url.trim())
1699
2116
  continue;
2117
+ let host = "127.0.0.1";
2118
+ let hostPort;
2119
+ if (typeof portOverride === "number" && portOverride > 0) {
2120
+ // Legacy startup-rebuild path: server.ts already resolved the actual
2121
+ // listening port via `instanceManager.getGatewayPort()`. Honor it for
2122
+ // the gateway-port provide (typically `provides[0]`); other provides
2123
+ // fall through to the spec-derived port.
2124
+ hostPort = portOverride;
2125
+ host = legacyInstanceManager.getAdvertisedHostForPort(portOverride);
2126
+ }
2127
+ if (typeof hostPort !== "number") {
2128
+ const resolved = resolveProvideEndpoint(instanceId, spec, provide);
2129
+ if (resolved) {
2130
+ host = resolved.host;
2131
+ hostPort = resolved.hostPort;
2132
+ }
2133
+ else {
2134
+ const declared = getProvidePort(spec, provide);
2135
+ if (declared == null)
2136
+ continue;
2137
+ hostPort = declared;
2138
+ host = legacyInstanceManager.getAdvertisedHostForPort(declared);
2139
+ }
1700
2140
  }
1701
- reg.capabilities[provide.capability] = {
2141
+ const protocol = resolveProvideProtocol(provide);
2142
+ const entry = {
1702
2143
  instanceId,
2144
+ name: appName,
2145
+ capability: provide.capability,
2146
+ host,
1703
2147
  hostPort,
2148
+ ...(provide.path ? { path: provide.path } : {}),
1704
2149
  address: buildCapabilityAddress(hostPort, provide.path),
1705
- path: provide.path,
1706
- registered_at: now,
2150
+ protocol,
2151
+ ...(provide.visibility ? { visibility: String(provide.visibility) } : {}),
2152
+ status: "running",
2153
+ lastSeenRunningAt: now,
2154
+ registeredAt: now,
2155
+ // §17 (PR 8) — carry MCP firewall canonical schema through so
2156
+ // adapters can resolve it during applyConnectionEnv without
2157
+ // reading the spec a second time.
2158
+ ...(provide.tool_schema ? { toolSchema: provide.tool_schema } : {}),
2159
+ // §6 (PR B) — carry auth config through so apply hooks can resolve
2160
+ // tokens at apply/runtime time without re-reading the spec.
2161
+ ...(provide.auth ? { auth: provide.auth } : {}),
1707
2162
  };
2163
+ capabilityRegistry.registerProvider(entry);
1708
2164
  }
1709
- writeRegistry(reg);
1710
2165
  }
2166
+ /**
2167
+ * Mark an instance's providers as `stopped` (preferred for stop) or
2168
+ * remove them entirely (uninstall / delete). Defaults to remove for
2169
+ * back-compat with existing call sites; new code should prefer
2170
+ * `markCapabilitiesStopped` to keep entries visible in the Connections UI.
2171
+ */
1711
2172
  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);
2173
+ capabilityRegistry.unregisterProviders(instanceId);
2174
+ }
2175
+ export function markCapabilitiesStopped(instanceId) {
2176
+ capabilityRegistry.setProviderStatus(instanceId, "stopped");
1719
2177
  }
1720
2178
  export function resolveRequires(spec) {
1721
2179
  if (!spec.requires || spec.requires.length === 0)
1722
2180
  return {};
1723
- const reg = readRegistry();
1724
2181
  const result = {};
1725
2182
  for (const req of spec.requires) {
1726
- const entry = reg.capabilities[req.capability];
2183
+ const entry = capabilityRegistry.getCapabilityEntry(req.capability);
1727
2184
  if (entry) {
1728
2185
  result[req.inject_as] = entry.address;
1729
2186
  }
@@ -1734,7 +2191,49 @@ export function resolveRequires(spec) {
1734
2191
  return result;
1735
2192
  }
1736
2193
  // ── App Lifecycle (delegates to nomad-manager) ───────────────────
2194
+ /**
2195
+ * Read `instance.json` for a generic container app. Returns the parsed
2196
+ * record or `null` if missing/unreadable. Adapter-managed consumers
2197
+ * (OpenClaw, Hermes) keep their state elsewhere; this is the generic
2198
+ * app-dir layout under `~/.jishushell/apps/<appId>/`.
2199
+ */
2200
+ function readAppInstanceJson(appId) {
2201
+ try {
2202
+ const path = join(APPS_DIR, appId, "instance.json");
2203
+ return safeReadJson(path, `app-instance:${appId}`) ?? null;
2204
+ }
2205
+ catch (e) {
2206
+ console.warn(`[app-instance] read failed for ${appId}: ${e?.message ?? e}`);
2207
+ return null;
2208
+ }
2209
+ }
2210
+ /**
2211
+ * Read `instance.json["connections-env"]` for a generic container app and
2212
+ * return a copy of the persisted env vars. Adapter-managed consumers
2213
+ * (OpenClaw, Hermes) route connections through `applyConnectionEnv` and
2214
+ * don't write to this field. Best-effort — failures return empty object
2215
+ * so they never block startup.
2216
+ */
2217
+ function loadConnectionsEnv(appId) {
2218
+ const inst = readAppInstanceJson(appId);
2219
+ const env = inst?.["connections-env"];
2220
+ if (env && typeof env === "object" && !Array.isArray(env)) {
2221
+ const out = {};
2222
+ for (const [k, v] of Object.entries(env)) {
2223
+ if (typeof v === "string")
2224
+ out[k] = v;
2225
+ }
2226
+ return out;
2227
+ }
2228
+ return {};
2229
+ }
1737
2230
  export async function startApp(appId) {
2231
+ // Serialize against PUT /connections, stopApp and concurrent startApp on
2232
+ // the same instance — see `utils/instance-lock.ts` and §10.3 of the
2233
+ // app-interconnect design.
2234
+ return withInstanceLock(appId, () => startAppImpl(appId));
2235
+ }
2236
+ async function startAppImpl(appId) {
1738
2237
  const appData = getApp(appId);
1739
2238
  if (!appData) {
1740
2239
  return { ok: false, error: `App '${appId}' not found` };
@@ -1743,6 +2242,9 @@ export async function startApp(appId) {
1743
2242
  return { ok: false, error: `App '${appId}' is still installing` };
1744
2243
  }
1745
2244
  if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
2245
+ if (appData.spec.lifecycle?.pre_start?.length) {
2246
+ await runLifecycleSteps(appData.spec.lifecycle.pre_start, "pre_start");
2247
+ }
1746
2248
  const { startNomadJobInstance } = await import("../nomad-manager.js");
1747
2249
  const result = await startNomadJobInstance(appId);
1748
2250
  if (!result.ok)
@@ -1755,18 +2257,47 @@ export async function startApp(appId) {
1755
2257
  }
1756
2258
  return result;
1757
2259
  }
2260
+ // Resolve requires through the v3 connection-resolver in runtime mode so
2261
+ // missing-required / ambiguous-prefix / invalid-binding all surface with
2262
+ // the structured 412/409/400 codes (§6.4 Phase 4 of the app-interconnect
2263
+ // design). The legacy `resolveRequires` path threw a bare Error which the
2264
+ // route handler could only forward as a generic 400 — losing the bind-vs-
2265
+ // start-vs-pick distinction the UI needs.
2266
+ const instJson = readAppInstanceJson(appId);
2267
+ const persistedEnv = loadConnectionsEnv(appId);
1758
2268
  let extraEnv = {};
1759
2269
  try {
1760
- extraEnv = resolveRequires(appData.spec);
2270
+ const { resolveConnections, resolvedToLegacyEnv } = await import("../connection-resolver.js");
2271
+ const { renderRuntimeConnectionsEnv } = await import("../connection-apply.js");
2272
+ const { resolved } = resolveConnections(appData.spec, { connections: instJson?.connections ?? {} }, "runtime");
2273
+ const runtimeEnv = await renderRuntimeConnectionsEnv(appData.spec, {
2274
+ id: appId,
2275
+ connections: instJson?.connections,
2276
+ });
2277
+ // Frozen `connections-env` is the lowest-priority fallback for legacy
2278
+ // apps that pre-date resolveConnections. Runtime-rendered env wins so
2279
+ // provider port/IP changes propagate without re-binding.
2280
+ extraEnv = { ...persistedEnv, ...resolvedToLegacyEnv(resolved), ...runtimeEnv };
1761
2281
  }
1762
2282
  catch (e) {
1763
- return { ok: false, error: e.message };
2283
+ return {
2284
+ ok: false,
2285
+ error: e.message,
2286
+ ...(e.code ? { code: e.code } : {}),
2287
+ ...(typeof e.statusCode === "number" ? { statusCode: e.statusCode } : {}),
2288
+ };
1764
2289
  }
1765
2290
  const { startAppJob: nomadStart, checkDependencies, waitForRunning } = await import("../nomad-manager.js");
1766
2291
  const depCheck = await checkDependencies(appData.spec);
1767
2292
  if (!depCheck.ok) {
1768
2293
  return { ok: false, error: depCheck.errors.join("; ") };
1769
2294
  }
2295
+ // Run pre_start steps right before submitting the Nomad job — gives
2296
+ // apps a place to enforce per-start invariants (e.g. chown the
2297
+ // bind-mount source so the container's runtime uid can write).
2298
+ if (appData.spec.lifecycle?.pre_start?.length) {
2299
+ await runLifecycleSteps(appData.spec.lifecycle.pre_start, "pre_start");
2300
+ }
1770
2301
  const result = await nomadStart(appData.spec, appId, extraEnv);
1771
2302
  if (!result.ok) {
1772
2303
  return result;
@@ -1794,6 +2325,9 @@ export function startAppTask(appId) {
1794
2325
  });
1795
2326
  }
1796
2327
  export async function stopApp(appId, purge = false) {
2328
+ return withInstanceLock(appId, () => stopAppImpl(appId, purge));
2329
+ }
2330
+ async function stopAppImpl(appId, purge) {
1797
2331
  const appData = getApp(appId);
1798
2332
  if (appData?.install_state === "installing") {
1799
2333
  return { ok: false, error: `App '${appId}' is still installing` };
@@ -1802,14 +2336,22 @@ export async function stopApp(appId, purge = false) {
1802
2336
  const { stopNomadJobInstance } = await import("../nomad-manager.js");
1803
2337
  const result = await stopNomadJobInstance(appId, purge);
1804
2338
  if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
1805
- unregisterCapabilities(appId);
2339
+ // Stop = mark stopped (keep entry visible in Connections UI as a
2340
+ // greyed candidate); only purge / uninstall fully unregisters.
2341
+ if (purge)
2342
+ unregisterCapabilities(appId);
2343
+ else
2344
+ markCapabilitiesStopped(appId);
1806
2345
  }
1807
2346
  return result;
1808
2347
  }
1809
2348
  const { stopAppJob } = await import("../nomad-manager.js");
1810
2349
  const result = await stopAppJob(appId, purge);
1811
2350
  if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
1812
- unregisterCapabilities(appId);
2351
+ if (purge)
2352
+ unregisterCapabilities(appId);
2353
+ else
2354
+ markCapabilitiesStopped(appId);
1813
2355
  }
1814
2356
  return result;
1815
2357
  }
@@ -1825,28 +2367,34 @@ export function stopAppTask(appId, purge = false) {
1825
2367
  });
1826
2368
  }
1827
2369
  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)
2370
+ // Hold the instance lock for the entire restart sequence so a concurrent
2371
+ // PUT /connections / startApp / stopApp on the same id can't observe a
2372
+ // half-restarted state. Inner stop/start calls reuse the same lock id;
2373
+ // we route them through the *Impl helpers to avoid re-acquiring.
2374
+ return withInstanceLock(appId, async () => {
2375
+ const appData = getApp(appId);
2376
+ if (appData?.install_state === "installing") {
2377
+ return { ok: false, error: `App '${appId}' is still installing` };
2378
+ }
2379
+ if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
2380
+ const { restartNomadJobInstance } = await import("../nomad-manager.js");
2381
+ const result = await restartNomadJobInstance(appId);
2382
+ if (!result.ok)
2383
+ return result;
2384
+ if (appData?.spec.provides?.length) {
2385
+ registerCapabilities(appId, appData.spec);
2386
+ }
2387
+ if (appData?.spec.lifecycle?.post_start?.length) {
2388
+ await runPostStartSteps(appData.spec);
2389
+ }
1836
2390
  return result;
1837
- if (appData?.spec.provides?.length) {
1838
- registerCapabilities(appId, appData.spec);
1839
2391
  }
1840
- if (appData?.spec.lifecycle?.post_start?.length) {
1841
- await runPostStartSteps(appData.spec);
2392
+ const stopResult = await stopAppImpl(appId, true);
2393
+ if (!stopResult.ok && !stopResult.error?.includes("not found")) {
2394
+ return stopResult;
1842
2395
  }
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);
2396
+ return startAppImpl(appId);
2397
+ });
1850
2398
  }
1851
2399
  export function restartAppTask(appId) {
1852
2400
  if (!getApp(appId)) {
@@ -1984,5 +2532,5 @@ export async function copyApp(sourceId) {
1984
2532
  // executes the install lifecycle in the new app dir.
1985
2533
  return installApp(baseYaml);
1986
2534
  }
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";
2535
+ 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
2536
  //# sourceMappingURL=app-manager.js.map