@botcord/daemon 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +15 -0
- package/dist/config.js +16 -0
- package/dist/daemon.js +24 -1
- package/dist/index.js +24 -1
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +41 -0
- package/dist/provision.js +225 -36
- package/package.json +2 -2
- package/src/__tests__/openclaw-discovery.test.ts +150 -0
- package/src/__tests__/provision.test.ts +105 -0
- package/src/config.ts +36 -0
- package/src/daemon.ts +30 -1
- package/src/index.ts +27 -1
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +277 -38
package/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:
|
|
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 },
|
|
@@ -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
|
|
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
|
}
|