@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/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:
|
|
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:
|
|
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
|
-
|
|
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 —
|
|
638
|
-
* and
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
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
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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: `
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
736
|
-
// L2 as ok (
|
|
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
|
|
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.
|
|
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.
|
|
31
|
+
"@botcord/protocol-core": "^0.2.2",
|
|
32
32
|
"ws": "^8.18.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|