@botcord/daemon 0.2.6 → 0.2.8

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/src/daemon.ts CHANGED
@@ -23,7 +23,12 @@ import { ensureAgentWorkspace } from "./agent-workspace.js";
23
23
  import { ControlChannel } from "./control-channel.js";
24
24
  import { toGatewayConfig } from "./daemon-config-map.js";
25
25
  import { log as daemonLog } from "./log.js";
26
- import { collectRuntimeSnapshot, createProvisioner } from "./provision.js";
26
+ import {
27
+ adoptDiscoveredOpenclawAgents,
28
+ collectRuntimeSnapshot,
29
+ createProvisioner,
30
+ } from "./provision.js";
31
+ import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
27
32
  import { SnapshotWriter } from "./snapshot-writer.js";
28
33
  import { createDaemonSystemContextBuilder } from "./system-context.js";
29
34
  import { createRoomStaticContextBuilder } from "./room-context.js";
@@ -422,6 +427,30 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
422
427
  await gateway.start();
423
428
  logger.info("daemon started", { agents: agentIds });
424
429
 
430
+ if (openclawAutoProvisionEnabled(opts.config)) {
431
+ try {
432
+ const adopted = await adoptDiscoveredOpenclawAgents({
433
+ gateway,
434
+ cfg: opts.config,
435
+ });
436
+ if (
437
+ adopted.adopted.length > 0 ||
438
+ adopted.failed.length > 0 ||
439
+ adopted.skipped.length > 0
440
+ ) {
441
+ logger.info("openclaw auto-provision completed", {
442
+ adopted: adopted.adopted,
443
+ skipped: adopted.skipped,
444
+ failed: adopted.failed,
445
+ });
446
+ }
447
+ } catch (err) {
448
+ logger.warn("openclaw auto-provision failed; continuing", {
449
+ error: err instanceof Error ? err.message : String(err),
450
+ });
451
+ }
452
+ }
453
+
425
454
  // Control channel is optional — daemon still runs (data-plane only)
426
455
  // when user-auth hasn't been set up yet. Operators can `login` later
427
456
  // without restarting, but for P0 we require a restart to pick it up.
package/src/index.ts CHANGED
@@ -57,6 +57,11 @@ import {
57
57
  updateWorkingMemory,
58
58
  DEFAULT_SECTION,
59
59
  } from "./working-memory.js";
60
+ import {
61
+ discoverLocalOpenclawGateways,
62
+ mergeOpenclawGateways,
63
+ openclawDiscoveryConfigEnabled,
64
+ } from "./openclaw-discovery.js";
60
65
 
61
66
  const ADAPTER_LIST = listAdapterIds().join("|");
62
67
 
@@ -402,7 +407,28 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
402
407
  }
403
408
 
404
409
  async function cmdStart(args: ParsedArgs): Promise<void> {
405
- const cfg = loadOrInitConfig(args);
410
+ let cfg = loadOrInitConfig(args);
411
+ if (openclawDiscoveryConfigEnabled(cfg)) {
412
+ try {
413
+ const found = await discoverLocalOpenclawGateways({
414
+ searchPaths: cfg.openclawDiscovery?.searchPaths,
415
+ defaultPorts: cfg.openclawDiscovery?.defaultPorts,
416
+ timeoutMs: 500,
417
+ });
418
+ const merged = mergeOpenclawGateways(cfg, found);
419
+ if (merged.changed) {
420
+ cfg = merged.cfg;
421
+ saveConfig(cfg);
422
+ log.info("openclaw discovery: gateways merged", {
423
+ added: merged.added.map((g) => ({ name: g.name, url: g.url })),
424
+ });
425
+ }
426
+ } catch (err) {
427
+ log.warn("openclaw discovery failed; continuing", {
428
+ error: err instanceof Error ? err.message : String(err),
429
+ });
430
+ }
431
+ }
406
432
  // Foreground is now the default. --background (alias -d) detaches.
407
433
  // --foreground is still accepted (no-op) for backwards compatibility and
408
434
  // is also what the detached child re-execs itself with.
@@ -0,0 +1,262 @@
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 existingUrls = new Set(existing.map((g) => normalizeUrlKey(g.url)));
88
+ const existingNames = new Set(existing.map((g) => g.name));
89
+ const added: OpenclawGatewayProfile[] = [];
90
+
91
+ for (const item of found) {
92
+ const key = normalizeUrlKey(item.url);
93
+ if (existingUrls.has(key)) continue;
94
+ const profile: OpenclawGatewayProfile = {
95
+ name: uniqueName(item.name, existingNames),
96
+ url: item.url,
97
+ };
98
+ if (item.token) profile.token = item.token;
99
+ else if (item.tokenFile) profile.tokenFile = item.tokenFile;
100
+ existingUrls.add(key);
101
+ existingNames.add(profile.name);
102
+ added.push(profile);
103
+ }
104
+
105
+ if (added.length === 0) return { cfg, changed: false, added };
106
+ return {
107
+ cfg: { ...cfg, openclawGateways: [...existing, ...added] },
108
+ changed: true,
109
+ added,
110
+ };
111
+ }
112
+
113
+ function discoverFromConfigDir(root: string): DiscoveredOpenclawGateway[] {
114
+ const dir = expandHome(root);
115
+ let names: string[];
116
+ try {
117
+ names = readdirSync(dir);
118
+ } catch {
119
+ return [];
120
+ }
121
+ const out: DiscoveredOpenclawGateway[] = [];
122
+ for (const name of names.sort()) {
123
+ if (!name.endsWith(".json") && !name.endsWith(".toml")) continue;
124
+ const file = path.join(dir, name);
125
+ try {
126
+ const st = statSync(file);
127
+ if (!st.isFile()) continue;
128
+ const raw = readFileSync(file, "utf8");
129
+ const parsed = name.endsWith(".json") ? parseJsonConfig(raw) : parseTomlConfig(raw);
130
+ if (!parsed?.url) continue;
131
+ const item: DiscoveredOpenclawGateway = {
132
+ name: nameFromUrl(parsed.url),
133
+ url: parsed.url,
134
+ source: "config-file",
135
+ };
136
+ if (parsed.token) item.token = parsed.token;
137
+ else if (parsed.tokenFile) item.tokenFile = parsed.tokenFile;
138
+ out.push(item);
139
+ } catch (err) {
140
+ daemonLog.debug("openclaw discovery config skipped", {
141
+ file,
142
+ error: err instanceof Error ? err.message : String(err),
143
+ });
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
150
+ const obj = JSON.parse(raw) as any;
151
+ const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
152
+ return pickConfigValues(acp);
153
+ }
154
+
155
+ function parseTomlConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
156
+ let inAcp = false;
157
+ const values: Record<string, string> = {};
158
+ for (const line of raw.split(/\r?\n/)) {
159
+ const trimmed = line.replace(/#.*/, "").trim();
160
+ if (!trimmed) continue;
161
+ const section = trimmed.match(/^\[([^\]]+)\]$/);
162
+ if (section) {
163
+ inAcp = section[1] === "acp" || section[1].endsWith(".acp");
164
+ continue;
165
+ }
166
+ if (!inAcp) continue;
167
+ const m = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*"(.*)"\s*$/);
168
+ if (m) values[m[1]] = m[2];
169
+ }
170
+ return pickConfigValues(values);
171
+ }
172
+
173
+ function pickConfigValues(obj: any): { url?: string; token?: string; tokenFile?: string } | null {
174
+ if (!obj || typeof obj !== "object") return null;
175
+ const url = pickString(obj, ["url", "wsUrl", "ws_url", "endpoint"]);
176
+ if (!url) return null;
177
+ const token = pickString(obj, ["token", "bearerToken", "bearer_token"]);
178
+ const tokenFile = pickString(obj, ["tokenFile", "token_file"]);
179
+ return { url, token, tokenFile };
180
+ }
181
+
182
+ function pickString(obj: Record<string, unknown>, keys: string[]): string | undefined {
183
+ for (const key of keys) {
184
+ const value = obj[key];
185
+ if (typeof value === "string" && value.trim()) return value.trim();
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ function dedupeDiscovered(items: DiscoveredOpenclawGateway[]): DiscoveredOpenclawGateway[] {
191
+ const priority: Record<DiscoveredOpenclawGatewaySource, number> = {
192
+ "config-file": 3,
193
+ env: 2,
194
+ "default-port": 1,
195
+ };
196
+ const byUrl = new Map<string, DiscoveredOpenclawGateway>();
197
+ for (const item of items) {
198
+ const key = normalizeUrlKey(item.url);
199
+ const prev = byUrl.get(key);
200
+ if (!prev || priority[item.source] > priority[prev.source] || hasMoreAuth(item, prev)) {
201
+ byUrl.set(key, item);
202
+ }
203
+ }
204
+ return [...byUrl.values()];
205
+ }
206
+
207
+ function hasMoreAuth(a: DiscoveredOpenclawGateway, b: DiscoveredOpenclawGateway): boolean {
208
+ const score = (x: DiscoveredOpenclawGateway): number => (x.token ? 2 : x.tokenFile ? 1 : 0);
209
+ return score(a) > score(b);
210
+ }
211
+
212
+ function nameFromUrl(raw: string): string {
213
+ try {
214
+ const u = new URL(raw);
215
+ const base = `${u.hostname}-${u.port || (u.protocol === "wss:" ? "443" : "80")}`;
216
+ return `openclaw-${base.replace(/[^A-Za-z0-9_-]+/g, "-")}`;
217
+ } catch {
218
+ return "openclaw-local";
219
+ }
220
+ }
221
+
222
+ function uniqueName(base: string, existing: Set<string>): string {
223
+ let candidate = base;
224
+ let i = 2;
225
+ while (existing.has(candidate)) {
226
+ candidate = `${base}-${i}`;
227
+ i += 1;
228
+ }
229
+ return candidate;
230
+ }
231
+
232
+ function normalizeUrlKey(raw: string): string {
233
+ try {
234
+ const u = new URL(raw);
235
+ u.hash = "";
236
+ return u.toString();
237
+ } catch {
238
+ return raw.trim();
239
+ }
240
+ }
241
+
242
+ function expandHome(p: string): string {
243
+ if (p === "~") return homedir();
244
+ if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
245
+ return p;
246
+ }
247
+
248
+ export function defaultOpenclawDiscoverySearchPaths(): string[] {
249
+ return DEFAULT_SEARCH_PATHS.slice();
250
+ }
251
+
252
+ export function defaultOpenclawDiscoveryPorts(): number[] {
253
+ return DEFAULT_PORTS.slice();
254
+ }
255
+
256
+ export function openclawDiscoveryConfigEnabled(cfg: DaemonConfig): boolean {
257
+ return cfg.openclawDiscovery?.enabled !== false;
258
+ }
259
+
260
+ export function openclawAutoProvisionEnabled(cfg: DaemonConfig): boolean {
261
+ return cfg.openclawDiscovery?.autoProvision !== false;
262
+ }