@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/config.d.ts +15 -0
- package/dist/config.js +16 -0
- package/dist/daemon.js +24 -1
- package/dist/index.js +24 -1
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +41 -0
- package/dist/provision.js +225 -36
- package/package.json +2 -2
- package/src/__tests__/openclaw-discovery.test.ts +150 -0
- package/src/__tests__/provision.test.ts +105 -0
- package/src/config.ts +36 -0
- package/src/daemon.ts +30 -1
- package/src/index.ts +27 -1
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +277 -38
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|