@botcord/daemon 0.2.4 → 0.2.6
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 +7 -3
- package/dist/agent-discovery.js +9 -1
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -10
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +29 -12
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +52 -5
- 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 +66 -1
- package/dist/gateway/dispatcher.js +583 -56
- 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 +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +73 -3
- package/dist/provision.js +373 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/turn-text.js +20 -1
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- package/package.json +2 -1
- 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__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +160 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +12 -4
- package/src/agent-workspace.ts +173 -9
- package/src/config.ts +132 -4
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +156 -12
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +440 -2
- 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 +681 -58
- 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 +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +446 -20
- package/src/system-context.ts +41 -9
- package/src/turn-text.ts +22 -1
- package/src/url-utils.ts +17 -0
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
package/src/provision.ts
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
* to this module with the parsed {@link ControlFrame}; we execute the
|
|
4
4
|
* side effects (register agent, write credentials, load route, add/remove
|
|
5
5
|
* gateway channel) and return an ack payload.
|
|
6
|
-
*
|
|
7
|
-
* See `docs/daemon-control-plane-plan.md` §4.3, §5.3, §8.
|
|
8
6
|
*/
|
|
9
7
|
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
10
8
|
import { homedir } from "node:os";
|
|
@@ -16,16 +14,21 @@ import {
|
|
|
16
14
|
derivePublicKey,
|
|
17
15
|
loadStoredCredentials,
|
|
18
16
|
writeCredentialsFile,
|
|
17
|
+
type AgentIdentitySnapshot,
|
|
19
18
|
type ControlAck,
|
|
20
19
|
type ControlFrame,
|
|
20
|
+
type HelloParams,
|
|
21
21
|
type ListRuntimesResult,
|
|
22
22
|
type ProvisionAgentParams,
|
|
23
23
|
type RevokeAgentParams,
|
|
24
24
|
type RevokeAgentResult,
|
|
25
25
|
type RuntimeProbeResult,
|
|
26
26
|
type StoredBotCordCredentials,
|
|
27
|
+
type UpdateAgentParams,
|
|
27
28
|
} from "@botcord/protocol-core";
|
|
28
29
|
import type { Gateway } from "./gateway/index.js";
|
|
30
|
+
import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
|
|
31
|
+
import type { PolicyUpdatedParams } from "@botcord/protocol-core";
|
|
29
32
|
import type {
|
|
30
33
|
GatewayChannelConfig,
|
|
31
34
|
GatewayRuntimeSnapshot,
|
|
@@ -38,11 +41,16 @@ import {
|
|
|
38
41
|
type RouteRule,
|
|
39
42
|
type RouteRuleMatch,
|
|
40
43
|
} from "./config.js";
|
|
41
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
BOTCORD_CHANNEL_TYPE,
|
|
46
|
+
buildManagedRoutes,
|
|
47
|
+
prepareGatewayProfile,
|
|
48
|
+
} from "./daemon-config-map.js";
|
|
42
49
|
import {
|
|
43
50
|
agentHomeDir,
|
|
44
51
|
agentStateDir,
|
|
45
52
|
agentWorkspaceDir,
|
|
53
|
+
applyAgentIdentity,
|
|
46
54
|
ensureAgentWorkspace,
|
|
47
55
|
} from "./agent-workspace.js";
|
|
48
56
|
import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
|
|
@@ -57,6 +65,14 @@ export interface ProvisionerOptions {
|
|
|
57
65
|
* run without a real Hub.
|
|
58
66
|
*/
|
|
59
67
|
register?: typeof BotCordClient.register;
|
|
68
|
+
/**
|
|
69
|
+
* Optional policy-resolver handle (PR3). When present, the
|
|
70
|
+
* `policy_updated` control frame routes through it: cache is invalidated
|
|
71
|
+
* for the (agent, room?) pair, and any embedded `policy` payload is
|
|
72
|
+
* applied directly so the next inbound sees the fresh policy without an
|
|
73
|
+
* extra round-trip.
|
|
74
|
+
*/
|
|
75
|
+
policyResolver?: PolicyResolverLike;
|
|
60
76
|
}
|
|
61
77
|
|
|
62
78
|
/** The value a frame handler returns (minus the `id` which the channel fills in). */
|
|
@@ -72,6 +88,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
72
88
|
) => Promise<AckBody> {
|
|
73
89
|
const gateway = opts.gateway;
|
|
74
90
|
const register = opts.register ?? BotCordClient.register;
|
|
91
|
+
const policyResolver = opts.policyResolver;
|
|
75
92
|
|
|
76
93
|
return async (frame: ControlFrame): Promise<AckBody> => {
|
|
77
94
|
daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
|
|
@@ -79,6 +96,38 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
79
96
|
case CONTROL_FRAME_TYPES.PING:
|
|
80
97
|
return { ok: true, result: { pong: true, ts: Date.now() } };
|
|
81
98
|
|
|
99
|
+
case CONTROL_FRAME_TYPES.HELLO: {
|
|
100
|
+
const params = (frame.params ?? {}) as unknown as HelloParams;
|
|
101
|
+
const result = applyHelloIdentitySnapshot(params.agents);
|
|
102
|
+
daemonLog.debug("hello: identity snapshot applied", {
|
|
103
|
+
frameId: frame.id,
|
|
104
|
+
received: params.agents?.length ?? 0,
|
|
105
|
+
updated: result.updated,
|
|
106
|
+
skipped: result.skipped,
|
|
107
|
+
});
|
|
108
|
+
return { ok: true, result };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case CONTROL_FRAME_TYPES.UPDATE_AGENT: {
|
|
112
|
+
const params = (frame.params ?? {}) as unknown as UpdateAgentParams;
|
|
113
|
+
if (!params.agentId) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: { code: "bad_params", message: "update_agent requires params.agentId" },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const result = applyAgentIdentity(params.agentId, {
|
|
120
|
+
displayName: params.displayName,
|
|
121
|
+
bio: params.bio,
|
|
122
|
+
});
|
|
123
|
+
daemonLog.info("update_agent applied", {
|
|
124
|
+
agentId: params.agentId,
|
|
125
|
+
changed: result.changed,
|
|
126
|
+
skipped: result.skipped ?? null,
|
|
127
|
+
});
|
|
128
|
+
return { ok: true, result };
|
|
129
|
+
}
|
|
130
|
+
|
|
82
131
|
case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
|
|
83
132
|
const params = (frame.params ?? {}) as unknown as ProvisionAgentParams;
|
|
84
133
|
daemonLog.info("provision_agent: start", {
|
|
@@ -88,6 +137,18 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
88
137
|
name: params.name ?? null,
|
|
89
138
|
});
|
|
90
139
|
const agent = await provisionAgent(params, { gateway, register });
|
|
140
|
+
// Seed the policy resolver from the optional `defaultAttention` /
|
|
141
|
+
// `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
|
|
142
|
+
// don't yet emit these stay backwards-compatible — the resolver just
|
|
143
|
+
// falls back to `mode=always` until a `policy_updated` frame arrives.
|
|
144
|
+
if (policyResolver && params.defaultAttention) {
|
|
145
|
+
policyResolver.put(agent.agentId, null, {
|
|
146
|
+
mode: params.defaultAttention,
|
|
147
|
+
keywords: Array.isArray(params.attentionKeywords)
|
|
148
|
+
? params.attentionKeywords.slice()
|
|
149
|
+
: [],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
91
152
|
return {
|
|
92
153
|
ok: true,
|
|
93
154
|
result: {
|
|
@@ -127,8 +188,57 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
127
188
|
return { ok: true, result: res };
|
|
128
189
|
}
|
|
129
190
|
|
|
191
|
+
case CONTROL_FRAME_TYPES.POLICY_UPDATED: {
|
|
192
|
+
const params = (frame.params ?? {}) as unknown as PolicyUpdatedParams;
|
|
193
|
+
const agentId = params.agent_id;
|
|
194
|
+
if (typeof agentId !== "string" || !agentId) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
error: { code: "bad_params", message: "policy_updated requires agent_id" },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (!policyResolver) {
|
|
201
|
+
// No resolver wired — quietly succeed; the daemon may be running
|
|
202
|
+
// without the gateway-level attention gate (e.g. legacy boot path).
|
|
203
|
+
daemonLog.debug("policy_updated: no resolver — noop", { agentId });
|
|
204
|
+
return { ok: true, result: { agent_id: agentId, applied: false } };
|
|
205
|
+
}
|
|
206
|
+
const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
|
|
207
|
+
if (params.policy) {
|
|
208
|
+
// Embedded policy payload — install directly to avoid a refetch.
|
|
209
|
+
policyResolver.put(agentId, roomId ?? null, {
|
|
210
|
+
mode: params.policy.mode,
|
|
211
|
+
keywords: Array.isArray(params.policy.keywords)
|
|
212
|
+
? params.policy.keywords.slice()
|
|
213
|
+
: [],
|
|
214
|
+
...(typeof params.policy.muted_until === "number"
|
|
215
|
+
? { muted_until: params.policy.muted_until }
|
|
216
|
+
: {}),
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
policyResolver.invalidate(agentId, roomId);
|
|
220
|
+
}
|
|
221
|
+
daemonLog.info("policy_updated: applied", {
|
|
222
|
+
agentId,
|
|
223
|
+
roomId: roomId ?? null,
|
|
224
|
+
embedded: !!params.policy,
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
result: { agent_id: agentId, applied: true, embedded: !!params.policy },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
130
232
|
case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
|
|
131
|
-
|
|
233
|
+
// Async path so the openclaw-acp endpoints get probed inline; gateway
|
|
234
|
+
// / WS errors are swallowed inside `collectRuntimeSnapshotAsync`.
|
|
235
|
+
let cfgForProbe: { openclawGateways?: any[] } | undefined;
|
|
236
|
+
try {
|
|
237
|
+
cfgForProbe = loadConfig();
|
|
238
|
+
} catch {
|
|
239
|
+
cfgForProbe = undefined;
|
|
240
|
+
}
|
|
241
|
+
const snapshot = await collectRuntimeSnapshotAsync({ cfg: cfgForProbe });
|
|
132
242
|
daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
|
|
133
243
|
return { ok: true, result: snapshot };
|
|
134
244
|
}
|
|
@@ -248,11 +358,37 @@ async function provisionAgent(
|
|
|
248
358
|
// Hot-add the synthesized per-agent managed route so the next turn picks
|
|
249
359
|
// the agent's runtime + workspace cwd without waiting for reload_config.
|
|
250
360
|
try {
|
|
251
|
-
|
|
361
|
+
const synthRoute: import("./gateway/index.js").GatewayRoute = {
|
|
252
362
|
match: { accountId: credentials.agentId },
|
|
253
363
|
runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
|
|
254
364
|
cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
|
|
255
|
-
}
|
|
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);
|
|
256
392
|
} catch (err) {
|
|
257
393
|
// Rollback the channel + config + credentials on managed-route failure
|
|
258
394
|
// (shouldn't happen — pure map op — but keeps the invariant tight).
|
|
@@ -297,9 +433,9 @@ async function materializeCredentials(
|
|
|
297
433
|
ctx: ProvisionCtx,
|
|
298
434
|
explicitCwd: string | undefined,
|
|
299
435
|
): Promise<StoredBotCordCredentials> {
|
|
300
|
-
// Runtime is an agent property
|
|
301
|
-
//
|
|
302
|
-
//
|
|
436
|
+
// Runtime is an agent property. Hub is authoritative; top-level `runtime`
|
|
437
|
+
// wins, `adapter` is a one-release alias, and `credentials.runtime` is the
|
|
438
|
+
// per-agent cached copy.
|
|
303
439
|
const runtime = pickRuntime(params);
|
|
304
440
|
if (runtime) assertKnownRuntime(runtime);
|
|
305
441
|
|
|
@@ -334,6 +470,9 @@ async function materializeCredentials(
|
|
|
334
470
|
if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
|
|
335
471
|
if (runtime) record.runtime = runtime;
|
|
336
472
|
record.cwd = cwd;
|
|
473
|
+
const openclawSel = pickOpenclawSelection(params);
|
|
474
|
+
if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
|
|
475
|
+
if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
|
|
337
476
|
return record;
|
|
338
477
|
}
|
|
339
478
|
|
|
@@ -363,9 +502,41 @@ async function materializeCredentials(
|
|
|
363
502
|
};
|
|
364
503
|
if (runtime) record.runtime = runtime;
|
|
365
504
|
record.cwd = cwd;
|
|
505
|
+
const openclawSel = pickOpenclawSelection(params);
|
|
506
|
+
if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
|
|
507
|
+
if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
|
|
366
508
|
return record;
|
|
367
509
|
}
|
|
368
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Resolve OpenClaw routing selection from a `provision_agent` frame. Top-level
|
|
513
|
+
* `params.openclaw` (nested) wins over the flat `credentials.openclaw*` mirror.
|
|
514
|
+
* Returning `{}` is fine — only meaningful when the agent's runtime is
|
|
515
|
+
* `openclaw-acp`, and `buildManagedRoutes` falls back to defaultRoute.gateway
|
|
516
|
+
* when both are missing.
|
|
517
|
+
*/
|
|
518
|
+
function pickOpenclawSelection(
|
|
519
|
+
params: ProvisionAgentParams,
|
|
520
|
+
): { gateway?: string; agent?: string } {
|
|
521
|
+
const out: { gateway?: string; agent?: string } = {};
|
|
522
|
+
const top = params.openclaw;
|
|
523
|
+
if (top && typeof top.gateway === "string" && top.gateway.length > 0) {
|
|
524
|
+
out.gateway = top.gateway;
|
|
525
|
+
if (typeof top.agent === "string" && top.agent.length > 0) out.agent = top.agent;
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
const flat = params.credentials;
|
|
529
|
+
if (flat) {
|
|
530
|
+
if (typeof flat.openclawGateway === "string" && flat.openclawGateway.length > 0) {
|
|
531
|
+
out.gateway = flat.openclawGateway;
|
|
532
|
+
}
|
|
533
|
+
if (typeof flat.openclawAgent === "string" && flat.openclawAgent.length > 0) {
|
|
534
|
+
out.agent = flat.openclawAgent;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
369
540
|
async function revokeAgent(
|
|
370
541
|
params: RevokeAgentParams,
|
|
371
542
|
ctx: { gateway: Gateway },
|
|
@@ -557,6 +728,250 @@ export function collectRuntimeSnapshot(): ListRuntimesResult {
|
|
|
557
728
|
return { runtimes, probedAt: Date.now() };
|
|
558
729
|
}
|
|
559
730
|
|
|
731
|
+
/** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
|
|
732
|
+
export const RUNTIME_ENDPOINTS_CAP = 32;
|
|
733
|
+
|
|
734
|
+
/** Injection seam for L2 + L3 endpoint probes — kept testable + side-effect-free. */
|
|
735
|
+
export type WsEndpointProbeFn = (args: {
|
|
736
|
+
url: string;
|
|
737
|
+
token?: string;
|
|
738
|
+
timeoutMs: number;
|
|
739
|
+
}) => Promise<{
|
|
740
|
+
ok: boolean;
|
|
741
|
+
version?: string;
|
|
742
|
+
/**
|
|
743
|
+
* L3 — populated when `agents.list` succeeds. `id` is the stable key
|
|
744
|
+
* consumed by route lookups / `openclawAgent`; `name` is display-only.
|
|
745
|
+
*/
|
|
746
|
+
agents?: Array<{
|
|
747
|
+
id: string;
|
|
748
|
+
name?: string;
|
|
749
|
+
workspace?: string;
|
|
750
|
+
model?: { name?: string; provider?: string };
|
|
751
|
+
}>;
|
|
752
|
+
error?: string;
|
|
753
|
+
}>;
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Default L2 + L3 probe — opens a WS handshake against the OpenClaw gateway
|
|
757
|
+
* and, when the connection is up, issues a JSON-RPC `agents.list` request to
|
|
758
|
+
* enumerate configured agent profiles. Best-effort: a successful WS open with
|
|
759
|
+
* a failed `agents.list` still reports `ok: true` (just without `agents`),
|
|
760
|
+
* matching the RFC's "agents populated only when listing succeeded" rule.
|
|
761
|
+
*
|
|
762
|
+
* Method name and result shape follow OpenClaw:
|
|
763
|
+
* `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
|
|
764
|
+
* `~/claws/openclaw/src/gateway/session-utils.ts:783` —
|
|
765
|
+
* `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
|
|
766
|
+
*/
|
|
767
|
+
async function defaultWsProbe(args: {
|
|
768
|
+
url: string;
|
|
769
|
+
token?: string;
|
|
770
|
+
timeoutMs: number;
|
|
771
|
+
}): Promise<{
|
|
772
|
+
ok: boolean;
|
|
773
|
+
version?: string;
|
|
774
|
+
agents?: Array<{
|
|
775
|
+
id: string;
|
|
776
|
+
name?: string;
|
|
777
|
+
workspace?: string;
|
|
778
|
+
model?: { name?: string; provider?: string };
|
|
779
|
+
}>;
|
|
780
|
+
error?: string;
|
|
781
|
+
}> {
|
|
782
|
+
type AgentRow = {
|
|
783
|
+
id: string;
|
|
784
|
+
name?: string;
|
|
785
|
+
workspace?: string;
|
|
786
|
+
model?: { name?: string; provider?: string };
|
|
787
|
+
};
|
|
788
|
+
type ProbeResult = {
|
|
789
|
+
ok: boolean;
|
|
790
|
+
version?: string;
|
|
791
|
+
agents?: AgentRow[];
|
|
792
|
+
error?: string;
|
|
793
|
+
};
|
|
794
|
+
const { default: WebSocket } = await import("ws");
|
|
795
|
+
return new Promise<ProbeResult>((resolve) => {
|
|
796
|
+
let settled = false;
|
|
797
|
+
let ws: any;
|
|
798
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
799
|
+
const settle = (v: ProbeResult): void => {
|
|
800
|
+
if (settled) return;
|
|
801
|
+
settled = true;
|
|
802
|
+
if (timer) clearTimeout(timer);
|
|
803
|
+
try {
|
|
804
|
+
ws?.terminate();
|
|
805
|
+
} catch {
|
|
806
|
+
// ignore
|
|
807
|
+
}
|
|
808
|
+
resolve(v);
|
|
809
|
+
};
|
|
810
|
+
try {
|
|
811
|
+
const headers: Record<string, string> = {};
|
|
812
|
+
if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
|
|
813
|
+
ws = new WebSocket(args.url, { headers });
|
|
814
|
+
} catch (err) {
|
|
815
|
+
resolve({ ok: false, error: (err as Error).message });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
|
|
819
|
+
const requestId = "probe-agents-list";
|
|
820
|
+
ws.on("open", () => {
|
|
821
|
+
// L3: enumerate agent profiles. We don't fail the L2 result if this
|
|
822
|
+
// call fails — the gateway is reachable either way.
|
|
823
|
+
try {
|
|
824
|
+
ws.send(
|
|
825
|
+
JSON.stringify({
|
|
826
|
+
jsonrpc: "2.0",
|
|
827
|
+
id: requestId,
|
|
828
|
+
method: "agents.list",
|
|
829
|
+
params: {},
|
|
830
|
+
}),
|
|
831
|
+
);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
settle({ ok: true, error: `agents.list send failed: ${(err as Error).message}` });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
ws.on("message", (raw: Buffer | string) => {
|
|
837
|
+
try {
|
|
838
|
+
const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
839
|
+
if (msg?.id !== requestId) return; // ignore unrelated frames
|
|
840
|
+
if (msg.error) {
|
|
841
|
+
settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
|
|
845
|
+
const agents: AgentRow[] = [];
|
|
846
|
+
for (const a of list) {
|
|
847
|
+
if (!a || typeof a.id !== "string" || a.id.length === 0) continue;
|
|
848
|
+
const row: AgentRow = { id: a.id };
|
|
849
|
+
if (typeof a.name === "string") row.name = a.name;
|
|
850
|
+
if (typeof a.workspace === "string") row.workspace = a.workspace;
|
|
851
|
+
if (a.model && typeof a.model === "object") {
|
|
852
|
+
const model: { name?: string; provider?: string } = {};
|
|
853
|
+
if (typeof a.model.name === "string") model.name = a.model.name;
|
|
854
|
+
if (typeof a.model.provider === "string") model.provider = a.model.provider;
|
|
855
|
+
if (model.name || model.provider) row.model = model;
|
|
856
|
+
}
|
|
857
|
+
agents.push(row);
|
|
858
|
+
}
|
|
859
|
+
settle({ ok: true, agents });
|
|
860
|
+
} catch (err) {
|
|
861
|
+
settle({ ok: true, error: `agents.list parse failed: ${(err as Error).message}` });
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
ws.on("error", (err: Error) => {
|
|
865
|
+
settle({ ok: false, error: err.message });
|
|
866
|
+
});
|
|
867
|
+
ws.on("close", () => {
|
|
868
|
+
// If the socket closes before `agents.list` resolved we still treat
|
|
869
|
+
// L2 as ok (open fired) and emit no agents.
|
|
870
|
+
settle({ ok: true });
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Async variant that includes L2 (gateway reachability) and L3 (agent listing)
|
|
877
|
+
* probes for runtimes that talk to external services. Used by the production
|
|
878
|
+
* `list_runtimes` and first-connect snapshot paths.
|
|
879
|
+
*
|
|
880
|
+
* `cfg` is optional so existing callers without a loaded config (e.g. tests)
|
|
881
|
+
* can keep using the sync `collectRuntimeSnapshot()` — when absent, the result
|
|
882
|
+
* is identical to that function.
|
|
883
|
+
*/
|
|
884
|
+
export async function collectRuntimeSnapshotAsync(opts: {
|
|
885
|
+
cfg?: { openclawGateways?: Array<{ name: string; url: string; token?: string; tokenFile?: string }> };
|
|
886
|
+
wsProbe?: WsEndpointProbeFn;
|
|
887
|
+
timeoutMs?: number;
|
|
888
|
+
} = {}): Promise<ListRuntimesResult> {
|
|
889
|
+
const base = collectRuntimeSnapshot();
|
|
890
|
+
const gateways = opts.cfg?.openclawGateways ?? [];
|
|
891
|
+
if (gateways.length === 0) return base;
|
|
892
|
+
const probe = opts.wsProbe ?? defaultWsProbe;
|
|
893
|
+
// Default daemon-side budget is 3s — it must stay below the Hub's
|
|
894
|
+
// `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
|
|
895
|
+
// so a single slow gateway can't blow the whole snapshot to a 504.
|
|
896
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
897
|
+
const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
|
|
898
|
+
const endpoints = await Promise.all(
|
|
899
|
+
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
|
+
try {
|
|
904
|
+
const res = await probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
|
|
905
|
+
const entry: any = { name: g.name, url: g.url, reachable: res.ok };
|
|
906
|
+
if (res.version) entry.version = res.version;
|
|
907
|
+
if (res.error) entry.error = res.error;
|
|
908
|
+
if (res.agents) entry.agents = res.agents;
|
|
909
|
+
return entry;
|
|
910
|
+
} catch (err) {
|
|
911
|
+
return {
|
|
912
|
+
name: g.name,
|
|
913
|
+
url: g.url,
|
|
914
|
+
reachable: false,
|
|
915
|
+
error: (err as Error).message,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
}),
|
|
919
|
+
);
|
|
920
|
+
const out: ListRuntimesResult = { ...base };
|
|
921
|
+
out.runtimes = base.runtimes.map((r) =>
|
|
922
|
+
r.id === "openclaw-acp" ? { ...r, endpoints } : r,
|
|
923
|
+
);
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// hello agents snapshot (lightweight identity sync)
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
interface HelloIdentityResult {
|
|
932
|
+
updated: number;
|
|
933
|
+
skipped: number;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Reconcile every agent identity carried by the `hello.agents` snapshot
|
|
938
|
+
* against the on-disk `identity.md`. Best-effort: a malformed entry or a
|
|
939
|
+
* file-system error for one agent never aborts the rest.
|
|
940
|
+
*
|
|
941
|
+
* Identity-snapshot semantics intentionally only touch the metadata
|
|
942
|
+
* line + Bio body — Role/Boundaries paragraphs the user authored locally
|
|
943
|
+
* are preserved (see `applyAgentIdentity`). Missing identity.md files
|
|
944
|
+
* (agent provisioned on a different daemon, or workspace cleared) are
|
|
945
|
+
* silently skipped.
|
|
946
|
+
*/
|
|
947
|
+
export function applyHelloIdentitySnapshot(
|
|
948
|
+
snapshot: AgentIdentitySnapshot[] | undefined,
|
|
949
|
+
): HelloIdentityResult {
|
|
950
|
+
const out: HelloIdentityResult = { updated: 0, skipped: 0 };
|
|
951
|
+
if (!Array.isArray(snapshot)) return out;
|
|
952
|
+
for (const entry of snapshot) {
|
|
953
|
+
if (!entry || typeof entry.agentId !== "string") {
|
|
954
|
+
out.skipped += 1;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const result = applyAgentIdentity(entry.agentId, {
|
|
959
|
+
displayName: entry.displayName,
|
|
960
|
+
bio: entry.bio,
|
|
961
|
+
});
|
|
962
|
+
if (result.changed) out.updated += 1;
|
|
963
|
+
else out.skipped += 1;
|
|
964
|
+
} catch (err) {
|
|
965
|
+
out.skipped += 1;
|
|
966
|
+
daemonLog.warn("hello.identity apply failed", {
|
|
967
|
+
agentId: entry.agentId,
|
|
968
|
+
error: err instanceof Error ? err.message : String(err),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return out;
|
|
973
|
+
}
|
|
974
|
+
|
|
560
975
|
// ---------------------------------------------------------------------------
|
|
561
976
|
// reload_config / list_agents / set_route handlers (P3)
|
|
562
977
|
// ---------------------------------------------------------------------------
|
|
@@ -643,17 +1058,19 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
|
|
|
643
1058
|
*/
|
|
644
1059
|
function readAgentRuntimesFromCredentials(
|
|
645
1060
|
agentIds: string[],
|
|
646
|
-
): Record<string, { runtime?: string; cwd?: string }> {
|
|
647
|
-
const out: Record<string, { runtime?: string; cwd?: string }> = {};
|
|
1061
|
+
): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> {
|
|
1062
|
+
const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> = {};
|
|
648
1063
|
for (const id of agentIds) {
|
|
649
1064
|
const file = defaultCredentialsFile(id);
|
|
650
1065
|
try {
|
|
651
1066
|
if (!existsSync(file)) continue;
|
|
652
1067
|
const creds = loadStoredCredentials(file);
|
|
653
|
-
const entry: { runtime?: string; cwd?: string } = {};
|
|
1068
|
+
const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string } = {};
|
|
654
1069
|
if (creds.runtime) entry.runtime = creds.runtime;
|
|
655
1070
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
656
|
-
if (
|
|
1071
|
+
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
1072
|
+
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
1073
|
+
if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent) out[id] = entry;
|
|
657
1074
|
} catch {
|
|
658
1075
|
// best-effort — skip agents with unreadable credentials
|
|
659
1076
|
}
|
|
@@ -662,8 +1079,7 @@ function readAgentRuntimesFromCredentials(
|
|
|
662
1079
|
}
|
|
663
1080
|
|
|
664
1081
|
/**
|
|
665
|
-
* Per-agent entry returned by `list_agents`.
|
|
666
|
-
* `docs/daemon-control-plane-api-contract.md` §3.2 — `{id, name, online}`.
|
|
1082
|
+
* Per-agent entry returned by `list_agents`. Wire shape: `{id, name, online}`.
|
|
667
1083
|
* `status` and `lastMessageAt` are extra daemon-only fields the dashboard
|
|
668
1084
|
* may ignore; kept so future contract revisions can promote them without
|
|
669
1085
|
* breaking the wire.
|
|
@@ -725,10 +1141,10 @@ interface SetRouteResult {
|
|
|
725
1141
|
interface SetRouteParams {
|
|
726
1142
|
agentId?: string;
|
|
727
1143
|
/**
|
|
728
|
-
* Contract shape
|
|
729
|
-
* `
|
|
730
|
-
*
|
|
731
|
-
* default
|
|
1144
|
+
* Contract shape `{pattern, agentId}`. `pattern` is treated as a
|
|
1145
|
+
* conversation-id prefix (`rm_oc_*` etc.). When `route` is omitted, we
|
|
1146
|
+
* synthesize a sensible default route record using the daemon's existing
|
|
1147
|
+
* default adapter+cwd.
|
|
732
1148
|
*/
|
|
733
1149
|
pattern?: string;
|
|
734
1150
|
/**
|
|
@@ -785,11 +1201,21 @@ export function setRoute(params: unknown): SetRouteResult {
|
|
|
785
1201
|
match.conversationPrefix = p.pattern;
|
|
786
1202
|
}
|
|
787
1203
|
|
|
1204
|
+
// Fall back to defaultRoute.extraArgs (mirrors adapter/cwd inheritance
|
|
1205
|
+
// above) so dashboard-driven `set_route` calls that only carry agentId +
|
|
1206
|
+
// pattern still pick up operator-wide flags like `--permission-mode
|
|
1207
|
+
// bypassPermissions`. Without this, every newly provisioned agent lost
|
|
1208
|
+
// those flags and Bash/MCP tool calls would deadlock on permission prompts.
|
|
1209
|
+
const extraArgs = Array.isArray(route?.extraArgs)
|
|
1210
|
+
? route!.extraArgs!.slice()
|
|
1211
|
+
: Array.isArray(cfg.defaultRoute.extraArgs)
|
|
1212
|
+
? cfg.defaultRoute.extraArgs.slice()
|
|
1213
|
+
: undefined;
|
|
788
1214
|
const newRule: RouteRule = {
|
|
789
1215
|
match,
|
|
790
1216
|
adapter,
|
|
791
1217
|
cwd,
|
|
792
|
-
...(
|
|
1218
|
+
...(extraArgs ? { extraArgs } : {}),
|
|
793
1219
|
};
|
|
794
1220
|
|
|
795
1221
|
const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
|