jishushell 0.4.24-beta.2 → 0.4.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +45 -8
- package/apps/ollama-cpu-container.yaml +8 -1
- package/apps/ollama-with-hollama-binary.yaml +45 -8
- 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 +12 -2
- 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 +490 -102
- 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 +169 -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 -26
- 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-D6JI6lLY.js → HermesChatPanel-_GHoklgo.js} +1 -1
- package/public/assets/HermesConfigForm-anDnwUp_.js +4 -0
- package/public/assets/{InitPassword-CFTKsED4.js → InitPassword-ZU9_-hDr.js} +1 -1
- package/public/assets/InstanceDetail-CN0FH1aw.js +92 -0
- package/public/assets/{Login-KB9qrtM0.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-gca0QTic.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-rh9qpYRR.js +0 -1
- package/public/assets/HermesConfigForm-DcbSemaj.js +0 -4
- package/public/assets/InstanceDetail-BhNIKA6Z.js +0 -91
- package/public/assets/NewInstance-CxkO8Hlq.js +0 -1
- package/public/assets/Settings-BVWJvOkU.js +0 -1
- package/public/assets/Setup-X-lzuaUT.js +0 -1
- package/public/assets/index-C8B0cFJM.js +0 -19
- package/public/assets/index-CPhVFEsx.css +0 -1
- package/public/assets/providers-V-vwrExZ.js +0 -1
- package/public/assets/registry-fVUSujib.js +0 -2
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { createHash } from "crypto";
|
|
2
|
-
import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync, } from "fs";
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync, chownSync, lstatSync, } from "fs";
|
|
3
3
|
import { homedir, tmpdir } from "os";
|
|
4
4
|
import { basename, extname, join, dirname } from "path";
|
|
5
|
-
import { spawn } from "child_process";
|
|
5
|
+
import { spawn, spawnSync } from "child_process";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { parse, stringify } from "yaml";
|
|
8
8
|
import * as config from "../../config.js";
|
|
9
9
|
import { ensureDirHost } from "../../utils/fs.js";
|
|
10
10
|
import { safeReadJson, safeWriteJson } from "../../utils/safe-json.js";
|
|
11
11
|
import * as legacyInstanceManager from "../instance-manager.js";
|
|
12
|
+
import { withInstanceLock } from "../../utils/instance-lock.js";
|
|
12
13
|
import { createTask, emitTask, getRunningTasks, getTask } from "../task-registry.js";
|
|
13
14
|
import { compileTaskRuntime } from "./app-compiler.js";
|
|
15
|
+
import * as capabilityRegistry from "../capability-registry.js";
|
|
16
|
+
import { resolveProvideEndpoint } from "./provide-resolver.js";
|
|
14
17
|
const DEFAULT_LIFECYCLE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
15
18
|
function getConfigValue(name) {
|
|
16
19
|
return name in config ? config[name] : undefined;
|
|
@@ -204,7 +207,9 @@ function createLifecycleSudoError(stderr, fallbackDisplay, hasPassword) {
|
|
|
204
207
|
return createNoNewPrivilegesSudoError();
|
|
205
208
|
}
|
|
206
209
|
if (isSudoAuthenticationError(message)) {
|
|
207
|
-
|
|
210
|
+
const err = new Error("sudo 密码错误,请重新输入。");
|
|
211
|
+
err.code = "INVALID_SUDO_PASSWORD";
|
|
212
|
+
return err;
|
|
208
213
|
}
|
|
209
214
|
if (!hasPassword && isSudoPasswordRequiredError(message)) {
|
|
210
215
|
return new Error("该生命周期步骤需要 sudo 密码;请在页面弹窗中输入后重试。");
|
|
@@ -219,8 +224,10 @@ function panelSystemdServicePath() {
|
|
|
219
224
|
return override || "/etc/systemd/system/jishushell.service";
|
|
220
225
|
}
|
|
221
226
|
function isLikelySystemdServiceProcess() {
|
|
222
|
-
return process.
|
|
223
|
-
|
|
227
|
+
return Boolean(process.env.INVOCATION_ID
|
|
228
|
+
|| process.env.JOURNAL_STREAM
|
|
229
|
+
|| process.env.NOTIFY_SOCKET
|
|
230
|
+
|| process.env.JISHUSHELL_PANEL_SYSTEMD_SERVICE_PATH?.trim());
|
|
224
231
|
}
|
|
225
232
|
function maybeRepairPanelAutostartNoNewPrivileges() {
|
|
226
233
|
if (!isLikelySystemdServiceProcess())
|
|
@@ -311,7 +318,9 @@ export async function validateSudoPassword(sudoPassword) {
|
|
|
311
318
|
return;
|
|
312
319
|
}
|
|
313
320
|
if (/incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message)) {
|
|
314
|
-
|
|
321
|
+
const err = new Error("sudo 密码错误,请重新输入。");
|
|
322
|
+
err.code = "INVALID_SUDO_PASSWORD";
|
|
323
|
+
reject(err);
|
|
315
324
|
return;
|
|
316
325
|
}
|
|
317
326
|
resolve();
|
|
@@ -388,24 +397,88 @@ function normalizePortVisibility(visibility) {
|
|
|
388
397
|
throw new Error(`port visibility '${visibility}' 仅支持 external 或 internal`);
|
|
389
398
|
}
|
|
390
399
|
function normalizeAppSpec(spec) {
|
|
400
|
+
const normalizedProvides = (spec.provides ?? []).map((provide) => {
|
|
401
|
+
if (spec.id === "browserless-chromium-container"
|
|
402
|
+
&& provide.capability === BROWSERLESS_DEBUGGER_CAPABILITY
|
|
403
|
+
&& (provide.path === "/" || provide.path === "/debugger")) {
|
|
404
|
+
return { ...provide, path: "/debugger/" };
|
|
405
|
+
}
|
|
406
|
+
if (spec.id === "browserless-chromium-container"
|
|
407
|
+
&& provide.capability === BROWSERLESS_DOCS_CAPABILITY
|
|
408
|
+
&& provide.path === "/docs") {
|
|
409
|
+
return { ...provide, path: "/docs/" };
|
|
410
|
+
}
|
|
411
|
+
if (spec.id === "browserless-chromium-container"
|
|
412
|
+
&& provide.capability === BROWSERLESS_API_CAPABILITY
|
|
413
|
+
&& provide.path !== "/") {
|
|
414
|
+
return { ...provide, path: "/" };
|
|
415
|
+
}
|
|
416
|
+
return provide;
|
|
417
|
+
});
|
|
418
|
+
// Inject browserless-api capability for legacy installed specs that lack it.
|
|
419
|
+
if (spec.id === "browserless-chromium-container"
|
|
420
|
+
&& normalizedProvides.length > 0
|
|
421
|
+
&& !normalizedProvides.some((p) => p.capability === BROWSERLESS_API_CAPABILITY)) {
|
|
422
|
+
const debugger_ = normalizedProvides.find((p) => p.capability === BROWSERLESS_DEBUGGER_CAPABILITY);
|
|
423
|
+
if (debugger_) {
|
|
424
|
+
normalizedProvides.push({
|
|
425
|
+
capability: BROWSERLESS_API_CAPABILITY,
|
|
426
|
+
port: debugger_.port ?? 3000,
|
|
427
|
+
path: "/",
|
|
428
|
+
protocol: "http",
|
|
429
|
+
description: "Browserless 根 API(供调试器页面连接 ws 与 sessions 接口)",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const normalizedTasks = (spec.tasks ?? []).map((task) => {
|
|
434
|
+
const rawTask = { ...task };
|
|
435
|
+
if (!rawTask.role) {
|
|
436
|
+
rawTask.role = "service";
|
|
437
|
+
}
|
|
438
|
+
if (!rawTask.command && rawTask.binary) {
|
|
439
|
+
rawTask.command = rawTask.binary;
|
|
440
|
+
}
|
|
441
|
+
if (Array.isArray(rawTask.ports)) {
|
|
442
|
+
rawTask.ports = rawTask.ports.map((port) => ({
|
|
443
|
+
...port,
|
|
444
|
+
visibility: normalizePortVisibility(port.visibility),
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
return rawTask;
|
|
448
|
+
});
|
|
449
|
+
let normalizedLifecycle = spec.lifecycle ? { ...spec.lifecycle } : undefined;
|
|
450
|
+
if (spec.id === "browserless-chromium-container") {
|
|
451
|
+
const browserlessDataDir = `~/.jishushell/apps/${spec.app_id || spec.id}/data`;
|
|
452
|
+
const browserlessDataDirTemplate = "~/.jishushell/apps/${app_id}/data";
|
|
453
|
+
const browserlessTask = normalizedTasks.find((task) => task.name === "browserless");
|
|
454
|
+
if (browserlessTask) {
|
|
455
|
+
const dataDirTarget = "/tmp/browserless-data";
|
|
456
|
+
const existingVolumes = Array.isArray(browserlessTask.volumes) ? [...browserlessTask.volumes] : [];
|
|
457
|
+
const hasDataDirVolume = existingVolumes.some((volume) => typeof volume === "object" && volume !== null && volume.target === dataDirTarget);
|
|
458
|
+
if (!hasDataDirVolume) {
|
|
459
|
+
existingVolumes.push({ source: browserlessDataDir, target: dataDirTarget });
|
|
460
|
+
}
|
|
461
|
+
browserlessTask.volumes = existingVolumes;
|
|
462
|
+
}
|
|
463
|
+
const install = [...(normalizedLifecycle?.install ?? [])];
|
|
464
|
+
if (!install.some((step) => "mkdir" in step && (step.mkdir === browserlessDataDir || step.mkdir === browserlessDataDirTemplate))) {
|
|
465
|
+
install.push({ mkdir: browserlessDataDir });
|
|
466
|
+
}
|
|
467
|
+
const preStart = [...(normalizedLifecycle?.pre_start ?? [])];
|
|
468
|
+
if (!preStart.some((step) => "mkdir" in step && (step.mkdir === browserlessDataDir || step.mkdir === browserlessDataDirTemplate))) {
|
|
469
|
+
preStart.push({ mkdir: browserlessDataDir });
|
|
470
|
+
}
|
|
471
|
+
normalizedLifecycle = {
|
|
472
|
+
...(normalizedLifecycle ?? {}),
|
|
473
|
+
install,
|
|
474
|
+
pre_start: preStart,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
391
477
|
return {
|
|
392
478
|
...spec,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
rawTask.role = "service";
|
|
397
|
-
}
|
|
398
|
-
if (!rawTask.command && rawTask.binary) {
|
|
399
|
-
rawTask.command = rawTask.binary;
|
|
400
|
-
}
|
|
401
|
-
if (Array.isArray(rawTask.ports)) {
|
|
402
|
-
rawTask.ports = rawTask.ports.map((port) => ({
|
|
403
|
-
...port,
|
|
404
|
-
visibility: normalizePortVisibility(port.visibility),
|
|
405
|
-
}));
|
|
406
|
-
}
|
|
407
|
-
return rawTask;
|
|
408
|
-
}),
|
|
479
|
+
...(spec.provides ? { provides: normalizedProvides } : {}),
|
|
480
|
+
tasks: normalizedTasks,
|
|
481
|
+
...(normalizedLifecycle ? { lifecycle: normalizedLifecycle } : {}),
|
|
409
482
|
};
|
|
410
483
|
}
|
|
411
484
|
function imageReferencedByOtherInstalledApps(currentAppId, imagePath) {
|
|
@@ -500,7 +573,8 @@ function getProvidePort(spec, provide) {
|
|
|
500
573
|
const firstPort = spec.tasks.find((task) => task.role === "service")?.ports?.[0];
|
|
501
574
|
if (!firstPort)
|
|
502
575
|
return null;
|
|
503
|
-
|
|
576
|
+
const p = firstPort.host_port ?? firstPort.port;
|
|
577
|
+
return typeof p === "number" && p > 0 ? p : null;
|
|
504
578
|
}
|
|
505
579
|
function getProvideUrl(provide) {
|
|
506
580
|
const raw = typeof provide.url === "string" ? provide.url.trim() : "";
|
|
@@ -518,7 +592,7 @@ function getProvideUrl(provide) {
|
|
|
518
592
|
}
|
|
519
593
|
}
|
|
520
594
|
function buildCapabilityAddress(port, path) {
|
|
521
|
-
const host = legacyInstanceManager.getAdvertisedHostForPort(port);
|
|
595
|
+
const host = port > 0 ? legacyInstanceManager.getAdvertisedHostForPort(port) : "127.0.0.1";
|
|
522
596
|
if (!path) {
|
|
523
597
|
return `${host}:${port}`;
|
|
524
598
|
}
|
|
@@ -742,11 +816,21 @@ const DOCKER_PULL_RETRY_ATTEMPTS = 3;
|
|
|
742
816
|
// extracts in 5 min on a Raspberry Pi. 30 min clears both with headroom while
|
|
743
817
|
// still capping runaway failures (total retry budget 90 min).
|
|
744
818
|
const DOCKER_PULL_TIMEOUT_MS = 1_800_000;
|
|
819
|
+
// Separate from the total timeout above: if docker pull stops producing any
|
|
820
|
+
// stdout/stderr for long enough, treat it as stalled and retry rather than
|
|
821
|
+
// waiting the full 30 minutes.
|
|
822
|
+
const DOCKER_PULL_IDLE_TIMEOUT_MS = 180_000;
|
|
745
823
|
async function pullDockerImageStep(label, image, display, task, timeoutMs = DOCKER_PULL_TIMEOUT_MS) {
|
|
824
|
+
if (await dockerImageExists(image)) {
|
|
825
|
+
const skipMessage = `[lifecycle:${label}] docker image '${image}' already exists locally; skipping pull`;
|
|
826
|
+
process.stdout.write(` ${skipMessage}\n`);
|
|
827
|
+
emitInstallTaskLog(task, skipMessage);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
746
830
|
let lastError;
|
|
747
831
|
for (let attempt = 1; attempt <= DOCKER_PULL_RETRY_ATTEMPTS; attempt++) {
|
|
748
832
|
try {
|
|
749
|
-
await spawnStepWithTimeout(label, display, display, "docker", ["pull", image], timeoutMs, task);
|
|
833
|
+
await spawnStepWithTimeout(label, display, display, "docker", ["pull", image], timeoutMs, task, undefined, undefined, { idleTimeoutMs: Math.min(DOCKER_PULL_IDLE_TIMEOUT_MS, timeoutMs) });
|
|
750
834
|
return;
|
|
751
835
|
}
|
|
752
836
|
catch (error) {
|
|
@@ -778,16 +862,18 @@ async function dockerImageExists(image) {
|
|
|
778
862
|
child.on("error", () => resolve(false));
|
|
779
863
|
});
|
|
780
864
|
}
|
|
781
|
-
function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions, sudo) {
|
|
865
|
+
function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions, sudo, runOptions) {
|
|
782
866
|
process.stdout.write(` [lifecycle:${label}] ${display}\n`);
|
|
783
867
|
emitInstallTaskLog(task, `[lifecycle:${label}] ${taskDisplay}`);
|
|
784
868
|
return new Promise((resolve, reject) => {
|
|
785
869
|
const preparedEnv = prepareLifecycleExecEnv(sudo ? execOptions : undefined);
|
|
786
870
|
let cleaned = false;
|
|
787
871
|
let heartbeatTimer = null;
|
|
872
|
+
let idleTimer = null;
|
|
788
873
|
let stdoutPending = "";
|
|
789
874
|
let stderrPending = "";
|
|
790
875
|
let capturedStderr = "";
|
|
876
|
+
let forcedError = null;
|
|
791
877
|
const cleanupPreparedEnv = () => {
|
|
792
878
|
if (cleaned)
|
|
793
879
|
return;
|
|
@@ -796,17 +882,37 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
|
|
|
796
882
|
clearInterval(heartbeatTimer);
|
|
797
883
|
heartbeatTimer = null;
|
|
798
884
|
}
|
|
885
|
+
if (idleTimer) {
|
|
886
|
+
clearTimeout(idleTimer);
|
|
887
|
+
idleTimer = null;
|
|
888
|
+
}
|
|
799
889
|
preparedEnv.cleanup();
|
|
800
890
|
};
|
|
801
891
|
const spawnTarget = sudo
|
|
802
892
|
? buildSudoWrappedCommand(cmd, args, preparedEnv.env, execOptions)
|
|
803
893
|
: { command: cmd, args };
|
|
804
|
-
const captureOutput = Boolean(task) || Boolean(sudo);
|
|
894
|
+
const captureOutput = Boolean(task) || Boolean(sudo) || Boolean(runOptions?.idleTimeoutMs);
|
|
805
895
|
const child = spawn(spawnTarget.command, spawnTarget.args, {
|
|
806
896
|
stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
807
897
|
timeout: timeoutMs,
|
|
808
898
|
env: preparedEnv.env,
|
|
809
899
|
});
|
|
900
|
+
const resetIdleTimer = () => {
|
|
901
|
+
if (!runOptions?.idleTimeoutMs)
|
|
902
|
+
return;
|
|
903
|
+
if (idleTimer)
|
|
904
|
+
clearTimeout(idleTimer);
|
|
905
|
+
idleTimer = setTimeout(() => {
|
|
906
|
+
const idleSeconds = Math.max(1, Math.round(runOptions.idleTimeoutMs / 1000));
|
|
907
|
+
const stallMessage = `[lifecycle:${label}] no output for ${idleSeconds}s; terminating stalled step: ${taskDisplay}`;
|
|
908
|
+
process.stdout.write(` ${stallMessage}\n`);
|
|
909
|
+
emitInstallTaskLog(task, stallMessage);
|
|
910
|
+
forcedError = new Error(`lifecycle '${label}' step stalled after ${idleSeconds}s with no output: ${display}`);
|
|
911
|
+
child.kill("SIGTERM");
|
|
912
|
+
}, runOptions.idleTimeoutMs);
|
|
913
|
+
idleTimer.unref?.();
|
|
914
|
+
};
|
|
915
|
+
resetIdleTimer();
|
|
810
916
|
if (captureOutput) {
|
|
811
917
|
const startedAt = Date.now();
|
|
812
918
|
const flushPendingLine = (line) => {
|
|
@@ -815,6 +921,7 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
|
|
|
815
921
|
emitInstallTaskLog(task, line);
|
|
816
922
|
};
|
|
817
923
|
const handleChunk = (chunk, stream) => {
|
|
924
|
+
resetIdleTimer();
|
|
818
925
|
const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
819
926
|
if (stream === "stderr") {
|
|
820
927
|
capturedStderr += text;
|
|
@@ -854,7 +961,9 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
|
|
|
854
961
|
}
|
|
855
962
|
child.on("close", (code) => {
|
|
856
963
|
cleanupPreparedEnv();
|
|
857
|
-
if (
|
|
964
|
+
if (forcedError)
|
|
965
|
+
reject(forcedError);
|
|
966
|
+
else if (code === 0)
|
|
858
967
|
resolve();
|
|
859
968
|
else if (sudo)
|
|
860
969
|
reject(createLifecycleSudoError(capturedStderr, display, Boolean(execOptions?.sudoPassword)));
|
|
@@ -863,6 +972,10 @@ function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs,
|
|
|
863
972
|
});
|
|
864
973
|
child.on("error", (err) => {
|
|
865
974
|
cleanupPreparedEnv();
|
|
975
|
+
if (forcedError) {
|
|
976
|
+
reject(forcedError);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
866
979
|
if (sudo && err.code === "ENOENT") {
|
|
867
980
|
reject(new Error("当前环境未检测到 sudo,无法执行需要 sudo 的生命周期步骤。请以 root 身份重试。"));
|
|
868
981
|
return;
|
|
@@ -879,7 +992,8 @@ function lifecycleRunStepDisplay(label, index) {
|
|
|
879
992
|
}
|
|
880
993
|
async function commandExists(command) {
|
|
881
994
|
return new Promise((resolve) => {
|
|
882
|
-
const
|
|
995
|
+
const quoted = command.replace(/'/g, "'\\''");
|
|
996
|
+
const child = spawn("sh", ["-c", `command -v '${quoted}' > /dev/null 2>&1`], {
|
|
883
997
|
stdio: "ignore",
|
|
884
998
|
env: buildLifecycleEnv(),
|
|
885
999
|
});
|
|
@@ -887,6 +1001,64 @@ async function commandExists(command) {
|
|
|
887
1001
|
child.on("error", () => resolve(false));
|
|
888
1002
|
});
|
|
889
1003
|
}
|
|
1004
|
+
// Recursively chown a path. Owner format is "uid:gid" (numeric only, e.g.
|
|
1005
|
+
// "0:0" or "1000:1000"). Used by container apps whose images run as a
|
|
1006
|
+
// different uid than the panel user — without this, bind-mounted data
|
|
1007
|
+
// dirs end up unwritable for the in-container process and fail with
|
|
1008
|
+
// SQLite "readonly database" or chroma init errors.
|
|
1009
|
+
function parseOwnerSpec(owner) {
|
|
1010
|
+
const m = /^(\d+):(\d+)$/.exec(owner);
|
|
1011
|
+
if (!m)
|
|
1012
|
+
throw new Error(`chown owner must be "uid:gid" (numeric), got "${owner}"`);
|
|
1013
|
+
return { uid: Number(m[1]), gid: Number(m[2]) };
|
|
1014
|
+
}
|
|
1015
|
+
function chownRecursive(path, uid, gid) {
|
|
1016
|
+
chownSync(path, uid, gid);
|
|
1017
|
+
let stat;
|
|
1018
|
+
try {
|
|
1019
|
+
stat = lstatSync(path);
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (!stat.isDirectory())
|
|
1025
|
+
return;
|
|
1026
|
+
for (const entry of readdirSync(path)) {
|
|
1027
|
+
chownRecursive(join(path, entry), uid, gid);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Try chowning via direct fs syscall first; on EPERM (panel runs as a
|
|
1032
|
+
* non-root user with no CAP_CHOWN) fall back to `sudo -n chown`. The
|
|
1033
|
+
* fallback only succeeds where passwordless sudo is configured for the
|
|
1034
|
+
* panel user (the canonical Pi setup); on other hosts the original
|
|
1035
|
+
* EPERM bubbles up as a clear error.
|
|
1036
|
+
*/
|
|
1037
|
+
function chownWithSudoFallback(path, uid, gid, recursive) {
|
|
1038
|
+
try {
|
|
1039
|
+
if (recursive)
|
|
1040
|
+
chownRecursive(path, uid, gid);
|
|
1041
|
+
else
|
|
1042
|
+
chownSync(path, uid, gid);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
catch (e) {
|
|
1046
|
+
if (e?.code !== "EPERM" && e?.code !== "EACCES")
|
|
1047
|
+
throw e;
|
|
1048
|
+
}
|
|
1049
|
+
const args = [
|
|
1050
|
+
"-n",
|
|
1051
|
+
"chown",
|
|
1052
|
+
...(recursive ? ["-R"] : []),
|
|
1053
|
+
`${uid}:${gid}`,
|
|
1054
|
+
path,
|
|
1055
|
+
];
|
|
1056
|
+
const r = spawnSync("sudo", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
1057
|
+
if (r.status !== 0) {
|
|
1058
|
+
const stderr = r.stderr ? r.stderr.toString().trim() : "";
|
|
1059
|
+
throw new Error(`chown ${recursive ? "-R " : ""}${uid}:${gid} ${path} failed: panel user lacks CAP_CHOWN and passwordless sudo also failed${stderr ? `: ${stderr}` : ""}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
890
1062
|
async function downloadBinaryStep(label, url, dest, chmod, task) {
|
|
891
1063
|
const expanded = expandPath(dest);
|
|
892
1064
|
process.stdout.write(` [lifecycle:${label}] downloadBinary: ${url} → ${expanded}\n`);
|
|
@@ -969,6 +1141,36 @@ async function runLifecycleSteps(steps, label, artifacts, task, execOptions) {
|
|
|
969
1141
|
mkdirSync(p, { recursive: true });
|
|
970
1142
|
artifacts?.push({ type: "dir", path: p });
|
|
971
1143
|
}
|
|
1144
|
+
else if ("chown" in step) {
|
|
1145
|
+
const p = expandPath(step.chown.path);
|
|
1146
|
+
const { uid, gid } = parseOwnerSpec(step.chown.owner);
|
|
1147
|
+
const recursive = step.chown.recursive !== false;
|
|
1148
|
+
const tag = recursive ? "chown -R" : "chown";
|
|
1149
|
+
process.stdout.write(` [lifecycle:${label}] ${tag} ${uid}:${gid} ${p}\n`);
|
|
1150
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] ${tag} ${uid}:${gid} ${p}`);
|
|
1151
|
+
if (!existsSync(p)) {
|
|
1152
|
+
// chown only makes sense if the target exists; surface a clear
|
|
1153
|
+
// error rather than letting fs throw an opaque ENOENT later.
|
|
1154
|
+
throw new Error(`chown target does not exist: ${p}`);
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
chownWithSudoFallback(p, uid, gid, recursive);
|
|
1158
|
+
}
|
|
1159
|
+
catch (chownErr) {
|
|
1160
|
+
// In pre_start, chown failures are non-fatal: on macOS Docker/Colima
|
|
1161
|
+
// the VM handles UID mapping for bind-mounts, so the container can
|
|
1162
|
+
// write even without matching ownership. Failing fatally here blocks
|
|
1163
|
+
// any non-root panel user from starting the app.
|
|
1164
|
+
if (label === "pre_start") {
|
|
1165
|
+
const msg = `[lifecycle:${label}] ${tag} ${uid}:${gid} ${p} failed (non-fatal): ${chownErr.message}`;
|
|
1166
|
+
process.stdout.write(` ${msg}\n`);
|
|
1167
|
+
emitInstallTaskLog(task, msg);
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
throw chownErr;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
972
1174
|
else if ("deleteDir" in step) {
|
|
973
1175
|
const p = expandPath(step.deleteDir);
|
|
974
1176
|
process.stdout.write(` [lifecycle:${label}] deleteDir: ${p}\n`);
|
|
@@ -1135,6 +1337,7 @@ function startAppLifecycleTask(appId, kind, startMessage, doneMessage, action) {
|
|
|
1135
1337
|
return {
|
|
1136
1338
|
ok: false,
|
|
1137
1339
|
error: `App '${appId}' 正在执行 ${currentTask.kind} 操作,请等待完成后再试`,
|
|
1340
|
+
code: "TASK_BUSY",
|
|
1138
1341
|
kind,
|
|
1139
1342
|
};
|
|
1140
1343
|
}
|
|
@@ -1354,7 +1557,22 @@ async function installIntoInstanceDir(spec, specYaml, requestedAppId, options =
|
|
|
1354
1557
|
return null;
|
|
1355
1558
|
const { appId, installedSpec, } = await resolveInstallTarget(spec, specYaml, requestedAppId);
|
|
1356
1559
|
let instanceSpec = rewriteInstanceScopedPaths(installedSpec, appId);
|
|
1357
|
-
|
|
1560
|
+
// PR 3 sub-step 3c: switch from legacy `resolveRequires(spec)` to the new
|
|
1561
|
+
// resolveConnections in preCreate mode. Legacy single-candidate fallback
|
|
1562
|
+
// still materializes its env (so meta-apps stay "open the box and it
|
|
1563
|
+
// works"); category-prefix requires + missing required producers fall
|
|
1564
|
+
// into `pending` for UI display via the install task event (PR 4 wires
|
|
1565
|
+
// task.event.affectedConsumers etc.). install never fails here — even if
|
|
1566
|
+
// a required capability is unavailable; start time will surface the
|
|
1567
|
+
// error. The previous `ensureRequiredCapabilitiesAvailable` block has
|
|
1568
|
+
// been removed for the same reason.
|
|
1569
|
+
const { resolveConnections, resolvedToLegacyEnv } = await import("../connection-resolver.js");
|
|
1570
|
+
const { resolved, pending } = resolveConnections(installedSpec, { connections: {} }, "preCreate");
|
|
1571
|
+
if (pending.length > 0) {
|
|
1572
|
+
console.log(`[install] ${appId}: ${pending.length} pending connection(s): ` +
|
|
1573
|
+
pending.map((p) => `${p.slot} (${p.capability}, ${p.reason})`).join(", "));
|
|
1574
|
+
}
|
|
1575
|
+
const resolvedRequires = resolvedToLegacyEnv(resolved);
|
|
1358
1576
|
if (Object.keys(resolvedRequires).length > 0) {
|
|
1359
1577
|
instanceSpec = {
|
|
1360
1578
|
...installedSpec,
|
|
@@ -1492,43 +1710,38 @@ export function getAppInstallState(appId) {
|
|
|
1492
1710
|
return null;
|
|
1493
1711
|
return hasInstallLock(location.dir) ? "installing" : "installed";
|
|
1494
1712
|
}
|
|
1713
|
+
const BROWSERLESS_DEBUGGER_CAPABILITY = "browserless-debugger";
|
|
1714
|
+
const BROWSERLESS_API_CAPABILITY = "browserless-api";
|
|
1715
|
+
const BROWSERLESS_DOCS_CAPABILITY = "browserless-docs";
|
|
1716
|
+
/**
|
|
1717
|
+
* Compat-view registry reader. Returns the legacy `{ capabilities: {} }`
|
|
1718
|
+
* shape that older call sites expect. The new `capability-registry.ts`
|
|
1719
|
+
* module migrates dual-shape entries on every read so the legacy view is
|
|
1720
|
+
* always populated. PR 3 sub-step 3f deletes this shim.
|
|
1721
|
+
*/
|
|
1495
1722
|
function readRegistry() {
|
|
1496
|
-
const
|
|
1497
|
-
return
|
|
1498
|
-
}
|
|
1499
|
-
function writeRegistry(reg) {
|
|
1500
|
-
ensureDirHost(APPS_DIR);
|
|
1501
|
-
safeWriteJson(REGISTRY_PATH, reg, true);
|
|
1723
|
+
const file = capabilityRegistry.readRegistry();
|
|
1724
|
+
return { capabilities: file.capabilities ?? {}, providersByCapability: file.providersByCapability };
|
|
1502
1725
|
}
|
|
1503
1726
|
function installedProvidersForCapability(capability) {
|
|
1504
1727
|
return listApps()
|
|
1505
1728
|
.filter((app) => app.spec.provides?.some((provide) => provide.capability === capability))
|
|
1506
1729
|
.map((app) => app.manifest.id);
|
|
1507
1730
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
const missing = spec.requires
|
|
1513
|
-
.filter((req) => req.required !== false && !reg.capabilities[req.capability])
|
|
1514
|
-
.map((req) => {
|
|
1515
|
-
const installedProviders = installedProvidersForCapability(req.capability);
|
|
1516
|
-
const providerHint = installedProviders.length > 0
|
|
1517
|
-
? `;已安装但未注册的 provider: ${installedProviders.join(", ")}`
|
|
1518
|
-
: "";
|
|
1519
|
-
return `- ${req.capability} -> ${req.inject_as}${providerHint}`;
|
|
1520
|
-
});
|
|
1521
|
-
if (missing.length === 0)
|
|
1522
|
-
return;
|
|
1523
|
-
throw new Error(`App '${spec.id}' 缺少必需能力,已跳过安装:\n${missing.join("\n")}\n请先启动对应 provider,再执行 jishushell app provides 查看当前可用能力。`);
|
|
1524
|
-
}
|
|
1731
|
+
// `ensureRequiredCapabilitiesAvailable` was removed in PR 3 sub-step 3c.
|
|
1732
|
+
// install never blocks on missing required providers any more —
|
|
1733
|
+
// resolveConnections(..., "preCreate") collects them into the `pending`
|
|
1734
|
+
// list and the UI surfaces them after install completes.
|
|
1525
1735
|
export function listProvidedCapabilities() {
|
|
1526
1736
|
const reg = readRegistry();
|
|
1527
1737
|
return listApps().flatMap((app) => (app.spec.provides ?? []).map((provide) => {
|
|
1528
1738
|
const url = getProvideUrl(provide) ?? undefined;
|
|
1529
1739
|
const port = getProvidePort(app.spec, provide) ?? undefined;
|
|
1530
1740
|
const address = !url && typeof port === "number" ? buildCapabilityAddress(port, provide.path) : undefined;
|
|
1531
|
-
const
|
|
1741
|
+
const providers = reg.providersByCapability?.[provide.capability] ?? [];
|
|
1742
|
+
const registered = providers.find((e) => e.instanceId === app.manifest.id)
|
|
1743
|
+
?? providers.find((e) => e.status === "running")
|
|
1744
|
+
?? providers[0];
|
|
1532
1745
|
const protocol = resolveProvideProtocol(provide);
|
|
1533
1746
|
return {
|
|
1534
1747
|
appId: app.manifest.id,
|
|
@@ -1554,7 +1767,11 @@ export function getEmbeddedUiHintForApp(appId) {
|
|
|
1554
1767
|
const provides = getProvidedCapabilitiesForApp(appId);
|
|
1555
1768
|
if (!provides.length)
|
|
1556
1769
|
return null;
|
|
1557
|
-
|
|
1770
|
+
const preferred = provides.find((provide) => provide.capability === BROWSERLESS_DEBUGGER_CAPABILITY);
|
|
1771
|
+
const orderedProvides = preferred
|
|
1772
|
+
? [preferred, ...provides.filter((provide) => provide !== preferred)]
|
|
1773
|
+
: provides;
|
|
1774
|
+
for (const provide of orderedProvides) {
|
|
1558
1775
|
const protocol = normalizeProvideProtocol(provide.protocol);
|
|
1559
1776
|
if (provide.visibility === "internal")
|
|
1560
1777
|
continue;
|
|
@@ -1570,14 +1787,43 @@ export function getEmbeddedUiHintForApp(appId) {
|
|
|
1570
1787
|
}
|
|
1571
1788
|
if (typeof provide.port !== "number" || provide.port < 1)
|
|
1572
1789
|
continue;
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1790
|
+
// Prefer a direct upstream URL when the container port is published to
|
|
1791
|
+
// a LAN-reachable address (Pi with host_network "external", etc.). The
|
|
1792
|
+
// same-origin reverse-proxy path is necessary only when the container
|
|
1793
|
+
// is bound to 127.0.0.1 (macOS+Colima, dev laptops without LAN
|
|
1794
|
+
// exposure) — there it's the only way for a remote browser to reach
|
|
1795
|
+
// the iframe content. Going through the proxy when the upstream is
|
|
1796
|
+
// already public causes path-collision bugs for apps that fetch
|
|
1797
|
+
// absolute URLs starting with `/api/...` (e.g. OpenWebUI), because
|
|
1798
|
+
// those calls bypass `<base href>` and hit the panel API instead.
|
|
1799
|
+
const listeningHost = legacyInstanceManager.getListeningHostForPort(provide.port);
|
|
1800
|
+
const directlyReachable = listeningHost && listeningHost !== "127.0.0.1" && listeningHost !== "::1";
|
|
1801
|
+
if (directlyReachable) {
|
|
1802
|
+
const advertised = legacyInstanceManager.getAdvertisedHostForPort(provide.port);
|
|
1803
|
+
const directUrl = `${protocol}://${advertised}:${provide.port}${provide.path ?? ""}`;
|
|
1804
|
+
return {
|
|
1805
|
+
capability: provide.capability,
|
|
1806
|
+
protocol,
|
|
1807
|
+
port: provide.port,
|
|
1808
|
+
url: directUrl,
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
// Use same-origin reverse-proxy path so the frontend iframe works for
|
|
1812
|
+
// remote browsers and macOS+Colima environments where the container
|
|
1813
|
+
// port is only published to 127.0.0.1.
|
|
1814
|
+
// Root-path UIs (path omitted) still need a trailing slash so the
|
|
1815
|
+
// browser treats the iframe src as a directory URL. Without it, some
|
|
1816
|
+
// SPA runtimes compute relative URLs from `/.../provides/<capability>`
|
|
1817
|
+
// as if `<capability>` were a file segment, which breaks boot under the
|
|
1818
|
+
// proxy even when the HTML/base rewrite succeeded.
|
|
1819
|
+
const normalizedProvidePath = typeof provide.path === "string" ? provide.path.trim() : "";
|
|
1820
|
+
const needsTrailingSlash = !normalizedProvidePath || normalizedProvidePath.endsWith("/");
|
|
1821
|
+
const proxyPath = `/api/instances/${encodeURIComponent(appId)}/provides/${encodeURIComponent(provide.capability)}${needsTrailingSlash ? "/" : ""}`;
|
|
1576
1822
|
return {
|
|
1577
1823
|
capability: provide.capability,
|
|
1578
1824
|
protocol,
|
|
1579
1825
|
port: provide.port,
|
|
1580
|
-
url:
|
|
1826
|
+
url: proxyPath,
|
|
1581
1827
|
};
|
|
1582
1828
|
}
|
|
1583
1829
|
return null;
|
|
@@ -1615,7 +1861,11 @@ export async function installApp(specYaml, requestedAppId, options = {}) {
|
|
|
1615
1861
|
throw new Error(`task '${task.name}' 的 image '${task.image}' 格式无效`);
|
|
1616
1862
|
}
|
|
1617
1863
|
}
|
|
1618
|
-
ensureRequiredCapabilitiesAvailable
|
|
1864
|
+
// PR 3 sub-step 3c: removed the legacy `ensureRequiredCapabilitiesAvailable`
|
|
1865
|
+
// hard-stop. install now never blocks on missing required providers —
|
|
1866
|
+
// resolveConnections(..., "preCreate") records them as `pending` on the
|
|
1867
|
+
// install task event so the UI can prompt the user; start time surfaces
|
|
1868
|
+
// the error if still unresolved.
|
|
1619
1869
|
const instanceBackedInstall = await installIntoInstanceDir(spec, specYaml, requestedAppId, options);
|
|
1620
1870
|
if (instanceBackedInstall) {
|
|
1621
1871
|
return instanceBackedInstall;
|
|
@@ -1846,44 +2096,91 @@ export function uninstallAppTask(id, exec) {
|
|
|
1846
2096
|
export async function runPostStartSteps(spec) {
|
|
1847
2097
|
await runLifecycleSteps(spec.lifecycle?.post_start, "post_start");
|
|
1848
2098
|
}
|
|
2099
|
+
/**
|
|
2100
|
+
* Register all `provides` for an instance. PR 1 routes through the new
|
|
2101
|
+
* `capability-registry.ts` module (which dual-writes the legacy
|
|
2102
|
+
* `capabilities` map for compat). `portOverride` is preserved as a
|
|
2103
|
+
* temporary parameter for the existing server.ts startup-rebuild path
|
|
2104
|
+
* (`server.ts:266-282`); PR 1 step 0 of `resolveProvideEndpoint` reads
|
|
2105
|
+
* the actual allocated port from instance runtime when available, so
|
|
2106
|
+
* once PR 3 lands `portOverride` becomes redundant and gets removed.
|
|
2107
|
+
*/
|
|
1849
2108
|
export function registerCapabilities(instanceId, spec, portOverride) {
|
|
1850
2109
|
if (!spec.provides || spec.provides.length === 0)
|
|
1851
2110
|
return;
|
|
1852
|
-
const reg = readRegistry();
|
|
1853
2111
|
const now = new Date().toISOString();
|
|
2112
|
+
const appName = spec.name ?? spec.id;
|
|
1854
2113
|
for (const provide of spec.provides) {
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
: getProvidePort(spec, provide);
|
|
1858
|
-
if (hostPort == null) {
|
|
2114
|
+
// url-only provide — out of capability registry scope (§5.1 boundary).
|
|
2115
|
+
if (typeof provide.url === "string" && provide.url.trim())
|
|
1859
2116
|
continue;
|
|
2117
|
+
let host = "127.0.0.1";
|
|
2118
|
+
let hostPort;
|
|
2119
|
+
if (typeof portOverride === "number" && portOverride > 0) {
|
|
2120
|
+
// Legacy startup-rebuild path: server.ts already resolved the actual
|
|
2121
|
+
// listening port via `instanceManager.getGatewayPort()`. Honor it for
|
|
2122
|
+
// the gateway-port provide (typically `provides[0]`); other provides
|
|
2123
|
+
// fall through to the spec-derived port.
|
|
2124
|
+
hostPort = portOverride;
|
|
2125
|
+
host = legacyInstanceManager.getAdvertisedHostForPort(portOverride);
|
|
2126
|
+
}
|
|
2127
|
+
if (typeof hostPort !== "number") {
|
|
2128
|
+
const resolved = resolveProvideEndpoint(instanceId, spec, provide);
|
|
2129
|
+
if (resolved) {
|
|
2130
|
+
host = resolved.host;
|
|
2131
|
+
hostPort = resolved.hostPort;
|
|
2132
|
+
}
|
|
2133
|
+
else {
|
|
2134
|
+
const declared = getProvidePort(spec, provide);
|
|
2135
|
+
if (declared == null)
|
|
2136
|
+
continue;
|
|
2137
|
+
hostPort = declared;
|
|
2138
|
+
host = legacyInstanceManager.getAdvertisedHostForPort(declared);
|
|
2139
|
+
}
|
|
1860
2140
|
}
|
|
1861
|
-
|
|
2141
|
+
const protocol = resolveProvideProtocol(provide);
|
|
2142
|
+
const entry = {
|
|
1862
2143
|
instanceId,
|
|
2144
|
+
name: appName,
|
|
2145
|
+
capability: provide.capability,
|
|
2146
|
+
host,
|
|
1863
2147
|
hostPort,
|
|
2148
|
+
...(provide.path ? { path: provide.path } : {}),
|
|
1864
2149
|
address: buildCapabilityAddress(hostPort, provide.path),
|
|
1865
|
-
|
|
1866
|
-
|
|
2150
|
+
protocol,
|
|
2151
|
+
...(provide.visibility ? { visibility: String(provide.visibility) } : {}),
|
|
2152
|
+
status: "running",
|
|
2153
|
+
lastSeenRunningAt: now,
|
|
2154
|
+
registeredAt: now,
|
|
2155
|
+
// §17 (PR 8) — carry MCP firewall canonical schema through so
|
|
2156
|
+
// adapters can resolve it during applyConnectionEnv without
|
|
2157
|
+
// reading the spec a second time.
|
|
2158
|
+
...(provide.tool_schema ? { toolSchema: provide.tool_schema } : {}),
|
|
2159
|
+
// §6 (PR B) — carry auth config through so apply hooks can resolve
|
|
2160
|
+
// tokens at apply/runtime time without re-reading the spec.
|
|
2161
|
+
...(provide.auth ? { auth: provide.auth } : {}),
|
|
1867
2162
|
};
|
|
2163
|
+
capabilityRegistry.registerProvider(entry);
|
|
1868
2164
|
}
|
|
1869
|
-
writeRegistry(reg);
|
|
1870
2165
|
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Mark an instance's providers as `stopped` (preferred for stop) or
|
|
2168
|
+
* remove them entirely (uninstall / delete). Defaults to remove for
|
|
2169
|
+
* back-compat with existing call sites; new code should prefer
|
|
2170
|
+
* `markCapabilitiesStopped` to keep entries visible in the Connections UI.
|
|
2171
|
+
*/
|
|
1871
2172
|
export function unregisterCapabilities(instanceId) {
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
}
|
|
1877
|
-
}
|
|
1878
|
-
writeRegistry(reg);
|
|
2173
|
+
capabilityRegistry.unregisterProviders(instanceId);
|
|
2174
|
+
}
|
|
2175
|
+
export function markCapabilitiesStopped(instanceId) {
|
|
2176
|
+
capabilityRegistry.setProviderStatus(instanceId, "stopped");
|
|
1879
2177
|
}
|
|
1880
2178
|
export function resolveRequires(spec) {
|
|
1881
2179
|
if (!spec.requires || spec.requires.length === 0)
|
|
1882
2180
|
return {};
|
|
1883
|
-
const reg = readRegistry();
|
|
1884
2181
|
const result = {};
|
|
1885
2182
|
for (const req of spec.requires) {
|
|
1886
|
-
const entry =
|
|
2183
|
+
const entry = capabilityRegistry.getCapabilityEntry(req.capability);
|
|
1887
2184
|
if (entry) {
|
|
1888
2185
|
result[req.inject_as] = entry.address;
|
|
1889
2186
|
}
|
|
@@ -1894,7 +2191,49 @@ export function resolveRequires(spec) {
|
|
|
1894
2191
|
return result;
|
|
1895
2192
|
}
|
|
1896
2193
|
// ── App Lifecycle (delegates to nomad-manager) ───────────────────
|
|
2194
|
+
/**
|
|
2195
|
+
* Read `instance.json` for a generic container app. Returns the parsed
|
|
2196
|
+
* record or `null` if missing/unreadable. Adapter-managed consumers
|
|
2197
|
+
* (OpenClaw, Hermes) keep their state elsewhere; this is the generic
|
|
2198
|
+
* app-dir layout under `~/.jishushell/apps/<appId>/`.
|
|
2199
|
+
*/
|
|
2200
|
+
function readAppInstanceJson(appId) {
|
|
2201
|
+
try {
|
|
2202
|
+
const path = join(APPS_DIR, appId, "instance.json");
|
|
2203
|
+
return safeReadJson(path, `app-instance:${appId}`) ?? null;
|
|
2204
|
+
}
|
|
2205
|
+
catch (e) {
|
|
2206
|
+
console.warn(`[app-instance] read failed for ${appId}: ${e?.message ?? e}`);
|
|
2207
|
+
return null;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Read `instance.json["connections-env"]` for a generic container app and
|
|
2212
|
+
* return a copy of the persisted env vars. Adapter-managed consumers
|
|
2213
|
+
* (OpenClaw, Hermes) route connections through `applyConnectionEnv` and
|
|
2214
|
+
* don't write to this field. Best-effort — failures return empty object
|
|
2215
|
+
* so they never block startup.
|
|
2216
|
+
*/
|
|
2217
|
+
function loadConnectionsEnv(appId) {
|
|
2218
|
+
const inst = readAppInstanceJson(appId);
|
|
2219
|
+
const env = inst?.["connections-env"];
|
|
2220
|
+
if (env && typeof env === "object" && !Array.isArray(env)) {
|
|
2221
|
+
const out = {};
|
|
2222
|
+
for (const [k, v] of Object.entries(env)) {
|
|
2223
|
+
if (typeof v === "string")
|
|
2224
|
+
out[k] = v;
|
|
2225
|
+
}
|
|
2226
|
+
return out;
|
|
2227
|
+
}
|
|
2228
|
+
return {};
|
|
2229
|
+
}
|
|
1897
2230
|
export async function startApp(appId) {
|
|
2231
|
+
// Serialize against PUT /connections, stopApp and concurrent startApp on
|
|
2232
|
+
// the same instance — see `utils/instance-lock.ts` and §10.3 of the
|
|
2233
|
+
// app-interconnect design.
|
|
2234
|
+
return withInstanceLock(appId, () => startAppImpl(appId));
|
|
2235
|
+
}
|
|
2236
|
+
async function startAppImpl(appId) {
|
|
1898
2237
|
const appData = getApp(appId);
|
|
1899
2238
|
if (!appData) {
|
|
1900
2239
|
return { ok: false, error: `App '${appId}' not found` };
|
|
@@ -1903,6 +2242,9 @@ export async function startApp(appId) {
|
|
|
1903
2242
|
return { ok: false, error: `App '${appId}' is still installing` };
|
|
1904
2243
|
}
|
|
1905
2244
|
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
2245
|
+
if (appData.spec.lifecycle?.pre_start?.length) {
|
|
2246
|
+
await runLifecycleSteps(appData.spec.lifecycle.pre_start, "pre_start");
|
|
2247
|
+
}
|
|
1906
2248
|
const { startNomadJobInstance } = await import("../nomad-manager.js");
|
|
1907
2249
|
const result = await startNomadJobInstance(appId);
|
|
1908
2250
|
if (!result.ok)
|
|
@@ -1915,18 +2257,47 @@ export async function startApp(appId) {
|
|
|
1915
2257
|
}
|
|
1916
2258
|
return result;
|
|
1917
2259
|
}
|
|
2260
|
+
// Resolve requires through the v3 connection-resolver in runtime mode so
|
|
2261
|
+
// missing-required / ambiguous-prefix / invalid-binding all surface with
|
|
2262
|
+
// the structured 412/409/400 codes (§6.4 Phase 4 of the app-interconnect
|
|
2263
|
+
// design). The legacy `resolveRequires` path threw a bare Error which the
|
|
2264
|
+
// route handler could only forward as a generic 400 — losing the bind-vs-
|
|
2265
|
+
// start-vs-pick distinction the UI needs.
|
|
2266
|
+
const instJson = readAppInstanceJson(appId);
|
|
2267
|
+
const persistedEnv = loadConnectionsEnv(appId);
|
|
1918
2268
|
let extraEnv = {};
|
|
1919
2269
|
try {
|
|
1920
|
-
|
|
2270
|
+
const { resolveConnections, resolvedToLegacyEnv } = await import("../connection-resolver.js");
|
|
2271
|
+
const { renderRuntimeConnectionsEnv } = await import("../connection-apply.js");
|
|
2272
|
+
const { resolved } = resolveConnections(appData.spec, { connections: instJson?.connections ?? {} }, "runtime");
|
|
2273
|
+
const runtimeEnv = await renderRuntimeConnectionsEnv(appData.spec, {
|
|
2274
|
+
id: appId,
|
|
2275
|
+
connections: instJson?.connections,
|
|
2276
|
+
});
|
|
2277
|
+
// Frozen `connections-env` is the lowest-priority fallback for legacy
|
|
2278
|
+
// apps that pre-date resolveConnections. Runtime-rendered env wins so
|
|
2279
|
+
// provider port/IP changes propagate without re-binding.
|
|
2280
|
+
extraEnv = { ...persistedEnv, ...resolvedToLegacyEnv(resolved), ...runtimeEnv };
|
|
1921
2281
|
}
|
|
1922
2282
|
catch (e) {
|
|
1923
|
-
return {
|
|
2283
|
+
return {
|
|
2284
|
+
ok: false,
|
|
2285
|
+
error: e.message,
|
|
2286
|
+
...(e.code ? { code: e.code } : {}),
|
|
2287
|
+
...(typeof e.statusCode === "number" ? { statusCode: e.statusCode } : {}),
|
|
2288
|
+
};
|
|
1924
2289
|
}
|
|
1925
2290
|
const { startAppJob: nomadStart, checkDependencies, waitForRunning } = await import("../nomad-manager.js");
|
|
1926
2291
|
const depCheck = await checkDependencies(appData.spec);
|
|
1927
2292
|
if (!depCheck.ok) {
|
|
1928
2293
|
return { ok: false, error: depCheck.errors.join("; ") };
|
|
1929
2294
|
}
|
|
2295
|
+
// Run pre_start steps right before submitting the Nomad job — gives
|
|
2296
|
+
// apps a place to enforce per-start invariants (e.g. chown the
|
|
2297
|
+
// bind-mount source so the container's runtime uid can write).
|
|
2298
|
+
if (appData.spec.lifecycle?.pre_start?.length) {
|
|
2299
|
+
await runLifecycleSteps(appData.spec.lifecycle.pre_start, "pre_start");
|
|
2300
|
+
}
|
|
1930
2301
|
const result = await nomadStart(appData.spec, appId, extraEnv);
|
|
1931
2302
|
if (!result.ok) {
|
|
1932
2303
|
return result;
|
|
@@ -1954,6 +2325,9 @@ export function startAppTask(appId) {
|
|
|
1954
2325
|
});
|
|
1955
2326
|
}
|
|
1956
2327
|
export async function stopApp(appId, purge = false) {
|
|
2328
|
+
return withInstanceLock(appId, () => stopAppImpl(appId, purge));
|
|
2329
|
+
}
|
|
2330
|
+
async function stopAppImpl(appId, purge) {
|
|
1957
2331
|
const appData = getApp(appId);
|
|
1958
2332
|
if (appData?.install_state === "installing") {
|
|
1959
2333
|
return { ok: false, error: `App '${appId}' is still installing` };
|
|
@@ -1962,14 +2336,22 @@ export async function stopApp(appId, purge = false) {
|
|
|
1962
2336
|
const { stopNomadJobInstance } = await import("../nomad-manager.js");
|
|
1963
2337
|
const result = await stopNomadJobInstance(appId, purge);
|
|
1964
2338
|
if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
|
|
1965
|
-
|
|
2339
|
+
// Stop = mark stopped (keep entry visible in Connections UI as a
|
|
2340
|
+
// greyed candidate); only purge / uninstall fully unregisters.
|
|
2341
|
+
if (purge)
|
|
2342
|
+
unregisterCapabilities(appId);
|
|
2343
|
+
else
|
|
2344
|
+
markCapabilitiesStopped(appId);
|
|
1966
2345
|
}
|
|
1967
2346
|
return result;
|
|
1968
2347
|
}
|
|
1969
2348
|
const { stopAppJob } = await import("../nomad-manager.js");
|
|
1970
2349
|
const result = await stopAppJob(appId, purge);
|
|
1971
2350
|
if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
|
|
1972
|
-
|
|
2351
|
+
if (purge)
|
|
2352
|
+
unregisterCapabilities(appId);
|
|
2353
|
+
else
|
|
2354
|
+
markCapabilitiesStopped(appId);
|
|
1973
2355
|
}
|
|
1974
2356
|
return result;
|
|
1975
2357
|
}
|
|
@@ -1985,28 +2367,34 @@ export function stopAppTask(appId, purge = false) {
|
|
|
1985
2367
|
});
|
|
1986
2368
|
}
|
|
1987
2369
|
export async function restartApp(appId) {
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
const
|
|
1994
|
-
|
|
1995
|
-
|
|
2370
|
+
// Hold the instance lock for the entire restart sequence so a concurrent
|
|
2371
|
+
// PUT /connections / startApp / stopApp on the same id can't observe a
|
|
2372
|
+
// half-restarted state. Inner stop/start calls reuse the same lock id;
|
|
2373
|
+
// we route them through the *Impl helpers to avoid re-acquiring.
|
|
2374
|
+
return withInstanceLock(appId, async () => {
|
|
2375
|
+
const appData = getApp(appId);
|
|
2376
|
+
if (appData?.install_state === "installing") {
|
|
2377
|
+
return { ok: false, error: `App '${appId}' is still installing` };
|
|
2378
|
+
}
|
|
2379
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
2380
|
+
const { restartNomadJobInstance } = await import("../nomad-manager.js");
|
|
2381
|
+
const result = await restartNomadJobInstance(appId);
|
|
2382
|
+
if (!result.ok)
|
|
2383
|
+
return result;
|
|
2384
|
+
if (appData?.spec.provides?.length) {
|
|
2385
|
+
registerCapabilities(appId, appData.spec);
|
|
2386
|
+
}
|
|
2387
|
+
if (appData?.spec.lifecycle?.post_start?.length) {
|
|
2388
|
+
await runPostStartSteps(appData.spec);
|
|
2389
|
+
}
|
|
1996
2390
|
return result;
|
|
1997
|
-
if (appData?.spec.provides?.length) {
|
|
1998
|
-
registerCapabilities(appId, appData.spec);
|
|
1999
2391
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2392
|
+
const stopResult = await stopAppImpl(appId, true);
|
|
2393
|
+
if (!stopResult.ok && !stopResult.error?.includes("not found")) {
|
|
2394
|
+
return stopResult;
|
|
2002
2395
|
}
|
|
2003
|
-
return
|
|
2004
|
-
}
|
|
2005
|
-
const stopResult = await stopApp(appId, true);
|
|
2006
|
-
if (!stopResult.ok && !stopResult.error?.includes("not found")) {
|
|
2007
|
-
return stopResult;
|
|
2008
|
-
}
|
|
2009
|
-
return startApp(appId);
|
|
2396
|
+
return startAppImpl(appId);
|
|
2397
|
+
});
|
|
2010
2398
|
}
|
|
2011
2399
|
export function restartAppTask(appId) {
|
|
2012
2400
|
if (!getApp(appId)) {
|
|
@@ -2144,5 +2532,5 @@ export async function copyApp(sourceId) {
|
|
|
2144
2532
|
// executes the install lifecycle in the new app dir.
|
|
2145
2533
|
return installApp(baseYaml);
|
|
2146
2534
|
}
|
|
2147
|
-
export { onConfigChange, notifyConfigChange, instanceDir, instanceMetaPath, defaultModelEnvFile, normalizePath, extractGatewayPort, isPortInUse, allocateGatewayPort, releasePendingPort, getResolvedOpenclawBin, resolveServiceUser, chownToServiceUser, parseEnvFile, updateEnvFile, inferProviderApiKeyEnvName, listInstances, getInstance, updateInstanceMeta, deleteInstance, getConfig, getStoredConfig, saveConfig, CHANNEL_PLUGIN_MAP, isChannelPluginInstalled, createInstance, getOpenclawHome, saveFeishuCredentials, saveWeixinCredentials, getWeixinAccounts, getOpenclawConfigPath, getLegacyOpenclawConfigPath, getInstanceRuntime, getRuntimeEnvFiles, getGatewayPort, getGatewayHost, getListeningHostForPort, urlHost, findInstancesSharingOpenclawHome, reallocateGatewayPort, findInstancesSharingGatewayPort, getRuntimeEnv, defaultGatewayPort, releasePort, } from "../instance-manager.js";
|
|
2535
|
+
export { onConfigChange, notifyConfigChange, instanceDir, instanceMetaPath, defaultModelEnvFile, normalizePath, extractGatewayPort, isPortInUse, allocateGatewayPort, releasePendingPort, getResolvedOpenclawBin, resolveServiceUser, chownToServiceUser, parseEnvFile, updateEnvFile, inferProviderApiKeyEnvName, listInstances, getInstance, updateInstanceMeta, deleteInstance, getConfig, getStoredConfig, saveConfig, CHANNEL_PLUGIN_MAP, isChannelPluginInstalled, createInstance, getOpenclawHome, saveFeishuCredentials, saveWeixinCredentials, getWeixinAccounts, getOpenclawConfigPath, getLegacyOpenclawConfigPath, getInstanceRuntime, getRuntimeEnvFiles, getGatewayPort, getGatewayHost, getListeningHostForPort, getHostForAppPort, urlHost, findInstancesSharingOpenclawHome, reallocateGatewayPort, findInstancesSharingGatewayPort, getRuntimeEnv, defaultGatewayPort, releasePort, } from "../instance-manager.js";
|
|
2148
2536
|
//# sourceMappingURL=app-manager.js.map
|