jishushell 0.4.10 → 0.4.17
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 +10 -12
- package/dist/cli/app.d.ts +3 -0
- package/dist/cli/app.js +156 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/{doctor.d.ts → cli/doctor.d.ts} +6 -1
- package/dist/{doctor.js → cli/doctor.js} +343 -14
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/helpers.d.ts +4 -0
- package/dist/cli/helpers.js +32 -0
- package/dist/cli/helpers.js.map +1 -0
- package/dist/cli/job.d.ts +3 -0
- package/dist/cli/job.js +260 -0
- package/dist/cli/job.js.map +1 -0
- package/dist/cli/llm.d.ts +24 -0
- package/dist/cli/llm.js +593 -0
- package/dist/cli/llm.js.map +1 -0
- package/dist/cli/openclaw.d.ts +12 -0
- package/dist/cli/openclaw.js +156 -0
- package/dist/cli/openclaw.js.map +1 -0
- package/dist/cli/panel.d.ts +25 -0
- package/dist/cli/panel.js +734 -0
- package/dist/cli/panel.js.map +1 -0
- package/dist/cli.js +67 -326
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -4
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +13 -41
- package/dist/control.js +12 -1355
- package/dist/control.js.map +1 -1
- package/dist/routes/apps.d.ts +3 -0
- package/dist/routes/apps.js +99 -0
- package/dist/routes/apps.js.map +1 -0
- package/dist/routes/instances.js +12 -6
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.d.ts +15 -0
- package/dist/routes/llm.js +246 -0
- package/dist/routes/llm.js.map +1 -0
- package/dist/routes/setup.js +29 -2
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +31 -6
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +40 -4
- package/dist/server.js.map +1 -1
- package/dist/services/app-compiler.d.ts +15 -0
- package/dist/services/app-compiler.js +169 -0
- package/dist/services/app-compiler.js.map +1 -0
- package/dist/services/app-manager.d.ts +17 -0
- package/dist/services/app-manager.js +168 -0
- package/dist/services/app-manager.js.map +1 -0
- package/dist/services/instance-manager.d.ts +51 -3
- package/dist/services/instance-manager.js +233 -30
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/job-manager.d.ts +22 -0
- package/dist/services/job-manager.js +102 -0
- package/dist/services/job-manager.js.map +1 -0
- package/dist/services/llm-proxy/adapters.js +5 -1
- package/dist/services/llm-proxy/adapters.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +30 -0
- package/dist/services/llm-proxy/index.js +71 -1
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/llm-proxy/ssrf.js +1 -1
- package/dist/services/llm-proxy/ssrf.js.map +1 -1
- package/dist/services/nomad-manager.js +192 -29
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +40 -0
- package/dist/services/panel-manager.js +346 -0
- package/dist/services/panel-manager.js.map +1 -0
- package/dist/services/process-manager.js +20 -7
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/setup-manager.js +316 -31
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/update-manager.d.ts +47 -0
- package/dist/services/update-manager.js +305 -0
- package/dist/services/update-manager.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/install/jishu-install.sh +279 -37
- package/install/post-install.sh +64 -5
- package/package.json +6 -2
- package/public/assets/Dashboard-CQsp1Mr9.js +1 -0
- package/public/assets/InitPassword-BEC8SE4A.js +1 -0
- package/public/assets/InstanceDetail-B5wTgNEg.js +17 -0
- package/public/assets/{Login-CUoEZOWR.js → Login-D1Bt-Lyk.js} +1 -1
- package/public/assets/NewInstance-GQzm3K9D.js +1 -0
- package/public/assets/Settings-ByjGlqhP.js +1 -0
- package/public/assets/Setup-cMF21Y-8.js +1 -0
- package/public/assets/index-B6qQP4mH.css +1 -0
- package/public/assets/index-BuTQtuNy.js +16 -0
- package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
- package/public/index.html +2 -2
- package/dist/doctor.js.map +0 -1
- package/install/jishu-install-china.sh +0 -3092
- package/public/assets/Dashboard-DhsrzJ4F.js +0 -1
- package/public/assets/InitPassword-BjubiVdd.js +0 -1
- package/public/assets/InstanceDetail-DMcywsof.js +0 -17
- package/public/assets/NewInstance-Bk0G4EiJ.js +0 -1
- package/public/assets/Settings-D5tHL_h5.js +0 -1
- package/public/assets/Setup-4t6E3Rut.js +0 -1
- package/public/assets/index-BJ47MWpF.css +0 -1
- package/public/assets/index-DbX85irc.js +0 -16
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile, execFileSync } from "child_process";
|
|
2
2
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } from "fs";
|
|
3
|
+
import { chmodSync, chownSync, closeSync, copyFileSync, cpSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } from "fs";
|
|
4
|
+
import { rm as rmAsync } from "fs/promises";
|
|
4
5
|
import { createServer as netCreateServer } from "net";
|
|
5
6
|
import { userInfo } from "os";
|
|
6
7
|
import { dirname, join, resolve } from "path";
|
|
@@ -8,6 +9,7 @@ import { BACKUPS_DIR, INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../
|
|
|
8
9
|
import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
|
|
9
10
|
import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
|
|
10
11
|
import { ensureDirContainer, writeConfigFile, writeSecretFile } from "../utils/fs.js";
|
|
12
|
+
import { compileTaskRuntime } from "./app-compiler.js";
|
|
11
13
|
const _configChangeListeners = [];
|
|
12
14
|
export function onConfigChange(listener) {
|
|
13
15
|
_configChangeListeners.push(listener);
|
|
@@ -119,7 +121,16 @@ function safePort(port) {
|
|
|
119
121
|
throw new Error(`Invalid port: ${port}`);
|
|
120
122
|
return String(port);
|
|
121
123
|
}
|
|
122
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Probes whether a port is currently held by any process on the host.
|
|
126
|
+
*
|
|
127
|
+
* Binds `0.0.0.0:port` — under the Linux default socket semantics, this fails
|
|
128
|
+
* with EADDRINUSE whenever another listener holds the port on `0.0.0.0`, on
|
|
129
|
+
* any specific interface (e.g. `127.0.0.1`), or via Docker's published port
|
|
130
|
+
* map. An IPv6-only listener on `::1` is not detected, but that edge case
|
|
131
|
+
* never comes up in the Nomad+Docker path this project uses.
|
|
132
|
+
*/
|
|
133
|
+
export function isPortInUse(port) {
|
|
123
134
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
124
135
|
return Promise.resolve(false);
|
|
125
136
|
return new Promise((resolve) => {
|
|
@@ -131,13 +142,25 @@ function isPortInUse(port) {
|
|
|
131
142
|
server.listen(port, "0.0.0.0");
|
|
132
143
|
});
|
|
133
144
|
}
|
|
134
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Picks a gateway port for an instance, starting at {@link DEFAULT_GATEWAY_PORT}
|
|
147
|
+
* and walking upward until a port is both unknown to jishushell's instance
|
|
148
|
+
* metadata and reports as free on the host. Concurrent callers coordinate
|
|
149
|
+
* through `_pendingPorts` so they never hand out the same port.
|
|
150
|
+
*
|
|
151
|
+
* Intentionally does not hold a reservation indefinitely — the caller is
|
|
152
|
+
* expected to either persist the port into instance metadata (which then
|
|
153
|
+
* shows up in `usedGatewayPorts`) or clear `_pendingPorts` on failure.
|
|
154
|
+
*/
|
|
155
|
+
async function allocateGatewayPort(instanceId) {
|
|
135
156
|
const used = usedGatewayPorts(instanceId);
|
|
157
|
+
const skipped = [];
|
|
136
158
|
let port = DEFAULT_GATEWAY_PORT;
|
|
137
159
|
while (true) {
|
|
138
160
|
if (port > 65535)
|
|
139
161
|
throw new Error("No available gateway port found (all ports 18789-65535 in use)");
|
|
140
162
|
if (used.has(port) || _pendingPorts.has(port)) {
|
|
163
|
+
skipped.push(port);
|
|
141
164
|
port++;
|
|
142
165
|
continue;
|
|
143
166
|
}
|
|
@@ -147,16 +170,18 @@ async function defaultGatewayPort(instanceId) {
|
|
|
147
170
|
try {
|
|
148
171
|
if (await isPortInUse(port)) {
|
|
149
172
|
_pendingPorts.delete(port);
|
|
173
|
+
skipped.push(port);
|
|
150
174
|
port++;
|
|
151
175
|
continue;
|
|
152
176
|
}
|
|
153
|
-
return port;
|
|
177
|
+
return { port, skipped };
|
|
154
178
|
}
|
|
155
179
|
catch {
|
|
156
180
|
_pendingPorts.delete(port);
|
|
157
181
|
// Skip this port on a transient OS error rather than failing the entire
|
|
158
182
|
// allocation — a single bad port check should not prevent instance creation.
|
|
159
183
|
console.warn(`[instance] Port ${port} availability check failed, trying next port`);
|
|
184
|
+
skipped.push(port);
|
|
160
185
|
port++;
|
|
161
186
|
continue;
|
|
162
187
|
}
|
|
@@ -243,8 +268,7 @@ function chownToServiceUser(...paths) {
|
|
|
243
268
|
}
|
|
244
269
|
}
|
|
245
270
|
}
|
|
246
|
-
|
|
247
|
-
const port = await defaultGatewayPort(instanceId);
|
|
271
|
+
function buildDefaultRuntime(instanceId, port, openclawHome) {
|
|
248
272
|
const home = openclawHome || defaultOpenclawHome(instanceId);
|
|
249
273
|
return {
|
|
250
274
|
command: resolveOpenclawBin(),
|
|
@@ -755,6 +779,26 @@ function getStockExtensionsDir() {
|
|
|
755
779
|
return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
|
|
756
780
|
}
|
|
757
781
|
// ── Public API ──
|
|
782
|
+
/**
|
|
783
|
+
* Probe whether a file is readable by the current process. Used to
|
|
784
|
+
* distinguish "primary missing / corrupted" (recoverable via safeReadJson's
|
|
785
|
+
* .bak chain) from "primary exists but permission denied" (EACCES — the
|
|
786
|
+
* common sudo-script footgun that leaves root-owned files). safeReadJson
|
|
787
|
+
* swallows every read error internally and returns null, so without this
|
|
788
|
+
* probe an unreadable primary looks identical to a truly-gone instance.
|
|
789
|
+
*/
|
|
790
|
+
function probeReadable(path) {
|
|
791
|
+
if (!existsSync(path))
|
|
792
|
+
return null;
|
|
793
|
+
try {
|
|
794
|
+
const fd = openSync(path, "r");
|
|
795
|
+
closeSync(fd);
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
catch (e) {
|
|
799
|
+
return e;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
758
802
|
export function listInstances() {
|
|
759
803
|
if (!existsSync(INSTANCES_DIR))
|
|
760
804
|
return [];
|
|
@@ -764,14 +808,33 @@ export function listInstances() {
|
|
|
764
808
|
const metaPath = join(INSTANCES_DIR, name, "instance.json");
|
|
765
809
|
const dirPath = join(INSTANCES_DIR, name);
|
|
766
810
|
try {
|
|
767
|
-
if (statSync(dirPath).isDirectory()
|
|
768
|
-
|
|
811
|
+
if (!statSync(dirPath).isDirectory())
|
|
812
|
+
continue;
|
|
813
|
+
// Use safeReadJson so primary instance.json that was corrupted or
|
|
814
|
+
// deleted mid-rename falls back to the .bak chain maintained by
|
|
815
|
+
// safeWriteJson. Without this, an interrupted safeWriteJson call
|
|
816
|
+
// would silently drop the instance from every list/get hot path
|
|
817
|
+
// even though the backup chain still holds valid content on disk.
|
|
818
|
+
const meta = safeReadJson(metaPath, `instance:${name}`);
|
|
819
|
+
if (meta) {
|
|
820
|
+
instances.push(meta);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
// safeReadJson → null can mean any of (a) primary missing + no
|
|
824
|
+
// backups, (b) all candidates unparseable, (c) permission denied.
|
|
825
|
+
// (a) and (b) are legitimate "drop from list" cases; (c) is the
|
|
826
|
+
// sudo-script footgun and must be logged loudly so the operator
|
|
827
|
+
// doesn't just see an empty instance list with no hint why.
|
|
828
|
+
const readErr = probeReadable(metaPath);
|
|
829
|
+
if (readErr && readErr.code === "EACCES") {
|
|
830
|
+
console.error(`[instance-manager] cannot read instance '${name}': ${readErr.message}. ` +
|
|
831
|
+
`Check file ownership with: ls -la ${metaPath}`);
|
|
769
832
|
}
|
|
770
833
|
}
|
|
771
834
|
catch (e) {
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
835
|
+
// Fallback for failures before the safeReadJson call (e.g. statSync
|
|
836
|
+
// on a directory we can't enter). Still log instead of silently
|
|
837
|
+
// dropping the entry.
|
|
775
838
|
console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
|
|
776
839
|
}
|
|
777
840
|
}
|
|
@@ -779,17 +842,26 @@ export function listInstances() {
|
|
|
779
842
|
}
|
|
780
843
|
export function getInstance(instanceId) {
|
|
781
844
|
const metaPath = instanceMetaPath(instanceId);
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
845
|
+
// Go through safeReadJson so primary missing/corrupted instance.json
|
|
846
|
+
// is still served from the .bak chain. Returning null on "truly gone"
|
|
847
|
+
// (no primary, no backups) keeps the existing 404 behavior intact.
|
|
848
|
+
const meta = safeReadJson(metaPath, `instance:${instanceId}`);
|
|
849
|
+
if (meta)
|
|
850
|
+
return meta;
|
|
851
|
+
// safeReadJson swallows every read error internally, which is exactly
|
|
852
|
+
// wrong for the EACCES case — a root-owned primary would silently
|
|
853
|
+
// return null and callers would report "Instance not found" instead
|
|
854
|
+
// of the actionable "check file ownership" message. Re-probe to
|
|
855
|
+
// distinguish and throw on permission denial. Missing/corrupted with
|
|
856
|
+
// no backup still returns null (→ 404 upstream).
|
|
857
|
+
const readErr = probeReadable(metaPath);
|
|
858
|
+
if (readErr && readErr.code === "EACCES") {
|
|
859
|
+
throw new Error(`Cannot read instance '${instanceId}' metadata: ${readErr.message}. ` +
|
|
860
|
+
`Check file ownership with: ls -la ${metaPath}`);
|
|
790
861
|
}
|
|
862
|
+
return null;
|
|
791
863
|
}
|
|
792
|
-
export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, cloneOptions) {
|
|
864
|
+
export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, appSpec, cloneOptions) {
|
|
793
865
|
const d = instanceDir(instanceId);
|
|
794
866
|
if (existsSync(d))
|
|
795
867
|
throw new Error(`Instance '${instanceId}' already exists`);
|
|
@@ -837,9 +909,18 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
837
909
|
catch { /* non-root without CAP_CHOWN — already correct owner */ }
|
|
838
910
|
ensureDirContainer(home);
|
|
839
911
|
ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
|
|
840
|
-
const
|
|
912
|
+
const portAlloc = await allocateGatewayPort(instanceId);
|
|
913
|
+
const baseRuntime = buildDefaultRuntime(instanceId, portAlloc.port, home);
|
|
914
|
+
let runtime = baseRuntime;
|
|
915
|
+
if (appSpec) {
|
|
916
|
+
const serviceTask = appSpec.tasks.find((t) => t.role === "service");
|
|
917
|
+
if (serviceTask) {
|
|
918
|
+
const compiled = compileTaskRuntime(serviceTask, instanceId);
|
|
919
|
+
runtime = { ...baseRuntime, ...compiled };
|
|
920
|
+
}
|
|
921
|
+
}
|
|
841
922
|
const allocatedPort = extractGatewayPort(runtime);
|
|
842
|
-
// Port already reserved inside
|
|
923
|
+
// Port already reserved inside allocateGatewayPort; just track for cleanup
|
|
843
924
|
try {
|
|
844
925
|
const meta = {
|
|
845
926
|
id: instanceId,
|
|
@@ -848,6 +929,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
848
929
|
openclaw_home: home,
|
|
849
930
|
runtime,
|
|
850
931
|
created_at: new Date().toISOString(),
|
|
932
|
+
...(appSpec ? { app_id: appSpec.id } : {}),
|
|
851
933
|
};
|
|
852
934
|
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
853
935
|
const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
|
|
@@ -959,6 +1041,34 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
959
1041
|
updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
|
|
960
1042
|
}
|
|
961
1043
|
}
|
|
1044
|
+
// Merge App-level config_defaults into openclaw.json (shallow merge, app values win)
|
|
1045
|
+
if (appSpec?.openclaw?.config_defaults && existsSync(configPath)) {
|
|
1046
|
+
try {
|
|
1047
|
+
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1048
|
+
const defaults = appSpec.openclaw.config_defaults;
|
|
1049
|
+
// Deep merge top-level keys
|
|
1050
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
1051
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && typeof existing[key] === "object" && existing[key] !== null) {
|
|
1052
|
+
existing[key] = { ...existing[key], ...value };
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
existing[key] = value;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
writeConfigFile(configPath, JSON.stringify(existing, null, 2));
|
|
1059
|
+
}
|
|
1060
|
+
catch { /* ignore merge errors, keep existing config */ }
|
|
1061
|
+
}
|
|
1062
|
+
// Record App-level skills for later installation into the instance
|
|
1063
|
+
if (appSpec?.openclaw?.skills && Array.isArray(appSpec.openclaw.skills)) {
|
|
1064
|
+
try {
|
|
1065
|
+
const skillsDir = join(dirname(configPath), "skills");
|
|
1066
|
+
ensureDirContainer(skillsDir);
|
|
1067
|
+
const skillMeta = join(skillsDir, ".app-skills.json");
|
|
1068
|
+
safeWriteJson(skillMeta, { app_id: appSpec.id, skills: appSpec.openclaw.skills });
|
|
1069
|
+
}
|
|
1070
|
+
catch { /* ignore */ }
|
|
1071
|
+
}
|
|
962
1072
|
// Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
|
|
963
1073
|
if (cloneFrom && envFiles.length) {
|
|
964
1074
|
const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
|
|
@@ -997,6 +1107,17 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
997
1107
|
console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
|
|
998
1108
|
}
|
|
999
1109
|
}
|
|
1110
|
+
// Attach transient port allocation info for the API response only — never
|
|
1111
|
+
// persisted in instance.json. If the caller (e.g. the create route) sees
|
|
1112
|
+
// skipped ports it can tell the user the default was busy.
|
|
1113
|
+
if (portAlloc.skipped.length > 0) {
|
|
1114
|
+
meta.port_allocation = {
|
|
1115
|
+
assigned: portAlloc.port,
|
|
1116
|
+
requested: DEFAULT_GATEWAY_PORT,
|
|
1117
|
+
reason: "default_busy",
|
|
1118
|
+
skipped: portAlloc.skipped,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1000
1121
|
return meta;
|
|
1001
1122
|
}
|
|
1002
1123
|
finally {
|
|
@@ -1023,7 +1144,7 @@ export function updateInstanceMeta(instanceId, patch) {
|
|
|
1023
1144
|
Object.assign(meta, patch);
|
|
1024
1145
|
safeWriteJson(metaPath, meta);
|
|
1025
1146
|
}
|
|
1026
|
-
export function deleteInstance(instanceId, purgeBackups = false) {
|
|
1147
|
+
export async function deleteInstance(instanceId, purgeBackups = false) {
|
|
1027
1148
|
const d = instanceDir(instanceId);
|
|
1028
1149
|
if (!existsSync(d))
|
|
1029
1150
|
return { ok: false, warnings: ["Instance directory not found"] };
|
|
@@ -1049,14 +1170,18 @@ export function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1049
1170
|
}).catch((e) => {
|
|
1050
1171
|
console.warn(`[instance] Could not load nomad-manager for cleanup:`, e.message);
|
|
1051
1172
|
});
|
|
1173
|
+
// Async rm so the Node event loop stays responsive during large deletes:
|
|
1174
|
+
// a fresh instance with a just-installed openclaw package can be 1+ GB
|
|
1175
|
+
// with hundreds of nested dirs, which takes 30-60s to unlink on SD storage.
|
|
1176
|
+
// rmSync would block every other HTTP request for that whole window.
|
|
1052
1177
|
let dirDeleted = false;
|
|
1053
1178
|
try {
|
|
1054
|
-
|
|
1179
|
+
await rmAsync(d, { recursive: true, force: true });
|
|
1055
1180
|
dirDeleted = true;
|
|
1056
1181
|
}
|
|
1057
1182
|
catch {
|
|
1058
1183
|
try {
|
|
1059
|
-
execFileSync("sudo", ["rm", "-rf", d], { timeout:
|
|
1184
|
+
execFileSync("sudo", ["rm", "-rf", d], { timeout: 300000 });
|
|
1060
1185
|
dirDeleted = true;
|
|
1061
1186
|
}
|
|
1062
1187
|
catch (e) {
|
|
@@ -1067,11 +1192,14 @@ export function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1067
1192
|
if (home && !home.startsWith(d) && existsSync(home)) {
|
|
1068
1193
|
warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
|
|
1069
1194
|
}
|
|
1070
|
-
// Handle backups (stored in separate directory, not affected by
|
|
1195
|
+
// Handle backups (stored in separate directory, not affected by the
|
|
1196
|
+
// instance rm above). Backups can be hundreds of MB each and accumulate
|
|
1197
|
+
// across retention windows, so use the same async rm path to keep the
|
|
1198
|
+
// event loop responsive.
|
|
1071
1199
|
const backupDir = join(BACKUPS_DIR, instanceId);
|
|
1072
1200
|
if (purgeBackups && existsSync(backupDir)) {
|
|
1073
1201
|
try {
|
|
1074
|
-
|
|
1202
|
+
await rmAsync(backupDir, { recursive: true, force: true });
|
|
1075
1203
|
}
|
|
1076
1204
|
catch (e) {
|
|
1077
1205
|
warnings.push(`Failed to delete backups: ${e.message}`);
|
|
@@ -1416,11 +1544,30 @@ export async function getGatewayHost(instanceId) {
|
|
|
1416
1544
|
const detail = await fetch(`${getNomadAddr()}/v1/allocation/${encodeURIComponent(alloc.ID)}`, { headers, signal: AbortSignal.timeout(5000) });
|
|
1417
1545
|
if (detail.ok) {
|
|
1418
1546
|
const d = await detail.json();
|
|
1419
|
-
|
|
1420
|
-
const
|
|
1547
|
+
// Preferred source: AllocatedResources.Shared.Ports (bridge mode).
|
|
1548
|
+
const sharedPorts = d?.AllocatedResources?.Shared?.Ports ?? [];
|
|
1549
|
+
const gwPort = sharedPorts.find((p) => p.Label === "gateway");
|
|
1421
1550
|
if (gwPort?.HostIP && gwPort.HostIP !== "0.0.0.0") {
|
|
1422
1551
|
result = gwPort.HostIP;
|
|
1423
1552
|
}
|
|
1553
|
+
else {
|
|
1554
|
+
// Host mode / task-level reservation: address lives under
|
|
1555
|
+
// AllocatedResources.Tasks.<task>.Networks[*].IP. On Nomad
|
|
1556
|
+
// 1.6.5 with `network_interface = "lo"`, the IP is whichever
|
|
1557
|
+
// address the OS enumerates first — which can be IPv6 `::1`
|
|
1558
|
+
// on systems where lo has both `127.0.0.1/8` and `::1/128`
|
|
1559
|
+
// (the default on most modern Linux distros). Reading it from
|
|
1560
|
+
// here is the authoritative source and matches what nomad
|
|
1561
|
+
// configures the docker-proxy bind to use.
|
|
1562
|
+
const taskNets = d?.AllocatedResources?.Tasks?.gateway?.Networks ?? [];
|
|
1563
|
+
const net = taskNets.find((n) => {
|
|
1564
|
+
const rps = n?.ReservedPorts ?? [];
|
|
1565
|
+
return rps.some((p) => p.Label === "gateway");
|
|
1566
|
+
});
|
|
1567
|
+
if (net?.IP && net.IP !== "0.0.0.0") {
|
|
1568
|
+
result = net.IP;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1424
1571
|
}
|
|
1425
1572
|
}
|
|
1426
1573
|
}
|
|
@@ -1436,7 +1583,11 @@ export async function getGatewayHost(instanceId) {
|
|
|
1436
1583
|
try {
|
|
1437
1584
|
const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
|
|
1438
1585
|
for (const line of out.split("\n")) {
|
|
1439
|
-
|
|
1586
|
+
// IPv4 dotted-quad: "... 127.0.0.1:18789 ..."
|
|
1587
|
+
// IPv6 bracketed: "... [::1]:18789 ..."
|
|
1588
|
+
let match = line.match(/\s([\d.]+):(\d+)\s/);
|
|
1589
|
+
if (!match)
|
|
1590
|
+
match = line.match(/\s\[([0-9a-fA-F:]+)\]:(\d+)\s/);
|
|
1440
1591
|
if (match && match[2] === String(port)) {
|
|
1441
1592
|
const addr = match[1];
|
|
1442
1593
|
result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
|
|
@@ -1448,6 +1599,16 @@ export async function getGatewayHost(instanceId) {
|
|
|
1448
1599
|
_gwHostCache.set(instanceId, { host: result, ts: Date.now() });
|
|
1449
1600
|
return result;
|
|
1450
1601
|
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Wrap an IPv6 literal in brackets for safe URL host-component / Host-header
|
|
1604
|
+
* use. Bare names ("gateway.local") and IPv4 ("127.0.0.1") contain no colon
|
|
1605
|
+
* and pass through unchanged; anything with a colon is an IPv6 literal and
|
|
1606
|
+
* MUST be bracketed before being concatenated with a port, otherwise
|
|
1607
|
+
* `http://::1:18789/` is unparseable.
|
|
1608
|
+
*/
|
|
1609
|
+
export function urlHost(host) {
|
|
1610
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
1611
|
+
}
|
|
1451
1612
|
export function findInstancesSharingOpenclawHome(instanceId) {
|
|
1452
1613
|
const targetHome = normalizePath(getOpenclawHome(instanceId));
|
|
1453
1614
|
return listInstances()
|
|
@@ -1455,6 +1616,48 @@ export function findInstancesSharingOpenclawHome(instanceId) {
|
|
|
1455
1616
|
.filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === targetHome)
|
|
1456
1617
|
.map((inst) => inst.id);
|
|
1457
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Re-pick a gateway port for an existing instance and rewrite its persisted
|
|
1621
|
+
* runtime metadata (`runtime.args` and `runtime.env.OPENCLAW_GATEWAY_PORT`).
|
|
1622
|
+
*
|
|
1623
|
+
* Used when {@link isPortInUse} reports that the previously-assigned port has
|
|
1624
|
+
* been taken by something else between create-time and start-time (e.g. a
|
|
1625
|
+
* host-side openclaw started by the user, an unrelated service that grabbed
|
|
1626
|
+
* the port at boot, or a Docker race on the next allocation). The Nomad job
|
|
1627
|
+
* spec is rebuilt from instance metadata on every submit, so updating
|
|
1628
|
+
* `instance.json` here is sufficient — no other files need patching.
|
|
1629
|
+
*/
|
|
1630
|
+
export async function reallocateGatewayPort(instanceId) {
|
|
1631
|
+
const meta = safeReadJson(instanceMetaPath(instanceId), "instance-meta");
|
|
1632
|
+
if (!meta)
|
|
1633
|
+
throw new Error(`Cannot reallocate port for unknown instance '${instanceId}'`);
|
|
1634
|
+
const fromPort = extractGatewayPort(meta.runtime) ?? DEFAULT_GATEWAY_PORT;
|
|
1635
|
+
const alloc = await allocateGatewayPort(instanceId);
|
|
1636
|
+
try {
|
|
1637
|
+
const runtime = (meta.runtime ?? {});
|
|
1638
|
+
const args = Array.isArray(runtime.args) ? [...runtime.args] : [];
|
|
1639
|
+
for (let i = 0; i < args.length; i++) {
|
|
1640
|
+
if (args[i] === "--port" && i + 1 < args.length) {
|
|
1641
|
+
args[i + 1] = String(alloc.port);
|
|
1642
|
+
}
|
|
1643
|
+
else if (typeof args[i] === "string" && args[i].startsWith("--port=")) {
|
|
1644
|
+
args[i] = `--port=${alloc.port}`;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
runtime.args = args;
|
|
1648
|
+
const env = (runtime.env ?? {});
|
|
1649
|
+
env.OPENCLAW_GATEWAY_PORT = String(alloc.port);
|
|
1650
|
+
runtime.env = env;
|
|
1651
|
+
meta.runtime = runtime;
|
|
1652
|
+
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
1653
|
+
chownToServiceUser(instanceMetaPath(instanceId));
|
|
1654
|
+
console.log(`[instance] ${instanceId}: gateway port reallocated ${fromPort} -> ${alloc.port}`);
|
|
1655
|
+
return { from: fromPort, to: alloc.port, skipped: alloc.skipped };
|
|
1656
|
+
}
|
|
1657
|
+
finally {
|
|
1658
|
+
_pendingPorts.delete(alloc.port);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1458
1661
|
export function findInstancesSharingGatewayPort(instanceId) {
|
|
1459
1662
|
const targetPort = getGatewayPort(instanceId);
|
|
1460
1663
|
return listInstances()
|