@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,198 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ discoverLocalOpenclawGateways,
7
+ mergeOpenclawGateways,
8
+ } from "../openclaw-discovery.js";
9
+ import type { DaemonConfig } from "../config.js";
10
+ import type { WsEndpointProbeFn } from "../provision.js";
11
+
12
+ let tmp: string | null = null;
13
+
14
+ afterEach(() => {
15
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
16
+ tmp = null;
17
+ });
18
+
19
+ function tempDir(): string {
20
+ tmp = mkdtempSync(path.join(tmpdir(), "openclaw-discovery-"));
21
+ return tmp;
22
+ }
23
+
24
+ function baseConfig(): DaemonConfig {
25
+ return {
26
+ defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
27
+ routes: [],
28
+ streamBlocks: true,
29
+ };
30
+ }
31
+
32
+ describe("discoverLocalOpenclawGateways", () => {
33
+ it("discovers JSON and TOML acp config files", async () => {
34
+ const dir = tempDir();
35
+ writeFileSync(
36
+ path.join(dir, "one.json"),
37
+ JSON.stringify({ acp: { url: "ws://127.0.0.1:18789/acp", tokenFile: "/tmp/token" } }),
38
+ );
39
+ writeFileSync(
40
+ path.join(dir, "two.toml"),
41
+ ['[acp]', 'url = "ws://127.0.0.1:18790/acp"', 'token = "secret"'].join("\n"),
42
+ );
43
+
44
+ const found = await discoverLocalOpenclawGateways({
45
+ searchPaths: [dir],
46
+ defaultPorts: [],
47
+ });
48
+
49
+ expect(found).toEqual(
50
+ expect.arrayContaining([
51
+ expect.objectContaining({
52
+ url: "ws://127.0.0.1:18789/acp",
53
+ tokenFile: "/tmp/token",
54
+ source: "config-file",
55
+ }),
56
+ expect.objectContaining({
57
+ url: "ws://127.0.0.1:18790/acp",
58
+ token: "secret",
59
+ source: "config-file",
60
+ }),
61
+ ]),
62
+ );
63
+ });
64
+
65
+ it("parses OpenClaw's native gateway.port + auth.token shape", async () => {
66
+ const dir = tempDir();
67
+ writeFileSync(
68
+ path.join(dir, "openclaw.json"),
69
+ JSON.stringify({
70
+ gateway: {
71
+ port: 18789,
72
+ bind: "loopback",
73
+ auth: { mode: "token", token: "native-token" },
74
+ },
75
+ }),
76
+ );
77
+
78
+ const found = await discoverLocalOpenclawGateways({
79
+ searchPaths: [dir],
80
+ defaultPorts: [],
81
+ });
82
+
83
+ expect(found).toEqual([
84
+ expect.objectContaining({
85
+ url: "ws://127.0.0.1:18789",
86
+ token: "native-token",
87
+ source: "config-file",
88
+ }),
89
+ ]);
90
+ });
91
+
92
+ it("uses OPENCLAW_ACP_URL and token env vars", async () => {
93
+ const found = await discoverLocalOpenclawGateways({
94
+ searchPaths: [],
95
+ defaultPorts: [],
96
+ env: {
97
+ OPENCLAW_ACP_URL: "ws://127.0.0.1:18888/acp",
98
+ OPENCLAW_ACP_TOKEN: "env-token",
99
+ },
100
+ });
101
+
102
+ expect(found).toEqual([
103
+ expect.objectContaining({
104
+ url: "ws://127.0.0.1:18888/acp",
105
+ token: "env-token",
106
+ source: "env",
107
+ }),
108
+ ]);
109
+ });
110
+
111
+ it("adds default-port candidates only when the probe succeeds", async () => {
112
+ const probe = vi.fn<WsEndpointProbeFn>(async ({ url }) => ({
113
+ ok: url.includes("18789"),
114
+ agents: [],
115
+ }));
116
+
117
+ const found = await discoverLocalOpenclawGateways({
118
+ searchPaths: [],
119
+ defaultPorts: [18789, 18790],
120
+ probe,
121
+ timeoutMs: 10,
122
+ });
123
+
124
+ expect(probe).toHaveBeenCalledTimes(2);
125
+ expect(found.map((g) => g.url)).toEqual(["ws://127.0.0.1:18789"]);
126
+ });
127
+
128
+ it("prefers config-file auth details over lower-priority duplicate sources", async () => {
129
+ const dir = tempDir();
130
+ writeFileSync(
131
+ path.join(dir, "one.json"),
132
+ JSON.stringify({ acp: { url: "ws://127.0.0.1:18789", token: "file-token" } }),
133
+ );
134
+ const probe = vi.fn<WsEndpointProbeFn>(async () => ({ ok: true }));
135
+
136
+ const found = await discoverLocalOpenclawGateways({
137
+ searchPaths: [dir],
138
+ defaultPorts: [18789],
139
+ probe,
140
+ env: {
141
+ OPENCLAW_ACP_URL: "ws://127.0.0.1:18789",
142
+ OPENCLAW_ACP_TOKEN: "env-token",
143
+ },
144
+ });
145
+
146
+ expect(found).toHaveLength(1);
147
+ expect(found[0]).toEqual(
148
+ expect.objectContaining({ source: "config-file", token: "file-token" }),
149
+ );
150
+ });
151
+ });
152
+
153
+ describe("mergeOpenclawGateways", () => {
154
+ it("backfills token onto an existing profile that lacks one", () => {
155
+ const cfg = baseConfig();
156
+ cfg.openclawGateways = [
157
+ { name: "openclaw-127-0-0-1-18789", url: "ws://127.0.0.1:18789" },
158
+ ];
159
+ const merged = mergeOpenclawGateways(cfg, [
160
+ {
161
+ name: "openclaw-127-0-0-1-18789",
162
+ url: "ws://127.0.0.1:18789",
163
+ token: "discovered",
164
+ source: "config-file",
165
+ },
166
+ ]);
167
+
168
+ expect(merged.changed).toBe(true);
169
+ expect(merged.added).toEqual([]);
170
+ expect(merged.cfg.openclawGateways).toEqual([
171
+ { name: "openclaw-127-0-0-1-18789", url: "ws://127.0.0.1:18789", token: "discovered" },
172
+ ]);
173
+ });
174
+
175
+ it("appends new URLs and keeps existing profiles untouched", () => {
176
+ const cfg = baseConfig();
177
+ cfg.openclawGateways = [{ name: "local", url: "ws://127.0.0.1:18789/acp", token: "user-token" }];
178
+ const merged = mergeOpenclawGateways(cfg, [
179
+ {
180
+ name: "openclaw-127-0-0-1-18789",
181
+ url: "ws://127.0.0.1:18789/acp",
182
+ token: "discovered-token",
183
+ source: "env",
184
+ },
185
+ {
186
+ name: "openclaw-127-0-0-1-18790",
187
+ url: "ws://127.0.0.1:18790/acp",
188
+ source: "default-port",
189
+ },
190
+ ]);
191
+
192
+ expect(merged.changed).toBe(true);
193
+ expect(merged.cfg.openclawGateways).toEqual([
194
+ { name: "local", url: "ws://127.0.0.1:18789/acp", token: "user-token" },
195
+ { name: "openclaw-127-0-0-1-18790", url: "ws://127.0.0.1:18790/acp" },
196
+ ]);
197
+ });
198
+ });
@@ -26,6 +26,7 @@ vi.mock("../config.js", async () => {
26
26
 
27
27
  const {
28
28
  addAgentToConfig,
29
+ adoptDiscoveredOpenclawAgents,
29
30
  removeAgentFromConfig,
30
31
  reloadConfig,
31
32
  setRoute,
@@ -779,6 +780,110 @@ describe("provision_agent seeds workspace + hot-adds managed route", () => {
779
780
  });
780
781
  });
781
782
 
783
+ describe("adoptDiscoveredOpenclawAgents", () => {
784
+ it("registers unbound OpenClaw agents and writes the routing binding", async () => {
785
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
786
+ const credDir = nodePath.join(tmp, ".botcord", "credentials");
787
+ fs.mkdirSync(credDir, { recursive: true });
788
+ fs.writeFileSync(
789
+ nodePath.join(credDir, "ag_seed.json"),
790
+ JSON.stringify({
791
+ version: 1,
792
+ hubUrl: "https://hub.example",
793
+ agentId: "ag_seed",
794
+ keyId: "k_seed",
795
+ privateKey: Buffer.alloc(32, 5).toString("base64"),
796
+ savedAt: new Date().toISOString(),
797
+ }),
798
+ );
799
+ mockState.cfg = {
800
+ defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
801
+ routes: [],
802
+ streamBlocks: true,
803
+ agents: ["ag_seed"],
804
+ openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
805
+ };
806
+
807
+ const gw = makeFakeGateway(["ag_seed"]);
808
+ const register = vi.fn(async () => ({
809
+ agentId: "ag_adopted",
810
+ keyId: "k_adopted",
811
+ privateKey: Buffer.alloc(32, 31).toString("base64"),
812
+ publicKey: Buffer.alloc(32, 32).toString("base64"),
813
+ hubUrl: "https://hub.example",
814
+ token: "tok",
815
+ expiresAt: Date.now() + 60_000,
816
+ }));
817
+
818
+ const res = await adoptDiscoveredOpenclawAgents({
819
+ gateway: gw as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["gateway"],
820
+ register: register as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["register"],
821
+ cfg: mockState.cfg as unknown as DaemonConfig,
822
+ probe: async () => ({ ok: true, agents: [{ id: "main", name: "Main Agent" }] }),
823
+ });
824
+
825
+ expect(res.adopted).toEqual(["ag_adopted"]);
826
+ expect(register).toHaveBeenCalledWith("https://hub.example", "Main Agent", undefined);
827
+ const saved = JSON.parse(
828
+ fs.readFileSync(nodePath.join(credDir, "ag_adopted.json"), "utf8"),
829
+ ) as Record<string, unknown>;
830
+ expect(saved.runtime).toBe("openclaw-acp");
831
+ expect(saved.openclawGateway).toBe("local");
832
+ expect(saved.openclawAgent).toBe("main");
833
+ expect((mockState.cfg.agents as string[])).toContain("ag_adopted");
834
+ expect(gw.addChannel).toHaveBeenCalledWith(
835
+ expect.objectContaining({ id: "ag_adopted", type: "botcord" }),
836
+ );
837
+ const route = gw.listManagedRoutes().find((r) => r.match?.accountId === "ag_adopted");
838
+ expect(route?.runtime).toBe("openclaw-acp");
839
+ expect(route?.gateway?.name).toBe("local");
840
+ expect(route?.gateway?.openclawAgent).toBe("main");
841
+ });
842
+ });
843
+
844
+ it("skips an OpenClaw agent that is already bound in credentials", async () => {
845
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
846
+ const credDir = nodePath.join(tmp, ".botcord", "credentials");
847
+ fs.mkdirSync(credDir, { recursive: true });
848
+ fs.writeFileSync(
849
+ nodePath.join(credDir, "ag_existing.json"),
850
+ JSON.stringify({
851
+ version: 1,
852
+ hubUrl: "https://hub.example",
853
+ agentId: "ag_existing",
854
+ keyId: "k_existing",
855
+ privateKey: Buffer.alloc(32, 6).toString("base64"),
856
+ savedAt: new Date().toISOString(),
857
+ runtime: "openclaw-acp",
858
+ openclawGateway: "local",
859
+ openclawAgent: "main",
860
+ }),
861
+ );
862
+ mockState.cfg = {
863
+ defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
864
+ routes: [],
865
+ streamBlocks: true,
866
+ agents: ["ag_existing"],
867
+ openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
868
+ };
869
+ const register = vi.fn();
870
+
871
+ const res = await adoptDiscoveredOpenclawAgents({
872
+ gateway: makeFakeGateway(["ag_existing"]) as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["gateway"],
873
+ register: register as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["register"],
874
+ cfg: mockState.cfg as unknown as DaemonConfig,
875
+ probe: async () => ({ ok: true, agents: [{ id: "main" }] }),
876
+ });
877
+
878
+ expect(res.adopted).toEqual([]);
879
+ expect(res.skipped).toEqual([
880
+ { gateway: "local", openclawAgent: "main", reason: "already_bound" },
881
+ ]);
882
+ expect(register).not.toHaveBeenCalled();
883
+ });
884
+ });
885
+ });
886
+
782
887
  // ---------------------------------------------------------------------------
783
888
  // revoke_agent — new flag semantics (plan §11.3)
784
889
  // ---------------------------------------------------------------------------
package/src/config.ts CHANGED
@@ -88,6 +88,17 @@ export interface AgentDiscoveryConfig {
88
88
  credentialsDir?: string;
89
89
  }
90
90
 
91
+ export interface OpenclawDiscoveryConfig {
92
+ /** Defaults to true. */
93
+ enabled?: boolean;
94
+ /** Overrides the local config-file search roots. */
95
+ searchPaths?: string[];
96
+ /** Overrides the local loopback ports to probe. */
97
+ defaultPorts?: number[];
98
+ /** Defaults to true. When false, discovery only persists gateways. */
99
+ autoProvision?: boolean;
100
+ }
101
+
91
102
  export interface DaemonConfig {
92
103
  /**
93
104
  * @deprecated Kept for backward compatibility with pre-multi-agent configs.
@@ -131,6 +142,12 @@ export interface DaemonConfig {
131
142
  * so the dispatcher never re-queries this list.
132
143
  */
133
144
  openclawGateways?: OpenclawGatewayProfile[];
145
+
146
+ /**
147
+ * Daemon-side local OpenClaw discovery. Omitted means enabled with default
148
+ * search paths/ports and automatic adoption of discovered agents.
149
+ */
150
+ openclawDiscovery?: OpenclawDiscoveryConfig;
134
151
  }
135
152
 
136
153
  /**
@@ -357,6 +374,25 @@ export function loadConfig(): DaemonConfig {
357
374
  }
358
375
  out.agentDiscovery = copy;
359
376
  }
377
+ const openclawDiscovery = parsed.openclawDiscovery;
378
+ if (openclawDiscovery && typeof openclawDiscovery === "object") {
379
+ const copy: OpenclawDiscoveryConfig = {};
380
+ if (typeof openclawDiscovery.enabled === "boolean") copy.enabled = openclawDiscovery.enabled;
381
+ if (Array.isArray(openclawDiscovery.searchPaths)) {
382
+ copy.searchPaths = openclawDiscovery.searchPaths.filter(
383
+ (p): p is string => typeof p === "string" && p.length > 0,
384
+ );
385
+ }
386
+ if (Array.isArray(openclawDiscovery.defaultPorts)) {
387
+ copy.defaultPorts = openclawDiscovery.defaultPorts.filter(
388
+ (p): p is number => Number.isInteger(p) && p > 0 && p < 65536,
389
+ );
390
+ }
391
+ if (typeof openclawDiscovery.autoProvision === "boolean") {
392
+ copy.autoProvision = openclawDiscovery.autoProvision;
393
+ }
394
+ out.openclawDiscovery = copy;
395
+ }
360
396
  return out;
361
397
  }
362
398
 
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.