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