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