@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.
- 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 +272 -0
- package/dist/provision.d.ts +41 -0
- package/dist/provision.js +369 -90
- package/package.json +2 -2
- package/src/__tests__/openclaw-discovery.test.ts +198 -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 +305 -0
- package/src/provision.ts +411 -86
package/src/provision.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* side effects (register agent, write credentials, load route, add/remove
|
|
5
5
|
* gateway channel) and return an ack payload.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import {
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from "./agent-workspace.js";
|
|
56
56
|
import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
|
|
57
57
|
import { log as daemonLog } from "./log.js";
|
|
58
|
+
import { discoverAgentCredentials } from "./agent-discovery.js";
|
|
58
59
|
|
|
59
60
|
/** Options accepted by {@link createProvisioner}. */
|
|
60
61
|
export interface ProvisionerOptions {
|
|
@@ -267,6 +268,8 @@ interface ProvisionCtx {
|
|
|
267
268
|
register: typeof BotCordClient.register;
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
const openclawProvisionLocks = new Map<string, Promise<unknown>>();
|
|
272
|
+
|
|
270
273
|
async function provisionAgent(
|
|
271
274
|
params: ProvisionAgentParams,
|
|
272
275
|
ctx: ProvisionCtx,
|
|
@@ -278,13 +281,53 @@ async function provisionAgent(
|
|
|
278
281
|
const explicitCwd = params.credentials?.cwd ?? params.cwd;
|
|
279
282
|
assertSafeCwd(explicitCwd);
|
|
280
283
|
|
|
284
|
+
const openclawSel = pickOpenclawSelection(params);
|
|
285
|
+
if (openclawSel.gateway && openclawSel.agent) {
|
|
286
|
+
return withOpenclawProvisionLock(openclawSel.gateway, openclawSel.agent, async () => {
|
|
287
|
+
const existing = findCredentialsByOpenclaw(openclawSel.gateway!, openclawSel.agent!);
|
|
288
|
+
if (existing) {
|
|
289
|
+
daemonLog.info("provision_agent: openclaw binding already exists", {
|
|
290
|
+
gateway: openclawSel.gateway,
|
|
291
|
+
openclawAgent: openclawSel.agent,
|
|
292
|
+
agentId: existing.agentId,
|
|
293
|
+
});
|
|
294
|
+
return installExistingOpenclawBinding(existing.agentId, ctx);
|
|
295
|
+
}
|
|
296
|
+
const cfg = loadConfig();
|
|
297
|
+
const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
|
|
298
|
+
return installLocalAgent(credentials, {
|
|
299
|
+
...ctx,
|
|
300
|
+
cfg,
|
|
301
|
+
bio: params.bio,
|
|
302
|
+
source: params.credentials ? "hub-supplied" : "registered",
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
281
307
|
const cfg = loadConfig();
|
|
282
308
|
const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
|
|
309
|
+
return installLocalAgent(credentials, {
|
|
310
|
+
...ctx,
|
|
311
|
+
cfg,
|
|
312
|
+
bio: params.bio,
|
|
313
|
+
source: params.credentials ? "hub-supplied" : "registered",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function installLocalAgent(
|
|
318
|
+
credentials: StoredBotCordCredentials,
|
|
319
|
+
ctx: ProvisionCtx & {
|
|
320
|
+
cfg: DaemonConfig;
|
|
321
|
+
bio?: string;
|
|
322
|
+
source: "hub-supplied" | "registered" | "adopted-openclaw";
|
|
323
|
+
},
|
|
324
|
+
): Promise<ProvisionedAgent> {
|
|
325
|
+
const cfg = ctx.cfg;
|
|
283
326
|
daemonLog.debug("provision: credentials materialized", {
|
|
284
327
|
agentId: credentials.agentId,
|
|
285
328
|
hubUrl: credentials.hubUrl,
|
|
286
329
|
runtime: credentials.runtime ?? null,
|
|
287
|
-
source:
|
|
330
|
+
source: ctx.source,
|
|
288
331
|
});
|
|
289
332
|
|
|
290
333
|
const credentialsFile = writeCredentialsFile(
|
|
@@ -298,7 +341,7 @@ async function provisionAgent(
|
|
|
298
341
|
try {
|
|
299
342
|
ensureAgentWorkspace(credentials.agentId, {
|
|
300
343
|
displayName: credentials.displayName,
|
|
301
|
-
bio:
|
|
344
|
+
bio: ctx.bio,
|
|
302
345
|
runtime: credentials.runtime,
|
|
303
346
|
keyId: credentials.keyId,
|
|
304
347
|
savedAt: credentials.savedAt,
|
|
@@ -358,37 +401,7 @@ async function provisionAgent(
|
|
|
358
401
|
// Hot-add the synthesized per-agent managed route so the next turn picks
|
|
359
402
|
// the agent's runtime + workspace cwd without waiting for reload_config.
|
|
360
403
|
try {
|
|
361
|
-
|
|
362
|
-
match: { accountId: credentials.agentId },
|
|
363
|
-
runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
|
|
364
|
-
cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
|
|
365
|
-
};
|
|
366
|
-
if (synthRoute.runtime === "openclaw-acp") {
|
|
367
|
-
// Resolve gateway from the freshly written credentials + the live
|
|
368
|
-
// openclawGateways registry. A missing/unknown gateway here yields a
|
|
369
|
-
// disabled route (set_route style); next turn for this agent falls
|
|
370
|
-
// back to defaultRoute. Caller already validated via reload semantics.
|
|
371
|
-
const profile = (cfg.openclawGateways ?? []).find(
|
|
372
|
-
(g) => g.name === credentials.openclawGateway,
|
|
373
|
-
);
|
|
374
|
-
if (profile) {
|
|
375
|
-
// Run the same tokenFile-aware resolver `toGatewayConfig` uses so the
|
|
376
|
-
// first turn after provisioning doesn't auth-fail when the gateway
|
|
377
|
-
// ships its bearer via `tokenFile` instead of an inline `token`.
|
|
378
|
-
const prepared = prepareGatewayProfile(profile);
|
|
379
|
-
synthRoute.gateway = {
|
|
380
|
-
name: prepared.name,
|
|
381
|
-
url: prepared.url,
|
|
382
|
-
...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
|
|
383
|
-
...(credentials.openclawAgent
|
|
384
|
-
? { openclawAgent: credentials.openclawAgent }
|
|
385
|
-
: prepared.defaultAgent
|
|
386
|
-
? { openclawAgent: prepared.defaultAgent }
|
|
387
|
-
: {}),
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
ctx.gateway.upsertManagedRoute(credentials.agentId, synthRoute);
|
|
404
|
+
upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
|
|
392
405
|
} catch (err) {
|
|
393
406
|
// Rollback the channel + config + credentials on managed-route failure
|
|
394
407
|
// (shouldn't happen — pure map op — but keeps the invariant tight).
|
|
@@ -427,6 +440,63 @@ async function provisionAgent(
|
|
|
427
440
|
};
|
|
428
441
|
}
|
|
429
442
|
|
|
443
|
+
function upsertManagedRouteForCredentials(
|
|
444
|
+
credentials: StoredBotCordCredentials,
|
|
445
|
+
cfg: DaemonConfig,
|
|
446
|
+
gateway: Gateway,
|
|
447
|
+
): void {
|
|
448
|
+
const synthRoute: import("./gateway/index.js").GatewayRoute = {
|
|
449
|
+
match: { accountId: credentials.agentId },
|
|
450
|
+
runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
|
|
451
|
+
cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
|
|
452
|
+
};
|
|
453
|
+
if (synthRoute.runtime === "openclaw-acp") {
|
|
454
|
+
const profile = (cfg.openclawGateways ?? []).find(
|
|
455
|
+
(g) => g.name === credentials.openclawGateway,
|
|
456
|
+
);
|
|
457
|
+
if (profile) {
|
|
458
|
+
const prepared = prepareGatewayProfile(profile);
|
|
459
|
+
synthRoute.gateway = {
|
|
460
|
+
name: prepared.name,
|
|
461
|
+
url: prepared.url,
|
|
462
|
+
...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
|
|
463
|
+
...(credentials.openclawAgent
|
|
464
|
+
? { openclawAgent: credentials.openclawAgent }
|
|
465
|
+
: prepared.defaultAgent
|
|
466
|
+
? { openclawAgent: prepared.defaultAgent }
|
|
467
|
+
: {}),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
gateway.upsertManagedRoute(credentials.agentId, synthRoute);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function installExistingOpenclawBinding(
|
|
475
|
+
agentId: string,
|
|
476
|
+
ctx: ProvisionCtx,
|
|
477
|
+
): Promise<ProvisionedAgent> {
|
|
478
|
+
const credentialsFile = defaultCredentialsFile(agentId);
|
|
479
|
+
const credentials = loadStoredCredentials(credentialsFile);
|
|
480
|
+
const cfg = loadConfig();
|
|
481
|
+
const updated = addAgentToConfig(cfg, credentials.agentId);
|
|
482
|
+
if (updated) saveConfig(updated);
|
|
483
|
+
const snap = ctx.gateway.snapshot();
|
|
484
|
+
if (!snap.channels[credentials.agentId]) {
|
|
485
|
+
await ctx.gateway.addChannel({
|
|
486
|
+
id: credentials.agentId,
|
|
487
|
+
type: BOTCORD_CHANNEL_TYPE,
|
|
488
|
+
accountId: credentials.agentId,
|
|
489
|
+
agentId: credentials.agentId,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
|
|
493
|
+
return {
|
|
494
|
+
agentId: credentials.agentId,
|
|
495
|
+
hubUrl: credentials.hubUrl,
|
|
496
|
+
credentialsFile,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
430
500
|
async function materializeCredentials(
|
|
431
501
|
params: ProvisionAgentParams,
|
|
432
502
|
cfg: DaemonConfig,
|
|
@@ -537,6 +607,140 @@ function pickOpenclawSelection(
|
|
|
537
607
|
return out;
|
|
538
608
|
}
|
|
539
609
|
|
|
610
|
+
async function withOpenclawProvisionLock<T>(
|
|
611
|
+
gateway: string,
|
|
612
|
+
agent: string,
|
|
613
|
+
fn: () => Promise<T>,
|
|
614
|
+
): Promise<T> {
|
|
615
|
+
const key = `${gateway}\0${agent}`;
|
|
616
|
+
const prev = openclawProvisionLocks.get(key) ?? Promise.resolve();
|
|
617
|
+
let release!: () => void;
|
|
618
|
+
const current = new Promise<void>((resolve) => {
|
|
619
|
+
release = resolve;
|
|
620
|
+
});
|
|
621
|
+
const chain = prev.then(() => current);
|
|
622
|
+
openclawProvisionLocks.set(key, chain);
|
|
623
|
+
await prev.catch(() => undefined);
|
|
624
|
+
try {
|
|
625
|
+
return await fn();
|
|
626
|
+
} finally {
|
|
627
|
+
release();
|
|
628
|
+
if (openclawProvisionLocks.get(key) === chain) {
|
|
629
|
+
openclawProvisionLocks.delete(key);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function findCredentialsByOpenclaw(
|
|
635
|
+
gateway: string,
|
|
636
|
+
openclawAgent: string,
|
|
637
|
+
): { agentId: string; credentialsFile: string } | null {
|
|
638
|
+
const discovered = discoverAgentCredentials({
|
|
639
|
+
credentialsDir: path.join(homedir(), ".botcord", "credentials"),
|
|
640
|
+
});
|
|
641
|
+
for (const a of discovered.agents) {
|
|
642
|
+
if (a.openclawGateway === gateway && a.openclawAgent === openclawAgent) {
|
|
643
|
+
return { agentId: a.agentId, credentialsFile: a.credentialsFile };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export interface AdoptDiscoveredOpenclawAgentsResult {
|
|
650
|
+
adopted: string[];
|
|
651
|
+
skipped: Array<{ gateway: string; openclawAgent?: string; reason: string }>;
|
|
652
|
+
failed: Array<{ gateway: string; openclawAgent?: string; error: string }>;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export async function adoptDiscoveredOpenclawAgents(ctx: {
|
|
656
|
+
gateway: Gateway;
|
|
657
|
+
register?: typeof BotCordClient.register;
|
|
658
|
+
cfg?: DaemonConfig;
|
|
659
|
+
timeoutMs?: number;
|
|
660
|
+
probe?: WsEndpointProbeFn;
|
|
661
|
+
}): Promise<AdoptDiscoveredOpenclawAgentsResult> {
|
|
662
|
+
const register = ctx.register ?? BotCordClient.register;
|
|
663
|
+
const cfg = ctx.cfg ?? loadConfig();
|
|
664
|
+
const result: AdoptDiscoveredOpenclawAgentsResult = {
|
|
665
|
+
adopted: [],
|
|
666
|
+
skipped: [],
|
|
667
|
+
failed: [],
|
|
668
|
+
};
|
|
669
|
+
for (const gw of cfg.openclawGateways ?? []) {
|
|
670
|
+
let probeResult: Awaited<ReturnType<typeof probeOpenclawAgents>>;
|
|
671
|
+
try {
|
|
672
|
+
probeResult = await probeOpenclawAgents(gw, {
|
|
673
|
+
timeoutMs: ctx.timeoutMs,
|
|
674
|
+
probe: ctx.probe,
|
|
675
|
+
});
|
|
676
|
+
} catch (err) {
|
|
677
|
+
result.failed.push({
|
|
678
|
+
gateway: gw.name,
|
|
679
|
+
error: err instanceof Error ? err.message : String(err),
|
|
680
|
+
});
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (!probeResult.ok) {
|
|
684
|
+
result.skipped.push({
|
|
685
|
+
gateway: gw.name,
|
|
686
|
+
reason: probeResult.error ?? "gateway_unreachable",
|
|
687
|
+
});
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
for (const oc of probeResult.agents ?? []) {
|
|
691
|
+
await withOpenclawProvisionLock(gw.name, oc.id, async () => {
|
|
692
|
+
const existing = findCredentialsByOpenclaw(gw.name, oc.id);
|
|
693
|
+
if (existing) {
|
|
694
|
+
result.skipped.push({
|
|
695
|
+
gateway: gw.name,
|
|
696
|
+
openclawAgent: oc.id,
|
|
697
|
+
reason: "already_bound",
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const freshCfg = loadConfig();
|
|
702
|
+
if (!inferHubUrl(freshCfg)) {
|
|
703
|
+
result.skipped.push({
|
|
704
|
+
gateway: gw.name,
|
|
705
|
+
openclawAgent: oc.id,
|
|
706
|
+
reason: "missing_hub_url",
|
|
707
|
+
});
|
|
708
|
+
daemonLog.warn("openclaw adopt skipped: no known hubUrl", {
|
|
709
|
+
gateway: gw.name,
|
|
710
|
+
openclawAgent: oc.id,
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
const params: ProvisionAgentParams = {
|
|
716
|
+
runtime: "openclaw-acp",
|
|
717
|
+
name: oc.name ?? `openclaw-${oc.id}`,
|
|
718
|
+
openclaw: { gateway: gw.name, agent: oc.id },
|
|
719
|
+
};
|
|
720
|
+
const credentials = await materializeCredentials(params, freshCfg, {
|
|
721
|
+
gateway: ctx.gateway,
|
|
722
|
+
register,
|
|
723
|
+
}, undefined);
|
|
724
|
+
const installed = await installLocalAgent(credentials, {
|
|
725
|
+
gateway: ctx.gateway,
|
|
726
|
+
register,
|
|
727
|
+
cfg: freshCfg,
|
|
728
|
+
source: "adopted-openclaw",
|
|
729
|
+
});
|
|
730
|
+
result.adopted.push(installed.agentId);
|
|
731
|
+
} catch (err) {
|
|
732
|
+
result.failed.push({
|
|
733
|
+
gateway: gw.name,
|
|
734
|
+
openclawAgent: oc.id,
|
|
735
|
+
error: err instanceof Error ? err.message : String(err),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return result;
|
|
742
|
+
}
|
|
743
|
+
|
|
540
744
|
async function revokeAgent(
|
|
541
745
|
params: RevokeAgentParams,
|
|
542
746
|
ctx: { gateway: Gateway },
|
|
@@ -753,16 +957,22 @@ export type WsEndpointProbeFn = (args: {
|
|
|
753
957
|
}>;
|
|
754
958
|
|
|
755
959
|
/**
|
|
756
|
-
* Default L2 + L3 probe —
|
|
757
|
-
* and
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
960
|
+
* Default L2 + L3 probe — speaks OpenClaw's WS frame protocol against the
|
|
961
|
+
* gateway and enumerates agent profiles via `agents.list`.
|
|
962
|
+
*
|
|
963
|
+
* Wire flow (see `~/claws/openclaw/src/gateway/server/ws-connection/message-handler.ts`
|
|
964
|
+
* and `~/claws/openclaw/src/gateway/protocol/schema/frames.ts`):
|
|
965
|
+
* 1. WS upgrade (no auth required at the HTTP layer).
|
|
966
|
+
* 2. Server emits `{type:"event", event:"connect.challenge", payload:{nonce}}`.
|
|
967
|
+
* 3. Client sends `{type:"req", id, method:"connect", params:{minProtocol, maxProtocol,
|
|
968
|
+
* client:{id:"openclaw-probe", mode:"probe", ...}, auth:{token}}}`.
|
|
969
|
+
* 4. Server responds `{type:"res", id, ok:true, payload:{type:"hello-ok", server:{version}, ...}}`.
|
|
970
|
+
* 5. Client sends `{type:"req", id, method:"agents.list", params:{}}`.
|
|
971
|
+
* 6. Server responds with `{payload: { defaultId, mainKey, scope, agents:[{id, name?, workspace?, model?}] }}`.
|
|
761
972
|
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
* `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
|
|
973
|
+
* Best-effort: a successful WS open with a failed handshake / `agents.list`
|
|
974
|
+
* still reports `ok: true` (just without `agents`), matching the RFC's
|
|
975
|
+
* "agents populated only when listing succeeded" rule.
|
|
766
976
|
*/
|
|
767
977
|
async function defaultWsProbe(args: {
|
|
768
978
|
url: string;
|
|
@@ -796,6 +1006,9 @@ async function defaultWsProbe(args: {
|
|
|
796
1006
|
let settled = false;
|
|
797
1007
|
let ws: any;
|
|
798
1008
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1009
|
+
let serverVersion: string | undefined;
|
|
1010
|
+
const CONNECT_ID = "probe-connect";
|
|
1011
|
+
let connectSent = false;
|
|
799
1012
|
const settle = (v: ProbeResult): void => {
|
|
800
1013
|
if (settled) return;
|
|
801
1014
|
settled = true;
|
|
@@ -809,6 +1022,8 @@ async function defaultWsProbe(args: {
|
|
|
809
1022
|
};
|
|
810
1023
|
try {
|
|
811
1024
|
const headers: Record<string, string> = {};
|
|
1025
|
+
// Some deployments gate the WS upgrade on Authorization too; harmless
|
|
1026
|
+
// when not enforced — auth is also re-asserted in the connect frame.
|
|
812
1027
|
if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
|
|
813
1028
|
ws = new WebSocket(args.url, { headers });
|
|
814
1029
|
} catch (err) {
|
|
@@ -816,62 +1031,165 @@ async function defaultWsProbe(args: {
|
|
|
816
1031
|
return;
|
|
817
1032
|
}
|
|
818
1033
|
timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1034
|
+
|
|
1035
|
+
const sendConnect = (): void => {
|
|
1036
|
+
if (connectSent) return;
|
|
1037
|
+
connectSent = true;
|
|
1038
|
+
const params: any = {
|
|
1039
|
+
minProtocol: 3,
|
|
1040
|
+
maxProtocol: 3,
|
|
1041
|
+
client: {
|
|
1042
|
+
id: "openclaw-probe",
|
|
1043
|
+
version: "0.1.0",
|
|
1044
|
+
platform: process.platform || "node",
|
|
1045
|
+
mode: "probe",
|
|
1046
|
+
},
|
|
1047
|
+
role: "operator",
|
|
1048
|
+
scopes: ["operator.read"],
|
|
1049
|
+
};
|
|
1050
|
+
if (args.token) params.auth = { token: args.token };
|
|
823
1051
|
try {
|
|
824
|
-
ws.send(
|
|
825
|
-
JSON.stringify({
|
|
826
|
-
jsonrpc: "2.0",
|
|
827
|
-
id: requestId,
|
|
828
|
-
method: "agents.list",
|
|
829
|
-
params: {},
|
|
830
|
-
}),
|
|
831
|
-
);
|
|
1052
|
+
ws.send(JSON.stringify({ type: "req", id: CONNECT_ID, method: "connect", params }));
|
|
832
1053
|
} catch (err) {
|
|
833
|
-
settle({ ok: true, error: `
|
|
1054
|
+
settle({ ok: true, error: `connect send failed: ${(err as Error).message}` });
|
|
834
1055
|
}
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
ws.on("open", () => {
|
|
1059
|
+
// Some servers send `connect.challenge` before the socket is fully
|
|
1060
|
+
// wired; if it never arrives we still try a best-effort connect after
|
|
1061
|
+
// a short delay so the probe doesn't stall on legacy gateways.
|
|
1062
|
+
setTimeout(() => {
|
|
1063
|
+
if (!connectSent && !settled) sendConnect();
|
|
1064
|
+
}, 250);
|
|
835
1065
|
});
|
|
836
1066
|
ws.on("message", (raw: Buffer | string) => {
|
|
1067
|
+
let msg: any;
|
|
837
1068
|
try {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1069
|
+
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
1070
|
+
} catch {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (!msg || typeof msg !== "object") return;
|
|
1074
|
+
if (msg.type === "event" && msg.event === "connect.challenge") {
|
|
1075
|
+
// Nonce only matters for device-pairing flows; token-only auth ignores it.
|
|
1076
|
+
sendConnect();
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (msg.type !== "res" || typeof msg.id !== "string") return;
|
|
1080
|
+
if (msg.id === CONNECT_ID) {
|
|
1081
|
+
if (!msg.ok) {
|
|
1082
|
+
const errMsg = msg.error?.message ? String(msg.error.message) : "connect rejected";
|
|
1083
|
+
settle({ ok: true, error: errMsg });
|
|
842
1084
|
return;
|
|
843
1085
|
}
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const model: { name?: string; provider?: string } = {};
|
|
853
|
-
if (typeof a.model.name === "string") model.name = a.model.name;
|
|
854
|
-
if (typeof a.model.provider === "string") model.provider = a.model.provider;
|
|
855
|
-
if (model.name || model.provider) row.model = model;
|
|
856
|
-
}
|
|
857
|
-
agents.push(row);
|
|
858
|
-
}
|
|
859
|
-
settle({ ok: true, agents });
|
|
860
|
-
} catch (err) {
|
|
861
|
-
settle({ ok: true, error: `agents.list parse failed: ${(err as Error).message}` });
|
|
1086
|
+
const v = msg.payload?.server?.version;
|
|
1087
|
+
if (typeof v === "string" && v) serverVersion = v;
|
|
1088
|
+
// We don't fetch agents.list over the wire: it requires `operator.read`
|
|
1089
|
+
// which the gateway only grants to clients that present a paired device
|
|
1090
|
+
// identity (see message-handler.ts:478 — self-declared scopes are
|
|
1091
|
+
// cleared without device pairing). For local OpenClaw the agent list
|
|
1092
|
+
// is sourced directly from disk by `probeOpenclawAgents`.
|
|
1093
|
+
settle({ ok: true, version: serverVersion });
|
|
862
1094
|
}
|
|
863
1095
|
});
|
|
864
1096
|
ws.on("error", (err: Error) => {
|
|
865
1097
|
settle({ ok: false, error: err.message });
|
|
866
1098
|
});
|
|
867
1099
|
ws.on("close", () => {
|
|
868
|
-
// If the socket closes before
|
|
869
|
-
// L2 as ok (
|
|
870
|
-
settle({ ok: true });
|
|
1100
|
+
// If the socket closes before we got our agents.list response, treat
|
|
1101
|
+
// L2 as ok (the upgrade succeeded) and emit no agents.
|
|
1102
|
+
settle({ ok: true, version: serverVersion });
|
|
871
1103
|
});
|
|
872
1104
|
});
|
|
873
1105
|
}
|
|
874
1106
|
|
|
1107
|
+
export async function probeOpenclawAgents(
|
|
1108
|
+
profile: { url: string; token?: string; tokenFile?: string },
|
|
1109
|
+
opts: { timeoutMs?: number; probe?: WsEndpointProbeFn } = {},
|
|
1110
|
+
): Promise<{
|
|
1111
|
+
ok: boolean;
|
|
1112
|
+
version?: string;
|
|
1113
|
+
agents?: Array<{
|
|
1114
|
+
id: string;
|
|
1115
|
+
name?: string;
|
|
1116
|
+
workspace?: string;
|
|
1117
|
+
model?: { name?: string; provider?: string };
|
|
1118
|
+
}>;
|
|
1119
|
+
error?: string;
|
|
1120
|
+
}> {
|
|
1121
|
+
const probe = opts.probe ?? defaultWsProbe;
|
|
1122
|
+
const prepared = prepareGatewayProfile({
|
|
1123
|
+
name: "probe",
|
|
1124
|
+
url: profile.url,
|
|
1125
|
+
...(profile.token ? { token: profile.token } : {}),
|
|
1126
|
+
...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
|
|
1127
|
+
});
|
|
1128
|
+
const result = await probe({
|
|
1129
|
+
url: profile.url,
|
|
1130
|
+
token: prepared.resolvedToken,
|
|
1131
|
+
timeoutMs: opts.timeoutMs ?? 3000,
|
|
1132
|
+
});
|
|
1133
|
+
// For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
|
|
1134
|
+
// and is the source of truth — listing it over the wire would require a
|
|
1135
|
+
// paired device identity (operator.read scope). When the WS probe is the
|
|
1136
|
+
// default (i.e. no test injection) we enrich the result from disk.
|
|
1137
|
+
if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
|
|
1138
|
+
const local = readLocalOpenclawAgents();
|
|
1139
|
+
if (local && local.length > 0) result.agents = local;
|
|
1140
|
+
}
|
|
1141
|
+
return result;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function isLoopbackUrl(raw: string): boolean {
|
|
1145
|
+
try {
|
|
1146
|
+
const u = new URL(raw);
|
|
1147
|
+
return u.hostname === "127.0.0.1" || u.hostname === "::1" || u.hostname === "localhost";
|
|
1148
|
+
} catch {
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function readLocalOpenclawAgents(): Array<{
|
|
1154
|
+
id: string;
|
|
1155
|
+
name?: string;
|
|
1156
|
+
workspace?: string;
|
|
1157
|
+
model?: { name?: string; provider?: string };
|
|
1158
|
+
}> | null {
|
|
1159
|
+
try {
|
|
1160
|
+
const file = path.join(homedir(), ".openclaw", "openclaw.json");
|
|
1161
|
+
if (!existsSync(file)) return null;
|
|
1162
|
+
const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
|
|
1163
|
+
const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
|
1164
|
+
const defaultId = typeof cfg?.agents?.defaults?.id === "string" ? cfg.agents.defaults.id : "default";
|
|
1165
|
+
const seen = new Set<string>();
|
|
1166
|
+
const out: Array<{ id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } }> = [];
|
|
1167
|
+
const push = (raw: any, fallbackId?: string): void => {
|
|
1168
|
+
const id = typeof raw?.id === "string" && raw.id ? raw.id : fallbackId;
|
|
1169
|
+
if (!id || seen.has(id)) return;
|
|
1170
|
+
seen.add(id);
|
|
1171
|
+
const row: { id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } } = { id };
|
|
1172
|
+
if (typeof raw?.name === "string") row.name = raw.name;
|
|
1173
|
+
if (typeof raw?.workspace === "string") row.workspace = raw.workspace;
|
|
1174
|
+
const m = raw?.model;
|
|
1175
|
+
if (m && typeof m === "object") {
|
|
1176
|
+
const model: { name?: string; provider?: string } = {};
|
|
1177
|
+
if (typeof m.primary === "string") model.name = m.primary;
|
|
1178
|
+
else if (typeof m.name === "string") model.name = m.name;
|
|
1179
|
+
if (typeof m.provider === "string") model.provider = m.provider;
|
|
1180
|
+
if (model.name || model.provider) row.model = model;
|
|
1181
|
+
}
|
|
1182
|
+
out.push(row);
|
|
1183
|
+
};
|
|
1184
|
+
// Default agent first so it surfaces at the top of the dropdown.
|
|
1185
|
+
push({ id: defaultId, workspace: cfg?.agents?.defaults?.workspace, model: cfg?.agents?.defaults?.model }, defaultId);
|
|
1186
|
+
for (const entry of list) push(entry);
|
|
1187
|
+
return out;
|
|
1188
|
+
} catch {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
875
1193
|
/**
|
|
876
1194
|
* Async variant that includes L2 (gateway reachability) and L3 (agent listing)
|
|
877
1195
|
* probes for runtimes that talk to external services. Used by the production
|
|
@@ -889,7 +1207,6 @@ export async function collectRuntimeSnapshotAsync(opts: {
|
|
|
889
1207
|
const base = collectRuntimeSnapshot();
|
|
890
1208
|
const gateways = opts.cfg?.openclawGateways ?? [];
|
|
891
1209
|
if (gateways.length === 0) return base;
|
|
892
|
-
const probe = opts.wsProbe ?? defaultWsProbe;
|
|
893
1210
|
// Default daemon-side budget is 3s — it must stay below the Hub's
|
|
894
1211
|
// `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
|
|
895
1212
|
// so a single slow gateway can't blow the whole snapshot to a 504.
|
|
@@ -897,11 +1214,11 @@ export async function collectRuntimeSnapshotAsync(opts: {
|
|
|
897
1214
|
const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
|
|
898
1215
|
const endpoints = await Promise.all(
|
|
899
1216
|
capped.map(async (g) => {
|
|
900
|
-
// Resolve `tokenFile` here so token-file-only profiles probe with auth
|
|
901
|
-
// and aren't falsely marked unreachable in the dashboard.
|
|
902
|
-
const prepared = prepareGatewayProfile(g);
|
|
903
1217
|
try {
|
|
904
|
-
const res = await
|
|
1218
|
+
const res = await probeOpenclawAgents(g, {
|
|
1219
|
+
probe: opts.wsProbe,
|
|
1220
|
+
timeoutMs,
|
|
1221
|
+
});
|
|
905
1222
|
const entry: any = { name: g.name, url: g.url, reachable: res.ok };
|
|
906
1223
|
if (res.version) entry.version = res.version;
|
|
907
1224
|
if (res.error) entry.error = res.error;
|
|
@@ -1301,5 +1618,13 @@ function inferHubUrl(cfg: DaemonConfig): string | null {
|
|
|
1301
1618
|
// skip
|
|
1302
1619
|
}
|
|
1303
1620
|
}
|
|
1621
|
+
if (ids.length === 0) {
|
|
1622
|
+
const discovered = discoverAgentCredentials({
|
|
1623
|
+
credentialsDir: path.join(homedir(), ".botcord", "credentials"),
|
|
1624
|
+
});
|
|
1625
|
+
for (const a of discovered.agents) {
|
|
1626
|
+
if (a.hubUrl) return a.hubUrl;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1304
1629
|
return null;
|
|
1305
1630
|
}
|