@botcord/daemon 0.2.5 → 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/agent-discovery.d.ts +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +64 -1
- package/dist/config.js +73 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +76 -6
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +3 -2
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/openclaw-discovery.test.ts +150 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +265 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +394 -26
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
package/dist/provision.js
CHANGED
|
@@ -9,10 +9,11 @@ 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";
|
|
11
11
|
import { loadConfig, resolveConfiguredAgentIds, saveConfig, } from "./config.js";
|
|
12
|
-
import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes } from "./daemon-config-map.js";
|
|
13
|
-
import { agentHomeDir, agentStateDir, agentWorkspaceDir, ensureAgentWorkspace, } from "./agent-workspace.js";
|
|
12
|
+
import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from "./daemon-config-map.js";
|
|
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
|
|
@@ -21,11 +22,42 @@ import { log as daemonLog } from "./log.js";
|
|
|
21
22
|
export function createProvisioner(opts) {
|
|
22
23
|
const gateway = opts.gateway;
|
|
23
24
|
const register = opts.register ?? BotCordClient.register;
|
|
25
|
+
const policyResolver = opts.policyResolver;
|
|
24
26
|
return async (frame) => {
|
|
25
27
|
daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
|
|
26
28
|
switch (frame.type) {
|
|
27
29
|
case CONTROL_FRAME_TYPES.PING:
|
|
28
30
|
return { ok: true, result: { pong: true, ts: Date.now() } };
|
|
31
|
+
case CONTROL_FRAME_TYPES.HELLO: {
|
|
32
|
+
const params = (frame.params ?? {});
|
|
33
|
+
const result = applyHelloIdentitySnapshot(params.agents);
|
|
34
|
+
daemonLog.debug("hello: identity snapshot applied", {
|
|
35
|
+
frameId: frame.id,
|
|
36
|
+
received: params.agents?.length ?? 0,
|
|
37
|
+
updated: result.updated,
|
|
38
|
+
skipped: result.skipped,
|
|
39
|
+
});
|
|
40
|
+
return { ok: true, result };
|
|
41
|
+
}
|
|
42
|
+
case CONTROL_FRAME_TYPES.UPDATE_AGENT: {
|
|
43
|
+
const params = (frame.params ?? {});
|
|
44
|
+
if (!params.agentId) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: { code: "bad_params", message: "update_agent requires params.agentId" },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const result = applyAgentIdentity(params.agentId, {
|
|
51
|
+
displayName: params.displayName,
|
|
52
|
+
bio: params.bio,
|
|
53
|
+
});
|
|
54
|
+
daemonLog.info("update_agent applied", {
|
|
55
|
+
agentId: params.agentId,
|
|
56
|
+
changed: result.changed,
|
|
57
|
+
skipped: result.skipped ?? null,
|
|
58
|
+
});
|
|
59
|
+
return { ok: true, result };
|
|
60
|
+
}
|
|
29
61
|
case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
|
|
30
62
|
const params = (frame.params ?? {});
|
|
31
63
|
daemonLog.info("provision_agent: start", {
|
|
@@ -35,6 +67,18 @@ export function createProvisioner(opts) {
|
|
|
35
67
|
name: params.name ?? null,
|
|
36
68
|
});
|
|
37
69
|
const agent = await provisionAgent(params, { gateway, register });
|
|
70
|
+
// Seed the policy resolver from the optional `defaultAttention` /
|
|
71
|
+
// `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
|
|
72
|
+
// don't yet emit these stay backwards-compatible — the resolver just
|
|
73
|
+
// falls back to `mode=always` until a `policy_updated` frame arrives.
|
|
74
|
+
if (policyResolver && params.defaultAttention) {
|
|
75
|
+
policyResolver.put(agent.agentId, null, {
|
|
76
|
+
mode: params.defaultAttention,
|
|
77
|
+
keywords: Array.isArray(params.attentionKeywords)
|
|
78
|
+
? params.attentionKeywords.slice()
|
|
79
|
+
: [],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
38
82
|
return {
|
|
39
83
|
ok: true,
|
|
40
84
|
result: {
|
|
@@ -69,8 +113,58 @@ export function createProvisioner(opts) {
|
|
|
69
113
|
const res = setRoute(frame.params ?? {});
|
|
70
114
|
return { ok: true, result: res };
|
|
71
115
|
}
|
|
116
|
+
case CONTROL_FRAME_TYPES.POLICY_UPDATED: {
|
|
117
|
+
const params = (frame.params ?? {});
|
|
118
|
+
const agentId = params.agent_id;
|
|
119
|
+
if (typeof agentId !== "string" || !agentId) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: { code: "bad_params", message: "policy_updated requires agent_id" },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (!policyResolver) {
|
|
126
|
+
// No resolver wired — quietly succeed; the daemon may be running
|
|
127
|
+
// without the gateway-level attention gate (e.g. legacy boot path).
|
|
128
|
+
daemonLog.debug("policy_updated: no resolver — noop", { agentId });
|
|
129
|
+
return { ok: true, result: { agent_id: agentId, applied: false } };
|
|
130
|
+
}
|
|
131
|
+
const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
|
|
132
|
+
if (params.policy) {
|
|
133
|
+
// Embedded policy payload — install directly to avoid a refetch.
|
|
134
|
+
policyResolver.put(agentId, roomId ?? null, {
|
|
135
|
+
mode: params.policy.mode,
|
|
136
|
+
keywords: Array.isArray(params.policy.keywords)
|
|
137
|
+
? params.policy.keywords.slice()
|
|
138
|
+
: [],
|
|
139
|
+
...(typeof params.policy.muted_until === "number"
|
|
140
|
+
? { muted_until: params.policy.muted_until }
|
|
141
|
+
: {}),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
policyResolver.invalidate(agentId, roomId);
|
|
146
|
+
}
|
|
147
|
+
daemonLog.info("policy_updated: applied", {
|
|
148
|
+
agentId,
|
|
149
|
+
roomId: roomId ?? null,
|
|
150
|
+
embedded: !!params.policy,
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
result: { agent_id: agentId, applied: true, embedded: !!params.policy },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
72
157
|
case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
|
|
73
|
-
|
|
158
|
+
// Async path so the openclaw-acp endpoints get probed inline; gateway
|
|
159
|
+
// / WS errors are swallowed inside `collectRuntimeSnapshotAsync`.
|
|
160
|
+
let cfgForProbe;
|
|
161
|
+
try {
|
|
162
|
+
cfgForProbe = loadConfig();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
cfgForProbe = undefined;
|
|
166
|
+
}
|
|
167
|
+
const snapshot = await collectRuntimeSnapshotAsync({ cfg: cfgForProbe });
|
|
74
168
|
daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
|
|
75
169
|
return { ok: true, result: snapshot };
|
|
76
170
|
}
|
|
@@ -86,6 +180,7 @@ export function createProvisioner(opts) {
|
|
|
86
180
|
}
|
|
87
181
|
};
|
|
88
182
|
}
|
|
183
|
+
const openclawProvisionLocks = new Map();
|
|
89
184
|
async function provisionAgent(params, ctx) {
|
|
90
185
|
// Validate both caller-supplied cwd sources up front. Previously only
|
|
91
186
|
// `params.cwd` was checked, so `params.credentials.cwd` could smuggle an
|
|
@@ -93,13 +188,44 @@ async function provisionAgent(params, ctx) {
|
|
|
93
188
|
// that hole by moving the check to the union of both.
|
|
94
189
|
const explicitCwd = params.credentials?.cwd ?? params.cwd;
|
|
95
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
|
+
}
|
|
96
213
|
const cfg = loadConfig();
|
|
97
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;
|
|
98
224
|
daemonLog.debug("provision: credentials materialized", {
|
|
99
225
|
agentId: credentials.agentId,
|
|
100
226
|
hubUrl: credentials.hubUrl,
|
|
101
227
|
runtime: credentials.runtime ?? null,
|
|
102
|
-
source:
|
|
228
|
+
source: ctx.source,
|
|
103
229
|
});
|
|
104
230
|
const credentialsFile = writeCredentialsFile(defaultCredentialsFile(credentials.agentId), credentials);
|
|
105
231
|
// Seed the per-agent workspace directory. On failure, unlink the fresh
|
|
@@ -108,7 +234,7 @@ async function provisionAgent(params, ctx) {
|
|
|
108
234
|
try {
|
|
109
235
|
ensureAgentWorkspace(credentials.agentId, {
|
|
110
236
|
displayName: credentials.displayName,
|
|
111
|
-
bio:
|
|
237
|
+
bio: ctx.bio,
|
|
112
238
|
runtime: credentials.runtime,
|
|
113
239
|
keyId: credentials.keyId,
|
|
114
240
|
savedAt: credentials.savedAt,
|
|
@@ -174,11 +300,7 @@ async function provisionAgent(params, ctx) {
|
|
|
174
300
|
// Hot-add the synthesized per-agent managed route so the next turn picks
|
|
175
301
|
// the agent's runtime + workspace cwd without waiting for reload_config.
|
|
176
302
|
try {
|
|
177
|
-
|
|
178
|
-
match: { accountId: credentials.agentId },
|
|
179
|
-
runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
|
|
180
|
-
cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
|
|
181
|
-
});
|
|
303
|
+
upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
|
|
182
304
|
}
|
|
183
305
|
catch (err) {
|
|
184
306
|
// Rollback the channel + config + credentials on managed-route failure
|
|
@@ -219,6 +341,53 @@ async function provisionAgent(params, ctx) {
|
|
|
219
341
|
credentialsFile,
|
|
220
342
|
};
|
|
221
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
|
+
}
|
|
222
391
|
async function materializeCredentials(params, cfg, ctx, explicitCwd) {
|
|
223
392
|
// Runtime is an agent property. Hub is authoritative; top-level `runtime`
|
|
224
393
|
// wins, `adapter` is a one-release alias, and `credentials.runtime` is the
|
|
@@ -259,6 +428,11 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
|
|
|
259
428
|
if (runtime)
|
|
260
429
|
record.runtime = runtime;
|
|
261
430
|
record.cwd = cwd;
|
|
431
|
+
const openclawSel = pickOpenclawSelection(params);
|
|
432
|
+
if (openclawSel.gateway)
|
|
433
|
+
record.openclawGateway = openclawSel.gateway;
|
|
434
|
+
if (openclawSel.agent)
|
|
435
|
+
record.openclawAgent = openclawSel.agent;
|
|
262
436
|
return record;
|
|
263
437
|
}
|
|
264
438
|
// Slow path: daemon registers a fresh identity against Hub. We need a
|
|
@@ -286,8 +460,155 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
|
|
|
286
460
|
if (runtime)
|
|
287
461
|
record.runtime = runtime;
|
|
288
462
|
record.cwd = cwd;
|
|
463
|
+
const openclawSel = pickOpenclawSelection(params);
|
|
464
|
+
if (openclawSel.gateway)
|
|
465
|
+
record.openclawGateway = openclawSel.gateway;
|
|
466
|
+
if (openclawSel.agent)
|
|
467
|
+
record.openclawAgent = openclawSel.agent;
|
|
289
468
|
return record;
|
|
290
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Resolve OpenClaw routing selection from a `provision_agent` frame. Top-level
|
|
472
|
+
* `params.openclaw` (nested) wins over the flat `credentials.openclaw*` mirror.
|
|
473
|
+
* Returning `{}` is fine — only meaningful when the agent's runtime is
|
|
474
|
+
* `openclaw-acp`, and `buildManagedRoutes` falls back to defaultRoute.gateway
|
|
475
|
+
* when both are missing.
|
|
476
|
+
*/
|
|
477
|
+
function pickOpenclawSelection(params) {
|
|
478
|
+
const out = {};
|
|
479
|
+
const top = params.openclaw;
|
|
480
|
+
if (top && typeof top.gateway === "string" && top.gateway.length > 0) {
|
|
481
|
+
out.gateway = top.gateway;
|
|
482
|
+
if (typeof top.agent === "string" && top.agent.length > 0)
|
|
483
|
+
out.agent = top.agent;
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
const flat = params.credentials;
|
|
487
|
+
if (flat) {
|
|
488
|
+
if (typeof flat.openclawGateway === "string" && flat.openclawGateway.length > 0) {
|
|
489
|
+
out.gateway = flat.openclawGateway;
|
|
490
|
+
}
|
|
491
|
+
if (typeof flat.openclawAgent === "string" && flat.openclawAgent.length > 0) {
|
|
492
|
+
out.agent = flat.openclawAgent;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
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
|
+
}
|
|
291
612
|
async function revokeAgent(params, ctx) {
|
|
292
613
|
if (!params.agentId) {
|
|
293
614
|
throw new Error("revoke_agent requires params.agentId");
|
|
@@ -477,6 +798,214 @@ export function collectRuntimeSnapshot() {
|
|
|
477
798
|
});
|
|
478
799
|
return { runtimes, probedAt: Date.now() };
|
|
479
800
|
}
|
|
801
|
+
/** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
|
|
802
|
+
export const RUNTIME_ENDPOINTS_CAP = 32;
|
|
803
|
+
/**
|
|
804
|
+
* Default L2 + L3 probe — opens a WS handshake against the OpenClaw gateway
|
|
805
|
+
* and, when the connection is up, issues a JSON-RPC `agents.list` request to
|
|
806
|
+
* enumerate configured agent profiles. Best-effort: a successful WS open with
|
|
807
|
+
* a failed `agents.list` still reports `ok: true` (just without `agents`),
|
|
808
|
+
* matching the RFC's "agents populated only when listing succeeded" rule.
|
|
809
|
+
*
|
|
810
|
+
* Method name and result shape follow OpenClaw:
|
|
811
|
+
* `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
|
|
812
|
+
* `~/claws/openclaw/src/gateway/session-utils.ts:783` —
|
|
813
|
+
* `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
|
|
814
|
+
*/
|
|
815
|
+
async function defaultWsProbe(args) {
|
|
816
|
+
const { default: WebSocket } = await import("ws");
|
|
817
|
+
return new Promise((resolve) => {
|
|
818
|
+
let settled = false;
|
|
819
|
+
let ws;
|
|
820
|
+
let timer;
|
|
821
|
+
const settle = (v) => {
|
|
822
|
+
if (settled)
|
|
823
|
+
return;
|
|
824
|
+
settled = true;
|
|
825
|
+
if (timer)
|
|
826
|
+
clearTimeout(timer);
|
|
827
|
+
try {
|
|
828
|
+
ws?.terminate();
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
// ignore
|
|
832
|
+
}
|
|
833
|
+
resolve(v);
|
|
834
|
+
};
|
|
835
|
+
try {
|
|
836
|
+
const headers = {};
|
|
837
|
+
if (args.token)
|
|
838
|
+
headers["Authorization"] = `Bearer ${args.token}`;
|
|
839
|
+
ws = new WebSocket(args.url, { headers });
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
resolve({ ok: false, error: err.message });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
|
|
846
|
+
const requestId = "probe-agents-list";
|
|
847
|
+
ws.on("open", () => {
|
|
848
|
+
// L3: enumerate agent profiles. We don't fail the L2 result if this
|
|
849
|
+
// call fails — the gateway is reachable either way.
|
|
850
|
+
try {
|
|
851
|
+
ws.send(JSON.stringify({
|
|
852
|
+
jsonrpc: "2.0",
|
|
853
|
+
id: requestId,
|
|
854
|
+
method: "agents.list",
|
|
855
|
+
params: {},
|
|
856
|
+
}));
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
settle({ ok: true, error: `agents.list send failed: ${err.message}` });
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
ws.on("message", (raw) => {
|
|
863
|
+
try {
|
|
864
|
+
const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
865
|
+
if (msg?.id !== requestId)
|
|
866
|
+
return; // ignore unrelated frames
|
|
867
|
+
if (msg.error) {
|
|
868
|
+
settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
|
|
872
|
+
const agents = [];
|
|
873
|
+
for (const a of list) {
|
|
874
|
+
if (!a || typeof a.id !== "string" || a.id.length === 0)
|
|
875
|
+
continue;
|
|
876
|
+
const row = { id: a.id };
|
|
877
|
+
if (typeof a.name === "string")
|
|
878
|
+
row.name = a.name;
|
|
879
|
+
if (typeof a.workspace === "string")
|
|
880
|
+
row.workspace = a.workspace;
|
|
881
|
+
if (a.model && typeof a.model === "object") {
|
|
882
|
+
const model = {};
|
|
883
|
+
if (typeof a.model.name === "string")
|
|
884
|
+
model.name = a.model.name;
|
|
885
|
+
if (typeof a.model.provider === "string")
|
|
886
|
+
model.provider = a.model.provider;
|
|
887
|
+
if (model.name || model.provider)
|
|
888
|
+
row.model = model;
|
|
889
|
+
}
|
|
890
|
+
agents.push(row);
|
|
891
|
+
}
|
|
892
|
+
settle({ ok: true, agents });
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
settle({ ok: true, error: `agents.list parse failed: ${err.message}` });
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
ws.on("error", (err) => {
|
|
899
|
+
settle({ ok: false, error: err.message });
|
|
900
|
+
});
|
|
901
|
+
ws.on("close", () => {
|
|
902
|
+
// If the socket closes before `agents.list` resolved we still treat
|
|
903
|
+
// L2 as ok (open fired) and emit no agents.
|
|
904
|
+
settle({ ok: true });
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
export async function probeOpenclawAgents(profile, opts = {}) {
|
|
909
|
+
const probe = opts.probe ?? defaultWsProbe;
|
|
910
|
+
const prepared = prepareGatewayProfile({
|
|
911
|
+
name: "probe",
|
|
912
|
+
url: profile.url,
|
|
913
|
+
...(profile.token ? { token: profile.token } : {}),
|
|
914
|
+
...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
|
|
915
|
+
});
|
|
916
|
+
return probe({
|
|
917
|
+
url: profile.url,
|
|
918
|
+
token: prepared.resolvedToken,
|
|
919
|
+
timeoutMs: opts.timeoutMs ?? 3000,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Async variant that includes L2 (gateway reachability) and L3 (agent listing)
|
|
924
|
+
* probes for runtimes that talk to external services. Used by the production
|
|
925
|
+
* `list_runtimes` and first-connect snapshot paths.
|
|
926
|
+
*
|
|
927
|
+
* `cfg` is optional so existing callers without a loaded config (e.g. tests)
|
|
928
|
+
* can keep using the sync `collectRuntimeSnapshot()` — when absent, the result
|
|
929
|
+
* is identical to that function.
|
|
930
|
+
*/
|
|
931
|
+
export async function collectRuntimeSnapshotAsync(opts = {}) {
|
|
932
|
+
const base = collectRuntimeSnapshot();
|
|
933
|
+
const gateways = opts.cfg?.openclawGateways ?? [];
|
|
934
|
+
if (gateways.length === 0)
|
|
935
|
+
return base;
|
|
936
|
+
// Default daemon-side budget is 3s — it must stay below the Hub's
|
|
937
|
+
// `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
|
|
938
|
+
// so a single slow gateway can't blow the whole snapshot to a 504.
|
|
939
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
940
|
+
const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
|
|
941
|
+
const endpoints = await Promise.all(capped.map(async (g) => {
|
|
942
|
+
try {
|
|
943
|
+
const res = await probeOpenclawAgents(g, {
|
|
944
|
+
probe: opts.wsProbe,
|
|
945
|
+
timeoutMs,
|
|
946
|
+
});
|
|
947
|
+
const entry = { name: g.name, url: g.url, reachable: res.ok };
|
|
948
|
+
if (res.version)
|
|
949
|
+
entry.version = res.version;
|
|
950
|
+
if (res.error)
|
|
951
|
+
entry.error = res.error;
|
|
952
|
+
if (res.agents)
|
|
953
|
+
entry.agents = res.agents;
|
|
954
|
+
return entry;
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
return {
|
|
958
|
+
name: g.name,
|
|
959
|
+
url: g.url,
|
|
960
|
+
reachable: false,
|
|
961
|
+
error: err.message,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
}));
|
|
965
|
+
const out = { ...base };
|
|
966
|
+
out.runtimes = base.runtimes.map((r) => r.id === "openclaw-acp" ? { ...r, endpoints } : r);
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Reconcile every agent identity carried by the `hello.agents` snapshot
|
|
971
|
+
* against the on-disk `identity.md`. Best-effort: a malformed entry or a
|
|
972
|
+
* file-system error for one agent never aborts the rest.
|
|
973
|
+
*
|
|
974
|
+
* Identity-snapshot semantics intentionally only touch the metadata
|
|
975
|
+
* line + Bio body — Role/Boundaries paragraphs the user authored locally
|
|
976
|
+
* are preserved (see `applyAgentIdentity`). Missing identity.md files
|
|
977
|
+
* (agent provisioned on a different daemon, or workspace cleared) are
|
|
978
|
+
* silently skipped.
|
|
979
|
+
*/
|
|
980
|
+
export function applyHelloIdentitySnapshot(snapshot) {
|
|
981
|
+
const out = { updated: 0, skipped: 0 };
|
|
982
|
+
if (!Array.isArray(snapshot))
|
|
983
|
+
return out;
|
|
984
|
+
for (const entry of snapshot) {
|
|
985
|
+
if (!entry || typeof entry.agentId !== "string") {
|
|
986
|
+
out.skipped += 1;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
const result = applyAgentIdentity(entry.agentId, {
|
|
991
|
+
displayName: entry.displayName,
|
|
992
|
+
bio: entry.bio,
|
|
993
|
+
});
|
|
994
|
+
if (result.changed)
|
|
995
|
+
out.updated += 1;
|
|
996
|
+
else
|
|
997
|
+
out.skipped += 1;
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
out.skipped += 1;
|
|
1001
|
+
daemonLog.warn("hello.identity apply failed", {
|
|
1002
|
+
agentId: entry.agentId,
|
|
1003
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|
|
480
1009
|
/**
|
|
481
1010
|
* Re-read `config.json` and reconcile the running gateway against it. New
|
|
482
1011
|
* agents in config but not in gateway snapshot → `addChannel`; agents in
|
|
@@ -564,7 +1093,11 @@ function readAgentRuntimesFromCredentials(agentIds) {
|
|
|
564
1093
|
entry.runtime = creds.runtime;
|
|
565
1094
|
if (creds.cwd)
|
|
566
1095
|
entry.cwd = creds.cwd;
|
|
567
|
-
if (
|
|
1096
|
+
if (creds.openclawGateway)
|
|
1097
|
+
entry.openclawGateway = creds.openclawGateway;
|
|
1098
|
+
if (creds.openclawAgent)
|
|
1099
|
+
entry.openclawAgent = creds.openclawAgent;
|
|
1100
|
+
if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent)
|
|
568
1101
|
out[id] = entry;
|
|
569
1102
|
}
|
|
570
1103
|
catch {
|
|
@@ -652,11 +1185,21 @@ export function setRoute(params) {
|
|
|
652
1185
|
if (p.pattern && typeof p.pattern === "string" && !match.conversationPrefix) {
|
|
653
1186
|
match.conversationPrefix = p.pattern;
|
|
654
1187
|
}
|
|
1188
|
+
// Fall back to defaultRoute.extraArgs (mirrors adapter/cwd inheritance
|
|
1189
|
+
// above) so dashboard-driven `set_route` calls that only carry agentId +
|
|
1190
|
+
// pattern still pick up operator-wide flags like `--permission-mode
|
|
1191
|
+
// bypassPermissions`. Without this, every newly provisioned agent lost
|
|
1192
|
+
// those flags and Bash/MCP tool calls would deadlock on permission prompts.
|
|
1193
|
+
const extraArgs = Array.isArray(route?.extraArgs)
|
|
1194
|
+
? route.extraArgs.slice()
|
|
1195
|
+
: Array.isArray(cfg.defaultRoute.extraArgs)
|
|
1196
|
+
? cfg.defaultRoute.extraArgs.slice()
|
|
1197
|
+
: undefined;
|
|
655
1198
|
const newRule = {
|
|
656
1199
|
match,
|
|
657
1200
|
adapter,
|
|
658
1201
|
cwd,
|
|
659
|
-
...(
|
|
1202
|
+
...(extraArgs ? { extraArgs } : {}),
|
|
660
1203
|
};
|
|
661
1204
|
const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
|
|
662
1205
|
// Replace an existing matching rule. We use the canonical signature
|
|
@@ -743,5 +1286,14 @@ function inferHubUrl(cfg) {
|
|
|
743
1286
|
// skip
|
|
744
1287
|
}
|
|
745
1288
|
}
|
|
1289
|
+
if (ids.length === 0) {
|
|
1290
|
+
const discovered = discoverAgentCredentials({
|
|
1291
|
+
credentialsDir: path.join(homedir(), ".botcord", "credentials"),
|
|
1292
|
+
});
|
|
1293
|
+
for (const a of discovered.agents) {
|
|
1294
|
+
if (a.hubUrl)
|
|
1295
|
+
return a.hubUrl;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
746
1298
|
return null;
|
|
747
1299
|
}
|