@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/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: params.credentials ? "hub-supplied" : "registered",
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: params.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
- const synthRoute: import("./gateway/index.js").GatewayRoute = {
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 — opens a WS handshake against the OpenClaw gateway
757
- * and, when the connection is up, issues a JSON-RPC `agents.list` request to
758
- * enumerate configured agent profiles. Best-effort: a successful WS open with
759
- * a failed `agents.list` still reports `ok: true` (just without `agents`),
760
- * matching the RFC's "agents populated only when listing succeeded" rule.
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
- * Method name and result shape follow OpenClaw:
763
- * `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
764
- * `~/claws/openclaw/src/gateway/session-utils.ts:783` —
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
- const requestId = "probe-agents-list";
820
- ws.on("open", () => {
821
- // L3: enumerate agent profiles. We don't fail the L2 result if this
822
- // call fails — the gateway is reachable either way.
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: `agents.list send failed: ${(err as Error).message}` });
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
- const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
839
- if (msg?.id !== requestId) return; // ignore unrelated frames
840
- if (msg.error) {
841
- settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
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 list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
845
- const agents: AgentRow[] = [];
846
- for (const a of list) {
847
- if (!a || typeof a.id !== "string" || a.id.length === 0) continue;
848
- const row: AgentRow = { id: a.id };
849
- if (typeof a.name === "string") row.name = a.name;
850
- if (typeof a.workspace === "string") row.workspace = a.workspace;
851
- if (a.model && typeof a.model === "object") {
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 `agents.list` resolved we still treat
869
- // L2 as ok (open fired) and emit no agents.
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 probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
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
  }