jishushell 0.4.17 → 0.4.24
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/Dockerfile.hermes-slim +193 -0
- package/apps/hermes-container.yaml +35 -0
- package/apps/ollama-binary.yaml +200 -0
- package/apps/ollama-cpu-container.yaml +37 -0
- package/apps/ollama-with-hollama-binary.yaml +195 -0
- package/apps/openclaw-binary.yaml +69 -0
- package/apps/openclaw-container.yaml +37 -0
- package/apps/openclaw-with-ollama-container.yaml +42 -0
- package/apps/openclaw-with-searxng-container.yaml +136 -0
- package/apps/openwebui-container.yaml +53 -0
- package/apps/playwright-container.yaml +120 -0
- package/apps/searxng-container.yaml +115 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +15 -14
- package/dist/auth.js.map +1 -1
- package/dist/cli/app.d.ts +1 -0
- package/dist/cli/app.js +710 -52
- package/dist/cli/app.js.map +1 -1
- package/dist/cli/backup.d.ts +3 -0
- package/dist/cli/backup.js +434 -0
- package/dist/cli/backup.js.map +1 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +61 -35
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/job.d.ts +1 -0
- package/dist/cli/job.js +37 -99
- package/dist/cli/job.js.map +1 -1
- package/dist/cli/llm.d.ts +1 -0
- package/dist/cli/llm.js +20 -14
- package/dist/cli/llm.js.map +1 -1
- package/dist/cli/managed-list.d.ts +30 -0
- package/dist/cli/managed-list.js +129 -0
- package/dist/cli/managed-list.js.map +1 -0
- package/dist/cli/panel.d.ts +4 -3
- package/dist/cli/panel.js +94 -24
- package/dist/cli/panel.js.map +1 -1
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +12 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli.js +47 -516
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +68 -0
- package/dist/config.js +266 -12
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +10 -6
- package/dist/control.js +87 -6
- package/dist/control.js.map +1 -1
- package/dist/install.d.ts +16 -0
- package/dist/install.js +75 -26
- package/dist/install.js.map +1 -1
- package/dist/routes/agent-apps.d.ts +15 -0
- package/dist/routes/agent-apps.js +78 -0
- package/dist/routes/agent-apps.js.map +1 -0
- package/dist/routes/apps.js +186 -7
- package/dist/routes/apps.js.map +1 -1
- package/dist/routes/backup.js +3 -3
- package/dist/routes/backup.js.map +1 -1
- package/dist/routes/instances.d.ts +6 -0
- package/dist/routes/instances.js +862 -879
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.js +9 -8
- package/dist/routes/llm.js.map +1 -1
- package/dist/routes/runtime.d.ts +15 -0
- package/dist/routes/runtime.js +69 -0
- package/dist/routes/runtime.js.map +1 -0
- package/dist/routes/setup.js +103 -8
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +25 -3
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +71 -7
- package/dist/server.js.map +1 -1
- package/dist/services/agent-apps/catalog.d.ts +30 -0
- package/dist/services/agent-apps/catalog.js +60 -0
- package/dist/services/agent-apps/catalog.js.map +1 -0
- package/dist/services/agent-apps/index.d.ts +36 -0
- package/dist/services/agent-apps/index.js +171 -0
- package/dist/services/agent-apps/index.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter-probes.d.ts +49 -0
- package/dist/services/agent-apps/installers/adapter-probes.js +223 -0
- package/dist/services/agent-apps/installers/adapter-probes.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter.d.ts +30 -0
- package/dist/services/agent-apps/installers/adapter.js +171 -0
- package/dist/services/agent-apps/installers/adapter.js.map +1 -0
- package/dist/services/agent-apps/installers/registry-probe.d.ts +38 -0
- package/dist/services/agent-apps/installers/registry-probe.js +183 -0
- package/dist/services/agent-apps/installers/registry-probe.js.map +1 -0
- package/dist/services/agent-apps/installers/shell-script.d.ts +47 -0
- package/dist/services/agent-apps/installers/shell-script.js +471 -0
- package/dist/services/agent-apps/installers/shell-script.js.map +1 -0
- package/dist/services/agent-apps/types.d.ts +125 -0
- package/dist/services/agent-apps/types.js +17 -0
- package/dist/services/agent-apps/types.js.map +1 -0
- package/dist/services/{app-compiler.d.ts → app/app-compiler.d.ts} +3 -3
- package/dist/services/{app-compiler.js → app/app-compiler.js} +10 -7
- package/dist/services/app/app-compiler.js.map +1 -0
- package/dist/services/app/app-manager.d.ts +142 -0
- package/dist/services/app/app-manager.js +1988 -0
- package/dist/services/app/app-manager.js.map +1 -0
- package/dist/services/app/custom-manager.d.ts +27 -0
- package/dist/services/app/custom-manager.js +285 -0
- package/dist/services/app/custom-manager.js.map +1 -0
- package/dist/services/app/hermes-agent-manager.d.ts +20 -0
- package/dist/services/app/hermes-agent-manager.js +289 -0
- package/dist/services/app/hermes-agent-manager.js.map +1 -0
- package/dist/services/app/id-normalizer.d.ts +27 -0
- package/dist/services/app/id-normalizer.js +77 -0
- package/dist/services/app/id-normalizer.js.map +1 -0
- package/dist/services/app/ollama-manager.d.ts +18 -0
- package/dist/services/app/ollama-manager.js +207 -0
- package/dist/services/app/ollama-manager.js.map +1 -0
- package/dist/services/app/openclaw-manager.d.ts +63 -0
- package/dist/services/app/openclaw-manager.js +1178 -0
- package/dist/services/app/openclaw-manager.js.map +1 -0
- package/dist/services/app/paths.d.ts +47 -0
- package/dist/services/app/paths.js +68 -0
- package/dist/services/app/paths.js.map +1 -0
- package/dist/services/app/registry.d.ts +17 -0
- package/dist/services/app/registry.js +31 -0
- package/dist/services/app/registry.js.map +1 -0
- package/dist/services/app/remote-spec.d.ts +14 -0
- package/dist/services/app/remote-spec.js +58 -0
- package/dist/services/app/remote-spec.js.map +1 -0
- package/dist/services/app/terminal-session-manager.d.ts +27 -0
- package/dist/services/app/terminal-session-manager.js +157 -0
- package/dist/services/app/terminal-session-manager.js.map +1 -0
- package/dist/services/app/types.d.ts +72 -0
- package/dist/services/app/types.js +16 -0
- package/dist/services/app/types.js.map +1 -0
- package/dist/services/backup-manager.js +60 -22
- package/dist/services/backup-manager.js.map +1 -1
- package/dist/services/instance-manager.d.ts +82 -39
- package/dist/services/instance-manager.js +575 -1142
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/llm-proxy/circuit-breaker.js +10 -2
- package/dist/services/llm-proxy/circuit-breaker.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +14 -1
- package/dist/services/llm-proxy/index.js +51 -6
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/nomad-manager.d.ts +260 -3
- package/dist/services/nomad-manager.js +2866 -449
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +10 -0
- package/dist/services/panel-manager.js +97 -0
- package/dist/services/panel-manager.js.map +1 -1
- package/dist/services/plugin-installer.js +28 -2
- package/dist/services/plugin-installer.js.map +1 -1
- package/dist/services/process-manager.js +22 -0
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/runtime/adapters/custom.d.ts +20 -0
- package/dist/services/runtime/adapters/custom.js +90 -0
- package/dist/services/runtime/adapters/custom.js.map +1 -0
- package/dist/services/runtime/adapters/hermes.d.ts +174 -0
- package/dist/services/runtime/adapters/hermes.js +1316 -0
- package/dist/services/runtime/adapters/hermes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw-routes.d.ts +17 -0
- package/dist/services/runtime/adapters/openclaw-routes.js +946 -0
- package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw.d.ts +188 -0
- package/dist/services/runtime/adapters/openclaw.js +2195 -0
- package/dist/services/runtime/adapters/openclaw.js.map +1 -0
- package/dist/services/runtime/errors.d.ts +28 -0
- package/dist/services/runtime/errors.js +31 -0
- package/dist/services/runtime/errors.js.map +1 -0
- package/dist/services/runtime/index.d.ts +34 -0
- package/dist/services/runtime/index.js +51 -0
- package/dist/services/runtime/index.js.map +1 -0
- package/dist/services/runtime/instance.d.ts +24 -0
- package/dist/services/runtime/instance.js +143 -0
- package/dist/services/runtime/instance.js.map +1 -0
- package/dist/services/runtime/migrations.d.ts +15 -0
- package/dist/services/runtime/migrations.js +25 -0
- package/dist/services/runtime/migrations.js.map +1 -0
- package/dist/services/runtime/registry.d.ts +13 -0
- package/dist/services/runtime/registry.js +32 -0
- package/dist/services/runtime/registry.js.map +1 -0
- package/dist/services/runtime/types.d.ts +545 -0
- package/dist/services/runtime/types.js +14 -0
- package/dist/services/runtime/types.js.map +1 -0
- package/dist/services/setup-manager.d.ts +70 -29
- package/dist/services/setup-manager.js +278 -597
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/task-registry.d.ts +44 -0
- package/dist/services/task-registry.js +74 -0
- package/dist/services/task-registry.js.map +1 -0
- package/dist/services/telemetry/heartbeat.d.ts +6 -6
- package/dist/services/telemetry/heartbeat.js +29 -30
- package/dist/services/telemetry/heartbeat.js.map +1 -1
- package/dist/types.d.ts +162 -2
- package/dist/utils/docker-host.d.ts +15 -0
- package/dist/utils/docker-host.js +64 -0
- package/dist/utils/docker-host.js.map +1 -0
- package/install/jishu-install.sh +25 -1
- package/package.json +14 -4
- package/public/assets/Dashboard-B-JoOjBQ.js +1 -0
- package/public/assets/HermesChatPanel-mFSureyc.js +1 -0
- package/public/assets/HermesConfigForm-DvR05LK1.js +4 -0
- package/public/assets/InitPassword-CVA8wQA6.js +1 -0
- package/public/assets/InstanceDetail-DcZW2QGO.js +91 -0
- package/public/assets/{Login-D1Bt-Lyk.js → Login-BWsZH2mu.js} +1 -1
- package/public/assets/NewInstance-BCIrAd86.js +1 -0
- package/public/assets/Settings-xkDcduFz.js +1 -0
- package/public/assets/Setup-Cfuwj4gV.js +1 -0
- package/public/assets/WeixinLoginPanel-CnjR8xMu.js +9 -0
- package/public/assets/index-CPhVFEsx.css +1 -0
- package/public/assets/index-DQsM6Joa.js +19 -0
- package/public/assets/input-paste-CrNVAyOy.js +1 -0
- package/public/assets/registry-B4UFJdpA.js +2 -0
- package/public/assets/{usePolling-CK0DfI4h.js → usePolling-Do5Erqm_.js} +1 -1
- package/public/assets/vendor-i18n-ucpM0OR0.js +9 -0
- package/public/assets/{vendor-react-B1-3Yrt-.js → vendor-react-Bk1hRGiY.js} +1 -1
- package/public/favicon.png +0 -0
- package/public/index.html +9 -4
- package/public/logos/hermes.png +0 -0
- package/public/logos/ollama.png +0 -0
- package/public/logos/openclaw.svg +60 -0
- package/scripts/build-hermes-image.sh +21 -0
- package/scripts/build-local.sh +54 -0
- package/scripts/check-adapter-isolation.ts +293 -0
- package/scripts/fixtures/instances/hermes-sample/instance.json +37 -0
- package/scripts/fixtures/instances/legacy-openclaw-sample/instance.json +7 -0
- package/scripts/smoke/hermes-bootstrap.sh +195 -0
- package/templates/hermes-entrypoint.sh +154 -0
- package/dist/cli/openclaw.d.ts +0 -12
- package/dist/cli/openclaw.js +0 -156
- package/dist/cli/openclaw.js.map +0 -1
- package/dist/services/app-compiler.js.map +0 -1
- package/dist/services/app-manager.d.ts +0 -17
- package/dist/services/app-manager.js +0 -168
- package/dist/services/app-manager.js.map +0 -1
- package/dist/services/job-manager.d.ts +0 -22
- package/dist/services/job-manager.js +0 -102
- package/dist/services/job-manager.js.map +0 -1
- package/public/assets/Dashboard-CQsp1Mr9.js +0 -1
- package/public/assets/InitPassword-BEC8SE4A.js +0 -1
- package/public/assets/InstanceDetail-B5wTgNEg.js +0 -17
- package/public/assets/NewInstance-GQzm3K9D.js +0 -1
- package/public/assets/Settings-ByjGlqhP.js +0 -1
- package/public/assets/Setup-cMF21Y-8.js +0 -1
- package/public/assets/index-B6qQP4mH.css +0 -1
- package/public/assets/index-BuTQtuNy.js +0 -16
- package/public/assets/vendor-i18n-CfW0RvgE.js +0 -9
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HermesAdapter — first non-OpenClaw runtime adapter.
|
|
3
|
+
*
|
|
4
|
+
* Design: docs/multi-agent-runtime-generalization-plan.md §11, §28, §32.
|
|
5
|
+
*
|
|
6
|
+
* MVP scope (§32.3):
|
|
7
|
+
* - Only create / buildRuntime / getRuntimeVersion are wired up.
|
|
8
|
+
* - Baseline image mode only. Overlay upgrade CLI is deferred.
|
|
9
|
+
* - Assumes panel.json.runtime_catalog.hermes has been populated by
|
|
10
|
+
* setup-manager.installHermes(): defaultImage (+ optional defaultImageDigest),
|
|
11
|
+
* shimPath, resources, homeDirName.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "child_process";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { chmodSync, chownSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "fs";
|
|
16
|
+
import { randomBytes } from "crypto";
|
|
17
|
+
import { dirname, join } from "path";
|
|
18
|
+
import { getPanelConfig, getRuntimeCatalogEntry, setRuntimeCatalogEntry, HERMES_DEFAULT_IMAGE, } from "../../../config.js";
|
|
19
|
+
import { ensureDirContainer, writeConfigFile, writeSecretFile, } from "../../../utils/fs.js";
|
|
20
|
+
import { safeWriteJson } from "../../../utils/safe-json.js";
|
|
21
|
+
import { ensureInstanceProxyToken, getProxyBaseUrl } from "../../llm-proxy/index.js";
|
|
22
|
+
import { allocateGatewayPort, releasePendingPort, resolveServiceUser, updateEnvFile, } from "../../instance-manager.js";
|
|
23
|
+
import { getInstanceDir as instanceDir, instanceMetaPath } from "../../../config.js";
|
|
24
|
+
import { createTask, emitTask, spawnWithTask, resolveDockerInvocation, captureImageDigest, } from "../../setup-manager.js";
|
|
25
|
+
import { InstanceCreationRejected } from "../errors.js";
|
|
26
|
+
import YAML from "yaml";
|
|
27
|
+
import { registerAdapter } from "../registry.js";
|
|
28
|
+
const HERMES_CONTAINER_PORT = 8642;
|
|
29
|
+
const HERMES_CONTAINER_HOME = "/opt/data";
|
|
30
|
+
const HERMES_CONTAINER_SHIM = "/usr/local/bin/jishushell-hermes-entry.sh";
|
|
31
|
+
const HERMES_CONTAINER_WORKDIR = "/opt/hermes";
|
|
32
|
+
// Runtime protocol versions this panel knows how to drive. Bumped together
|
|
33
|
+
// with any backward-incompatible change to the shim/panel contract (command
|
|
34
|
+
// line, env vars, entrypoint path, LABEL keys, …). Images advertise their
|
|
35
|
+
// version via `LABEL runtime.protocol.version=N` in Dockerfile.hermes-slim.
|
|
36
|
+
const SUPPORTED_HERMES_PROTOCOL_VERSIONS = [1];
|
|
37
|
+
const HERMES_PROTOCOL_LABEL = "runtime.protocol.version";
|
|
38
|
+
export const HERMES_DEFAULT_GATEWAY_PORT = HERMES_CONTAINER_PORT;
|
|
39
|
+
/**
|
|
40
|
+
* Read the runtime.protocol.version LABEL from a local image. Returns null
|
|
41
|
+
* if docker is unreachable, the image isn't present locally, or the label
|
|
42
|
+
* isn't set — all of which buildRuntime treats as a hard failure because
|
|
43
|
+
* this panel no longer ships a legacy bind-mount fallback.
|
|
44
|
+
*/
|
|
45
|
+
function readHermesImageProtocolVersion(image) {
|
|
46
|
+
try {
|
|
47
|
+
const out = execFileSync("docker", ["inspect", "--format", `{{ index .Config.Labels "${HERMES_PROTOCOL_LABEL}" }}`, image], { timeout: 5_000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
48
|
+
if (!out)
|
|
49
|
+
return null;
|
|
50
|
+
const parsed = Number.parseInt(out, 10);
|
|
51
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function assertHermesImageProtocolCompatible(image) {
|
|
58
|
+
const version = readHermesImageProtocolVersion(image);
|
|
59
|
+
if (version == null) {
|
|
60
|
+
throw new Error(`Hermes image ${image} does not advertise runtime.protocol.version. ` +
|
|
61
|
+
`This panel expects the baked-shim image (${HERMES_DEFAULT_IMAGE} or later). ` +
|
|
62
|
+
`Re-run POST /api/setup/install/hermes to pull a compatible image.`);
|
|
63
|
+
}
|
|
64
|
+
if (!SUPPORTED_HERMES_PROTOCOL_VERSIONS.includes(version)) {
|
|
65
|
+
throw new Error(`Hermes image ${image} declares runtime.protocol.version=${version}, ` +
|
|
66
|
+
`but this panel supports ${SUPPORTED_HERMES_PROTOCOL_VERSIONS.join(", ")}. ` +
|
|
67
|
+
`Upgrade the panel (or pull a compatible Hermes image).`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const HERMES_VERSION_LABEL = "org.opencontainers.image.version";
|
|
71
|
+
const HERMES_MUTABLE_TAG_RE = /:(latest|slim)$/;
|
|
72
|
+
/**
|
|
73
|
+
* Read `org.opencontainers.image.version` LABEL from a local image.
|
|
74
|
+
* Returns the version string if it matches `N.N.N...`, null otherwise.
|
|
75
|
+
*/
|
|
76
|
+
function readHermesImageVersion(image) {
|
|
77
|
+
try {
|
|
78
|
+
const out = execFileSync("docker", ["inspect", "--format", `{{ index .Config.Labels "${HERMES_VERSION_LABEL}" }}`, image], { timeout: 5_000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
79
|
+
return /^\d+\.\d+\.\d+/.test(out) ? out : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* If `stored` has a mutable tag (`:latest` / `:slim`), read the bundled
|
|
87
|
+
* version from the image's LABEL and create a local `:${version}` tag
|
|
88
|
+
* alias, then drop the mutable source tag. Mirrors the OpenClaw pin in
|
|
89
|
+
* config.ts:migrateOpenclawImageTagIfNeeded, but uses the LABEL instead
|
|
90
|
+
* of spawning the container (Hermes images bake the version into the
|
|
91
|
+
* manifest, OpenClaw's used to lie — see that function's docstring).
|
|
92
|
+
*
|
|
93
|
+
* Returns the pinned tag on success, null when nothing was changed.
|
|
94
|
+
*/
|
|
95
|
+
function pinHermesImageToLabeledVersion(stored) {
|
|
96
|
+
if (!HERMES_MUTABLE_TAG_RE.test(stored))
|
|
97
|
+
return null;
|
|
98
|
+
const version = readHermesImageVersion(stored);
|
|
99
|
+
if (!version)
|
|
100
|
+
return null;
|
|
101
|
+
const colonIdx = stored.lastIndexOf(":");
|
|
102
|
+
const slashIdx = stored.lastIndexOf("/");
|
|
103
|
+
if (colonIdx <= slashIdx)
|
|
104
|
+
return null;
|
|
105
|
+
const pinnedTag = `${stored.slice(0, colonIdx)}:${version}`;
|
|
106
|
+
if (pinnedTag === stored)
|
|
107
|
+
return null;
|
|
108
|
+
try {
|
|
109
|
+
execFileSync("docker", ["tag", stored, pinnedTag], { timeout: 10_000, stdio: "ignore" });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
execFileSync("docker", ["rmi", stored], { timeout: 10_000, stdio: "ignore" });
|
|
116
|
+
}
|
|
117
|
+
catch { /* best-effort cleanup */ }
|
|
118
|
+
return pinnedTag;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One-shot startup migration for Hermes installs from older panels whose
|
|
122
|
+
* catalog still stores `:latest` / `:slim`. Silent no-op when the catalog
|
|
123
|
+
* already holds a pinned tag, when docker is unreachable, or when the
|
|
124
|
+
* local image lacks the expected LABEL. Called from server.ts onReady.
|
|
125
|
+
*/
|
|
126
|
+
export function migrateHermesImageTagIfNeeded() {
|
|
127
|
+
const entry = getRuntimeCatalogEntry("hermes");
|
|
128
|
+
if (!entry || typeof entry.defaultImage !== "string")
|
|
129
|
+
return;
|
|
130
|
+
const stored = entry.defaultImage;
|
|
131
|
+
const pinned = pinHermesImageToLabeledVersion(stored);
|
|
132
|
+
if (!pinned)
|
|
133
|
+
return;
|
|
134
|
+
setRuntimeCatalogEntry("hermes", { ...entry, defaultImage: pinned });
|
|
135
|
+
console.log(`[hermes] migrated runtime_catalog.hermes.defaultImage: ${stored} → ${pinned}`);
|
|
136
|
+
}
|
|
137
|
+
// ── Nomad primitives (mirrored from nomad-manager.ts so HermesAdapter's
|
|
138
|
+
// buildNomadTask is self-contained) ─────────────────────────────────
|
|
139
|
+
const DOCKER_IMAGE_RE = /^[a-zA-Z0-9][a-zA-Z0-9\-_.:/@]*$/;
|
|
140
|
+
const MAX_DOCKER_IMAGE_NAME_LEN = 256;
|
|
141
|
+
const VALID_USER_RE = /^[a-z0-9._-]{1,32}$/;
|
|
142
|
+
const DEFAULT_PIDS_LIMIT = 512;
|
|
143
|
+
const MAX_CPU_MHZ = 4000;
|
|
144
|
+
const MAX_MEMORY_MB = 4096;
|
|
145
|
+
const MAX_MEMORY_MAX_MB = 4096;
|
|
146
|
+
const NOMAD_TEMPLATE_UNSAFE_RE = /[{}"\\]/;
|
|
147
|
+
// Matches the shape Feishu issues from `archetype=PersonalAgent` device-flow
|
|
148
|
+
// (cli_ prefix + alphanumerics). Kept narrow on purpose — wider wildcards
|
|
149
|
+
// would accept untrusted strings propagated from the OAuth response body.
|
|
150
|
+
const FEISHU_APP_ID_RE = /^cli_[a-zA-Z0-9]{8,64}$/;
|
|
151
|
+
// iLink Bot IDs are numeric-ish bot handles issued by Tencent. Keep the
|
|
152
|
+
// same 128-char ceiling + charset OpenClaw's adapter uses so invalid
|
|
153
|
+
// payloads from a tampered OAuth response don't become filesystem paths.
|
|
154
|
+
const SAFE_WEIXIN_ACCOUNT_ID_RE = /^[a-zA-Z0-9@._-]{1,128}$/;
|
|
155
|
+
function assertSafeTemplateId(id) {
|
|
156
|
+
if (NOMAD_TEMPLATE_UNSAFE_RE.test(id)) {
|
|
157
|
+
throw new Error(`Job ID "${id}" contains characters unsafe for Nomad Template interpolation`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function resolveUidGid(username) {
|
|
161
|
+
try {
|
|
162
|
+
if (!VALID_USER_RE.test(username)) {
|
|
163
|
+
return `${process.getuid()}:${process.getgid()}`;
|
|
164
|
+
}
|
|
165
|
+
const passwd = readFileSync("/etc/passwd", "utf-8");
|
|
166
|
+
const line = passwd.split("\n").find((l) => l.startsWith(username + ":"));
|
|
167
|
+
if (line) {
|
|
168
|
+
const parts = line.split(":");
|
|
169
|
+
const uid = parseInt(parts[2], 10);
|
|
170
|
+
const gid = parseInt(parts[3], 10);
|
|
171
|
+
if (!isNaN(uid) && !isNaN(gid))
|
|
172
|
+
return `${uid}:${gid}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* ignore */
|
|
177
|
+
}
|
|
178
|
+
return `${process.getuid()}:${process.getgid()}`;
|
|
179
|
+
}
|
|
180
|
+
const DEFAULT_CAPABILITIES = {
|
|
181
|
+
gateway: {
|
|
182
|
+
http: true,
|
|
183
|
+
websocket: false,
|
|
184
|
+
// "inline" = JishuShell-side chat UI that POSTs to
|
|
185
|
+
// /api/instances/:id/agent/chat (which forwards to Hermes's own
|
|
186
|
+
// /v1/chat/completions endpoint with the per-instance API_SERVER_KEY).
|
|
187
|
+
// NOT the same as "openclaw" which iframes a full OpenClaw gateway UI.
|
|
188
|
+
chatPanel: "inline",
|
|
189
|
+
},
|
|
190
|
+
pairing: {
|
|
191
|
+
list: true,
|
|
192
|
+
approve: true,
|
|
193
|
+
// Capability is `false` until the adapter, routes, and CLI actually
|
|
194
|
+
// ship revoke / clear-pending surfaces. Declaring them true here
|
|
195
|
+
// with no backing implementation would mislead the UI and any
|
|
196
|
+
// external caller into attempting unreachable operations.
|
|
197
|
+
revoke: false,
|
|
198
|
+
clearPending: false,
|
|
199
|
+
},
|
|
200
|
+
configEditor: "yaml+env",
|
|
201
|
+
configSchema: false,
|
|
202
|
+
customProvider: true,
|
|
203
|
+
pluginInstall: false,
|
|
204
|
+
skills: true,
|
|
205
|
+
mcp: true,
|
|
206
|
+
memory: true,
|
|
207
|
+
backupRestore: false,
|
|
208
|
+
usageStats: false,
|
|
209
|
+
restartlessReload: false,
|
|
210
|
+
messagingPlatforms: [
|
|
211
|
+
"feishu",
|
|
212
|
+
"weixin",
|
|
213
|
+
"telegram",
|
|
214
|
+
"discord",
|
|
215
|
+
"slack",
|
|
216
|
+
"whatsapp",
|
|
217
|
+
"signal",
|
|
218
|
+
"email",
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
const HERMES_AGENT_HOME_SUBDIRS = [
|
|
222
|
+
"cron",
|
|
223
|
+
"sessions",
|
|
224
|
+
"logs",
|
|
225
|
+
"hooks",
|
|
226
|
+
"memories",
|
|
227
|
+
"skills",
|
|
228
|
+
"skins",
|
|
229
|
+
"plans",
|
|
230
|
+
"workspace",
|
|
231
|
+
"home",
|
|
232
|
+
];
|
|
233
|
+
const HERMES_AGENT_HOME_NESTED_DIRS = [
|
|
234
|
+
["weixin"],
|
|
235
|
+
["weixin", "accounts"],
|
|
236
|
+
["hermes-overlay"],
|
|
237
|
+
];
|
|
238
|
+
const HERMES_SECRET_ENV_RE = /(KEY|TOKEN|SECRET|PASSWORD)$/i;
|
|
239
|
+
function defaultHermesModelId() {
|
|
240
|
+
const dp = getPanelConfig().default_provider;
|
|
241
|
+
const selected = typeof dp?.selectedModelId === "string" ? dp.selectedModelId.trim() : "";
|
|
242
|
+
if (selected)
|
|
243
|
+
return selected;
|
|
244
|
+
const first = Array.isArray(dp?.models) ? dp.models.find((model) => typeof model?.id === "string" && model.id.trim()) : null;
|
|
245
|
+
return first?.id || "default";
|
|
246
|
+
}
|
|
247
|
+
function defaultHermesConfigYaml() {
|
|
248
|
+
// Main agent routes through our `jsproxy` custom provider — honest: we
|
|
249
|
+
// really are an OpenAI-compatible proxy that may sit in front of any
|
|
250
|
+
// upstream (MiniMax/OpenAI/Anthropic/…). Aliasing to any built-in
|
|
251
|
+
// provider name (e.g. `minimax`) would leak upstream-specific request
|
|
252
|
+
// fields to unrelated backends and create surprising breakage.
|
|
253
|
+
//
|
|
254
|
+
// Hermes expects `custom_providers` as a YAML list (each entry an
|
|
255
|
+
// object with a `name` field). See Hermes config schema at
|
|
256
|
+
// https://hermes-agent.nousresearch.com/docs/user-guide/configuration/
|
|
257
|
+
const doc = {
|
|
258
|
+
model: {
|
|
259
|
+
provider: "jsproxy",
|
|
260
|
+
default: defaultHermesModelId(),
|
|
261
|
+
},
|
|
262
|
+
terminal: {
|
|
263
|
+
backend: "local",
|
|
264
|
+
},
|
|
265
|
+
custom_providers: [
|
|
266
|
+
{
|
|
267
|
+
name: "jsproxy",
|
|
268
|
+
base_url: getProxyBaseUrl(),
|
|
269
|
+
api_key: "${JSPROXY_API_KEY}",
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
return YAML.stringify(doc).trimEnd() + "\n";
|
|
274
|
+
}
|
|
275
|
+
function defaultHermesEnvText() {
|
|
276
|
+
// Hermes refuses to bind 0.0.0.0 without an API_SERVER_KEY (safety gate).
|
|
277
|
+
// Generate a random 32-byte hex key per instance so unauthenticated access
|
|
278
|
+
// is impossible even though the host port is published.
|
|
279
|
+
const apiServerKey = randomBytes(32).toString("hex");
|
|
280
|
+
return [
|
|
281
|
+
"API_SERVER_ENABLED=true",
|
|
282
|
+
"API_SERVER_HOST=0.0.0.0",
|
|
283
|
+
`API_SERVER_PORT=${HERMES_CONTAINER_PORT}`,
|
|
284
|
+
`API_SERVER_KEY=${apiServerKey}`,
|
|
285
|
+
// Main agent provider lookup uses JSPROXY_API_KEY (referenced from
|
|
286
|
+
// config.yaml custom_providers). Seeded by ensureInstanceProxyToken.
|
|
287
|
+
"JSPROXY_API_KEY=",
|
|
288
|
+
// OPENAI_BASE_URL + OPENAI_API_KEY are the standard env vars Hermes's
|
|
289
|
+
// `auxiliary_client._try_custom_endpoint` reads to auto-discover a
|
|
290
|
+
// generic OpenAI-compatible endpoint. Pointing them at our jsproxy
|
|
291
|
+
// stops the auxiliary resolution chain from falling through to the
|
|
292
|
+
// Nous Portal (which needs cookie auth and emits a 401 warning at
|
|
293
|
+
// every agent startup). The aux token is a mirror of JSPROXY_API_KEY
|
|
294
|
+
// populated by syncHermesProxyEnv after ensureInstanceProxyToken runs.
|
|
295
|
+
`OPENAI_BASE_URL=${getProxyBaseUrl()}`,
|
|
296
|
+
"OPENAI_API_KEY=",
|
|
297
|
+
// Allow all users for the smoke path; production deployments should
|
|
298
|
+
// configure per-platform allowlists via TELEGRAM_ALLOWED_USERS etc.
|
|
299
|
+
"GATEWAY_ALLOW_ALL_USERS=true",
|
|
300
|
+
"",
|
|
301
|
+
].join("\n");
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* After `ensureInstanceProxyToken(instanceId)` writes JSPROXY_API_KEY into
|
|
305
|
+
* the Hermes .env, mirror the same token into OPENAI_API_KEY and make
|
|
306
|
+
* sure OPENAI_BASE_URL points at our jsproxy. This gives the
|
|
307
|
+
* auxiliary_client / vision client / trajectory compressor a valid
|
|
308
|
+
* OpenAI-compatible endpoint to auto-discover, so startup no longer
|
|
309
|
+
* emits the `custom/main requested but no endpoint credentials found`
|
|
310
|
+
* warning + Nous 401. Safe to call repeatedly (idempotent).
|
|
311
|
+
*/
|
|
312
|
+
function syncHermesProxyEnv(instanceId) {
|
|
313
|
+
const paths = resolveHermesPaths(instanceId);
|
|
314
|
+
if (!paths.secretEnv || !existsSync(paths.secretEnv))
|
|
315
|
+
return;
|
|
316
|
+
const env = parseEnvFileFromText(readFileSync(paths.secretEnv, "utf-8"));
|
|
317
|
+
const token = env.JSPROXY_API_KEY?.trim();
|
|
318
|
+
if (!token)
|
|
319
|
+
return;
|
|
320
|
+
const updates = {};
|
|
321
|
+
if (env.OPENAI_API_KEY !== token)
|
|
322
|
+
updates.OPENAI_API_KEY = token;
|
|
323
|
+
const expectedBase = getProxyBaseUrl();
|
|
324
|
+
if (env.OPENAI_BASE_URL !== expectedBase)
|
|
325
|
+
updates.OPENAI_BASE_URL = expectedBase;
|
|
326
|
+
if (Object.keys(updates).length === 0)
|
|
327
|
+
return;
|
|
328
|
+
updateEnvFile(paths.secretEnv, updates);
|
|
329
|
+
}
|
|
330
|
+
function ensureHermesConfigFiles(paths) {
|
|
331
|
+
if (!paths.primaryConfig || !existsSync(paths.primaryConfig)) {
|
|
332
|
+
writeConfigFile(paths.primaryConfig, defaultHermesConfigYaml());
|
|
333
|
+
}
|
|
334
|
+
if (!paths.secretEnv || !existsSync(paths.secretEnv)) {
|
|
335
|
+
writeSecretFile(paths.secretEnv, defaultHermesEnvText());
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function secretEnvFields(envText) {
|
|
339
|
+
const env = parseEnvFileFromText(envText);
|
|
340
|
+
return Object.keys(env)
|
|
341
|
+
.filter((key) => HERMES_SECRET_ENV_RE.test(key))
|
|
342
|
+
.map((key) => `env.${key}`);
|
|
343
|
+
}
|
|
344
|
+
function parseEnvFileFromText(envText) {
|
|
345
|
+
const env = {};
|
|
346
|
+
for (let line of envText.split("\n")) {
|
|
347
|
+
line = line.trim();
|
|
348
|
+
if (!line || line.startsWith("#"))
|
|
349
|
+
continue;
|
|
350
|
+
if (line.startsWith("export "))
|
|
351
|
+
line = line.slice(7).trimStart();
|
|
352
|
+
if (!line.includes("="))
|
|
353
|
+
continue;
|
|
354
|
+
const eqIdx = line.indexOf("=");
|
|
355
|
+
const key = line.slice(0, eqIdx).trim();
|
|
356
|
+
let value = line.slice(eqIdx + 1).trim();
|
|
357
|
+
if (!key)
|
|
358
|
+
continue;
|
|
359
|
+
if (value.length >= 2 && value[0] === value[value.length - 1] && (value[0] === "'" || value[0] === '"')) {
|
|
360
|
+
value = value.slice(1, -1);
|
|
361
|
+
}
|
|
362
|
+
env[key] = value;
|
|
363
|
+
}
|
|
364
|
+
return env;
|
|
365
|
+
}
|
|
366
|
+
function normalizeEnvText(raw) {
|
|
367
|
+
const trimmed = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trimEnd();
|
|
368
|
+
return trimmed ? `${trimmed}\n` : "";
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Resolve the hermes instance filesystem layout. Agent-home lives inside the
|
|
372
|
+
* per-instance directory and is bind-mounted to /opt/data inside the container.
|
|
373
|
+
*/
|
|
374
|
+
export function resolveHermesPaths(instanceId) {
|
|
375
|
+
const catalog = getRuntimeCatalogEntry("hermes");
|
|
376
|
+
const homeDirName = catalog?.homeDirName || "agent-home";
|
|
377
|
+
const resolvedInstanceDir = instanceDir(instanceId);
|
|
378
|
+
const agentHome = join(resolvedInstanceDir, homeDirName);
|
|
379
|
+
return {
|
|
380
|
+
instanceDir: resolvedInstanceDir,
|
|
381
|
+
agentHome,
|
|
382
|
+
primaryConfig: join(agentHome, "config.yaml"),
|
|
383
|
+
secretEnv: join(agentHome, ".env"),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function isPrecreatedManagedAppDir(dir) {
|
|
387
|
+
return existsSync(join(dir, "app-spec.yaml")) && existsSync(join(dir, "manifest.json"));
|
|
388
|
+
}
|
|
389
|
+
class HermesAdapter {
|
|
390
|
+
agentType = "hermes";
|
|
391
|
+
displayName = "Hermes Agent";
|
|
392
|
+
defaultCapabilities = DEFAULT_CAPABILITIES;
|
|
393
|
+
defaultGatewayPort = HERMES_DEFAULT_GATEWAY_PORT;
|
|
394
|
+
manifest = {
|
|
395
|
+
agentType: "hermes",
|
|
396
|
+
displayName: "Hermes Agent",
|
|
397
|
+
description: "NousResearch Hermes — Telegram / Discord / Slack / Signal 等 IM 接入",
|
|
398
|
+
defaultCapabilities: DEFAULT_CAPABILITIES,
|
|
399
|
+
requiresNomadDocker: true,
|
|
400
|
+
diskSpaceMB: 5120,
|
|
401
|
+
};
|
|
402
|
+
hooks = {
|
|
403
|
+
/**
|
|
404
|
+
* Hermes pre-start: validate the docker image exists locally. No config
|
|
405
|
+
* patches (the shim bootstraps `.env` / `config.yaml` on first run)
|
|
406
|
+
* and no Nomad Variables template (Hermes reads keys from the
|
|
407
|
+
* bind-mounted `.env`, not from secrets).
|
|
408
|
+
*/
|
|
409
|
+
onBeforeStart: async (args) => {
|
|
410
|
+
const catalog = getRuntimeCatalogEntry("hermes");
|
|
411
|
+
const image = catalog?.defaultImageDigest
|
|
412
|
+
|| catalog?.defaultImage;
|
|
413
|
+
if (!image) {
|
|
414
|
+
throw new Error("Hermes runtime is not installed. Run POST /api/setup/install/hermes first.");
|
|
415
|
+
}
|
|
416
|
+
if (!DOCKER_IMAGE_RE.test(image) || image.length > MAX_DOCKER_IMAGE_NAME_LEN) {
|
|
417
|
+
throw new Error(`Invalid Hermes docker image: "${image}"`);
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
execFileSync("docker", ["image", "inspect", image], {
|
|
421
|
+
timeout: 10000,
|
|
422
|
+
stdio: "ignore",
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
throw new Error(`Hermes image ${image} is not present locally. ` +
|
|
427
|
+
`Run POST /api/setup/install/hermes to pull it.`);
|
|
428
|
+
}
|
|
429
|
+
// Idempotent migration: pre-alias Hermes instances created before the
|
|
430
|
+
// OPENAI_* env switch still carry only JSPROXY_API_KEY. Every start
|
|
431
|
+
// mirrors that token into OPENAI_API_KEY and pins OPENAI_BASE_URL at
|
|
432
|
+
// the jsproxy so auxiliary_client auto-discovery stops hitting Nous.
|
|
433
|
+
// Safe to re-run on every restart.
|
|
434
|
+
try {
|
|
435
|
+
syncHermesProxyEnv(args.instanceId);
|
|
436
|
+
}
|
|
437
|
+
catch { /* best-effort */ }
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
441
|
+
* Full Hermes instance bootstrap, physically migrated from the legacy
|
|
442
|
+
* `instance-manager.createHermesInstance()`. The framework routes layer
|
|
443
|
+
* calls this uniformly via `getAdapter(agentType).createInstance(args)` —
|
|
444
|
+
* instance-manager no longer owns any Hermes business logic.
|
|
445
|
+
*
|
|
446
|
+
* Rejection cases (all raise `InstanceCreationRejected`):
|
|
447
|
+
* - panel.service_manager !== "nomad"
|
|
448
|
+
* - panel.nomad_driver !== "docker"
|
|
449
|
+
* - panel.runtime_catalog.hermes not present (install not run yet)
|
|
450
|
+
*/
|
|
451
|
+
async createInstance(args) {
|
|
452
|
+
if (args.cloneFrom || args.agentHome || args.cloneOptions) {
|
|
453
|
+
throw new Error(`Hermes does not support clone_from / agent_home / clone_options — ` +
|
|
454
|
+
`these are OpenClaw-specific create fields.`);
|
|
455
|
+
}
|
|
456
|
+
const { instanceId, name, description = "" } = args;
|
|
457
|
+
const d = instanceDir(instanceId);
|
|
458
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
459
|
+
if (existsSync(metaPath))
|
|
460
|
+
throw new Error(`Instance '${instanceId}' already exists`);
|
|
461
|
+
if (existsSync(d) && !isPrecreatedManagedAppDir(d)) {
|
|
462
|
+
throw new Error(`Instance '${instanceId}' already exists`);
|
|
463
|
+
}
|
|
464
|
+
// 1. Prereq validation (structured reject with hint)
|
|
465
|
+
const panel = getPanelConfig();
|
|
466
|
+
const svcMgr = panel.service_manager || "nomad";
|
|
467
|
+
const driver = panel.nomad_driver || "docker";
|
|
468
|
+
if (svcMgr !== "nomad") {
|
|
469
|
+
throw new InstanceCreationRejected({
|
|
470
|
+
code: "RUNTIME_REQUIRES_NOMAD_DOCKER",
|
|
471
|
+
requestedKind: "hermes",
|
|
472
|
+
currentServiceManager: svcMgr,
|
|
473
|
+
currentNomadDriver: driver,
|
|
474
|
+
hint: `Hermes requires service_manager="nomad" (current: "${svcMgr}"). ` +
|
|
475
|
+
`Switch in panel.json or via settings before creating a Hermes instance.`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (driver !== "docker") {
|
|
479
|
+
throw new InstanceCreationRejected({
|
|
480
|
+
code: "RUNTIME_REQUIRES_NOMAD_DOCKER",
|
|
481
|
+
requestedKind: "hermes",
|
|
482
|
+
currentServiceManager: svcMgr,
|
|
483
|
+
currentNomadDriver: driver,
|
|
484
|
+
hint: `Hermes requires nomad_driver="docker" (current: "${driver}").`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// Catalog must exist (populated by setup-manager.installHermes)
|
|
488
|
+
const catalog = panel.runtime_catalog?.hermes;
|
|
489
|
+
if (!catalog || !catalog.defaultImage) {
|
|
490
|
+
throw new InstanceCreationRejected({
|
|
491
|
+
code: "RUNTIME_PREREQ_MISSING",
|
|
492
|
+
requestedKind: "hermes",
|
|
493
|
+
currentServiceManager: svcMgr,
|
|
494
|
+
currentNomadDriver: driver,
|
|
495
|
+
hint: `Hermes runtime is not installed. Run POST /api/setup/install/hermes first.`,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
// 2. Lay out filesystem
|
|
499
|
+
ensureDirContainer(d);
|
|
500
|
+
try {
|
|
501
|
+
const parentGid = statSync(dirname(d)).gid;
|
|
502
|
+
chownSync(d, -1, parentGid);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
/* best effort */
|
|
506
|
+
}
|
|
507
|
+
const paths = await this.createInitialLayout({ instanceId, name, description });
|
|
508
|
+
// 3. Allocate gateway host port (kind-agnostic framework primitive)
|
|
509
|
+
const portAlloc = await allocateGatewayPort(instanceId, HERMES_DEFAULT_GATEWAY_PORT);
|
|
510
|
+
// 4. Build runtime spec, inject allocated port into ports[] / env / health
|
|
511
|
+
const runtimeSpec = await this.buildRuntime(instanceId);
|
|
512
|
+
if (runtimeSpec.ports && runtimeSpec.ports.length > 0) {
|
|
513
|
+
runtimeSpec.ports = runtimeSpec.ports.map((port, idx) => idx === 0
|
|
514
|
+
? { ...port, hostPort: portAlloc.port, containerPort: portAlloc.port }
|
|
515
|
+
: port);
|
|
516
|
+
}
|
|
517
|
+
if (runtimeSpec.env && runtimeSpec.env.API_SERVER_PORT !== undefined) {
|
|
518
|
+
runtimeSpec.env.API_SERVER_PORT = String(portAlloc.port);
|
|
519
|
+
}
|
|
520
|
+
if (runtimeSpec.health && typeof runtimeSpec.health === "object" && runtimeSpec.health.port != null) {
|
|
521
|
+
runtimeSpec.health = { ...runtimeSpec.health, port: portAlloc.port };
|
|
522
|
+
}
|
|
523
|
+
// python-dotenv override=True can overwrite docker -e env vars, so the
|
|
524
|
+
// allocated port must also be rewritten into the .env file.
|
|
525
|
+
if (paths.secretEnv && existsSync(paths.secretEnv)) {
|
|
526
|
+
try {
|
|
527
|
+
updateEnvFile(paths.secretEnv, { API_SERVER_PORT: String(portAlloc.port) });
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
console.warn(`[hermes] failed to patch .env API_SERVER_PORT for ${instanceId}: ${e.message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// 5. Persist instance.json
|
|
534
|
+
const meta = {
|
|
535
|
+
id: instanceId,
|
|
536
|
+
name,
|
|
537
|
+
description,
|
|
538
|
+
agentType: "hermes",
|
|
539
|
+
paths: {
|
|
540
|
+
instanceDir: paths.instanceDir,
|
|
541
|
+
agentHome: paths.agentHome,
|
|
542
|
+
primaryConfig: paths.primaryConfig,
|
|
543
|
+
secretEnv: paths.secretEnv,
|
|
544
|
+
},
|
|
545
|
+
runtime: runtimeSpec,
|
|
546
|
+
created_at: new Date().toISOString(),
|
|
547
|
+
};
|
|
548
|
+
// Propagate panel.default_provider → instance.json.x-jishushell.proxy.upstream
|
|
549
|
+
// so the LLM proxy's deriveUpstreamConfig can find this Hermes instance's
|
|
550
|
+
// upstream. Hermes has no openclaw.json, so the upstream block is inlined
|
|
551
|
+
// into instance.json instead.
|
|
552
|
+
const dp = panel.default_provider;
|
|
553
|
+
if (dp && typeof dp === "object" && dp.providerId && !dp.skipped) {
|
|
554
|
+
const modelList = Array.isArray(dp.models)
|
|
555
|
+
? dp.models
|
|
556
|
+
.map((m) => ({
|
|
557
|
+
id: String(m?.id ?? ""),
|
|
558
|
+
name: String(m?.name ?? m?.id ?? ""),
|
|
559
|
+
contextWindow: Number(m?.contextWindow) || 128000,
|
|
560
|
+
}))
|
|
561
|
+
.filter((m) => m.id)
|
|
562
|
+
: [];
|
|
563
|
+
const selectedModelId = String(dp.selectedModelId || modelList[0]?.id || "");
|
|
564
|
+
if (selectedModelId && modelList.every((m) => m.id !== selectedModelId)) {
|
|
565
|
+
modelList.unshift({ id: selectedModelId, name: selectedModelId, contextWindow: 128000 });
|
|
566
|
+
}
|
|
567
|
+
meta["x-jishushell"] = {
|
|
568
|
+
proxy: {
|
|
569
|
+
upstream: {
|
|
570
|
+
providerId: String(dp.providerId),
|
|
571
|
+
baseUrl: String(dp.baseUrl || ""),
|
|
572
|
+
api: String(dp.api || "openai-completions"),
|
|
573
|
+
authHeader: dp.authHeader === true,
|
|
574
|
+
headers: {},
|
|
575
|
+
models: modelList,
|
|
576
|
+
selectedModelId,
|
|
577
|
+
apiKey: "",
|
|
578
|
+
hasApiKey: !!dp.apiKey,
|
|
579
|
+
clearApiKey: false,
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
586
|
+
try {
|
|
587
|
+
ensureInstanceProxyToken(instanceId);
|
|
588
|
+
syncHermesProxyEnv(instanceId);
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
console.warn(`[hermes] failed to seed proxy token for ${instanceId}: ${e.message}`);
|
|
592
|
+
}
|
|
593
|
+
// If running as root, hand ownership of created files to service user
|
|
594
|
+
const svcUser = resolveServiceUser();
|
|
595
|
+
if (svcUser) {
|
|
596
|
+
try {
|
|
597
|
+
execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, d], { timeout: 10_000 });
|
|
598
|
+
}
|
|
599
|
+
catch (e) {
|
|
600
|
+
console.warn(`[hermes] chown for ${instanceId} failed:`, e.message);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (portAlloc.skipped.length > 0) {
|
|
604
|
+
meta.port_allocation = {
|
|
605
|
+
assigned: portAlloc.port,
|
|
606
|
+
requested: HERMES_DEFAULT_GATEWAY_PORT,
|
|
607
|
+
reason: "default_busy",
|
|
608
|
+
skipped: portAlloc.skipped,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
return meta;
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
releasePendingPort(portAlloc.port);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async createInitialLayout(ctx) {
|
|
618
|
+
const paths = resolveHermesPaths(ctx.instanceId);
|
|
619
|
+
mkdirSync(paths.agentHome, { recursive: true });
|
|
620
|
+
for (const sub of HERMES_AGENT_HOME_SUBDIRS) {
|
|
621
|
+
mkdirSync(join(paths.agentHome, sub), { recursive: true });
|
|
622
|
+
}
|
|
623
|
+
for (const segments of HERMES_AGENT_HOME_NESTED_DIRS) {
|
|
624
|
+
mkdirSync(join(paths.agentHome, ...segments), { recursive: true });
|
|
625
|
+
}
|
|
626
|
+
ensureHermesConfigFiles(paths);
|
|
627
|
+
return paths;
|
|
628
|
+
}
|
|
629
|
+
async buildRuntime(instanceId) {
|
|
630
|
+
const catalog = getRuntimeCatalogEntry("hermes");
|
|
631
|
+
if (!catalog) {
|
|
632
|
+
throw new Error("Hermes runtime is not installed. Run POST /api/setup/install/hermes first.");
|
|
633
|
+
}
|
|
634
|
+
// Prefer digest over tag for image pinning per §32.1. When installHermes
|
|
635
|
+
// captured a digest it is stored alongside the tag; adapter favors digest.
|
|
636
|
+
const image = catalog.defaultImageDigest
|
|
637
|
+
|| catalog.defaultImage;
|
|
638
|
+
if (!image) {
|
|
639
|
+
throw new Error("Hermes runtime catalog missing defaultImage");
|
|
640
|
+
}
|
|
641
|
+
// Enforce the runtime protocol contract. The baked-shim image carries
|
|
642
|
+
// `runtime.protocol.version=1`; an image without the label pre-dates
|
|
643
|
+
// the shim bake and must be re-pulled so its /usr/local/bin/... entry
|
|
644
|
+
// script exists. Absence is a hard failure now rather than a warning
|
|
645
|
+
// because we have already removed the legacy bind-mount fallback.
|
|
646
|
+
assertHermesImageProtocolCompatible(image);
|
|
647
|
+
const paths = resolveHermesPaths(instanceId);
|
|
648
|
+
// Migrate pre-alias instances (created before the switch to
|
|
649
|
+
// provider:minimax) in-place: mirror JSPROXY_API_KEY into MINIMAX_API_KEY
|
|
650
|
+
// and pin MINIMAX_BASE_URL at the jsproxy. Idempotent — no-op on
|
|
651
|
+
// instances already aligned with the new template.
|
|
652
|
+
try {
|
|
653
|
+
syncHermesProxyEnv(instanceId);
|
|
654
|
+
}
|
|
655
|
+
catch { /* best-effort */ }
|
|
656
|
+
// §32.1.3: baseline mode is detected by ABSENCE of JISHUSHELL_HERMES_SOURCE_REF.
|
|
657
|
+
// We deliberately do NOT inject this env here. Only the (future) upgrade CLI
|
|
658
|
+
// populates it after a per-instance overlay build.
|
|
659
|
+
const env = {
|
|
660
|
+
API_SERVER_ENABLED: "true",
|
|
661
|
+
API_SERVER_HOST: "0.0.0.0",
|
|
662
|
+
API_SERVER_PORT: String(HERMES_CONTAINER_PORT),
|
|
663
|
+
HERMES_UID: String(process.getuid?.() ?? 1000),
|
|
664
|
+
HERMES_GID: String(process.getgid?.() ?? 1000),
|
|
665
|
+
};
|
|
666
|
+
const resources = catalog.resources && typeof catalog.resources === "object"
|
|
667
|
+
? {
|
|
668
|
+
CPU: Number(catalog.resources.CPU) || 1000,
|
|
669
|
+
MemoryMB: Number(catalog.resources.MemoryMB) || 1024,
|
|
670
|
+
MemoryMaxMB: catalog.resources.MemoryMaxMB != null
|
|
671
|
+
? Number(catalog.resources.MemoryMaxMB)
|
|
672
|
+
: undefined,
|
|
673
|
+
}
|
|
674
|
+
: { CPU: 1000, MemoryMB: 1024 };
|
|
675
|
+
return {
|
|
676
|
+
image,
|
|
677
|
+
// Invoke the shim via /bin/bash explicitly instead of execve'ing the
|
|
678
|
+
// script file directly. Debian-based containers always ship /bin/bash,
|
|
679
|
+
// and this bypasses EACCES on execve when bind-mounted script files
|
|
680
|
+
// occasionally lose their exec bit through the container runtime.
|
|
681
|
+
command: "/bin/bash",
|
|
682
|
+
args: [HERMES_CONTAINER_SHIM, "gateway", "run"],
|
|
683
|
+
cwd: HERMES_CONTAINER_WORKDIR,
|
|
684
|
+
user: "root", // shim exec-gosu-downgrades to hermes uid
|
|
685
|
+
env,
|
|
686
|
+
envFiles: paths.secretEnv ? [paths.secretEnv] : undefined,
|
|
687
|
+
resources,
|
|
688
|
+
volumes: [
|
|
689
|
+
{ hostPath: paths.agentHome, containerPath: HERMES_CONTAINER_HOME, mode: "rw" },
|
|
690
|
+
// Shim no longer bind-mounted — it is baked at HERMES_CONTAINER_SHIM
|
|
691
|
+
// in the image (see Dockerfile.hermes-slim). Any remaining legacy
|
|
692
|
+
// shimPath in runtime_catalog is scrubbed by migrateHermesShimOut.
|
|
693
|
+
],
|
|
694
|
+
ports: [
|
|
695
|
+
{
|
|
696
|
+
name: "gateway",
|
|
697
|
+
containerPort: HERMES_CONTAINER_PORT,
|
|
698
|
+
hostPort: 0, // allocated by instance-manager
|
|
699
|
+
visibility: "external",
|
|
700
|
+
},
|
|
701
|
+
],
|
|
702
|
+
health: {
|
|
703
|
+
type: "http",
|
|
704
|
+
path: "/health",
|
|
705
|
+
port: HERMES_CONTAINER_PORT,
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Hermes has no `openclaw.json` — it stores upstream metadata directly
|
|
711
|
+
* on instance.json under `x-jishushell`. Return a synthetic stub
|
|
712
|
+
* containing just that block so the LLM proxy's
|
|
713
|
+
* `deriveUpstreamConfig()` finds what it needs.
|
|
714
|
+
*/
|
|
715
|
+
getNativeConfig(instanceId) {
|
|
716
|
+
return this.getStoredNativeConfig(instanceId);
|
|
717
|
+
}
|
|
718
|
+
getStoredNativeConfig(instanceId) {
|
|
719
|
+
// Lazy import to avoid pulling the full instance-manager into top-level
|
|
720
|
+
// static graph (adapters reach back for framework primitives on demand).
|
|
721
|
+
try {
|
|
722
|
+
const req = createRequire(import.meta.url);
|
|
723
|
+
const im = req("../../instance-manager.js");
|
|
724
|
+
const meta = im.getInstance(instanceId);
|
|
725
|
+
if (!meta)
|
|
726
|
+
return null;
|
|
727
|
+
return { "x-jishushell": meta["x-jishushell"] || {} };
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Persist the `x-jishushell` meta block to instance.json. Hermes's user-
|
|
735
|
+
* facing config lives in yaml+env (editable via `writeConfig`), so this
|
|
736
|
+
* hook is scoped to the meta block only — any other keys in the payload
|
|
737
|
+
* are ignored intentionally.
|
|
738
|
+
*
|
|
739
|
+
* Called by `llm-proxy.saveInstanceConfig` when the editor saves a
|
|
740
|
+
* per-instance provider override. By implementing the same
|
|
741
|
+
* `saveNativeConfig(instanceId, {x-jishushell})` contract OpenClaw uses,
|
|
742
|
+
* Hermes piggy-backs on the shared API key encryption + rollback flow.
|
|
743
|
+
*/
|
|
744
|
+
saveNativeConfig(instanceId, config) {
|
|
745
|
+
const xJishushell = config?.["x-jishushell"];
|
|
746
|
+
if (!xJishushell || typeof xJishushell !== "object")
|
|
747
|
+
return false;
|
|
748
|
+
try {
|
|
749
|
+
const req = createRequire(import.meta.url);
|
|
750
|
+
const im = req("../../instance-manager.js");
|
|
751
|
+
const metaPath = im.instanceMetaPath(instanceId);
|
|
752
|
+
if (!existsSync(metaPath))
|
|
753
|
+
return false;
|
|
754
|
+
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
755
|
+
meta["x-jishushell"] = xJishushell;
|
|
756
|
+
safeWriteJson(metaPath, meta);
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
catch (e) {
|
|
760
|
+
console.warn(`[hermes] saveNativeConfig failed for ${instanceId}: ${e.message}`);
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async getRuntimeVersion(instanceId) {
|
|
765
|
+
const catalog = getRuntimeCatalogEntry("hermes");
|
|
766
|
+
const digest = catalog?.defaultImageDigest;
|
|
767
|
+
// Read per-instance SOURCE_REF from instance.json without introducing
|
|
768
|
+
// a runtime→instance-manager cycle: late require to break the chain.
|
|
769
|
+
// If the env isn't set, we are in baseline mode.
|
|
770
|
+
let ref;
|
|
771
|
+
try {
|
|
772
|
+
const { getInstance } = await import("../../instance-manager.js");
|
|
773
|
+
const meta = getInstance(instanceId);
|
|
774
|
+
const envRef = meta?.runtime?.env?.JISHUSHELL_HERMES_SOURCE_REF;
|
|
775
|
+
if (typeof envRef === "string" && envRef.trim())
|
|
776
|
+
ref = envRef.trim();
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
// best-effort: if instance lookup fails, report baseline
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
agentType: "hermes",
|
|
783
|
+
ref,
|
|
784
|
+
digest,
|
|
785
|
+
mode: ref ? "overlay" : "baseline",
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
async getConfigMeta(instanceId) {
|
|
789
|
+
const doc = await this.readConfig(instanceId);
|
|
790
|
+
return {
|
|
791
|
+
agentType: "hermes",
|
|
792
|
+
format: "yaml+env",
|
|
793
|
+
schemaId: "hermes/v1",
|
|
794
|
+
capabilities: this.defaultCapabilities,
|
|
795
|
+
secretFields: doc.format === "yaml+env" ? secretEnvFields(doc.env) : [],
|
|
796
|
+
runtimeVersion: await this.getRuntimeVersion(instanceId),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
async readConfig(instanceId) {
|
|
800
|
+
const paths = resolveHermesPaths(instanceId);
|
|
801
|
+
ensureHermesConfigFiles(paths);
|
|
802
|
+
// Surface the instance.json `x-jishushell` meta block alongside yaml/env
|
|
803
|
+
// so the Config form can round-trip per-instance upstream provider
|
|
804
|
+
// overrides through the same ConfigDocument shape OpenClaw uses.
|
|
805
|
+
const xJishushell = this.getStoredNativeConfig(instanceId)?.["x-jishushell"];
|
|
806
|
+
const doc = {
|
|
807
|
+
format: "yaml+env",
|
|
808
|
+
yaml: readFileSync(paths.primaryConfig, "utf-8"),
|
|
809
|
+
env: readFileSync(paths.secretEnv, "utf-8"),
|
|
810
|
+
};
|
|
811
|
+
if (xJishushell && typeof xJishushell === "object") {
|
|
812
|
+
doc["x-jishushell"] = xJishushell;
|
|
813
|
+
}
|
|
814
|
+
return doc;
|
|
815
|
+
}
|
|
816
|
+
async writeConfig(instanceId, doc) {
|
|
817
|
+
if (doc.format !== "yaml+env") {
|
|
818
|
+
throw new Error(`Hermes config requires format="yaml+env", got "${doc.format}"`);
|
|
819
|
+
}
|
|
820
|
+
YAML.parse(doc.yaml);
|
|
821
|
+
const envText = normalizeEnvText(doc.env);
|
|
822
|
+
parseEnvFileFromText(envText);
|
|
823
|
+
const paths = resolveHermesPaths(instanceId);
|
|
824
|
+
ensureHermesConfigFiles(paths);
|
|
825
|
+
// Atomicity: the x-jishushell save path (via llm-proxy.saveInstanceConfig)
|
|
826
|
+
// can reject for reasons we only learn after some I/O — missing upstream
|
|
827
|
+
// API key, provider.env write failure, etc. Snapshot the current YAML/ENV
|
|
828
|
+
// contents BEFORE overwriting them so we can restore on failure and
|
|
829
|
+
// callers never see a half-applied save (new YAML on disk + rejected
|
|
830
|
+
// x-jishushell would leave the instance in a dirty state).
|
|
831
|
+
const priorYaml = readFileSync(paths.primaryConfig, "utf-8");
|
|
832
|
+
const priorEnv = readFileSync(paths.secretEnv, "utf-8");
|
|
833
|
+
writeConfigFile(paths.primaryConfig, doc.yaml.trimEnd() + "\n");
|
|
834
|
+
writeSecretFile(paths.secretEnv, envText);
|
|
835
|
+
// If the caller included per-instance upstream metadata, route it
|
|
836
|
+
// through the shared llm-proxy save flow — that path owns API key
|
|
837
|
+
// encryption, provider.env persistence, rollback on secret failures,
|
|
838
|
+
// and upstream cache invalidation. We deliberately reuse it instead
|
|
839
|
+
// of duplicating the logic here so Hermes and OpenClaw agree bit-
|
|
840
|
+
// for-bit on how per-instance provider config is stored.
|
|
841
|
+
const xJishushell = doc["x-jishushell"];
|
|
842
|
+
if (xJishushell && typeof xJishushell === "object") {
|
|
843
|
+
try {
|
|
844
|
+
const { saveInstanceConfig } = await import("../../llm-proxy/index.js");
|
|
845
|
+
await saveInstanceConfig(instanceId, { "x-jishushell": xJishushell });
|
|
846
|
+
}
|
|
847
|
+
catch (e) {
|
|
848
|
+
try {
|
|
849
|
+
writeConfigFile(paths.primaryConfig, priorYaml);
|
|
850
|
+
writeSecretFile(paths.secretEnv, priorEnv);
|
|
851
|
+
}
|
|
852
|
+
catch (rollbackErr) {
|
|
853
|
+
console.error(`[hermes] CRITICAL: YAML/ENV rollback failed for ${instanceId}:`, rollbackErr?.message || rollbackErr);
|
|
854
|
+
}
|
|
855
|
+
throw e;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return this.readConfig(instanceId);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Persist Feishu/Lark OAuth credentials returned by the device-flow
|
|
862
|
+
* endpoints (handled by openclaw-routes.ts but adapter-agnostic at the
|
|
863
|
+
* persistence layer — framework code dispatches here via
|
|
864
|
+
* `instance-manager.saveFeishuCredentials`).
|
|
865
|
+
*
|
|
866
|
+
* Hermes reads platform config from two places on every gateway start:
|
|
867
|
+
* - `config.yaml` → `platforms.feishu.enabled` + `platforms.feishu.extra.*`
|
|
868
|
+
* - `.env` → `FEISHU_APP_ID`, `FEISHU_APP_SECRET`
|
|
869
|
+
*
|
|
870
|
+
* We write into BOTH. The YAML holds structural config (enabled flag,
|
|
871
|
+
* connection mode, domain) and references the secret env vars via
|
|
872
|
+
* `${...}` interpolation. That way the YAML can be backed up / shared
|
|
873
|
+
* without leaking credentials, and the real secrets live next to the
|
|
874
|
+
* .env the user already edits.
|
|
875
|
+
*/
|
|
876
|
+
saveFeishuCredentials(instanceId, creds) {
|
|
877
|
+
if (!FEISHU_APP_ID_RE.test(creds.appId)) {
|
|
878
|
+
throw new Error(`Invalid Feishu appId format: expected cli_<alnum> (got "${creds.appId}")`);
|
|
879
|
+
}
|
|
880
|
+
if (!creds.appSecret || typeof creds.appSecret !== "string" || creds.appSecret.length < 4) {
|
|
881
|
+
throw new Error("Invalid Feishu appSecret: must be a non-empty string");
|
|
882
|
+
}
|
|
883
|
+
const domainName = creds.domain === "lark" ? "lark" : "feishu";
|
|
884
|
+
const paths = resolveHermesPaths(instanceId);
|
|
885
|
+
ensureHermesConfigFiles(paths);
|
|
886
|
+
// Merge into existing YAML rather than overwrite — the user may have
|
|
887
|
+
// customised model/terminal/custom_providers/etc. via the Config tab.
|
|
888
|
+
const currentYaml = readFileSync(paths.primaryConfig, "utf-8");
|
|
889
|
+
const parsed = YAML.parse(currentYaml) || {};
|
|
890
|
+
parsed.platforms = parsed.platforms || {};
|
|
891
|
+
const prior = parsed.platforms.feishu || {};
|
|
892
|
+
const priorExtra = prior.extra || {};
|
|
893
|
+
parsed.platforms.feishu = {
|
|
894
|
+
...prior,
|
|
895
|
+
enabled: true,
|
|
896
|
+
extra: {
|
|
897
|
+
...priorExtra,
|
|
898
|
+
// Env interpolation: hermes expands ${FOO} at load time against
|
|
899
|
+
// the process env, which Docker populates from agent-home/.env.
|
|
900
|
+
app_id: "${FEISHU_APP_ID}",
|
|
901
|
+
app_secret: "${FEISHU_APP_SECRET}",
|
|
902
|
+
domain_name: domainName,
|
|
903
|
+
connection_mode: priorExtra.connection_mode || "websocket",
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
writeConfigFile(paths.primaryConfig, YAML.stringify(parsed).trimEnd() + "\n");
|
|
907
|
+
// Env merge preserves unrelated keys (API_SERVER_KEY, JSPROXY_API_KEY, …).
|
|
908
|
+
updateEnvFile(paths.secretEnv, {
|
|
909
|
+
FEISHU_APP_ID: creds.appId,
|
|
910
|
+
FEISHU_APP_SECRET: creds.appSecret,
|
|
911
|
+
});
|
|
912
|
+
console.log(`[hermes] Feishu credentials saved for ${instanceId}, domain=${domainName}`);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Persist Weixin (个人微信) credentials returned by iLink Bot API's QR
|
|
916
|
+
* login flow. Called from the shared `/api/instances/:id/weixin/login`
|
|
917
|
+
* handler via `instance-manager.saveWeixinCredentials` → adapter dispatch.
|
|
918
|
+
*
|
|
919
|
+
* Upstream Hermes's `WeixinAdapter.__init__` scans
|
|
920
|
+
* `$HERMES_HOME/weixin/accounts/*.json` at startup and auto-connects every
|
|
921
|
+
* account file it finds, so the handshake we persist here is everything
|
|
922
|
+
* the runtime needs to come online after an instance restart. The JSON
|
|
923
|
+
* shape is pinned to upstream's `save_weixin_account` helper:
|
|
924
|
+
* `{token, base_url, user_id, saved_at}` with mode 0600.
|
|
925
|
+
*
|
|
926
|
+
* We also flip `platforms.weixin.enabled: true` in the instance's
|
|
927
|
+
* config.yaml so the gateway actually tries to connect the adapter on
|
|
928
|
+
* next start (without it, hermes ignores the weixin folder entirely).
|
|
929
|
+
*/
|
|
930
|
+
saveWeixinCredentials(instanceId, creds) {
|
|
931
|
+
if (!creds.accountId || !SAFE_WEIXIN_ACCOUNT_ID_RE.test(creds.accountId)) {
|
|
932
|
+
throw new Error(`Invalid WeChat accountId: must be 1-128 chars of [a-zA-Z0-9@._-], got "${creds.accountId}"`);
|
|
933
|
+
}
|
|
934
|
+
if (!creds.token || typeof creds.token !== "string" || creds.token.length < 4) {
|
|
935
|
+
throw new Error("Invalid WeChat bot token: must be a non-empty string");
|
|
936
|
+
}
|
|
937
|
+
const home = resolveHermesPaths(instanceId).agentHome;
|
|
938
|
+
const accountsDir = join(home, "weixin", "accounts");
|
|
939
|
+
ensureDirContainer(accountsDir);
|
|
940
|
+
// Match upstream `save_weixin_account` exactly — WeixinAdapter reloads
|
|
941
|
+
// this file at connect time so we can't improvise the field names.
|
|
942
|
+
const payload = {
|
|
943
|
+
token: creds.token,
|
|
944
|
+
base_url: creds.baseUrl,
|
|
945
|
+
user_id: creds.userId || "",
|
|
946
|
+
saved_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
|
947
|
+
};
|
|
948
|
+
const accountPath = join(accountsDir, `${creds.accountId}.json`);
|
|
949
|
+
safeWriteJson(accountPath, payload);
|
|
950
|
+
try {
|
|
951
|
+
chmodSync(accountPath, 0o600);
|
|
952
|
+
}
|
|
953
|
+
catch { /* best effort */ }
|
|
954
|
+
// Hermes runs as the `hermes` user inside the container; on the host
|
|
955
|
+
// the bind-mount is owned by the panel service user. `ensureDirContainer`
|
|
956
|
+
// already handles ownership for the dir — file-level chown is a noop
|
|
957
|
+
// in the container path and not available as a shared helper here, so
|
|
958
|
+
// we rely on the directory permissions inheriting to the new file.
|
|
959
|
+
// Enable platforms.weixin in config.yaml (merge, preserve other keys).
|
|
960
|
+
// Upstream WeixinAdapter resolves startup credentials in this order at
|
|
961
|
+
// init time (weixin.py:1066-1067):
|
|
962
|
+
// token = config.token || extra.token || env WEIXIN_TOKEN
|
|
963
|
+
// account_id = extra.account_id || env WEIXIN_ACCOUNT_ID
|
|
964
|
+
// The per-account JSON we wrote above is state storage (refresh
|
|
965
|
+
// cursor, context token cache), not the startup seed — without the
|
|
966
|
+
// YAML + env entries below the adapter crashes with
|
|
967
|
+
// "WEIXIN_TOKEN is required".
|
|
968
|
+
const paths = resolveHermesPaths(instanceId);
|
|
969
|
+
ensureHermesConfigFiles(paths);
|
|
970
|
+
const currentYaml = readFileSync(paths.primaryConfig, "utf-8");
|
|
971
|
+
const parsed = YAML.parse(currentYaml) || {};
|
|
972
|
+
parsed.platforms = parsed.platforms || {};
|
|
973
|
+
const prior = parsed.platforms.weixin || {};
|
|
974
|
+
const priorExtra = prior.extra || {};
|
|
975
|
+
parsed.platforms.weixin = {
|
|
976
|
+
...prior,
|
|
977
|
+
enabled: true,
|
|
978
|
+
extra: {
|
|
979
|
+
...priorExtra,
|
|
980
|
+
// Same env-interpolation convention used by the feishu save path:
|
|
981
|
+
// YAML references ${VAR}, the real value lives in agent-home/.env.
|
|
982
|
+
account_id: "${WEIXIN_ACCOUNT_ID}",
|
|
983
|
+
token: "${WEIXIN_TOKEN}",
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
writeConfigFile(paths.primaryConfig, YAML.stringify(parsed).trimEnd() + "\n");
|
|
987
|
+
updateEnvFile(paths.secretEnv, {
|
|
988
|
+
WEIXIN_ACCOUNT_ID: creds.accountId,
|
|
989
|
+
WEIXIN_TOKEN: creds.token,
|
|
990
|
+
});
|
|
991
|
+
console.log(`[hermes] WeChat credentials saved for ${instanceId}, account=${creds.accountId}`);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* List connected WeChat accounts for an instance. Backs the panel UI's
|
|
995
|
+
* "Connected Accounts" section in WeixinLoginPanel. Tolerates a missing
|
|
996
|
+
* directory (returns empty) and skips sidecar files the runtime writes
|
|
997
|
+
* alongside account records (sync buffers + context-token caches).
|
|
998
|
+
*/
|
|
999
|
+
getWeixinAccounts(instanceId) {
|
|
1000
|
+
const home = resolveHermesPaths(instanceId).agentHome;
|
|
1001
|
+
if (!home)
|
|
1002
|
+
return [];
|
|
1003
|
+
const accountsDir = join(home, "weixin", "accounts");
|
|
1004
|
+
if (!existsSync(accountsDir))
|
|
1005
|
+
return [];
|
|
1006
|
+
const results = [];
|
|
1007
|
+
for (const f of readdirSync(accountsDir)) {
|
|
1008
|
+
if (!f.endsWith(".json"))
|
|
1009
|
+
continue;
|
|
1010
|
+
// Skip sidecar files written by the runtime (sync cursor + context
|
|
1011
|
+
// token cache). They share the accounts dir but aren't accounts.
|
|
1012
|
+
if (f.endsWith(".sync.json") || f.endsWith(".context-tokens.json"))
|
|
1013
|
+
continue;
|
|
1014
|
+
try {
|
|
1015
|
+
const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
|
|
1016
|
+
results.push({
|
|
1017
|
+
accountId: f.replace(/\.json$/, ""),
|
|
1018
|
+
userId: typeof data.user_id === "string" ? data.user_id : undefined,
|
|
1019
|
+
savedAt: typeof data.saved_at === "string" ? data.saved_at : undefined,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
/* malformed account file — skip */
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return results;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Build the Nomad docker task spec directly from the persisted RuntimeSpec.
|
|
1030
|
+
* Physically migrated from `nomad-manager.buildHermesTaskDocker`. Framework
|
|
1031
|
+
* code now calls `getAdapter(agentType).buildNomadTask(id)` uniformly.
|
|
1032
|
+
*/
|
|
1033
|
+
async buildNomadTask(instanceId) {
|
|
1034
|
+
const { getInstanceRuntime } = await import("../../instance-manager.js");
|
|
1035
|
+
const safeJobId = `${this.nomadJobPrefix}${instanceId}`;
|
|
1036
|
+
assertSafeTemplateId(safeJobId);
|
|
1037
|
+
const spec = getInstanceRuntime(instanceId);
|
|
1038
|
+
if (!spec || typeof spec !== "object") {
|
|
1039
|
+
throw new Error(`Hermes instance ${instanceId} has no persisted runtime spec`);
|
|
1040
|
+
}
|
|
1041
|
+
if (!spec.image || typeof spec.image !== "string") {
|
|
1042
|
+
throw new Error(`Hermes runtime spec missing image for ${instanceId}`);
|
|
1043
|
+
}
|
|
1044
|
+
if (!DOCKER_IMAGE_RE.test(spec.image) || spec.image.length > MAX_DOCKER_IMAGE_NAME_LEN) {
|
|
1045
|
+
throw new Error(`Invalid Hermes docker image: "${spec.image}"`);
|
|
1046
|
+
}
|
|
1047
|
+
const volumes = [];
|
|
1048
|
+
for (const vol of (spec.volumes || [])) {
|
|
1049
|
+
volumes.push(`${vol.hostPath}:${vol.containerPath}:${vol.mode}`);
|
|
1050
|
+
}
|
|
1051
|
+
const ports = Array.isArray(spec.ports) ? spec.ports : [];
|
|
1052
|
+
const gatewayPort = Number(ports[0]?.hostPort) || 0;
|
|
1053
|
+
if (!gatewayPort) {
|
|
1054
|
+
throw new Error(`Hermes instance ${instanceId} has no allocated gateway hostPort`);
|
|
1055
|
+
}
|
|
1056
|
+
const resourcesRaw = (spec.resources || {});
|
|
1057
|
+
const requestedCpu = Math.max(1, Math.min(Number(resourcesRaw.CPU) || 1000, MAX_CPU_MHZ));
|
|
1058
|
+
const requestedMem = Math.max(1, Math.min(Number(resourcesRaw.MemoryMB) || 1024, MAX_MEMORY_MB));
|
|
1059
|
+
const requestedMemMax = Math.min(Number(resourcesRaw.MemoryMaxMB ?? requestedMem), MAX_MEMORY_MAX_MB);
|
|
1060
|
+
// Start non-root directly as the host's uid:gid — aligns with OpenClaw's
|
|
1061
|
+
// security model (openclaw.ts:1404) and makes `cap_drop: ["ALL"]` viable
|
|
1062
|
+
// without any cap_add. The entrypoint shim detects non-root via
|
|
1063
|
+
// `if [ "$(id -u)" = "0" ]; then ... gosu ...; fi` and cleanly falls
|
|
1064
|
+
// through to the venv-activation branch when started this way.
|
|
1065
|
+
//
|
|
1066
|
+
// Bind-mounted /opt/data on the host is already owned by this uid:gid
|
|
1067
|
+
// (jishushell creates instance dirs as the panel user), so mkdir/cp/chmod
|
|
1068
|
+
// inside the shim's non-root branch all succeed without CAP_CHOWN.
|
|
1069
|
+
const hostUid = process.getuid?.() ?? 1000;
|
|
1070
|
+
const hostGid = process.getgid?.() ?? 1000;
|
|
1071
|
+
return {
|
|
1072
|
+
Name: "gateway",
|
|
1073
|
+
Driver: "docker",
|
|
1074
|
+
User: `${hostUid}:${hostGid}`,
|
|
1075
|
+
Config: {
|
|
1076
|
+
image: spec.image,
|
|
1077
|
+
force_pull: false,
|
|
1078
|
+
entrypoint: [spec.command],
|
|
1079
|
+
args: [...(spec.args || [])],
|
|
1080
|
+
work_dir: spec.cwd || HERMES_CONTAINER_WORKDIR,
|
|
1081
|
+
volumes,
|
|
1082
|
+
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
1083
|
+
// No cap_add — starting as non-root uid:gid means the shim's
|
|
1084
|
+
// privilege-drop ritual (usermod / chown / gosu) is skipped
|
|
1085
|
+
// entirely. `security_opt: no-new-privileges` prevents the
|
|
1086
|
+
// unprivileged process from regaining any caps via setuid
|
|
1087
|
+
// binaries. If browser_navigate's chromium sandbox fails, fix
|
|
1088
|
+
// it with `--no-sandbox` in Dockerfile.hermes-agent rather
|
|
1089
|
+
// than adding CAP_SYS_ADMIN here.
|
|
1090
|
+
cap_drop: ["ALL"],
|
|
1091
|
+
security_opt: ["no-new-privileges"],
|
|
1092
|
+
pids_limit: DEFAULT_PIDS_LIMIT,
|
|
1093
|
+
readonly_rootfs: false,
|
|
1094
|
+
//
|
|
1095
|
+
// `init: true` asks docker to run docker-init (a tini fork) as PID 1.
|
|
1096
|
+
// This is the authoritative zombie-reaper for our containers — the
|
|
1097
|
+
// image itself deliberately does NOT set ENTRYPOINT, so anyone who
|
|
1098
|
+
// runs the image outside of Nomad must pass `--init` themselves.
|
|
1099
|
+
// Without a real init, the chromium / agent-browser subprocess
|
|
1100
|
+
// zombies never get reaped and we hit `pids_limit` after ~50
|
|
1101
|
+
// browser_navigate calls.
|
|
1102
|
+
//
|
|
1103
|
+
// `shm_size` raises /dev/shm from docker's 64 MB default. Chromium
|
|
1104
|
+
// uses shared memory for render/GPU buffers; 64 MB SIGBUSes on any
|
|
1105
|
+
// JS-heavy page. 1 GB matches Chrome's default heap budget and is
|
|
1106
|
+
// what Playwright / Puppeteer docker guides recommend.
|
|
1107
|
+
//
|
|
1108
|
+
init: true,
|
|
1109
|
+
shm_size: 1073741824,
|
|
1110
|
+
mounts: [{ type: "tmpfs", target: "/tmp", tmpfs_options: { size: 536870912 } }],
|
|
1111
|
+
},
|
|
1112
|
+
Env: { ...spec.env },
|
|
1113
|
+
Resources: {
|
|
1114
|
+
CPU: requestedCpu,
|
|
1115
|
+
MemoryMB: requestedMem,
|
|
1116
|
+
MemoryMaxMB: requestedMemMax,
|
|
1117
|
+
Networks: [{ ReservedPorts: [{ Label: "gateway", Value: gatewayPort }] }],
|
|
1118
|
+
},
|
|
1119
|
+
LogConfig: { MaxFiles: 3, MaxFileSizeMB: 10 },
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Rewrite a persisted Hermes runtime spec to use a new host port.
|
|
1124
|
+
* Hermes persists its port in `ports[0].hostPort/containerPort`,
|
|
1125
|
+
* `env.API_SERVER_PORT`, and `health.port`.
|
|
1126
|
+
*/
|
|
1127
|
+
reallocateRuntimePort(runtime, newPort) {
|
|
1128
|
+
if (Array.isArray(runtime.ports) && runtime.ports.length > 0) {
|
|
1129
|
+
runtime.ports = runtime.ports.map((port, idx) => idx === 0 ? { ...port, hostPort: newPort, containerPort: newPort } : port);
|
|
1130
|
+
}
|
|
1131
|
+
if (runtime.env && runtime.env.API_SERVER_PORT !== undefined) {
|
|
1132
|
+
runtime.env.API_SERVER_PORT = String(newPort);
|
|
1133
|
+
}
|
|
1134
|
+
if (runtime.health && typeof runtime.health === "object" && runtime.health.port != null) {
|
|
1135
|
+
runtime.health = { ...runtime.health, port: newPort };
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Each adapter owns its own Nomad job prefix, derived from the agent
|
|
1139
|
+
// type (`hermes-<id>`, `openclaw-<id>`, …). Any new runtime should
|
|
1140
|
+
// declare its own prefix here so job IDs are self-describing and
|
|
1141
|
+
// multiple runtime kinds never collide in the Nomad Raft namespace.
|
|
1142
|
+
nomadJobPrefix = "hermes-";
|
|
1143
|
+
migrateStartup() {
|
|
1144
|
+
migrateHermesImageTagIfNeeded();
|
|
1145
|
+
}
|
|
1146
|
+
// Inline chat forwarder dispatch — framework reads this from
|
|
1147
|
+
// `routes/instances.ts:/api/instances/:id/agent/chat`. Declaring the
|
|
1148
|
+
// descriptor (rather than hardcoding API_SERVER_KEY in the route) lets
|
|
1149
|
+
// other OpenAI-compat agents plug in without touching framework code.
|
|
1150
|
+
inlineChatDescriptor = {
|
|
1151
|
+
apiKeyEnvVar: "API_SERVER_KEY",
|
|
1152
|
+
endpointPath: "/v1/chat/completions",
|
|
1153
|
+
};
|
|
1154
|
+
async buildPairingListCommand(_instanceId) {
|
|
1155
|
+
return ["hermes", "pairing", "list"];
|
|
1156
|
+
}
|
|
1157
|
+
async buildPairingApproveCommand(_instanceId, input) {
|
|
1158
|
+
return ["hermes", "pairing", "approve", input.channel, input.code];
|
|
1159
|
+
}
|
|
1160
|
+
// ── §32.2.4 runtime install (aligned with OpenClaw pull flow) ─────────
|
|
1161
|
+
//
|
|
1162
|
+
// Pull-only flow — no local Dockerfile build fallback. Hermes ships as
|
|
1163
|
+
// a ~2.4 GB pre-built image on GHCR; building from upstream source on
|
|
1164
|
+
// a Raspberry Pi would take 20+ minutes and almost always fails on
|
|
1165
|
+
// resource-constrained hardware. If the pull fails we return a clear
|
|
1166
|
+
// error so the user can fix network / registry access rather than
|
|
1167
|
+
// silently burning an hour on a futile source build.
|
|
1168
|
+
//
|
|
1169
|
+
// Steps:
|
|
1170
|
+
// 1. docker image inspect — reuse existing local image if present
|
|
1171
|
+
// 2. docker pull ghcr.io/x-aijishu/hermes-runtime:latest
|
|
1172
|
+
// 3. capture RepoDigest for digest pinning (best-effort)
|
|
1173
|
+
// 4. Copy templates/hermes-entrypoint.sh to ~/.jishushell/runtimes/hermes/
|
|
1174
|
+
// 5. Write runtime catalog entry into panel.json
|
|
1175
|
+
async installRuntime(_opts) {
|
|
1176
|
+
const task = createTask("hermes");
|
|
1177
|
+
try {
|
|
1178
|
+
await this.prepareHermesWithTask(task);
|
|
1179
|
+
if (task.status === "error") {
|
|
1180
|
+
return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
message: "Hermes installation failed",
|
|
1183
|
+
taskId: task.id,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
ok: true,
|
|
1188
|
+
message: "Hermes runtime installed",
|
|
1189
|
+
taskId: task.id,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
catch (e) {
|
|
1193
|
+
emitTask(task, { type: "error", message: `Hermes 安装失败: ${e.message}` });
|
|
1194
|
+
task.status = "error";
|
|
1195
|
+
return {
|
|
1196
|
+
ok: false,
|
|
1197
|
+
message: "Hermes installation failed",
|
|
1198
|
+
error: e.message,
|
|
1199
|
+
taskId: task.id,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Non-blocking variant — returns a task id immediately and runs the
|
|
1205
|
+
* install in the background. Matches OpenClaw's startBuildRuntimeImage.
|
|
1206
|
+
*/
|
|
1207
|
+
startInstallRuntime(_opts) {
|
|
1208
|
+
const task = createTask("hermes");
|
|
1209
|
+
this.prepareHermesWithTask(task).catch((e) => {
|
|
1210
|
+
// Mirror errors to stderr so post-mortem via journalctl works.
|
|
1211
|
+
// Task events live in memory and vanish after TASK_MAX_AGE / panel
|
|
1212
|
+
// restart — leaving us blind if the UI stops polling before the
|
|
1213
|
+
// failure arrives.
|
|
1214
|
+
console.error(`[hermes] install failed (task=${task.id}):`, e?.stack || e?.message || e);
|
|
1215
|
+
emitTask(task, { type: "error", message: `Hermes 安装失败: ${e?.message || e}` });
|
|
1216
|
+
task.status = "error";
|
|
1217
|
+
});
|
|
1218
|
+
return { ok: true, message: "Hermes install started", taskId: task.id };
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Shared helper that pulls (or builds) the Hermes runtime image and
|
|
1222
|
+
* finalises the runtime catalog entry. Mirrors OpenClaw's
|
|
1223
|
+
* `pullOrBuildOpenclawImageWithTask` so both runtimes expose the same
|
|
1224
|
+
* install UX (local → pull → fallback build → digest pin).
|
|
1225
|
+
*/
|
|
1226
|
+
async prepareHermesWithTask(task) {
|
|
1227
|
+
emitTask(task, { type: "progress", message: "准备 Hermes runtime...", progress: 0 });
|
|
1228
|
+
const invocation = resolveDockerInvocation();
|
|
1229
|
+
// Always pull. When the image is already local and up-to-date with
|
|
1230
|
+
// upstream, docker pull returns in a few seconds after a registry
|
|
1231
|
+
// digest check — the trade is worthwhile because it keeps "重新安装"
|
|
1232
|
+
// from turning into a three-second no-op. Failures are clearly
|
|
1233
|
+
// reported instead of silently retrying with a local fallback: Hermes
|
|
1234
|
+
// cannot be built locally (Python toolchain + wheels OOMs on Pi).
|
|
1235
|
+
emitTask(task, {
|
|
1236
|
+
type: "progress",
|
|
1237
|
+
message: `拉取 Hermes 镜像: ${HERMES_DEFAULT_IMAGE} (已缓存时约 10s)...`,
|
|
1238
|
+
progress: 10,
|
|
1239
|
+
});
|
|
1240
|
+
const pull = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", HERMES_DEFAULT_IMAGE], { timeout: 1800000 });
|
|
1241
|
+
if (!pull.ok) {
|
|
1242
|
+
console.error(`[hermes] docker pull failed for ${HERMES_DEFAULT_IMAGE} — output follows:\n${pull.output}`);
|
|
1243
|
+
emitTask(task, {
|
|
1244
|
+
type: "error",
|
|
1245
|
+
message: `Hermes 镜像拉取失败。请检查网络/registry 认证,或手动执行: docker pull ${HERMES_DEFAULT_IMAGE}`,
|
|
1246
|
+
});
|
|
1247
|
+
task.status = "error";
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
// 3. capture digest (best-effort)
|
|
1251
|
+
emitTask(task, { type: "progress", message: "捕获镜像 digest...", progress: 75 });
|
|
1252
|
+
const digest = captureImageDigest(HERMES_DEFAULT_IMAGE);
|
|
1253
|
+
if (digest) {
|
|
1254
|
+
emitTask(task, { type: "log", message: `Digest: ${digest}` });
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
emitTask(task, {
|
|
1258
|
+
type: "log",
|
|
1259
|
+
message: "No RepoDigest available (local build); will use :latest tag",
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
// 4. pin the mutable tag to the image's LABEL version (e.g.
|
|
1263
|
+
// ghcr.io/…:latest → ghcr.io/…:0.10.0). Parity with OpenClaw's
|
|
1264
|
+
// migrateOpenclawImageTagIfNeeded: keeps panel.json referencing an
|
|
1265
|
+
// immutable tag so nothing — Nomad image_pull, doctor, UI — ever
|
|
1266
|
+
// sees a mutable alias after install. No-op when the image was
|
|
1267
|
+
// locally built or lacks the LABEL.
|
|
1268
|
+
emitTask(task, { type: "progress", message: "锁定镜像版本标签...", progress: 85 });
|
|
1269
|
+
const pinnedTag = pinHermesImageToLabeledVersion(HERMES_DEFAULT_IMAGE);
|
|
1270
|
+
const storedImage = pinnedTag ?? HERMES_DEFAULT_IMAGE;
|
|
1271
|
+
if (pinnedTag) {
|
|
1272
|
+
emitTask(task, { type: "log", message: `Pinned tag: ${pinnedTag}` });
|
|
1273
|
+
}
|
|
1274
|
+
// 5. persist runtime catalog
|
|
1275
|
+
// The shim is baked into the image (see Dockerfile.hermes-slim's
|
|
1276
|
+
// COPY / LABEL). No host-side shim install needed anymore — we
|
|
1277
|
+
// assert the image carries runtime.protocol.version=1 at
|
|
1278
|
+
// buildRuntime() time instead.
|
|
1279
|
+
emitTask(task, { type: "progress", message: "写入 runtime catalog...", progress: 95 });
|
|
1280
|
+
setRuntimeCatalogEntry("hermes", {
|
|
1281
|
+
defaultImage: storedImage,
|
|
1282
|
+
defaultImageDigest: digest,
|
|
1283
|
+
configFormat: "yaml+env",
|
|
1284
|
+
homeDirName: "agent-home",
|
|
1285
|
+
resources: { CPU: 1000, MemoryMB: 1024 },
|
|
1286
|
+
allowDockerSock: false,
|
|
1287
|
+
});
|
|
1288
|
+
emitTask(task, {
|
|
1289
|
+
type: "done",
|
|
1290
|
+
message: digest
|
|
1291
|
+
? `Hermes 安装完成, 镜像已 pin 到 digest (tag=${storedImage})`
|
|
1292
|
+
: `Hermes 安装完成 (tag=${storedImage})`,
|
|
1293
|
+
progress: 100,
|
|
1294
|
+
});
|
|
1295
|
+
task.status = "done";
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Hermes readiness: the runtime_catalog entry is written on successful
|
|
1299
|
+
* install (image pull + shim), so its presence + defaultImage is the
|
|
1300
|
+
* canonical gate. `imageReady` is true once a digest has been captured.
|
|
1301
|
+
*/
|
|
1302
|
+
getInstallStatus() {
|
|
1303
|
+
const entry = getRuntimeCatalogEntry("hermes");
|
|
1304
|
+
if (!entry || !entry.defaultImage) {
|
|
1305
|
+
return { installed: false, imageReady: false };
|
|
1306
|
+
}
|
|
1307
|
+
return {
|
|
1308
|
+
installed: true,
|
|
1309
|
+
imageReady: !!entry.defaultImageDigest,
|
|
1310
|
+
digest: entry.defaultImageDigest,
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
export const hermesAdapter = new HermesAdapter();
|
|
1315
|
+
registerAdapter(hermesAdapter);
|
|
1316
|
+
//# sourceMappingURL=hermes.js.map
|