@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/provision.js 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 { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
@@ -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");
@@ -634,16 +801,22 @@ export function collectRuntimeSnapshot() {
634
801
  /** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
635
802
  export const RUNTIME_ENDPOINTS_CAP = 32;
636
803
  /**
637
- * Default L2 + L3 probe — opens a WS handshake against the OpenClaw gateway
638
- * and, when the connection is up, issues a JSON-RPC `agents.list` request to
639
- * enumerate configured agent profiles. Best-effort: a successful WS open with
640
- * a failed `agents.list` still reports `ok: true` (just without `agents`),
641
- * matching the RFC's "agents populated only when listing succeeded" rule.
804
+ * Default L2 + L3 probe — speaks OpenClaw's WS frame protocol against the
805
+ * gateway and enumerates agent profiles via `agents.list`.
806
+ *
807
+ * Wire flow (see `~/claws/openclaw/src/gateway/server/ws-connection/message-handler.ts`
808
+ * and `~/claws/openclaw/src/gateway/protocol/schema/frames.ts`):
809
+ * 1. WS upgrade (no auth required at the HTTP layer).
810
+ * 2. Server emits `{type:"event", event:"connect.challenge", payload:{nonce}}`.
811
+ * 3. Client sends `{type:"req", id, method:"connect", params:{minProtocol, maxProtocol,
812
+ * client:{id:"openclaw-probe", mode:"probe", ...}, auth:{token}}}`.
813
+ * 4. Server responds `{type:"res", id, ok:true, payload:{type:"hello-ok", server:{version}, ...}}`.
814
+ * 5. Client sends `{type:"req", id, method:"agents.list", params:{}}`.
815
+ * 6. Server responds with `{payload: { defaultId, mainKey, scope, agents:[{id, name?, workspace?, model?}] }}`.
642
816
  *
643
- * Method name and result shape follow OpenClaw:
644
- * `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
645
- * `~/claws/openclaw/src/gateway/session-utils.ts:783` —
646
- * `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
817
+ * Best-effort: a successful WS open with a failed handshake / `agents.list`
818
+ * still reports `ok: true` (just without `agents`), matching the RFC's
819
+ * "agents populated only when listing succeeded" rule.
647
820
  */
648
821
  async function defaultWsProbe(args) {
649
822
  const { default: WebSocket } = await import("ws");
@@ -651,6 +824,9 @@ async function defaultWsProbe(args) {
651
824
  let settled = false;
652
825
  let ws;
653
826
  let timer;
827
+ let serverVersion;
828
+ const CONNECT_ID = "probe-connect";
829
+ let connectSent = false;
654
830
  const settle = (v) => {
655
831
  if (settled)
656
832
  return;
@@ -667,6 +843,8 @@ async function defaultWsProbe(args) {
667
843
  };
668
844
  try {
669
845
  const headers = {};
846
+ // Some deployments gate the WS upgrade on Authorization too; harmless
847
+ // when not enforced — auth is also re-asserted in the connect frame.
670
848
  if (args.token)
671
849
  headers["Authorization"] = `Bearer ${args.token}`;
672
850
  ws = new WebSocket(args.url, { headers });
@@ -676,68 +854,161 @@ async function defaultWsProbe(args) {
676
854
  return;
677
855
  }
678
856
  timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
679
- const requestId = "probe-agents-list";
680
- ws.on("open", () => {
681
- // L3: enumerate agent profiles. We don't fail the L2 result if this
682
- // call fails — the gateway is reachable either way.
857
+ const sendConnect = () => {
858
+ if (connectSent)
859
+ return;
860
+ connectSent = true;
861
+ const params = {
862
+ minProtocol: 3,
863
+ maxProtocol: 3,
864
+ client: {
865
+ id: "openclaw-probe",
866
+ version: "0.1.0",
867
+ platform: process.platform || "node",
868
+ mode: "probe",
869
+ },
870
+ role: "operator",
871
+ scopes: ["operator.read"],
872
+ };
873
+ if (args.token)
874
+ params.auth = { token: args.token };
683
875
  try {
684
- ws.send(JSON.stringify({
685
- jsonrpc: "2.0",
686
- id: requestId,
687
- method: "agents.list",
688
- params: {},
689
- }));
876
+ ws.send(JSON.stringify({ type: "req", id: CONNECT_ID, method: "connect", params }));
690
877
  }
691
878
  catch (err) {
692
- settle({ ok: true, error: `agents.list send failed: ${err.message}` });
879
+ settle({ ok: true, error: `connect send failed: ${err.message}` });
693
880
  }
881
+ };
882
+ ws.on("open", () => {
883
+ // Some servers send `connect.challenge` before the socket is fully
884
+ // wired; if it never arrives we still try a best-effort connect after
885
+ // a short delay so the probe doesn't stall on legacy gateways.
886
+ setTimeout(() => {
887
+ if (!connectSent && !settled)
888
+ sendConnect();
889
+ }, 250);
694
890
  });
695
891
  ws.on("message", (raw) => {
892
+ let msg;
696
893
  try {
697
- const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
698
- if (msg?.id !== requestId)
699
- return; // ignore unrelated frames
700
- if (msg.error) {
701
- settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
894
+ msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
895
+ }
896
+ catch {
897
+ return;
898
+ }
899
+ if (!msg || typeof msg !== "object")
900
+ return;
901
+ if (msg.type === "event" && msg.event === "connect.challenge") {
902
+ // Nonce only matters for device-pairing flows; token-only auth ignores it.
903
+ sendConnect();
904
+ return;
905
+ }
906
+ if (msg.type !== "res" || typeof msg.id !== "string")
907
+ return;
908
+ if (msg.id === CONNECT_ID) {
909
+ if (!msg.ok) {
910
+ const errMsg = msg.error?.message ? String(msg.error.message) : "connect rejected";
911
+ settle({ ok: true, error: errMsg });
702
912
  return;
703
913
  }
704
- const list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
705
- const agents = [];
706
- for (const a of list) {
707
- if (!a || typeof a.id !== "string" || a.id.length === 0)
708
- continue;
709
- const row = { id: a.id };
710
- if (typeof a.name === "string")
711
- row.name = a.name;
712
- if (typeof a.workspace === "string")
713
- row.workspace = a.workspace;
714
- if (a.model && typeof a.model === "object") {
715
- const model = {};
716
- if (typeof a.model.name === "string")
717
- model.name = a.model.name;
718
- if (typeof a.model.provider === "string")
719
- model.provider = a.model.provider;
720
- if (model.name || model.provider)
721
- row.model = model;
722
- }
723
- agents.push(row);
724
- }
725
- settle({ ok: true, agents });
726
- }
727
- catch (err) {
728
- settle({ ok: true, error: `agents.list parse failed: ${err.message}` });
914
+ const v = msg.payload?.server?.version;
915
+ if (typeof v === "string" && v)
916
+ serverVersion = v;
917
+ // We don't fetch agents.list over the wire: it requires `operator.read`
918
+ // which the gateway only grants to clients that present a paired device
919
+ // identity (see message-handler.ts:478 self-declared scopes are
920
+ // cleared without device pairing). For local OpenClaw the agent list
921
+ // is sourced directly from disk by `probeOpenclawAgents`.
922
+ settle({ ok: true, version: serverVersion });
729
923
  }
730
924
  });
731
925
  ws.on("error", (err) => {
732
926
  settle({ ok: false, error: err.message });
733
927
  });
734
928
  ws.on("close", () => {
735
- // If the socket closes before `agents.list` resolved we still treat
736
- // L2 as ok (open fired) and emit no agents.
737
- settle({ ok: true });
929
+ // If the socket closes before we got our agents.list response, treat
930
+ // L2 as ok (the upgrade succeeded) and emit no agents.
931
+ settle({ ok: true, version: serverVersion });
738
932
  });
739
933
  });
740
934
  }
935
+ export async function probeOpenclawAgents(profile, opts = {}) {
936
+ const probe = opts.probe ?? defaultWsProbe;
937
+ const prepared = prepareGatewayProfile({
938
+ name: "probe",
939
+ url: profile.url,
940
+ ...(profile.token ? { token: profile.token } : {}),
941
+ ...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
942
+ });
943
+ const result = await probe({
944
+ url: profile.url,
945
+ token: prepared.resolvedToken,
946
+ timeoutMs: opts.timeoutMs ?? 3000,
947
+ });
948
+ // For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
949
+ // and is the source of truth — listing it over the wire would require a
950
+ // paired device identity (operator.read scope). When the WS probe is the
951
+ // default (i.e. no test injection) we enrich the result from disk.
952
+ if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
953
+ const local = readLocalOpenclawAgents();
954
+ if (local && local.length > 0)
955
+ result.agents = local;
956
+ }
957
+ return result;
958
+ }
959
+ function isLoopbackUrl(raw) {
960
+ try {
961
+ const u = new URL(raw);
962
+ return u.hostname === "127.0.0.1" || u.hostname === "::1" || u.hostname === "localhost";
963
+ }
964
+ catch {
965
+ return false;
966
+ }
967
+ }
968
+ function readLocalOpenclawAgents() {
969
+ try {
970
+ const file = path.join(homedir(), ".openclaw", "openclaw.json");
971
+ if (!existsSync(file))
972
+ return null;
973
+ const cfg = JSON.parse(readFileSync(file, "utf8"));
974
+ const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
975
+ const defaultId = typeof cfg?.agents?.defaults?.id === "string" ? cfg.agents.defaults.id : "default";
976
+ const seen = new Set();
977
+ const out = [];
978
+ const push = (raw, fallbackId) => {
979
+ const id = typeof raw?.id === "string" && raw.id ? raw.id : fallbackId;
980
+ if (!id || seen.has(id))
981
+ return;
982
+ seen.add(id);
983
+ const row = { id };
984
+ if (typeof raw?.name === "string")
985
+ row.name = raw.name;
986
+ if (typeof raw?.workspace === "string")
987
+ row.workspace = raw.workspace;
988
+ const m = raw?.model;
989
+ if (m && typeof m === "object") {
990
+ const model = {};
991
+ if (typeof m.primary === "string")
992
+ model.name = m.primary;
993
+ else if (typeof m.name === "string")
994
+ model.name = m.name;
995
+ if (typeof m.provider === "string")
996
+ model.provider = m.provider;
997
+ if (model.name || model.provider)
998
+ row.model = model;
999
+ }
1000
+ out.push(row);
1001
+ };
1002
+ // Default agent first so it surfaces at the top of the dropdown.
1003
+ push({ id: defaultId, workspace: cfg?.agents?.defaults?.workspace, model: cfg?.agents?.defaults?.model }, defaultId);
1004
+ for (const entry of list)
1005
+ push(entry);
1006
+ return out;
1007
+ }
1008
+ catch {
1009
+ return null;
1010
+ }
1011
+ }
741
1012
  /**
742
1013
  * Async variant that includes L2 (gateway reachability) and L3 (agent listing)
743
1014
  * probes for runtimes that talk to external services. Used by the production
@@ -752,18 +1023,17 @@ export async function collectRuntimeSnapshotAsync(opts = {}) {
752
1023
  const gateways = opts.cfg?.openclawGateways ?? [];
753
1024
  if (gateways.length === 0)
754
1025
  return base;
755
- const probe = opts.wsProbe ?? defaultWsProbe;
756
1026
  // Default daemon-side budget is 3s — it must stay below the Hub's
757
1027
  // `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
758
1028
  // so a single slow gateway can't blow the whole snapshot to a 504.
759
1029
  const timeoutMs = opts.timeoutMs ?? 3000;
760
1030
  const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
761
1031
  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
1032
  try {
766
- const res = await probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
1033
+ const res = await probeOpenclawAgents(g, {
1034
+ probe: opts.wsProbe,
1035
+ timeoutMs,
1036
+ });
767
1037
  const entry = { name: g.name, url: g.url, reachable: res.ok };
768
1038
  if (res.version)
769
1039
  entry.version = res.version;
@@ -1106,5 +1376,14 @@ function inferHubUrl(cfg) {
1106
1376
  // skip
1107
1377
  }
1108
1378
  }
1379
+ if (ids.length === 0) {
1380
+ const discovered = discoverAgentCredentials({
1381
+ credentialsDir: path.join(homedir(), ".botcord", "credentials"),
1382
+ });
1383
+ for (const a of discovered.agents) {
1384
+ if (a.hubUrl)
1385
+ return a.hubUrl;
1386
+ }
1387
+ }
1109
1388
  return null;
1110
1389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
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": {