@botcord/daemon 0.2.6 → 0.2.9

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.
@@ -0,0 +1,305 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import type { DaemonConfig, OpenclawGatewayProfile } from "./config.js";
5
+ import { log as daemonLog } from "./log.js";
6
+ import { probeOpenclawAgents, type WsEndpointProbeFn } from "./provision.js";
7
+
8
+ export type DiscoveredOpenclawGatewaySource = "config-file" | "env" | "default-port";
9
+
10
+ export interface DiscoveredOpenclawGateway {
11
+ name: string;
12
+ url: string;
13
+ token?: string;
14
+ tokenFile?: string;
15
+ source: DiscoveredOpenclawGatewaySource;
16
+ }
17
+
18
+ export interface OpenclawGatewayDiscoveryOptions {
19
+ searchPaths?: string[];
20
+ defaultPorts?: number[];
21
+ probe?: WsEndpointProbeFn;
22
+ timeoutMs?: number;
23
+ env?: NodeJS.ProcessEnv;
24
+ }
25
+
26
+ export interface MergeOpenclawGatewayResult {
27
+ cfg: DaemonConfig;
28
+ changed: boolean;
29
+ added: OpenclawGatewayProfile[];
30
+ }
31
+
32
+ const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
33
+ const DEFAULT_PORTS = [18789];
34
+
35
+ export async function discoverLocalOpenclawGateways(
36
+ opts: OpenclawGatewayDiscoveryOptions = {},
37
+ ): Promise<DiscoveredOpenclawGateway[]> {
38
+ const found: DiscoveredOpenclawGateway[] = [];
39
+ for (const root of opts.searchPaths ?? DEFAULT_SEARCH_PATHS) {
40
+ found.push(...discoverFromConfigDir(root));
41
+ }
42
+
43
+ const env = opts.env ?? process.env;
44
+ const envUrl = env.OPENCLAW_ACP_URL;
45
+ if (envUrl) {
46
+ const item: DiscoveredOpenclawGateway = {
47
+ name: nameFromUrl(envUrl),
48
+ url: envUrl,
49
+ source: "env",
50
+ };
51
+ if (env.OPENCLAW_ACP_TOKEN) item.token = env.OPENCLAW_ACP_TOKEN;
52
+ else if (env.OPENCLAW_ACP_TOKEN_FILE) item.tokenFile = env.OPENCLAW_ACP_TOKEN_FILE;
53
+ found.push(item);
54
+ }
55
+
56
+ const ports = opts.defaultPorts ?? DEFAULT_PORTS;
57
+ if (ports.length > 0) {
58
+ await Promise.all(
59
+ ports.map(async (port) => {
60
+ const url = `ws://127.0.0.1:${port}`;
61
+ try {
62
+ const res = await probeOpenclawAgents(
63
+ { url },
64
+ { probe: opts.probe, timeoutMs: opts.timeoutMs },
65
+ );
66
+ if (res.ok) {
67
+ found.push({ name: nameFromUrl(url), url, source: "default-port" });
68
+ }
69
+ } catch (err) {
70
+ daemonLog.debug("openclaw discovery default-port probe failed", {
71
+ url,
72
+ error: err instanceof Error ? err.message : String(err),
73
+ });
74
+ }
75
+ }),
76
+ );
77
+ }
78
+
79
+ return dedupeDiscovered(found);
80
+ }
81
+
82
+ export function mergeOpenclawGateways(
83
+ cfg: DaemonConfig,
84
+ found: DiscoveredOpenclawGateway[],
85
+ ): MergeOpenclawGatewayResult {
86
+ const existing = cfg.openclawGateways ?? [];
87
+ const byUrl = new Map<string, number>();
88
+ existing.forEach((g, i) => byUrl.set(normalizeUrlKey(g.url), i));
89
+ const existingNames = new Set(existing.map((g) => g.name));
90
+ const merged = existing.map((g) => ({ ...g }));
91
+ const added: OpenclawGatewayProfile[] = [];
92
+ let mutated = false;
93
+
94
+ for (const item of found) {
95
+ const key = normalizeUrlKey(item.url);
96
+ const idx = byUrl.get(key);
97
+ if (idx !== undefined) {
98
+ // Same URL already configured — only fill in auth that the user is
99
+ // missing, never overwrite an existing token / tokenFile.
100
+ const cur = merged[idx];
101
+ if (!cur.token && !cur.tokenFile) {
102
+ if (item.token) {
103
+ cur.token = item.token;
104
+ mutated = true;
105
+ } else if (item.tokenFile) {
106
+ cur.tokenFile = item.tokenFile;
107
+ mutated = true;
108
+ }
109
+ }
110
+ continue;
111
+ }
112
+ const profile: OpenclawGatewayProfile = {
113
+ name: uniqueName(item.name, existingNames),
114
+ url: item.url,
115
+ };
116
+ if (item.token) profile.token = item.token;
117
+ else if (item.tokenFile) profile.tokenFile = item.tokenFile;
118
+ byUrl.set(key, merged.length);
119
+ existingNames.add(profile.name);
120
+ merged.push(profile);
121
+ added.push(profile);
122
+ }
123
+
124
+ if (added.length === 0 && !mutated) return { cfg, changed: false, added };
125
+ return {
126
+ cfg: { ...cfg, openclawGateways: merged },
127
+ changed: true,
128
+ added,
129
+ };
130
+ }
131
+
132
+ function discoverFromConfigDir(root: string): DiscoveredOpenclawGateway[] {
133
+ const dir = expandHome(root);
134
+ let names: string[];
135
+ try {
136
+ names = readdirSync(dir);
137
+ } catch {
138
+ return [];
139
+ }
140
+ const out: DiscoveredOpenclawGateway[] = [];
141
+ for (const name of names.sort()) {
142
+ if (!name.endsWith(".json") && !name.endsWith(".toml")) continue;
143
+ const file = path.join(dir, name);
144
+ try {
145
+ const st = statSync(file);
146
+ if (!st.isFile()) continue;
147
+ const raw = readFileSync(file, "utf8");
148
+ const parsed = name.endsWith(".json") ? parseJsonConfig(raw) : parseTomlConfig(raw);
149
+ if (!parsed?.url) continue;
150
+ const item: DiscoveredOpenclawGateway = {
151
+ name: nameFromUrl(parsed.url),
152
+ url: parsed.url,
153
+ source: "config-file",
154
+ };
155
+ if (parsed.token) item.token = parsed.token;
156
+ else if (parsed.tokenFile) item.tokenFile = parsed.tokenFile;
157
+ out.push(item);
158
+ } catch (err) {
159
+ daemonLog.debug("openclaw discovery config skipped", {
160
+ file,
161
+ error: err instanceof Error ? err.message : String(err),
162
+ });
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
169
+ const obj = JSON.parse(raw) as any;
170
+ // Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
171
+ // The legacy `acp.url` shape is also supported for explicit user-authored configs.
172
+ const native = pickOpenclawGatewayValues(obj?.gateway);
173
+ if (native) return native;
174
+ const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
175
+ return pickConfigValues(acp);
176
+ }
177
+
178
+ function pickOpenclawGatewayValues(
179
+ gw: any,
180
+ ): { url?: string; token?: string; tokenFile?: string } | null {
181
+ if (!gw || typeof gw !== "object") return null;
182
+ const port = typeof gw.port === "number" ? gw.port : undefined;
183
+ if (!port) return null;
184
+ // Local discovery always targets the loopback interface, regardless of how
185
+ // the gateway is bound — the daemon is on the same machine.
186
+ const url = `ws://127.0.0.1:${port}`;
187
+ const auth = gw.auth;
188
+ const out: { url: string; token?: string; tokenFile?: string } = { url };
189
+ if (auth && typeof auth === "object" && auth.mode === "token") {
190
+ if (typeof auth.token === "string" && auth.token.trim()) out.token = auth.token.trim();
191
+ else if (typeof auth.tokenFile === "string" && auth.tokenFile.trim()) {
192
+ out.tokenFile = auth.tokenFile.trim();
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ function parseTomlConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
199
+ let inAcp = false;
200
+ const values: Record<string, string> = {};
201
+ for (const line of raw.split(/\r?\n/)) {
202
+ const trimmed = line.replace(/#.*/, "").trim();
203
+ if (!trimmed) continue;
204
+ const section = trimmed.match(/^\[([^\]]+)\]$/);
205
+ if (section) {
206
+ inAcp = section[1] === "acp" || section[1].endsWith(".acp");
207
+ continue;
208
+ }
209
+ if (!inAcp) continue;
210
+ const m = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*"(.*)"\s*$/);
211
+ if (m) values[m[1]] = m[2];
212
+ }
213
+ return pickConfigValues(values);
214
+ }
215
+
216
+ function pickConfigValues(obj: any): { url?: string; token?: string; tokenFile?: string } | null {
217
+ if (!obj || typeof obj !== "object") return null;
218
+ const url = pickString(obj, ["url", "wsUrl", "ws_url", "endpoint"]);
219
+ if (!url) return null;
220
+ const token = pickString(obj, ["token", "bearerToken", "bearer_token"]);
221
+ const tokenFile = pickString(obj, ["tokenFile", "token_file"]);
222
+ return { url, token, tokenFile };
223
+ }
224
+
225
+ function pickString(obj: Record<string, unknown>, keys: string[]): string | undefined {
226
+ for (const key of keys) {
227
+ const value = obj[key];
228
+ if (typeof value === "string" && value.trim()) return value.trim();
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ function dedupeDiscovered(items: DiscoveredOpenclawGateway[]): DiscoveredOpenclawGateway[] {
234
+ const priority: Record<DiscoveredOpenclawGatewaySource, number> = {
235
+ "config-file": 3,
236
+ env: 2,
237
+ "default-port": 1,
238
+ };
239
+ const byUrl = new Map<string, DiscoveredOpenclawGateway>();
240
+ for (const item of items) {
241
+ const key = normalizeUrlKey(item.url);
242
+ const prev = byUrl.get(key);
243
+ if (!prev || priority[item.source] > priority[prev.source] || hasMoreAuth(item, prev)) {
244
+ byUrl.set(key, item);
245
+ }
246
+ }
247
+ return [...byUrl.values()];
248
+ }
249
+
250
+ function hasMoreAuth(a: DiscoveredOpenclawGateway, b: DiscoveredOpenclawGateway): boolean {
251
+ const score = (x: DiscoveredOpenclawGateway): number => (x.token ? 2 : x.tokenFile ? 1 : 0);
252
+ return score(a) > score(b);
253
+ }
254
+
255
+ function nameFromUrl(raw: string): string {
256
+ try {
257
+ const u = new URL(raw);
258
+ const base = `${u.hostname}-${u.port || (u.protocol === "wss:" ? "443" : "80")}`;
259
+ return `openclaw-${base.replace(/[^A-Za-z0-9_-]+/g, "-")}`;
260
+ } catch {
261
+ return "openclaw-local";
262
+ }
263
+ }
264
+
265
+ function uniqueName(base: string, existing: Set<string>): string {
266
+ let candidate = base;
267
+ let i = 2;
268
+ while (existing.has(candidate)) {
269
+ candidate = `${base}-${i}`;
270
+ i += 1;
271
+ }
272
+ return candidate;
273
+ }
274
+
275
+ function normalizeUrlKey(raw: string): string {
276
+ try {
277
+ const u = new URL(raw);
278
+ u.hash = "";
279
+ return u.toString();
280
+ } catch {
281
+ return raw.trim();
282
+ }
283
+ }
284
+
285
+ function expandHome(p: string): string {
286
+ if (p === "~") return homedir();
287
+ if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
288
+ return p;
289
+ }
290
+
291
+ export function defaultOpenclawDiscoverySearchPaths(): string[] {
292
+ return DEFAULT_SEARCH_PATHS.slice();
293
+ }
294
+
295
+ export function defaultOpenclawDiscoveryPorts(): number[] {
296
+ return DEFAULT_PORTS.slice();
297
+ }
298
+
299
+ export function openclawDiscoveryConfigEnabled(cfg: DaemonConfig): boolean {
300
+ return cfg.openclawDiscovery?.enabled !== false;
301
+ }
302
+
303
+ export function openclawAutoProvisionEnabled(cfg: DaemonConfig): boolean {
304
+ return cfg.openclawDiscovery?.autoProvision !== false;
305
+ }