@botcord/daemon 0.2.27 → 0.2.29
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/dist/agent-discovery.d.ts +2 -0
- package/dist/agent-discovery.js +2 -0
- package/dist/agent-workspace.d.ts +11 -1
- package/dist/agent-workspace.js +20 -2
- package/dist/daemon-config-map.d.ts +2 -0
- package/dist/daemon-config-map.js +3 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +2 -1
- package/dist/gateway/dispatcher.js +1 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +35 -0
- package/dist/gateway/runtimes/hermes-agent.js +135 -4
- package/dist/gateway/runtimes/openclaw-acp.d.ts +6 -3
- package/dist/gateway/runtimes/openclaw-acp.js +75 -9
- package/dist/gateway/types.d.ts +17 -0
- package/dist/openclaw-discovery.d.ts +3 -1
- package/dist/openclaw-discovery.js +176 -2
- package/dist/provision.d.ts +12 -8
- package/dist/provision.js +198 -4
- package/package.json +1 -1
- package/src/__tests__/agent-workspace.test.ts +18 -0
- package/src/__tests__/openclaw-acp.test.ts +172 -0
- package/src/__tests__/openclaw-discovery.test.ts +64 -0
- package/src/__tests__/provision.test.ts +164 -0
- package/src/agent-discovery.ts +3 -0
- package/src/agent-workspace.ts +24 -2
- package/src/daemon-config-map.ts +5 -0
- package/src/daemon.ts +3 -2
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +87 -0
- package/src/gateway/dispatcher.ts +1 -0
- package/src/gateway/runtimes/hermes-agent.ts +151 -3
- package/src/gateway/runtimes/openclaw-acp.ts +82 -9
- package/src/gateway/types.ts +17 -0
- package/src/openclaw-discovery.ts +180 -3
- package/src/provision.ts +221 -6
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import {
|
|
4
5
|
agentHermesHomeDir,
|
|
5
6
|
agentHermesWorkspaceDir,
|
|
7
|
+
ensureAttachedHermesProfileSkills,
|
|
6
8
|
ensureAgentHermesWorkspace,
|
|
7
9
|
} from "../../agent-workspace.js";
|
|
8
10
|
import { buildCliEnv } from "../cli-resolver.js";
|
|
@@ -65,6 +67,139 @@ export function probeHermesAgent(deps: ProbeDeps = {}): RuntimeProbeResult {
|
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Discovered hermes profile entry (daemon-side shape; wire shape lives in
|
|
72
|
+
* protocol-core's `HermesProfileProbe`). Occupancy is filled in later by
|
|
73
|
+
* `provision.ts` from local credentials, not here.
|
|
74
|
+
*/
|
|
75
|
+
export interface HermesProfileInfo {
|
|
76
|
+
name: string;
|
|
77
|
+
home: string;
|
|
78
|
+
isDefault?: boolean;
|
|
79
|
+
isActive?: boolean;
|
|
80
|
+
modelName?: string;
|
|
81
|
+
sessionsCount?: number;
|
|
82
|
+
hasSoul?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the hermes root (`~/.hermes`) — this is the location of the
|
|
87
|
+
* synthetic `default` profile per upstream's "default profile = HERMES_HOME
|
|
88
|
+
* itself" convention (`hermes_cli/profiles.py:8`).
|
|
89
|
+
*/
|
|
90
|
+
export function hermesRootDir(): string {
|
|
91
|
+
return path.join(homedir(), ".hermes");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Profile-name shape mirrors `hermes_cli/profiles.py:_PROFILE_ID_RE`. */
|
|
95
|
+
const HERMES_PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
96
|
+
|
|
97
|
+
export function isValidHermesProfileName(name: string): boolean {
|
|
98
|
+
return name === "default" || HERMES_PROFILE_NAME_RE.test(name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a hermes profile's HERMES_HOME directory. `default` maps to
|
|
103
|
+
* `~/.hermes`; all other names map to `~/.hermes/profiles/<name>`. Mirrors
|
|
104
|
+
* `hermes_cli/profiles.py:get_profile_dir`.
|
|
105
|
+
*/
|
|
106
|
+
export function hermesProfileHomeDir(name: string): string {
|
|
107
|
+
if (!isValidHermesProfileName(name)) {
|
|
108
|
+
throw new Error(`Invalid hermes profile name: ${name}`);
|
|
109
|
+
}
|
|
110
|
+
if (name === "default") return hermesRootDir();
|
|
111
|
+
return path.join(hermesRootDir(), "profiles", name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readActiveProfileName(): string {
|
|
115
|
+
try {
|
|
116
|
+
const raw = readFileSync(path.join(hermesRootDir(), "active_profile"), "utf8").trim();
|
|
117
|
+
return raw || "default";
|
|
118
|
+
} catch {
|
|
119
|
+
return "default";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readProfileModelName(profileHome: string): string | undefined {
|
|
124
|
+
try {
|
|
125
|
+
const raw = readFileSync(path.join(profileHome, "config.yaml"), "utf8");
|
|
126
|
+
// Cheap surface-level YAML peek — config.yaml's first block is
|
|
127
|
+
// `model:\n default: <name>`. Avoid pulling in a YAML dependency for
|
|
128
|
+
// a single optional field.
|
|
129
|
+
const match = raw.match(/^model:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+default:\s*([^\n#]+)/m);
|
|
130
|
+
if (!match) return undefined;
|
|
131
|
+
return match[1].trim().replace(/^['"]|['"]$/g, "") || undefined;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function countSessions(profileHome: string): number | undefined {
|
|
138
|
+
try {
|
|
139
|
+
const dir = path.join(profileHome, "sessions");
|
|
140
|
+
if (!existsSync(dir)) return 0;
|
|
141
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).length;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasSoul(profileHome: string): boolean {
|
|
148
|
+
return existsSync(path.join(profileHome, "SOUL.md"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Enumerate available hermes profiles on this device. Pure local filesystem
|
|
153
|
+
* scan — does not invoke any hermes binary. Returns the synthetic `default`
|
|
154
|
+
* entry first when `~/.hermes` exists (which it should, given that the probe
|
|
155
|
+
* already located `hermes-acp`); each `~/.hermes/profiles/<name>/` directory
|
|
156
|
+
* follows.
|
|
157
|
+
*/
|
|
158
|
+
export function listHermesProfiles(): HermesProfileInfo[] {
|
|
159
|
+
const out: HermesProfileInfo[] = [];
|
|
160
|
+
const root = hermesRootDir();
|
|
161
|
+
const active = readActiveProfileName();
|
|
162
|
+
|
|
163
|
+
if (existsSync(root)) {
|
|
164
|
+
out.push({
|
|
165
|
+
name: "default",
|
|
166
|
+
home: root,
|
|
167
|
+
isDefault: true,
|
|
168
|
+
isActive: active === "default",
|
|
169
|
+
modelName: readProfileModelName(root),
|
|
170
|
+
sessionsCount: countSessions(root),
|
|
171
|
+
hasSoul: hasSoul(root),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const profilesDir = path.join(root, "profiles");
|
|
176
|
+
let entries: string[] = [];
|
|
177
|
+
try {
|
|
178
|
+
entries = readdirSync(profilesDir);
|
|
179
|
+
} catch {
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
for (const name of entries) {
|
|
183
|
+
if (!HERMES_PROFILE_NAME_RE.test(name)) continue;
|
|
184
|
+
const home = path.join(profilesDir, name);
|
|
185
|
+
try {
|
|
186
|
+
if (!statSync(home).isDirectory()) continue;
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
out.push({
|
|
191
|
+
name,
|
|
192
|
+
home,
|
|
193
|
+
isActive: active === name,
|
|
194
|
+
modelName: readProfileModelName(home),
|
|
195
|
+
sessionsCount: countSessions(home),
|
|
196
|
+
hasSoul: hasSoul(home),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
68
203
|
/**
|
|
69
204
|
* Hermes Agent adapter. Drives `hermes-acp` (the ACP stdio adapter shipped
|
|
70
205
|
* with `pip install "hermes-agent[acp]"`).
|
|
@@ -141,7 +276,15 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
141
276
|
// Route dangerous tool calls through ACP request_permission.
|
|
142
277
|
HERMES_INTERACTIVE: "1",
|
|
143
278
|
};
|
|
144
|
-
|
|
279
|
+
// Attach mode: BotCord agent shares a hermes profile (state.db /
|
|
280
|
+
// sessions / skills / .env) with the user's command-line `hermes`. In
|
|
281
|
+
// this mode we DO NOT seed a private home — AGENTS.md is written under
|
|
282
|
+
// the per-agent hermes-workspace cwd (NOT into the profile root) by
|
|
283
|
+
// `prepareTurn`, while bundled BotCord skills are installed into the
|
|
284
|
+
// attached profile's `skills/` directory so hermes can discover them.
|
|
285
|
+
if (opts.hermesProfile) {
|
|
286
|
+
env.HERMES_HOME = hermesProfileHomeDir(opts.hermesProfile);
|
|
287
|
+
} else if (opts.accountId) {
|
|
145
288
|
env.HERMES_HOME = agentHermesHomeDir(opts.accountId);
|
|
146
289
|
}
|
|
147
290
|
return env;
|
|
@@ -160,7 +303,12 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
160
303
|
*/
|
|
161
304
|
protected prepareTurn(opts: RuntimeRunOptions): void {
|
|
162
305
|
if (!opts.accountId) return;
|
|
163
|
-
const { hermesWorkspace } = ensureAgentHermesWorkspace(opts.accountId
|
|
306
|
+
const { hermesWorkspace } = ensureAgentHermesWorkspace(opts.accountId, {
|
|
307
|
+
attached: !!opts.hermesProfile,
|
|
308
|
+
});
|
|
309
|
+
if (opts.hermesProfile) {
|
|
310
|
+
ensureAttachedHermesProfileSkills(hermesProfileHomeDir(opts.hermesProfile));
|
|
311
|
+
}
|
|
164
312
|
const target = path.join(hermesWorkspace, "AGENTS.md");
|
|
165
313
|
const tmp = path.join(hermesWorkspace, `.AGENTS.md.${process.pid}.tmp`);
|
|
166
314
|
mkdirSync(hermesWorkspace, { recursive: true, mode: 0o700 });
|
|
@@ -140,9 +140,11 @@ interface SpawnDeps {
|
|
|
140
140
|
*
|
|
141
141
|
* Spawns `openclaw acp --url <gateway> [--token <token>]` per
|
|
142
142
|
* `(accountId, gatewayName)` pair and reuses the process across turns. The
|
|
143
|
-
* child speaks JSON-RPC over stdio; we send `initialize` once, then
|
|
144
|
-
*
|
|
145
|
-
*
|
|
143
|
+
* child speaks JSON-RPC over stdio; we send `initialize` once, then derive a
|
|
144
|
+
* stable OpenClaw `sessionKey` for the BotCord conversation. The persisted
|
|
145
|
+
* `runtimeSessionId` is only an ACP transport handle cached from a previous
|
|
146
|
+
* turn, so every resume first goes through `session/load` with
|
|
147
|
+
* `_meta.sessionKey` before `prompt`. Streaming `session/update`
|
|
146
148
|
* notifications are relayed to `onBlock`.
|
|
147
149
|
*
|
|
148
150
|
* Process-pool lifetime + abort/cancel semantics live at module scope; see
|
|
@@ -190,6 +192,8 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
190
192
|
handle.inFlight += 1;
|
|
191
193
|
if (handle.idleTimer) clearTimeout(handle.idleTimer);
|
|
192
194
|
|
|
195
|
+
// ACP session ids are process-local transport handles. They are useful as
|
|
196
|
+
// a cache, but the stable conversation identity is `sessionKey`.
|
|
193
197
|
let acpSessionId = opts.sessionId ?? "";
|
|
194
198
|
let seq = 0;
|
|
195
199
|
let assistantText = "";
|
|
@@ -230,8 +234,27 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
230
234
|
|
|
231
235
|
let abortListener: (() => void) | undefined;
|
|
232
236
|
try {
|
|
233
|
-
// Ensure we have
|
|
234
|
-
//
|
|
237
|
+
// Ensure we have a live ACP transport session. If the dispatcher passes a
|
|
238
|
+
// cached session id, ask OpenClaw to load/rebind it with the stable
|
|
239
|
+
// sessionKey. If that handle is gone, discard it and create a fresh one.
|
|
240
|
+
if (acpSessionId) {
|
|
241
|
+
try {
|
|
242
|
+
acpSessionId = await this.loadSession(handle, {
|
|
243
|
+
sessionId: acpSessionId,
|
|
244
|
+
cwd: opts.cwd,
|
|
245
|
+
sessionKey,
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (!isSessionNotFoundError(err)) throw err;
|
|
249
|
+
log.warn("openclaw-acp.session-load-not-found", {
|
|
250
|
+
accountId: opts.accountId,
|
|
251
|
+
oldSessionId: acpSessionId,
|
|
252
|
+
sessionKey,
|
|
253
|
+
});
|
|
254
|
+
acpSessionId = "";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
if (!acpSessionId) {
|
|
236
259
|
try {
|
|
237
260
|
acpSessionId = await this.newSession(handle, {
|
|
@@ -261,11 +284,16 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
261
284
|
text: opts.text,
|
|
262
285
|
});
|
|
263
286
|
} catch (err) {
|
|
264
|
-
const msg = (err as Error).message ?? "prompt failed";
|
|
265
287
|
// If the child says the session is gone (process restart, GC),
|
|
266
288
|
// recreate it so the next turn doesn't hard-fail.
|
|
267
|
-
if (
|
|
289
|
+
if (isSessionNotFoundError(err)) {
|
|
268
290
|
try {
|
|
291
|
+
const oldSessionId = acpSessionId;
|
|
292
|
+
log.warn("openclaw-acp.prompt-session-not-found-retry", {
|
|
293
|
+
accountId: opts.accountId,
|
|
294
|
+
oldSessionId,
|
|
295
|
+
sessionKey,
|
|
296
|
+
});
|
|
269
297
|
const fresh = await this.newSession(handle, {
|
|
270
298
|
cwd: opts.cwd,
|
|
271
299
|
sessionKey,
|
|
@@ -273,6 +301,12 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
273
301
|
handle.subscribers.delete(acpSessionId);
|
|
274
302
|
acpSessionId = fresh;
|
|
275
303
|
handle.subscribers.set(acpSessionId, onNotification);
|
|
304
|
+
log.info("openclaw-acp.session-recreated", {
|
|
305
|
+
accountId: opts.accountId,
|
|
306
|
+
oldSessionId,
|
|
307
|
+
newSessionId: acpSessionId,
|
|
308
|
+
sessionKey,
|
|
309
|
+
});
|
|
276
310
|
promptResult = await this.prompt(handle, {
|
|
277
311
|
sessionId: acpSessionId,
|
|
278
312
|
text: opts.text,
|
|
@@ -299,7 +333,11 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
299
333
|
newSessionId: acpSessionId,
|
|
300
334
|
};
|
|
301
335
|
} catch (err) {
|
|
302
|
-
|
|
336
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
337
|
+
return failResult(
|
|
338
|
+
isSessionNotFoundError(err) ? "" : acpSessionId,
|
|
339
|
+
`openclaw-acp: ${message}`,
|
|
340
|
+
);
|
|
303
341
|
} finally {
|
|
304
342
|
if (abortListener && opts.signal) {
|
|
305
343
|
try {
|
|
@@ -426,6 +464,22 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
426
464
|
return result.sessionId;
|
|
427
465
|
}
|
|
428
466
|
|
|
467
|
+
private async loadSession(
|
|
468
|
+
handle: AcpProcessHandle,
|
|
469
|
+
args: { sessionId: string; cwd: string; sessionKey: string },
|
|
470
|
+
): Promise<string> {
|
|
471
|
+
const result = (await sendRequest(handle, "session/load", {
|
|
472
|
+
sessionId: args.sessionId,
|
|
473
|
+
cwd: args.cwd,
|
|
474
|
+
mcpServers: [],
|
|
475
|
+
_meta: { sessionKey: args.sessionKey },
|
|
476
|
+
})) as { sessionId?: string } | null;
|
|
477
|
+
if (result?.sessionId && typeof result.sessionId === "string") {
|
|
478
|
+
return result.sessionId;
|
|
479
|
+
}
|
|
480
|
+
return args.sessionId;
|
|
481
|
+
}
|
|
482
|
+
|
|
429
483
|
private async prompt(
|
|
430
484
|
handle: AcpProcessHandle,
|
|
431
485
|
args: { sessionId: string; text: string },
|
|
@@ -469,7 +523,7 @@ function routeMessage(handle: AcpProcessHandle, msg: any): void {
|
|
|
469
523
|
if (!pending) return;
|
|
470
524
|
handle.pending.delete(id);
|
|
471
525
|
if (msg.error) {
|
|
472
|
-
const message =
|
|
526
|
+
const message = formatRpcError(msg.error);
|
|
473
527
|
pending.reject(new Error(message));
|
|
474
528
|
} else {
|
|
475
529
|
pending.resolve(msg.result);
|
|
@@ -539,6 +593,25 @@ function failResult(sessionId: string, error: string): RuntimeRunResult {
|
|
|
539
593
|
};
|
|
540
594
|
}
|
|
541
595
|
|
|
596
|
+
function formatRpcError(error: unknown): string {
|
|
597
|
+
if (!error || typeof error !== "object") return "rpc error";
|
|
598
|
+
const e = error as Record<string, unknown>;
|
|
599
|
+
const message = typeof e.message === "string" ? e.message : "rpc error";
|
|
600
|
+
const data = e.data;
|
|
601
|
+
if (data && typeof data === "object") {
|
|
602
|
+
const details = (data as Record<string, unknown>).details;
|
|
603
|
+
if (typeof details === "string" && details.length > 0) {
|
|
604
|
+
return `${message}: ${details}`;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return message;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function isSessionNotFoundError(err: unknown): boolean {
|
|
611
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
612
|
+
return /session(?:\s+[\w-]+)?\s+not\s+found|unknown\s+session/i.test(msg);
|
|
613
|
+
}
|
|
614
|
+
|
|
542
615
|
function classifyAcpUpdate(note: AcpNotification): StreamBlock["kind"] {
|
|
543
616
|
const update = note.params?.update;
|
|
544
617
|
const kind: string | undefined = update?.sessionUpdate;
|
package/src/gateway/types.ts
CHANGED
|
@@ -45,6 +45,14 @@ export interface GatewayRoute {
|
|
|
45
45
|
trustLevel?: TrustLevel;
|
|
46
46
|
/** Required when `runtime === "openclaw-acp"`. Resolved at config-load time. */
|
|
47
47
|
gateway?: ResolvedOpenclawGateway;
|
|
48
|
+
/**
|
|
49
|
+
* Hermes profile name to attach to. Set when `runtime === "hermes-agent"`
|
|
50
|
+
* and the agent is bound to a specific `~/.hermes/profiles/<name>/`. The
|
|
51
|
+
* dispatcher forwards this to the adapter as
|
|
52
|
+
* {@link RuntimeRunOptions.hermesProfile}, which is what the adapter uses
|
|
53
|
+
* to switch `HERMES_HOME` at spawn time.
|
|
54
|
+
*/
|
|
55
|
+
hermesProfile?: string;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
// ---------------------------------------------------------------------------
|
|
@@ -362,6 +370,15 @@ export interface RuntimeRunOptions {
|
|
|
362
370
|
* lifting service URLs out of `extraArgs` into typed first-class fields.
|
|
363
371
|
*/
|
|
364
372
|
gateway?: ResolvedOpenclawGateway;
|
|
373
|
+
/**
|
|
374
|
+
* Hermes profile to attach to. Only meaningful when `runtime ===
|
|
375
|
+
* "hermes-agent"`. When set, the adapter switches
|
|
376
|
+
* `HERMES_HOME=~/.hermes/profiles/<name>/` (or `~/.hermes` for `default`)
|
|
377
|
+
* so the BotCord agent shares state.db / sessions / skills with the
|
|
378
|
+
* user's command-line `hermes`. Mirrors how `gateway` is lifted out of
|
|
379
|
+
* `extraArgs` for the openclaw-acp runtime.
|
|
380
|
+
*/
|
|
381
|
+
hermesProfile?: string;
|
|
365
382
|
}
|
|
366
383
|
|
|
367
384
|
/** Result returned by a runtime adapter after a turn completes. */
|
|
@@ -5,7 +5,11 @@ import type { DaemonConfig, OpenclawGatewayProfile } from "./config.js";
|
|
|
5
5
|
import { log as daemonLog } from "./log.js";
|
|
6
6
|
import { probeOpenclawAgents, type WsEndpointProbeFn } from "./provision.js";
|
|
7
7
|
|
|
8
|
-
export type DiscoveredOpenclawGatewaySource =
|
|
8
|
+
export type DiscoveredOpenclawGatewaySource =
|
|
9
|
+
| "config-file"
|
|
10
|
+
| "env"
|
|
11
|
+
| "systemd-unit"
|
|
12
|
+
| "default-port";
|
|
9
13
|
|
|
10
14
|
export interface DiscoveredOpenclawGateway {
|
|
11
15
|
name: string;
|
|
@@ -21,6 +25,7 @@ export interface OpenclawGatewayDiscoveryOptions {
|
|
|
21
25
|
probe?: WsEndpointProbeFn;
|
|
22
26
|
timeoutMs?: number;
|
|
23
27
|
env?: NodeJS.ProcessEnv;
|
|
28
|
+
systemdUnitPaths?: string[];
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export interface MergeOpenclawGatewayResult {
|
|
@@ -36,6 +41,14 @@ const DEFAULT_TOKEN_FILE_PATHS = [
|
|
|
36
41
|
"/var/run/openclaw/gateway-token",
|
|
37
42
|
"~/.openclaw/gateway-token",
|
|
38
43
|
];
|
|
44
|
+
const DEFAULT_SYSTEMD_UNIT_PATHS = [
|
|
45
|
+
"/etc/systemd/system/openclaw.service",
|
|
46
|
+
"/etc/systemd/system/openclaw-gateway.service",
|
|
47
|
+
"/lib/systemd/system/openclaw.service",
|
|
48
|
+
"/lib/systemd/system/openclaw-gateway.service",
|
|
49
|
+
"/usr/lib/systemd/system/openclaw.service",
|
|
50
|
+
"/usr/lib/systemd/system/openclaw-gateway.service",
|
|
51
|
+
];
|
|
39
52
|
|
|
40
53
|
export async function discoverLocalOpenclawGateways(
|
|
41
54
|
opts: OpenclawGatewayDiscoveryOptions = {},
|
|
@@ -47,6 +60,7 @@ export async function discoverLocalOpenclawGateways(
|
|
|
47
60
|
|
|
48
61
|
const env = opts.env ?? process.env;
|
|
49
62
|
found.push(...discoverFromEnv(env));
|
|
63
|
+
found.push(...discoverFromSystemdUnits(opts.systemdUnitPaths ?? DEFAULT_SYSTEMD_UNIT_PATHS));
|
|
50
64
|
const envAuth = pickOpenclawEnvAuth(env) ?? pickDefaultTokenFile();
|
|
51
65
|
|
|
52
66
|
const ports = opts.defaultPorts ?? DEFAULT_PORTS;
|
|
@@ -75,6 +89,164 @@ export async function discoverLocalOpenclawGateways(
|
|
|
75
89
|
return dedupeDiscovered(found);
|
|
76
90
|
}
|
|
77
91
|
|
|
92
|
+
function discoverFromSystemdUnits(paths: string[]): DiscoveredOpenclawGateway[] {
|
|
93
|
+
const out: DiscoveredOpenclawGateway[] = [];
|
|
94
|
+
for (const unitPath of paths) {
|
|
95
|
+
try {
|
|
96
|
+
if (!existsSync(unitPath)) continue;
|
|
97
|
+
const parsed = parseSystemdUnit(readFileSync(unitPath, "utf8"), path.dirname(unitPath));
|
|
98
|
+
const url = parsed.url ?? urlFromGatewayPort(parsed.env);
|
|
99
|
+
if (!url) continue;
|
|
100
|
+
const auth = pickOpenclawEnvAuth(parsed.env);
|
|
101
|
+
out.push({
|
|
102
|
+
name: nameFromUrl(url),
|
|
103
|
+
url,
|
|
104
|
+
source: "systemd-unit",
|
|
105
|
+
...auth,
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
daemonLog.debug("openclaw discovery systemd unit skipped", {
|
|
109
|
+
file: unitPath,
|
|
110
|
+
error: err instanceof Error ? err.message : String(err),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseSystemdUnit(
|
|
118
|
+
raw: string,
|
|
119
|
+
unitDir: string,
|
|
120
|
+
): { env: NodeJS.ProcessEnv; url?: string } {
|
|
121
|
+
const env: NodeJS.ProcessEnv = {};
|
|
122
|
+
let url: string | undefined;
|
|
123
|
+
for (const line of joinedSystemdLines(raw)) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
126
|
+
const eq = trimmed.indexOf("=");
|
|
127
|
+
if (eq <= 0) continue;
|
|
128
|
+
const key = trimmed.slice(0, eq);
|
|
129
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
130
|
+
if (key === "Environment") {
|
|
131
|
+
Object.assign(env, parseSystemdEnvironment(value));
|
|
132
|
+
} else if (key === "EnvironmentFile") {
|
|
133
|
+
for (const file of splitSystemdWords(value)) {
|
|
134
|
+
const optional = file.startsWith("-");
|
|
135
|
+
const resolved = path.resolve(unitDir, expandHome(optional ? file.slice(1) : file));
|
|
136
|
+
try {
|
|
137
|
+
Object.assign(env, parseEnvFile(readFileSync(resolved, "utf8")));
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (!optional) {
|
|
140
|
+
daemonLog.debug("openclaw discovery environment file skipped", {
|
|
141
|
+
file: resolved,
|
|
142
|
+
error: err instanceof Error ? err.message : String(err),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else if (key === "ExecStart") {
|
|
148
|
+
url = urlFromExecStart(value) ?? url;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { env, url };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function joinedSystemdLines(raw: string): string[] {
|
|
155
|
+
const out: string[] = [];
|
|
156
|
+
let cur = "";
|
|
157
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
158
|
+
const trimmedEnd = line.replace(/\s+$/, "");
|
|
159
|
+
if (trimmedEnd.endsWith("\\")) {
|
|
160
|
+
cur += trimmedEnd.slice(0, -1) + " ";
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
out.push(cur + line);
|
|
164
|
+
cur = "";
|
|
165
|
+
}
|
|
166
|
+
if (cur) out.push(cur);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseSystemdEnvironment(raw: string): NodeJS.ProcessEnv {
|
|
171
|
+
const env: NodeJS.ProcessEnv = {};
|
|
172
|
+
for (const word of splitSystemdWords(raw)) {
|
|
173
|
+
const eq = word.indexOf("=");
|
|
174
|
+
if (eq <= 0) continue;
|
|
175
|
+
env[word.slice(0, eq)] = word.slice(eq + 1);
|
|
176
|
+
}
|
|
177
|
+
return env;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseEnvFile(raw: string): NodeJS.ProcessEnv {
|
|
181
|
+
const env: NodeJS.ProcessEnv = {};
|
|
182
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
185
|
+
const eq = trimmed.indexOf("=");
|
|
186
|
+
if (eq <= 0) continue;
|
|
187
|
+
env[trimmed.slice(0, eq)] = unquote(trimmed.slice(eq + 1).trim());
|
|
188
|
+
}
|
|
189
|
+
return env;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function urlFromExecStart(raw: string): string | undefined {
|
|
193
|
+
const words = splitSystemdWords(raw);
|
|
194
|
+
const portIdx = words.indexOf("--port");
|
|
195
|
+
const rawPort =
|
|
196
|
+
portIdx >= 0 ? words[portIdx + 1] : words.find((w) => w.startsWith("--port="))?.slice(7);
|
|
197
|
+
if (!rawPort) return undefined;
|
|
198
|
+
const port = Number(rawPort);
|
|
199
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined;
|
|
200
|
+
return `ws://127.0.0.1:${port}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function splitSystemdWords(raw: string): string[] {
|
|
204
|
+
const words: string[] = [];
|
|
205
|
+
let cur = "";
|
|
206
|
+
let quote: '"' | "'" | null = null;
|
|
207
|
+
let escaped = false;
|
|
208
|
+
for (const ch of raw) {
|
|
209
|
+
if (escaped) {
|
|
210
|
+
cur += ch;
|
|
211
|
+
escaped = false;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (ch === "\\") {
|
|
215
|
+
escaped = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (quote) {
|
|
219
|
+
if (ch === quote) quote = null;
|
|
220
|
+
else cur += ch;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (ch === '"' || ch === "'") {
|
|
224
|
+
quote = ch;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (/\s/.test(ch)) {
|
|
228
|
+
if (cur) {
|
|
229
|
+
words.push(cur);
|
|
230
|
+
cur = "";
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
cur += ch;
|
|
235
|
+
}
|
|
236
|
+
if (cur) words.push(cur);
|
|
237
|
+
return words.map(unquote);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function unquote(raw: string): string {
|
|
241
|
+
if (
|
|
242
|
+
(raw.startsWith('"') && raw.endsWith('"')) ||
|
|
243
|
+
(raw.startsWith("'") && raw.endsWith("'"))
|
|
244
|
+
) {
|
|
245
|
+
return raw.slice(1, -1);
|
|
246
|
+
}
|
|
247
|
+
return raw;
|
|
248
|
+
}
|
|
249
|
+
|
|
78
250
|
function discoverFromEnv(env: NodeJS.ProcessEnv): DiscoveredOpenclawGateway[] {
|
|
79
251
|
const url =
|
|
80
252
|
pickEnv(env, "OPENCLAW_ACP_URL") ??
|
|
@@ -275,8 +447,9 @@ function pickString(obj: Record<string, unknown>, keys: string[]): string | unde
|
|
|
275
447
|
|
|
276
448
|
function dedupeDiscovered(items: DiscoveredOpenclawGateway[]): DiscoveredOpenclawGateway[] {
|
|
277
449
|
const priority: Record<DiscoveredOpenclawGatewaySource, number> = {
|
|
278
|
-
"config-file":
|
|
279
|
-
env:
|
|
450
|
+
"config-file": 4,
|
|
451
|
+
env: 3,
|
|
452
|
+
"systemd-unit": 2,
|
|
280
453
|
"default-port": 1,
|
|
281
454
|
};
|
|
282
455
|
const byUrl = new Map<string, DiscoveredOpenclawGateway>();
|
|
@@ -343,6 +516,10 @@ export function defaultOpenclawDiscoveryTokenFilePaths(): string[] {
|
|
|
343
516
|
return DEFAULT_TOKEN_FILE_PATHS.slice();
|
|
344
517
|
}
|
|
345
518
|
|
|
519
|
+
export function defaultOpenclawDiscoverySystemdUnitPaths(): string[] {
|
|
520
|
+
return DEFAULT_SYSTEMD_UNIT_PATHS.slice();
|
|
521
|
+
}
|
|
522
|
+
|
|
346
523
|
export function openclawDiscoveryConfigEnabled(cfg: DaemonConfig): boolean {
|
|
347
524
|
return cfg.openclawDiscovery?.enabled !== false;
|
|
348
525
|
}
|