jishushell 0.4.24-beta.2 → 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 +45 -8
  5. package/apps/ollama-cpu-container.yaml +8 -1
  6. package/apps/ollama-with-hollama-binary.yaml +45 -8
  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 +12 -2
  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 +490 -102
  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 +169 -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 -26
  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-D6JI6lLY.js → HermesChatPanel-_GHoklgo.js} +1 -1
  141. package/public/assets/HermesConfigForm-anDnwUp_.js +4 -0
  142. package/public/assets/{InitPassword-CFTKsED4.js → InitPassword-ZU9_-hDr.js} +1 -1
  143. package/public/assets/InstanceDetail-CN0FH1aw.js +92 -0
  144. package/public/assets/{Login-KB9qrtM0.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-gca0QTic.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-rh9qpYRR.js +0 -1
  159. package/public/assets/HermesConfigForm-DcbSemaj.js +0 -4
  160. package/public/assets/InstanceDetail-BhNIKA6Z.js +0 -91
  161. package/public/assets/NewInstance-CxkO8Hlq.js +0 -1
  162. package/public/assets/Settings-BVWJvOkU.js +0 -1
  163. package/public/assets/Setup-X-lzuaUT.js +0 -1
  164. package/public/assets/index-C8B0cFJM.js +0 -19
  165. package/public/assets/index-CPhVFEsx.css +0 -1
  166. package/public/assets/providers-V-vwrExZ.js +0 -1
  167. package/public/assets/registry-fVUSujib.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;
@@ -204,7 +207,9 @@ function createLifecycleSudoError(stderr, fallbackDisplay, hasPassword) {
204
207
  return createNoNewPrivilegesSudoError();
205
208
  }
206
209
  if (isSudoAuthenticationError(message)) {
207
- return new Error("sudo 密码错误,请重新输入。");
210
+ const err = new Error("sudo 密码错误,请重新输入。");
211
+ err.code = "INVALID_SUDO_PASSWORD";
212
+ return err;
208
213
  }
209
214
  if (!hasPassword && isSudoPasswordRequiredError(message)) {
210
215
  return new Error("该生命周期步骤需要 sudo 密码;请在页面弹窗中输入后重试。");
@@ -219,8 +224,10 @@ function panelSystemdServicePath() {
219
224
  return override || "/etc/systemd/system/jishushell.service";
220
225
  }
221
226
  function isLikelySystemdServiceProcess() {
222
- return process.platform === "linux"
223
- && Boolean(process.env.INVOCATION_ID || process.env.JOURNAL_STREAM || process.env.NOTIFY_SOCKET);
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());
224
231
  }
225
232
  function maybeRepairPanelAutostartNoNewPrivileges() {
226
233
  if (!isLikelySystemdServiceProcess())
@@ -311,7 +318,9 @@ export async function validateSudoPassword(sudoPassword) {
311
318
  return;
312
319
  }
313
320
  if (/incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message)) {
314
- reject(new Error("sudo 密码错误,请重新输入。"));
321
+ const err = new Error("sudo 密码错误,请重新输入。");
322
+ err.code = "INVALID_SUDO_PASSWORD";
323
+ reject(err);
315
324
  return;
316
325
  }
317
326
  resolve();
@@ -388,24 +397,88 @@ function normalizePortVisibility(visibility) {
388
397
  throw new Error(`port visibility '${visibility}' 仅支持 external 或 internal`);
389
398
  }
390
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
+ }
391
477
  return {
392
478
  ...spec,
393
- tasks: (spec.tasks ?? []).map((task) => {
394
- const rawTask = { ...task };
395
- if (!rawTask.role) {
396
- rawTask.role = "service";
397
- }
398
- if (!rawTask.command && rawTask.binary) {
399
- rawTask.command = rawTask.binary;
400
- }
401
- if (Array.isArray(rawTask.ports)) {
402
- rawTask.ports = rawTask.ports.map((port) => ({
403
- ...port,
404
- visibility: normalizePortVisibility(port.visibility),
405
- }));
406
- }
407
- return rawTask;
408
- }),
479
+ ...(spec.provides ? { provides: normalizedProvides } : {}),
480
+ tasks: normalizedTasks,
481
+ ...(normalizedLifecycle ? { lifecycle: normalizedLifecycle } : {}),
409
482
  };
410
483
  }
411
484
  function imageReferencedByOtherInstalledApps(currentAppId, imagePath) {
@@ -500,7 +573,8 @@ function getProvidePort(spec, provide) {
500
573
  const firstPort = spec.tasks.find((task) => task.role === "service")?.ports?.[0];
501
574
  if (!firstPort)
502
575
  return null;
503
- return firstPort.host_port ?? firstPort.port;
576
+ const p = firstPort.host_port ?? firstPort.port;
577
+ return typeof p === "number" && p > 0 ? p : null;
504
578
  }
505
579
  function getProvideUrl(provide) {
506
580
  const raw = typeof provide.url === "string" ? provide.url.trim() : "";
@@ -518,7 +592,7 @@ function getProvideUrl(provide) {
518
592
  }
519
593
  }
520
594
  function buildCapabilityAddress(port, path) {
521
- const host = legacyInstanceManager.getAdvertisedHostForPort(port);
595
+ const host = port > 0 ? legacyInstanceManager.getAdvertisedHostForPort(port) : "127.0.0.1";
522
596
  if (!path) {
523
597
  return `${host}:${port}`;
524
598
  }
@@ -742,11 +816,21 @@ const DOCKER_PULL_RETRY_ATTEMPTS = 3;
742
816
  // extracts in 5 min on a Raspberry Pi. 30 min clears both with headroom while
743
817
  // still capping runaway failures (total retry budget 90 min).
744
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;
745
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
+ }
746
830
  let lastError;
747
831
  for (let attempt = 1; attempt <= DOCKER_PULL_RETRY_ATTEMPTS; attempt++) {
748
832
  try {
749
- 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) });
750
834
  return;
751
835
  }
752
836
  catch (error) {
@@ -778,16 +862,18 @@ async function dockerImageExists(image) {
778
862
  child.on("error", () => resolve(false));
779
863
  });
780
864
  }
781
- function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions, sudo) {
865
+ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions, sudo, runOptions) {
782
866
  process.stdout.write(` [lifecycle:${label}] ${display}\n`);
783
867
  emitInstallTaskLog(task, `[lifecycle:${label}] ${taskDisplay}`);
784
868
  return new Promise((resolve, reject) => {
785
869
  const preparedEnv = prepareLifecycleExecEnv(sudo ? execOptions : undefined);
786
870
  let cleaned = false;
787
871
  let heartbeatTimer = null;
872
+ let idleTimer = null;
788
873
  let stdoutPending = "";
789
874
  let stderrPending = "";
790
875
  let capturedStderr = "";
876
+ let forcedError = null;
791
877
  const cleanupPreparedEnv = () => {
792
878
  if (cleaned)
793
879
  return;
@@ -796,17 +882,37 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
796
882
  clearInterval(heartbeatTimer);
797
883
  heartbeatTimer = null;
798
884
  }
885
+ if (idleTimer) {
886
+ clearTimeout(idleTimer);
887
+ idleTimer = null;
888
+ }
799
889
  preparedEnv.cleanup();
800
890
  };
801
891
  const spawnTarget = sudo
802
892
  ? buildSudoWrappedCommand(cmd, args, preparedEnv.env, execOptions)
803
893
  : { command: cmd, args };
804
- const captureOutput = Boolean(task) || Boolean(sudo);
894
+ const captureOutput = Boolean(task) || Boolean(sudo) || Boolean(runOptions?.idleTimeoutMs);
805
895
  const child = spawn(spawnTarget.command, spawnTarget.args, {
806
896
  stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
807
897
  timeout: timeoutMs,
808
898
  env: preparedEnv.env,
809
899
  });
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();
810
916
  if (captureOutput) {
811
917
  const startedAt = Date.now();
812
918
  const flushPendingLine = (line) => {
@@ -815,6 +921,7 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
815
921
  emitInstallTaskLog(task, line);
816
922
  };
817
923
  const handleChunk = (chunk, stream) => {
924
+ resetIdleTimer();
818
925
  const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
819
926
  if (stream === "stderr") {
820
927
  capturedStderr += text;
@@ -854,7 +961,9 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
854
961
  }
855
962
  child.on("close", (code) => {
856
963
  cleanupPreparedEnv();
857
- if (code === 0)
964
+ if (forcedError)
965
+ reject(forcedError);
966
+ else if (code === 0)
858
967
  resolve();
859
968
  else if (sudo)
860
969
  reject(createLifecycleSudoError(capturedStderr, display, Boolean(execOptions?.sudoPassword)));
@@ -863,6 +972,10 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
863
972
  });
864
973
  child.on("error", (err) => {
865
974
  cleanupPreparedEnv();
975
+ if (forcedError) {
976
+ reject(forcedError);
977
+ return;
978
+ }
866
979
  if (sudo && err.code === "ENOENT") {
867
980
  reject(new Error("当前环境未检测到 sudo,无法执行需要 sudo 的生命周期步骤。请以 root 身份重试。"));
868
981
  return;
@@ -879,7 +992,8 @@ function lifecycleRunStepDisplay(label, index) {
879
992
  }
880
993
  async function commandExists(command) {
881
994
  return new Promise((resolve) => {
882
- 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`], {
883
997
  stdio: "ignore",
884
998
  env: buildLifecycleEnv(),
885
999
  });
@@ -887,6 +1001,64 @@ async function commandExists(command) {
887
1001
  child.on("error", () => resolve(false));
888
1002
  });
889
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
+ }
890
1062
  async function downloadBinaryStep(label, url, dest, chmod, task) {
891
1063
  const expanded = expandPath(dest);
892
1064
  process.stdout.write(` [lifecycle:${label}] downloadBinary: ${url} → ${expanded}\n`);
@@ -969,6 +1141,36 @@ async function runLifecycleSteps(steps, label, artifacts, task, execOptions) {
969
1141
  mkdirSync(p, { recursive: true });
970
1142
  artifacts?.push({ type: "dir", path: p });
971
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
+ }
972
1174
  else if ("deleteDir" in step) {
973
1175
  const p = expandPath(step.deleteDir);
974
1176
  process.stdout.write(` [lifecycle:${label}] deleteDir: ${p}\n`);
@@ -1135,6 +1337,7 @@ function startAppLifecycleTask(appId, kind, startMessage, doneMessage, action) {
1135
1337
  return {
1136
1338
  ok: false,
1137
1339
  error: `App '${appId}' 正在执行 ${currentTask.kind} 操作,请等待完成后再试`,
1340
+ code: "TASK_BUSY",
1138
1341
  kind,
1139
1342
  };
1140
1343
  }
@@ -1354,7 +1557,22 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
1354
1557
  return null;
1355
1558
  const { appId, installedSpec, } = await resolveInstallTarget(spec, specYaml, requestedAppId);
1356
1559
  let instanceSpec = rewriteInstanceScopedPaths(installedSpec, appId);
1357
- 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);
1358
1576
  if (Object.keys(resolvedRequires).length > 0) {
1359
1577
  instanceSpec = {
1360
1578
  ...installedSpec,
@@ -1492,43 +1710,38 @@ export function getAppInstallState(appId) {
1492
1710
  return null;
1493
1711
  return hasInstallLock(location.dir) ? "installing" : "installed";
1494
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
+ */
1495
1722
  function readRegistry() {
1496
- const reg = safeReadJson(REGISTRY_PATH, "capability-registry");
1497
- return reg ?? { capabilities: {} };
1498
- }
1499
- function writeRegistry(reg) {
1500
- ensureDirHost(APPS_DIR);
1501
- safeWriteJson(REGISTRY_PATH, reg, true);
1723
+ const file = capabilityRegistry.readRegistry();
1724
+ return { capabilities: file.capabilities ?? {}, providersByCapability: file.providersByCapability };
1502
1725
  }
1503
1726
  function installedProvidersForCapability(capability) {
1504
1727
  return listApps()
1505
1728
  .filter((app) => app.spec.provides?.some((provide) => provide.capability === capability))
1506
1729
  .map((app) => app.manifest.id);
1507
1730
  }
1508
- function ensureRequiredCapabilitiesAvailable(spec) {
1509
- if (!spec.requires?.length)
1510
- return;
1511
- const reg = readRegistry();
1512
- const missing = spec.requires
1513
- .filter((req) => req.required !== false && !reg.capabilities[req.capability])
1514
- .map((req) => {
1515
- const installedProviders = installedProvidersForCapability(req.capability);
1516
- const providerHint = installedProviders.length > 0
1517
- ? `;已安装但未注册的 provider: ${installedProviders.join(", ")}`
1518
- : "";
1519
- return `- ${req.capability} -> ${req.inject_as}${providerHint}`;
1520
- });
1521
- if (missing.length === 0)
1522
- return;
1523
- throw new Error(`App '${spec.id}' 缺少必需能力,已跳过安装:\n${missing.join("\n")}\n请先启动对应 provider,再执行 jishushell app provides 查看当前可用能力。`);
1524
- }
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.
1525
1735
  export function listProvidedCapabilities() {
1526
1736
  const reg = readRegistry();
1527
1737
  return listApps().flatMap((app) => (app.spec.provides ?? []).map((provide) => {
1528
1738
  const url = getProvideUrl(provide) ?? undefined;
1529
1739
  const port = getProvidePort(app.spec, provide) ?? undefined;
1530
1740
  const address = !url && typeof port === "number" ? buildCapabilityAddress(port, provide.path) : undefined;
1531
- 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];
1532
1745
  const protocol = resolveProvideProtocol(provide);
1533
1746
  return {
1534
1747
  appId: app.manifest.id,
@@ -1554,7 +1767,11 @@ export function getEmbeddedUiHintForApp(appId) {
1554
1767
  const provides = getProvidedCapabilitiesForApp(appId);
1555
1768
  if (!provides.length)
1556
1769
  return null;
1557
- 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) {
1558
1775
  const protocol = normalizeProvideProtocol(provide.protocol);
1559
1776
  if (provide.visibility === "internal")
1560
1777
  continue;
@@ -1570,14 +1787,43 @@ export function getEmbeddedUiHintForApp(appId) {
1570
1787
  }
1571
1788
  if (typeof provide.port !== "number" || provide.port < 1)
1572
1789
  continue;
1573
- const address = typeof provide.address === "string" && provide.address.trim()
1574
- ? provide.address.trim()
1575
- : 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 ? "/" : ""}`;
1576
1822
  return {
1577
1823
  capability: provide.capability,
1578
1824
  protocol,
1579
1825
  port: provide.port,
1580
- url: `${protocol}://${address}`,
1826
+ url: proxyPath,
1581
1827
  };
1582
1828
  }
1583
1829
  return null;
@@ -1615,7 +1861,11 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
1615
1861
  throw new Error(`task '${task.name}' 的 image '${task.image}' 格式无效`);
1616
1862
  }
1617
1863
  }
1618
- 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.
1619
1869
  const instanceBackedInstall = await installIntoInstanceDir(spec, specYaml, requestedAppId, options);
1620
1870
  if (instanceBackedInstall) {
1621
1871
  return instanceBackedInstall;
@@ -1846,44 +2096,91 @@ export function uninstallAppTask(id, exec) {
1846
2096
  export async function runPostStartSteps(spec) {
1847
2097
  await runLifecycleSteps(spec.lifecycle?.post_start, "post_start");
1848
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
+ */
1849
2108
  export function registerCapabilities(instanceId, spec, portOverride) {
1850
2109
  if (!spec.provides || spec.provides.length === 0)
1851
2110
  return;
1852
- const reg = readRegistry();
1853
2111
  const now = new Date().toISOString();
2112
+ const appName = spec.name ?? spec.id;
1854
2113
  for (const provide of spec.provides) {
1855
- const hostPort = typeof portOverride === "number" && portOverride > 0
1856
- ? portOverride
1857
- : getProvidePort(spec, provide);
1858
- if (hostPort == null) {
2114
+ // url-only provide out of capability registry scope (§5.1 boundary).
2115
+ if (typeof provide.url === "string" && provide.url.trim())
1859
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
+ }
1860
2140
  }
1861
- reg.capabilities[provide.capability] = {
2141
+ const protocol = resolveProvideProtocol(provide);
2142
+ const entry = {
1862
2143
  instanceId,
2144
+ name: appName,
2145
+ capability: provide.capability,
2146
+ host,
1863
2147
  hostPort,
2148
+ ...(provide.path ? { path: provide.path } : {}),
1864
2149
  address: buildCapabilityAddress(hostPort, provide.path),
1865
- path: provide.path,
1866
- 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 } : {}),
1867
2162
  };
2163
+ capabilityRegistry.registerProvider(entry);
1868
2164
  }
1869
- writeRegistry(reg);
1870
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
+ */
1871
2172
  export function unregisterCapabilities(instanceId) {
1872
- const reg = readRegistry();
1873
- for (const key of Object.keys(reg.capabilities)) {
1874
- if (reg.capabilities[key].instanceId === instanceId) {
1875
- delete reg.capabilities[key];
1876
- }
1877
- }
1878
- writeRegistry(reg);
2173
+ capabilityRegistry.unregisterProviders(instanceId);
2174
+ }
2175
+ export function markCapabilitiesStopped(instanceId) {
2176
+ capabilityRegistry.setProviderStatus(instanceId, "stopped");
1879
2177
  }
1880
2178
  export function resolveRequires(spec) {
1881
2179
  if (!spec.requires || spec.requires.length === 0)
1882
2180
  return {};
1883
- const reg = readRegistry();
1884
2181
  const result = {};
1885
2182
  for (const req of spec.requires) {
1886
- const entry = reg.capabilities[req.capability];
2183
+ const entry = capabilityRegistry.getCapabilityEntry(req.capability);
1887
2184
  if (entry) {
1888
2185
  result[req.inject_as] = entry.address;
1889
2186
  }
@@ -1894,7 +2191,49 @@ export function resolveRequires(spec) {
1894
2191
  return result;
1895
2192
  }
1896
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
+ }
1897
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) {
1898
2237
  const appData = getApp(appId);
1899
2238
  if (!appData) {
1900
2239
  return { ok: false, error: `App '${appId}' not found` };
@@ -1903,6 +2242,9 @@ export async function startApp(appId) {
1903
2242
  return { ok: false, error: `App '${appId}' is still installing` };
1904
2243
  }
1905
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
+ }
1906
2248
  const { startNomadJobInstance } = await import("../nomad-manager.js");
1907
2249
  const result = await startNomadJobInstance(appId);
1908
2250
  if (!result.ok)
@@ -1915,18 +2257,47 @@ export async function startApp(appId) {
1915
2257
  }
1916
2258
  return result;
1917
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);
1918
2268
  let extraEnv = {};
1919
2269
  try {
1920
- 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 };
1921
2281
  }
1922
2282
  catch (e) {
1923
- 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
+ };
1924
2289
  }
1925
2290
  const { startAppJob: nomadStart, checkDependencies, waitForRunning } = await import("../nomad-manager.js");
1926
2291
  const depCheck = await checkDependencies(appData.spec);
1927
2292
  if (!depCheck.ok) {
1928
2293
  return { ok: false, error: depCheck.errors.join("; ") };
1929
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
+ }
1930
2301
  const result = await nomadStart(appData.spec, appId, extraEnv);
1931
2302
  if (!result.ok) {
1932
2303
  return result;
@@ -1954,6 +2325,9 @@ export function startAppTask(appId) {
1954
2325
  });
1955
2326
  }
1956
2327
  export async function stopApp(appId, purge = false) {
2328
+ return withInstanceLock(appId, () => stopAppImpl(appId, purge));
2329
+ }
2330
+ async function stopAppImpl(appId, purge) {
1957
2331
  const appData = getApp(appId);
1958
2332
  if (appData?.install_state === "installing") {
1959
2333
  return { ok: false, error: `App '${appId}' is still installing` };
@@ -1962,14 +2336,22 @@ export async function stopApp(appId, purge = false) {
1962
2336
  const { stopNomadJobInstance } = await import("../nomad-manager.js");
1963
2337
  const result = await stopNomadJobInstance(appId, purge);
1964
2338
  if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
1965
- 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);
1966
2345
  }
1967
2346
  return result;
1968
2347
  }
1969
2348
  const { stopAppJob } = await import("../nomad-manager.js");
1970
2349
  const result = await stopAppJob(appId, purge);
1971
2350
  if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
1972
- unregisterCapabilities(appId);
2351
+ if (purge)
2352
+ unregisterCapabilities(appId);
2353
+ else
2354
+ markCapabilitiesStopped(appId);
1973
2355
  }
1974
2356
  return result;
1975
2357
  }
@@ -1985,28 +2367,34 @@ export function stopAppTask(appId, purge = false) {
1985
2367
  });
1986
2368
  }
1987
2369
  export async function restartApp(appId) {
1988
- const appData = getApp(appId);
1989
- if (appData?.install_state === "installing") {
1990
- return { ok: false, error: `App '${appId}' is still installing` };
1991
- }
1992
- if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
1993
- const { restartNomadJobInstance } = await import("../nomad-manager.js");
1994
- const result = await restartNomadJobInstance(appId);
1995
- 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
+ }
1996
2390
  return result;
1997
- if (appData?.spec.provides?.length) {
1998
- registerCapabilities(appId, appData.spec);
1999
2391
  }
2000
- if (appData?.spec.lifecycle?.post_start?.length) {
2001
- await runPostStartSteps(appData.spec);
2392
+ const stopResult = await stopAppImpl(appId, true);
2393
+ if (!stopResult.ok && !stopResult.error?.includes("not found")) {
2394
+ return stopResult;
2002
2395
  }
2003
- return result;
2004
- }
2005
- const stopResult = await stopApp(appId, true);
2006
- if (!stopResult.ok && !stopResult.error?.includes("not found")) {
2007
- return stopResult;
2008
- }
2009
- return startApp(appId);
2396
+ return startAppImpl(appId);
2397
+ });
2010
2398
  }
2011
2399
  export function restartAppTask(appId) {
2012
2400
  if (!getApp(appId)) {
@@ -2144,5 +2532,5 @@ export async function copyApp(sourceId) {
2144
2532
  // executes the install lifecycle in the new app dir.
2145
2533
  return installApp(baseYaml);
2146
2534
  }
2147
- 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";
2148
2536
  //# sourceMappingURL=app-manager.js.map