@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/src/provision.ts CHANGED
@@ -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 },
@@ -872,6 +1076,34 @@ async function defaultWsProbe(args: {
872
1076
  });
873
1077
  }
874
1078
 
1079
+ export async function probeOpenclawAgents(
1080
+ profile: { url: string; token?: string; tokenFile?: string },
1081
+ opts: { timeoutMs?: number; probe?: WsEndpointProbeFn } = {},
1082
+ ): Promise<{
1083
+ ok: boolean;
1084
+ version?: string;
1085
+ agents?: Array<{
1086
+ id: string;
1087
+ name?: string;
1088
+ workspace?: string;
1089
+ model?: { name?: string; provider?: string };
1090
+ }>;
1091
+ error?: string;
1092
+ }> {
1093
+ const probe = opts.probe ?? defaultWsProbe;
1094
+ const prepared = prepareGatewayProfile({
1095
+ name: "probe",
1096
+ url: profile.url,
1097
+ ...(profile.token ? { token: profile.token } : {}),
1098
+ ...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
1099
+ });
1100
+ return probe({
1101
+ url: profile.url,
1102
+ token: prepared.resolvedToken,
1103
+ timeoutMs: opts.timeoutMs ?? 3000,
1104
+ });
1105
+ }
1106
+
875
1107
  /**
876
1108
  * Async variant that includes L2 (gateway reachability) and L3 (agent listing)
877
1109
  * probes for runtimes that talk to external services. Used by the production
@@ -889,7 +1121,6 @@ export async function collectRuntimeSnapshotAsync(opts: {
889
1121
  const base = collectRuntimeSnapshot();
890
1122
  const gateways = opts.cfg?.openclawGateways ?? [];
891
1123
  if (gateways.length === 0) return base;
892
- const probe = opts.wsProbe ?? defaultWsProbe;
893
1124
  // Default daemon-side budget is 3s — it must stay below the Hub's
894
1125
  // `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
895
1126
  // so a single slow gateway can't blow the whole snapshot to a 504.
@@ -897,11 +1128,11 @@ export async function collectRuntimeSnapshotAsync(opts: {
897
1128
  const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
898
1129
  const endpoints = await Promise.all(
899
1130
  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
1131
  try {
904
- const res = await probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
1132
+ const res = await probeOpenclawAgents(g, {
1133
+ probe: opts.wsProbe,
1134
+ timeoutMs,
1135
+ });
905
1136
  const entry: any = { name: g.name, url: g.url, reachable: res.ok };
906
1137
  if (res.version) entry.version = res.version;
907
1138
  if (res.error) entry.error = res.error;
@@ -1301,5 +1532,13 @@ function inferHubUrl(cfg: DaemonConfig): string | null {
1301
1532
  // skip
1302
1533
  }
1303
1534
  }
1535
+ if (ids.length === 0) {
1536
+ const discovered = discoverAgentCredentials({
1537
+ credentialsDir: path.join(homedir(), ".botcord", "credentials"),
1538
+ });
1539
+ for (const a of discovered.agents) {
1540
+ if (a.hubUrl) return a.hubUrl;
1541
+ }
1542
+ }
1304
1543
  return null;
1305
1544
  }