forge-openclaw-plugin 0.2.12 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/openclaw/api-client.d.ts +1 -0
- package/dist/openclaw/local-runtime.js +244 -16
- package/dist/openclaw/plugin-entry-shared.js +38 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -201,7 +201,7 @@ The skill is entity-format-driven. It teaches the agent how to:
|
|
|
201
201
|
- ask only for missing fields
|
|
202
202
|
- capture goals, projects, tasks, values, patterns, behaviors, beliefs, and trigger reports
|
|
203
203
|
|
|
204
|
-
For local use, set the plugin origin to `http://127.0.0.1` or `http://localhost` and the plugin will bring Forge up
|
|
204
|
+
For local use, set the plugin origin to `http://127.0.0.1` or `http://localhost` and the plugin will bring Forge up automatically. If you leave the default localhost setup alone and `4317` is already taken, Forge now moves to the next free local port and remembers that choice for future runs.
|
|
205
205
|
|
|
206
206
|
If you want to manage that plugin-managed local runtime cleanly, use:
|
|
207
207
|
|
|
@@ -214,6 +214,16 @@ openclaw forge status
|
|
|
214
214
|
|
|
215
215
|
These commands only manage the runtime when it was auto-started by the OpenClaw plugin. If Forge was started manually some other way, they tell you that instead of killing unrelated processes.
|
|
216
216
|
|
|
217
|
+
If the local runtime fails to come up, check the plugin-managed runtime log at:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
~/.openclaw/logs/forge-openclaw-plugin/<host>-<port>.log
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
On clean installs, the plugin now also repairs missing bundled runtime dependencies on first local start before it launches Forge.
|
|
224
|
+
|
|
225
|
+
The startup error now points at that log file when the child process exits before Forge becomes healthy.
|
|
226
|
+
|
|
217
227
|
## Publishing and listing
|
|
218
228
|
|
|
219
229
|
The reliable publication path for the Forge plugin is:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -8,15 +9,79 @@ const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
|
8
9
|
const STARTUP_TIMEOUT_MS = 15_000;
|
|
9
10
|
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
|
10
11
|
const HEALTHCHECK_INTERVAL_MS = 250;
|
|
12
|
+
const EXISTING_RUNTIME_GRACE_MS = 3_000;
|
|
13
|
+
const MAX_PORT_SCAN_ATTEMPTS = 20;
|
|
14
|
+
const FORGE_PLUGIN_ID = "forge-openclaw-plugin";
|
|
11
15
|
let managedRuntimeChild = null;
|
|
12
16
|
let managedRuntimeKey = null;
|
|
17
|
+
let managedRuntimeLogPath = null;
|
|
18
|
+
let lastRuntimeExitDetails = null;
|
|
13
19
|
let startupPromise = null;
|
|
20
|
+
let startupRuntimeKey = null;
|
|
21
|
+
const dependencyInstallPromises = new Map();
|
|
14
22
|
function runtimeKey(config) {
|
|
15
23
|
return `${config.origin}:${config.port}`;
|
|
16
24
|
}
|
|
25
|
+
function buildForgeBaseUrl(origin, port) {
|
|
26
|
+
const url = new URL(origin.endsWith("/") ? origin : `${origin}/`);
|
|
27
|
+
url.port = String(port);
|
|
28
|
+
url.pathname = "/";
|
|
29
|
+
url.search = "";
|
|
30
|
+
url.hash = "";
|
|
31
|
+
return url.origin;
|
|
32
|
+
}
|
|
33
|
+
function buildForgeWebAppUrl(origin, port) {
|
|
34
|
+
return `${buildForgeBaseUrl(origin, port)}/forge/`;
|
|
35
|
+
}
|
|
17
36
|
function getRuntimeStatePath(config) {
|
|
18
37
|
const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
19
|
-
return path.join(homedir(), ".openclaw", "run",
|
|
38
|
+
return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${origin}-${config.port}.json`);
|
|
39
|
+
}
|
|
40
|
+
function getPreferredPortStatePath(origin) {
|
|
41
|
+
const hostname = new URL(origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
42
|
+
return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${hostname}-preferred-port.json`);
|
|
43
|
+
}
|
|
44
|
+
function applyPortToConfig(config, port, portSource) {
|
|
45
|
+
config.port = port;
|
|
46
|
+
config.baseUrl = buildForgeBaseUrl(config.origin, port);
|
|
47
|
+
config.webAppUrl = buildForgeWebAppUrl(config.origin, port);
|
|
48
|
+
config.portSource = portSource;
|
|
49
|
+
}
|
|
50
|
+
async function writePreferredPortState(config, port) {
|
|
51
|
+
const statePath = getPreferredPortStatePath(config.origin);
|
|
52
|
+
await mkdir(path.dirname(statePath), { recursive: true });
|
|
53
|
+
await writeFile(statePath, `${JSON.stringify({ origin: config.origin, port, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
54
|
+
}
|
|
55
|
+
async function isPortAvailable(host, port) {
|
|
56
|
+
return await new Promise((resolve) => {
|
|
57
|
+
const server = net.createServer();
|
|
58
|
+
server.unref();
|
|
59
|
+
server.once("error", (error) => {
|
|
60
|
+
resolve(error.code !== "EADDRINUSE");
|
|
61
|
+
});
|
|
62
|
+
server.listen({ host, port, exclusive: true }, () => {
|
|
63
|
+
server.close(() => resolve(true));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function findAvailableLocalPort(host, startPort) {
|
|
68
|
+
for (let candidate = Math.max(1, startPort), attempts = 0; candidate <= 65_535 && attempts < MAX_PORT_SCAN_ATTEMPTS; candidate += 1, attempts += 1) {
|
|
69
|
+
if (await isPortAvailable(host, candidate)) {
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
async function relocateLocalRuntimePort(config) {
|
|
76
|
+
if (config.portSource === "configured") {
|
|
77
|
+
throw new Error(`Configured Forge port ${config.port} is already in use on ${new URL(config.origin).hostname}. Set a different plugin port or stop the process using it.`);
|
|
78
|
+
}
|
|
79
|
+
const nextPort = await findAvailableLocalPort("127.0.0.1", config.port + 1);
|
|
80
|
+
if (nextPort === null) {
|
|
81
|
+
throw new Error(`Forge could not find a free localhost port after ${config.port}.`);
|
|
82
|
+
}
|
|
83
|
+
applyPortToConfig(config, nextPort, "preferred");
|
|
84
|
+
await writePreferredPortState(config, nextPort);
|
|
20
85
|
}
|
|
21
86
|
async function writeRuntimeState(config, pid) {
|
|
22
87
|
const statePath = getRuntimeStatePath(config);
|
|
@@ -26,7 +91,8 @@ async function writeRuntimeState(config, pid) {
|
|
|
26
91
|
origin: config.origin,
|
|
27
92
|
port: config.port,
|
|
28
93
|
baseUrl: config.baseUrl,
|
|
29
|
-
startedAt: new Date().toISOString()
|
|
94
|
+
startedAt: new Date().toISOString(),
|
|
95
|
+
logPath: managedRuntimeLogPath
|
|
30
96
|
};
|
|
31
97
|
await writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
32
98
|
}
|
|
@@ -45,7 +111,8 @@ async function readRuntimeState(config) {
|
|
|
45
111
|
origin: typeof parsed.origin === "string" ? parsed.origin : config.origin,
|
|
46
112
|
port: typeof parsed.port === "number" ? parsed.port : config.port,
|
|
47
113
|
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : config.baseUrl,
|
|
48
|
-
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : new Date(0).toISOString()
|
|
114
|
+
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : new Date(0).toISOString(),
|
|
115
|
+
logPath: typeof parsed.logPath === "string" ? parsed.logPath : null
|
|
49
116
|
};
|
|
50
117
|
}
|
|
51
118
|
catch {
|
|
@@ -82,6 +149,86 @@ function isLocalOrigin(origin) {
|
|
|
82
149
|
function getCurrentModuleRoot() {
|
|
83
150
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
84
151
|
}
|
|
152
|
+
function getRuntimeLogPath(config) {
|
|
153
|
+
const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
154
|
+
return path.join(homedir(), ".openclaw", "logs", FORGE_PLUGIN_ID, `${origin}-${config.port}.log`);
|
|
155
|
+
}
|
|
156
|
+
function openRuntimeLogFile(logPath) {
|
|
157
|
+
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
158
|
+
return openSync(logPath, "a");
|
|
159
|
+
}
|
|
160
|
+
function isPackagedServerPlan(plan) {
|
|
161
|
+
return plan.entryFile.endsWith(path.join("dist", "server", "index.js"));
|
|
162
|
+
}
|
|
163
|
+
function getNpmInvocation() {
|
|
164
|
+
const binDir = path.dirname(process.execPath);
|
|
165
|
+
const npmCli = process.platform === "win32" ? path.join(binDir, "npm.cmd") : path.join(binDir, "npm");
|
|
166
|
+
if (existsSync(npmCli)) {
|
|
167
|
+
return {
|
|
168
|
+
command: process.execPath,
|
|
169
|
+
args: [npmCli]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
command: "npm",
|
|
174
|
+
args: []
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
async function getMissingRuntimeDependencies(packageRoot) {
|
|
178
|
+
const packageJsonPath = path.join(packageRoot, "package.json");
|
|
179
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
180
|
+
const dependencyNames = Object.keys(packageJson.dependencies ?? {});
|
|
181
|
+
return dependencyNames.filter((dependencyName) => !existsSync(path.join(packageRoot, "node_modules", dependencyName, "package.json")));
|
|
182
|
+
}
|
|
183
|
+
async function installMissingRuntimeDependencies(packageRoot, logPath) {
|
|
184
|
+
const { command, args } = getNpmInvocation();
|
|
185
|
+
const logFd = openRuntimeLogFile(logPath);
|
|
186
|
+
try {
|
|
187
|
+
await new Promise((resolve, reject) => {
|
|
188
|
+
const child = spawn(command, [...args, "install", "--omit=dev", "--silent", "--ignore-scripts"], {
|
|
189
|
+
cwd: packageRoot,
|
|
190
|
+
env: process.env,
|
|
191
|
+
stdio: ["ignore", logFd, logFd]
|
|
192
|
+
});
|
|
193
|
+
child.once("error", reject);
|
|
194
|
+
child.once("exit", (code, signal) => {
|
|
195
|
+
if (code === 0) {
|
|
196
|
+
resolve();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
reject(new Error(`npm dependency install exited with ${signal ? `signal ${signal}` : `code ${code ?? "unknown"}`}`));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
closeSync(logFd);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function ensurePackagedRuntimeDependencies(plan, config) {
|
|
208
|
+
if (!isPackagedServerPlan(plan)) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const missingDependencies = await getMissingRuntimeDependencies(plan.packageRoot);
|
|
212
|
+
if (missingDependencies.length === 0) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const logPath = getRuntimeLogPath(config);
|
|
216
|
+
managedRuntimeLogPath = logPath;
|
|
217
|
+
const installKey = plan.packageRoot;
|
|
218
|
+
const existingInstall = dependencyInstallPromises.get(installKey);
|
|
219
|
+
if (existingInstall) {
|
|
220
|
+
return existingInstall;
|
|
221
|
+
}
|
|
222
|
+
const installPromise = installMissingRuntimeDependencies(plan.packageRoot, logPath)
|
|
223
|
+
.catch((error) => {
|
|
224
|
+
throw new Error(`Forge runtime dependencies are missing (${missingDependencies.join(", ")}) and automatic install failed. Check logs at ${logPath}. Cause: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
})
|
|
226
|
+
.finally(() => {
|
|
227
|
+
dependencyInstallPromises.delete(installKey);
|
|
228
|
+
});
|
|
229
|
+
dependencyInstallPromises.set(installKey, installPromise);
|
|
230
|
+
return installPromise;
|
|
231
|
+
}
|
|
85
232
|
function resolveLaunchPlan() {
|
|
86
233
|
const moduleRoot = getCurrentModuleRoot();
|
|
87
234
|
// Published or linked plugin package runtime.
|
|
@@ -125,9 +272,11 @@ async function isForgeHealthy(config, timeoutMs) {
|
|
|
125
272
|
clearTimeout(timeout);
|
|
126
273
|
}
|
|
127
274
|
}
|
|
128
|
-
function spawnManagedRuntime(config, plan) {
|
|
129
|
-
const isPackagedServer = plan
|
|
275
|
+
async function spawnManagedRuntime(config, plan) {
|
|
276
|
+
const isPackagedServer = isPackagedServerPlan(plan);
|
|
130
277
|
const args = isPackagedServer ? [plan.entryFile] : [plan.entryFile, path.join(plan.packageRoot, "server", "src", "index.ts")];
|
|
278
|
+
const logPath = getRuntimeLogPath(config);
|
|
279
|
+
const logFd = openRuntimeLogFile(logPath);
|
|
131
280
|
const child = spawn(process.execPath, args, {
|
|
132
281
|
cwd: plan.packageRoot,
|
|
133
282
|
env: {
|
|
@@ -137,11 +286,20 @@ function spawnManagedRuntime(config, plan) {
|
|
|
137
286
|
FORGE_BASE_PATH: "/forge/",
|
|
138
287
|
...(config.dataRoot ? { FORGE_DATA_ROOT: config.dataRoot } : {})
|
|
139
288
|
},
|
|
140
|
-
stdio: "ignore",
|
|
289
|
+
stdio: ["ignore", logFd, logFd],
|
|
141
290
|
detached: true
|
|
142
291
|
});
|
|
292
|
+
closeSync(logFd);
|
|
143
293
|
child.unref();
|
|
144
|
-
|
|
294
|
+
managedRuntimeLogPath = logPath;
|
|
295
|
+
lastRuntimeExitDetails = null;
|
|
296
|
+
child.once("exit", (code, signal) => {
|
|
297
|
+
lastRuntimeExitDetails = {
|
|
298
|
+
pid: child.pid ?? -1,
|
|
299
|
+
code,
|
|
300
|
+
signal,
|
|
301
|
+
logPath
|
|
302
|
+
};
|
|
145
303
|
if (managedRuntimeChild === child) {
|
|
146
304
|
managedRuntimeChild = null;
|
|
147
305
|
managedRuntimeKey = null;
|
|
@@ -150,19 +308,46 @@ function spawnManagedRuntime(config, plan) {
|
|
|
150
308
|
});
|
|
151
309
|
managedRuntimeChild = child;
|
|
152
310
|
managedRuntimeKey = runtimeKey(config);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
311
|
+
try {
|
|
312
|
+
await writeRuntimeState(config, child.pid);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
managedRuntimeChild = null;
|
|
316
|
+
managedRuntimeKey = null;
|
|
317
|
+
try {
|
|
318
|
+
process.kill(child.pid, "SIGTERM");
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// If the child already exited we still want to surface the state-write failure.
|
|
322
|
+
}
|
|
323
|
+
throw new Error(`Forge local runtime started on ${config.baseUrl}, but the plugin could not persist its state. ${error instanceof Error ? error.message : String(error)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function formatRuntimeFailure(details, config) {
|
|
327
|
+
if (!details) {
|
|
328
|
+
return `Forge local runtime did not become healthy at ${config.baseUrl} within ${STARTUP_TIMEOUT_MS}ms`;
|
|
329
|
+
}
|
|
330
|
+
const suffix = details.logPath ? ` Check logs at ${details.logPath}.` : "";
|
|
331
|
+
if (details.signal) {
|
|
332
|
+
return `Forge local runtime exited before becoming healthy at ${config.baseUrl} (signal ${details.signal}).${suffix}`;
|
|
333
|
+
}
|
|
334
|
+
if (typeof details.code === "number") {
|
|
335
|
+
return `Forge local runtime exited before becoming healthy at ${config.baseUrl} (code ${details.code}).${suffix}`;
|
|
336
|
+
}
|
|
337
|
+
return `Forge local runtime exited before becoming healthy at ${config.baseUrl}.${suffix}`;
|
|
156
338
|
}
|
|
157
|
-
async function waitForRuntime(config, timeoutMs) {
|
|
339
|
+
async function waitForRuntime(config, timeoutMs, expectedPid) {
|
|
158
340
|
const deadline = Date.now() + timeoutMs;
|
|
159
341
|
while (Date.now() < deadline) {
|
|
160
342
|
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
161
343
|
return;
|
|
162
344
|
}
|
|
345
|
+
if (expectedPid !== null && lastRuntimeExitDetails?.pid === expectedPid) {
|
|
346
|
+
throw new Error(formatRuntimeFailure(lastRuntimeExitDetails, config));
|
|
347
|
+
}
|
|
163
348
|
await new Promise((resolve) => setTimeout(resolve, HEALTHCHECK_INTERVAL_MS));
|
|
164
349
|
}
|
|
165
|
-
throw new Error(
|
|
350
|
+
throw new Error(formatRuntimeFailure(lastRuntimeExitDetails, config));
|
|
166
351
|
}
|
|
167
352
|
export async function ensureForgeRuntimeReady(config) {
|
|
168
353
|
if (!isLocalOrigin(config.origin)) {
|
|
@@ -171,8 +356,21 @@ export async function ensureForgeRuntimeReady(config) {
|
|
|
171
356
|
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
172
357
|
return;
|
|
173
358
|
}
|
|
359
|
+
const savedState = await readRuntimeState(config);
|
|
360
|
+
if (savedState && !processExists(savedState.pid)) {
|
|
361
|
+
await clearRuntimeState(config);
|
|
362
|
+
}
|
|
363
|
+
else if (savedState && processExists(savedState.pid)) {
|
|
364
|
+
try {
|
|
365
|
+
await waitForRuntime(config, EXISTING_RUNTIME_GRACE_MS, null);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
await stopForgeRuntime(config);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
174
372
|
const key = runtimeKey(config);
|
|
175
|
-
if (startupPromise &&
|
|
373
|
+
if (startupPromise && (startupRuntimeKey === null || startupRuntimeKey === key)) {
|
|
176
374
|
return startupPromise;
|
|
177
375
|
}
|
|
178
376
|
const plan = resolveLaunchPlan();
|
|
@@ -183,12 +381,22 @@ export async function ensureForgeRuntimeReady(config) {
|
|
|
183
381
|
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
184
382
|
return;
|
|
185
383
|
}
|
|
384
|
+
startupRuntimeKey = runtimeKey(config);
|
|
385
|
+
if (!(await isPortAvailable("127.0.0.1", config.port))) {
|
|
386
|
+
await relocateLocalRuntimePort(config);
|
|
387
|
+
startupRuntimeKey = runtimeKey(config);
|
|
388
|
+
if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
await ensurePackagedRuntimeDependencies(plan, config);
|
|
186
393
|
if (!managedRuntimeChild || managedRuntimeKey !== key || managedRuntimeChild.killed) {
|
|
187
|
-
spawnManagedRuntime(config, plan);
|
|
394
|
+
await spawnManagedRuntime(config, plan);
|
|
188
395
|
}
|
|
189
|
-
await waitForRuntime(config, STARTUP_TIMEOUT_MS);
|
|
396
|
+
await waitForRuntime(config, STARTUP_TIMEOUT_MS, managedRuntimeChild?.pid ?? null);
|
|
190
397
|
})().finally(() => {
|
|
191
398
|
startupPromise = null;
|
|
399
|
+
startupRuntimeKey = null;
|
|
192
400
|
});
|
|
193
401
|
return startupPromise;
|
|
194
402
|
}
|
|
@@ -204,6 +412,16 @@ export async function startForgeRuntime(config) {
|
|
|
204
412
|
};
|
|
205
413
|
}
|
|
206
414
|
const existingState = await readRuntimeState(config);
|
|
415
|
+
if (!existingState && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
|
|
416
|
+
return {
|
|
417
|
+
ok: true,
|
|
418
|
+
started: false,
|
|
419
|
+
managed: false,
|
|
420
|
+
message: `Forge is already running on ${config.baseUrl}, but it does not look like a plugin-managed runtime.`,
|
|
421
|
+
pid: null,
|
|
422
|
+
baseUrl: config.baseUrl
|
|
423
|
+
};
|
|
424
|
+
}
|
|
207
425
|
if (existingState && processExists(existingState.pid) && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
|
|
208
426
|
return {
|
|
209
427
|
ok: true,
|
|
@@ -216,6 +434,16 @@ export async function startForgeRuntime(config) {
|
|
|
216
434
|
}
|
|
217
435
|
await ensureForgeRuntimeReady(config);
|
|
218
436
|
const state = await readRuntimeState(config);
|
|
437
|
+
if (!state && (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))) {
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
started: false,
|
|
441
|
+
managed: false,
|
|
442
|
+
message: `Forge is healthy on ${config.baseUrl}, but it does not look like a plugin-managed runtime.`,
|
|
443
|
+
pid: null,
|
|
444
|
+
baseUrl: config.baseUrl
|
|
445
|
+
};
|
|
446
|
+
}
|
|
219
447
|
return {
|
|
220
448
|
ok: true,
|
|
221
449
|
started: true,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { buildForgeBaseUrl, buildForgeWebAppUrl } from "./api-client.js";
|
|
2
5
|
import { primeForgeRuntime } from "./local-runtime.js";
|
|
3
6
|
import { registerForgePluginCli, registerForgePluginRoutes } from "./routes.js";
|
|
@@ -7,6 +10,7 @@ export const FORGE_PLUGIN_NAME = "Forge";
|
|
|
7
10
|
export const FORGE_PLUGIN_DESCRIPTION = "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.";
|
|
8
11
|
export const DEFAULT_FORGE_ORIGIN = "http://127.0.0.1";
|
|
9
12
|
export const DEFAULT_FORGE_PORT = 4317;
|
|
13
|
+
const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
10
14
|
function normalizeString(value, fallback) {
|
|
11
15
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
|
|
12
16
|
}
|
|
@@ -36,15 +40,46 @@ function normalizeTimeout(value, fallback) {
|
|
|
36
40
|
}
|
|
37
41
|
return Math.min(120_000, Math.max(1000, Math.round(value)));
|
|
38
42
|
}
|
|
43
|
+
function isLocalOrigin(origin) {
|
|
44
|
+
try {
|
|
45
|
+
return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function getPreferredLocalPortPath(origin) {
|
|
52
|
+
const hostname = new URL(origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
53
|
+
return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${hostname}-preferred-port.json`);
|
|
54
|
+
}
|
|
55
|
+
function readPreferredLocalPort(origin) {
|
|
56
|
+
if (!isLocalOrigin(origin)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const preferredPortPath = getPreferredLocalPortPath(origin);
|
|
61
|
+
if (!existsSync(preferredPortPath)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const payload = JSON.parse(readFileSync(preferredPortPath, "utf8"));
|
|
65
|
+
return typeof payload.port === "number" && Number.isFinite(payload.port) ? payload.port : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
39
71
|
export function resolveForgePluginConfig(pluginConfig) {
|
|
40
72
|
const raw = (pluginConfig ?? {});
|
|
41
73
|
const origin = normalizeOrigin(raw.origin, DEFAULT_FORGE_ORIGIN);
|
|
42
|
-
const
|
|
74
|
+
const hasConfiguredPort = typeof raw.port === "number" && Number.isFinite(raw.port);
|
|
75
|
+
const preferredPort = hasConfiguredPort ? null : readPreferredLocalPort(origin);
|
|
76
|
+
const port = normalizePort(hasConfiguredPort ? raw.port : preferredPort ?? DEFAULT_FORGE_PORT, DEFAULT_FORGE_PORT);
|
|
43
77
|
return {
|
|
44
78
|
origin,
|
|
45
79
|
port,
|
|
46
80
|
baseUrl: buildForgeBaseUrl(origin, port),
|
|
47
81
|
webAppUrl: buildForgeWebAppUrl(origin, port),
|
|
82
|
+
portSource: hasConfiguredPort ? "configured" : preferredPort !== null ? "preferred" : "default",
|
|
48
83
|
dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot.trim() : "",
|
|
49
84
|
apiToken: typeof raw.apiToken === "string" ? raw.apiToken.trim() : "",
|
|
50
85
|
actorLabel: normalizeString(raw.actorLabel, "aurel"),
|
|
@@ -69,7 +104,7 @@ export const forgePluginConfigSchema = {
|
|
|
69
104
|
default: DEFAULT_FORGE_PORT,
|
|
70
105
|
minimum: 1,
|
|
71
106
|
maximum: 65535,
|
|
72
|
-
description: "Forge server port. Override this when
|
|
107
|
+
description: "Forge server port. Override this only when you want to pin a specific port. Default localhost installs can move to the next free port automatically if 4317 is already taken."
|
|
73
108
|
},
|
|
74
109
|
dataRoot: {
|
|
75
110
|
type: "string",
|
|
@@ -103,7 +138,7 @@ export const forgePluginConfigSchema = {
|
|
|
103
138
|
},
|
|
104
139
|
port: {
|
|
105
140
|
label: "Forge Port",
|
|
106
|
-
help: "Forge server port. Change this
|
|
141
|
+
help: "Forge server port. Change this only when you want to pin a specific port. Default localhost installs can move to the next free port automatically if 4317 is busy.",
|
|
107
142
|
placeholder: "4317"
|
|
108
143
|
},
|
|
109
144
|
dataRoot: {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED