@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/dist/provision.js CHANGED
@@ -13,6 +13,7 @@ import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from
13
13
  import { agentHomeDir, agentStateDir, agentWorkspaceDir, applyAgentIdentity, ensureAgentWorkspace, } from "./agent-workspace.js";
14
14
  import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
15
15
  import { log as daemonLog } from "./log.js";
16
+ import { discoverAgentCredentials } from "./agent-discovery.js";
16
17
  /**
17
18
  * Build a dispatcher function that routes a `ControlFrame` to the right
18
19
  * handler. Returned function signature matches
@@ -179,6 +180,7 @@ export function createProvisioner(opts) {
179
180
  }
180
181
  };
181
182
  }
183
+ const openclawProvisionLocks = new Map();
182
184
  async function provisionAgent(params, ctx) {
183
185
  // Validate both caller-supplied cwd sources up front. Previously only
184
186
  // `params.cwd` was checked, so `params.credentials.cwd` could smuggle an
@@ -186,13 +188,44 @@ async function provisionAgent(params, ctx) {
186
188
  // that hole by moving the check to the union of both.
187
189
  const explicitCwd = params.credentials?.cwd ?? params.cwd;
188
190
  assertSafeCwd(explicitCwd);
191
+ const openclawSel = pickOpenclawSelection(params);
192
+ if (openclawSel.gateway && openclawSel.agent) {
193
+ return withOpenclawProvisionLock(openclawSel.gateway, openclawSel.agent, async () => {
194
+ const existing = findCredentialsByOpenclaw(openclawSel.gateway, openclawSel.agent);
195
+ if (existing) {
196
+ daemonLog.info("provision_agent: openclaw binding already exists", {
197
+ gateway: openclawSel.gateway,
198
+ openclawAgent: openclawSel.agent,
199
+ agentId: existing.agentId,
200
+ });
201
+ return installExistingOpenclawBinding(existing.agentId, ctx);
202
+ }
203
+ const cfg = loadConfig();
204
+ const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
205
+ return installLocalAgent(credentials, {
206
+ ...ctx,
207
+ cfg,
208
+ bio: params.bio,
209
+ source: params.credentials ? "hub-supplied" : "registered",
210
+ });
211
+ });
212
+ }
189
213
  const cfg = loadConfig();
190
214
  const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
215
+ return installLocalAgent(credentials, {
216
+ ...ctx,
217
+ cfg,
218
+ bio: params.bio,
219
+ source: params.credentials ? "hub-supplied" : "registered",
220
+ });
221
+ }
222
+ async function installLocalAgent(credentials, ctx) {
223
+ const cfg = ctx.cfg;
191
224
  daemonLog.debug("provision: credentials materialized", {
192
225
  agentId: credentials.agentId,
193
226
  hubUrl: credentials.hubUrl,
194
227
  runtime: credentials.runtime ?? null,
195
- source: params.credentials ? "hub-supplied" : "registered",
228
+ source: ctx.source,
196
229
  });
197
230
  const credentialsFile = writeCredentialsFile(defaultCredentialsFile(credentials.agentId), credentials);
198
231
  // Seed the per-agent workspace directory. On failure, unlink the fresh
@@ -201,7 +234,7 @@ async function provisionAgent(params, ctx) {
201
234
  try {
202
235
  ensureAgentWorkspace(credentials.agentId, {
203
236
  displayName: credentials.displayName,
204
- bio: params.bio,
237
+ bio: ctx.bio,
205
238
  runtime: credentials.runtime,
206
239
  keyId: credentials.keyId,
207
240
  savedAt: credentials.savedAt,
@@ -267,35 +300,7 @@ async function provisionAgent(params, ctx) {
267
300
  // Hot-add the synthesized per-agent managed route so the next turn picks
268
301
  // the agent's runtime + workspace cwd without waiting for reload_config.
269
302
  try {
270
- const synthRoute = {
271
- match: { accountId: credentials.agentId },
272
- runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
273
- cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
274
- };
275
- if (synthRoute.runtime === "openclaw-acp") {
276
- // Resolve gateway from the freshly written credentials + the live
277
- // openclawGateways registry. A missing/unknown gateway here yields a
278
- // disabled route (set_route style); next turn for this agent falls
279
- // back to defaultRoute. Caller already validated via reload semantics.
280
- const profile = (cfg.openclawGateways ?? []).find((g) => g.name === credentials.openclawGateway);
281
- if (profile) {
282
- // Run the same tokenFile-aware resolver `toGatewayConfig` uses so the
283
- // first turn after provisioning doesn't auth-fail when the gateway
284
- // ships its bearer via `tokenFile` instead of an inline `token`.
285
- const prepared = prepareGatewayProfile(profile);
286
- synthRoute.gateway = {
287
- name: prepared.name,
288
- url: prepared.url,
289
- ...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
290
- ...(credentials.openclawAgent
291
- ? { openclawAgent: credentials.openclawAgent }
292
- : prepared.defaultAgent
293
- ? { openclawAgent: prepared.defaultAgent }
294
- : {}),
295
- };
296
- }
297
- }
298
- ctx.gateway.upsertManagedRoute(credentials.agentId, synthRoute);
303
+ upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
299
304
  }
300
305
  catch (err) {
301
306
  // Rollback the channel + config + credentials on managed-route failure
@@ -336,6 +341,53 @@ async function provisionAgent(params, ctx) {
336
341
  credentialsFile,
337
342
  };
338
343
  }
344
+ function upsertManagedRouteForCredentials(credentials, cfg, gateway) {
345
+ const synthRoute = {
346
+ match: { accountId: credentials.agentId },
347
+ runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
348
+ cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
349
+ };
350
+ if (synthRoute.runtime === "openclaw-acp") {
351
+ const profile = (cfg.openclawGateways ?? []).find((g) => g.name === credentials.openclawGateway);
352
+ if (profile) {
353
+ const prepared = prepareGatewayProfile(profile);
354
+ synthRoute.gateway = {
355
+ name: prepared.name,
356
+ url: prepared.url,
357
+ ...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
358
+ ...(credentials.openclawAgent
359
+ ? { openclawAgent: credentials.openclawAgent }
360
+ : prepared.defaultAgent
361
+ ? { openclawAgent: prepared.defaultAgent }
362
+ : {}),
363
+ };
364
+ }
365
+ }
366
+ gateway.upsertManagedRoute(credentials.agentId, synthRoute);
367
+ }
368
+ async function installExistingOpenclawBinding(agentId, ctx) {
369
+ const credentialsFile = defaultCredentialsFile(agentId);
370
+ const credentials = loadStoredCredentials(credentialsFile);
371
+ const cfg = loadConfig();
372
+ const updated = addAgentToConfig(cfg, credentials.agentId);
373
+ if (updated)
374
+ saveConfig(updated);
375
+ const snap = ctx.gateway.snapshot();
376
+ if (!snap.channels[credentials.agentId]) {
377
+ await ctx.gateway.addChannel({
378
+ id: credentials.agentId,
379
+ type: BOTCORD_CHANNEL_TYPE,
380
+ accountId: credentials.agentId,
381
+ agentId: credentials.agentId,
382
+ });
383
+ }
384
+ upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
385
+ return {
386
+ agentId: credentials.agentId,
387
+ hubUrl: credentials.hubUrl,
388
+ credentialsFile,
389
+ };
390
+ }
339
391
  async function materializeCredentials(params, cfg, ctx, explicitCwd) {
340
392
  // Runtime is an agent property. Hub is authoritative; top-level `runtime`
341
393
  // wins, `adapter` is a one-release alias, and `credentials.runtime` is the
@@ -442,6 +494,121 @@ function pickOpenclawSelection(params) {
442
494
  }
443
495
  return out;
444
496
  }
497
+ async function withOpenclawProvisionLock(gateway, agent, fn) {
498
+ const key = `${gateway}\0${agent}`;
499
+ const prev = openclawProvisionLocks.get(key) ?? Promise.resolve();
500
+ let release;
501
+ const current = new Promise((resolve) => {
502
+ release = resolve;
503
+ });
504
+ const chain = prev.then(() => current);
505
+ openclawProvisionLocks.set(key, chain);
506
+ await prev.catch(() => undefined);
507
+ try {
508
+ return await fn();
509
+ }
510
+ finally {
511
+ release();
512
+ if (openclawProvisionLocks.get(key) === chain) {
513
+ openclawProvisionLocks.delete(key);
514
+ }
515
+ }
516
+ }
517
+ function findCredentialsByOpenclaw(gateway, openclawAgent) {
518
+ const discovered = discoverAgentCredentials({
519
+ credentialsDir: path.join(homedir(), ".botcord", "credentials"),
520
+ });
521
+ for (const a of discovered.agents) {
522
+ if (a.openclawGateway === gateway && a.openclawAgent === openclawAgent) {
523
+ return { agentId: a.agentId, credentialsFile: a.credentialsFile };
524
+ }
525
+ }
526
+ return null;
527
+ }
528
+ export async function adoptDiscoveredOpenclawAgents(ctx) {
529
+ const register = ctx.register ?? BotCordClient.register;
530
+ const cfg = ctx.cfg ?? loadConfig();
531
+ const result = {
532
+ adopted: [],
533
+ skipped: [],
534
+ failed: [],
535
+ };
536
+ for (const gw of cfg.openclawGateways ?? []) {
537
+ let probeResult;
538
+ try {
539
+ probeResult = await probeOpenclawAgents(gw, {
540
+ timeoutMs: ctx.timeoutMs,
541
+ probe: ctx.probe,
542
+ });
543
+ }
544
+ catch (err) {
545
+ result.failed.push({
546
+ gateway: gw.name,
547
+ error: err instanceof Error ? err.message : String(err),
548
+ });
549
+ continue;
550
+ }
551
+ if (!probeResult.ok) {
552
+ result.skipped.push({
553
+ gateway: gw.name,
554
+ reason: probeResult.error ?? "gateway_unreachable",
555
+ });
556
+ continue;
557
+ }
558
+ for (const oc of probeResult.agents ?? []) {
559
+ await withOpenclawProvisionLock(gw.name, oc.id, async () => {
560
+ const existing = findCredentialsByOpenclaw(gw.name, oc.id);
561
+ if (existing) {
562
+ result.skipped.push({
563
+ gateway: gw.name,
564
+ openclawAgent: oc.id,
565
+ reason: "already_bound",
566
+ });
567
+ return;
568
+ }
569
+ const freshCfg = loadConfig();
570
+ if (!inferHubUrl(freshCfg)) {
571
+ result.skipped.push({
572
+ gateway: gw.name,
573
+ openclawAgent: oc.id,
574
+ reason: "missing_hub_url",
575
+ });
576
+ daemonLog.warn("openclaw adopt skipped: no known hubUrl", {
577
+ gateway: gw.name,
578
+ openclawAgent: oc.id,
579
+ });
580
+ return;
581
+ }
582
+ try {
583
+ const params = {
584
+ runtime: "openclaw-acp",
585
+ name: oc.name ?? `openclaw-${oc.id}`,
586
+ openclaw: { gateway: gw.name, agent: oc.id },
587
+ };
588
+ const credentials = await materializeCredentials(params, freshCfg, {
589
+ gateway: ctx.gateway,
590
+ register,
591
+ }, undefined);
592
+ const installed = await installLocalAgent(credentials, {
593
+ gateway: ctx.gateway,
594
+ register,
595
+ cfg: freshCfg,
596
+ source: "adopted-openclaw",
597
+ });
598
+ result.adopted.push(installed.agentId);
599
+ }
600
+ catch (err) {
601
+ result.failed.push({
602
+ gateway: gw.name,
603
+ openclawAgent: oc.id,
604
+ error: err instanceof Error ? err.message : String(err),
605
+ });
606
+ }
607
+ });
608
+ }
609
+ }
610
+ return result;
611
+ }
445
612
  async function revokeAgent(params, ctx) {
446
613
  if (!params.agentId) {
447
614
  throw new Error("revoke_agent requires params.agentId");
@@ -738,6 +905,20 @@ async function defaultWsProbe(args) {
738
905
  });
739
906
  });
740
907
  }
908
+ export async function probeOpenclawAgents(profile, opts = {}) {
909
+ const probe = opts.probe ?? defaultWsProbe;
910
+ const prepared = prepareGatewayProfile({
911
+ name: "probe",
912
+ url: profile.url,
913
+ ...(profile.token ? { token: profile.token } : {}),
914
+ ...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
915
+ });
916
+ return probe({
917
+ url: profile.url,
918
+ token: prepared.resolvedToken,
919
+ timeoutMs: opts.timeoutMs ?? 3000,
920
+ });
921
+ }
741
922
  /**
742
923
  * Async variant that includes L2 (gateway reachability) and L3 (agent listing)
743
924
  * probes for runtimes that talk to external services. Used by the production
@@ -752,18 +933,17 @@ export async function collectRuntimeSnapshotAsync(opts = {}) {
752
933
  const gateways = opts.cfg?.openclawGateways ?? [];
753
934
  if (gateways.length === 0)
754
935
  return base;
755
- const probe = opts.wsProbe ?? defaultWsProbe;
756
936
  // Default daemon-side budget is 3s — it must stay below the Hub's
757
937
  // `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
758
938
  // so a single slow gateway can't blow the whole snapshot to a 504.
759
939
  const timeoutMs = opts.timeoutMs ?? 3000;
760
940
  const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
761
941
  const endpoints = await Promise.all(capped.map(async (g) => {
762
- // Resolve `tokenFile` here so token-file-only profiles probe with auth
763
- // and aren't falsely marked unreachable in the dashboard.
764
- const prepared = prepareGatewayProfile(g);
765
942
  try {
766
- const res = await probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
943
+ const res = await probeOpenclawAgents(g, {
944
+ probe: opts.wsProbe,
945
+ timeoutMs,
946
+ });
767
947
  const entry = { name: g.name, url: g.url, reachable: res.ok };
768
948
  if (res.version)
769
949
  entry.version = res.version;
@@ -1106,5 +1286,14 @@ function inferHubUrl(cfg) {
1106
1286
  // skip
1107
1287
  }
1108
1288
  }
1289
+ if (ids.length === 0) {
1290
+ const discovered = discoverAgentCredentials({
1291
+ credentialsDir: path.join(homedir(), ".botcord", "credentials"),
1292
+ });
1293
+ for (const a of discovered.agents) {
1294
+ if (a.hubUrl)
1295
+ return a.hubUrl;
1296
+ }
1297
+ }
1109
1298
  return null;
1110
1299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@botcord/cli": "^0.1.7",
31
- "@botcord/protocol-core": "^0.2.0",
31
+ "@botcord/protocol-core": "^0.2.2",
32
32
  "ws": "^8.18.0"
33
33
  },
34
34
  "devDependencies": {
@@ -0,0 +1,150 @@
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("uses OPENCLAW_ACP_URL and token env vars", async () => {
66
+ const found = await discoverLocalOpenclawGateways({
67
+ searchPaths: [],
68
+ defaultPorts: [],
69
+ env: {
70
+ OPENCLAW_ACP_URL: "ws://127.0.0.1:18888/acp",
71
+ OPENCLAW_ACP_TOKEN: "env-token",
72
+ },
73
+ });
74
+
75
+ expect(found).toEqual([
76
+ expect.objectContaining({
77
+ url: "ws://127.0.0.1:18888/acp",
78
+ token: "env-token",
79
+ source: "env",
80
+ }),
81
+ ]);
82
+ });
83
+
84
+ it("adds default-port candidates only when the probe succeeds", async () => {
85
+ const probe = vi.fn<WsEndpointProbeFn>(async ({ url }) => ({
86
+ ok: url.includes("18789"),
87
+ agents: [],
88
+ }));
89
+
90
+ const found = await discoverLocalOpenclawGateways({
91
+ searchPaths: [],
92
+ defaultPorts: [18789, 18790],
93
+ probe,
94
+ timeoutMs: 10,
95
+ });
96
+
97
+ expect(probe).toHaveBeenCalledTimes(2);
98
+ expect(found.map((g) => g.url)).toEqual(["ws://127.0.0.1:18789"]);
99
+ });
100
+
101
+ it("prefers config-file auth details over lower-priority duplicate sources", async () => {
102
+ const dir = tempDir();
103
+ writeFileSync(
104
+ path.join(dir, "one.json"),
105
+ JSON.stringify({ acp: { url: "ws://127.0.0.1:18789", token: "file-token" } }),
106
+ );
107
+ const probe = vi.fn<WsEndpointProbeFn>(async () => ({ ok: true }));
108
+
109
+ const found = await discoverLocalOpenclawGateways({
110
+ searchPaths: [dir],
111
+ defaultPorts: [18789],
112
+ probe,
113
+ env: {
114
+ OPENCLAW_ACP_URL: "ws://127.0.0.1:18789",
115
+ OPENCLAW_ACP_TOKEN: "env-token",
116
+ },
117
+ });
118
+
119
+ expect(found).toHaveLength(1);
120
+ expect(found[0]).toEqual(
121
+ expect.objectContaining({ source: "config-file", token: "file-token" }),
122
+ );
123
+ });
124
+ });
125
+
126
+ describe("mergeOpenclawGateways", () => {
127
+ it("appends new URLs and keeps existing profiles untouched", () => {
128
+ const cfg = baseConfig();
129
+ cfg.openclawGateways = [{ name: "local", url: "ws://127.0.0.1:18789/acp", token: "user-token" }];
130
+ const merged = mergeOpenclawGateways(cfg, [
131
+ {
132
+ name: "openclaw-127-0-0-1-18789",
133
+ url: "ws://127.0.0.1:18789/acp",
134
+ token: "discovered-token",
135
+ source: "env",
136
+ },
137
+ {
138
+ name: "openclaw-127-0-0-1-18790",
139
+ url: "ws://127.0.0.1:18790/acp",
140
+ source: "default-port",
141
+ },
142
+ ]);
143
+
144
+ expect(merged.changed).toBe(true);
145
+ expect(merged.cfg.openclawGateways).toEqual([
146
+ { name: "local", url: "ws://127.0.0.1:18789/acp", token: "user-token" },
147
+ { name: "openclaw-127-0-0-1-18790", url: "ws://127.0.0.1:18790/acp" },
148
+ ]);
149
+ });
150
+ });
@@ -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