@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/src/config.ts
CHANGED
|
@@ -13,7 +13,24 @@ export const SNAPSHOT_PATH = path.join(DAEMON_DIR, "snapshot.json");
|
|
|
13
13
|
* Adapter ids. Built-in adapters are enumerated for editor hints; any string
|
|
14
14
|
* accepted by the registry is valid at runtime.
|
|
15
15
|
*/
|
|
16
|
-
export type AdapterName = "claude-code" | "codex" | "gemini" | (string & {});
|
|
16
|
+
export type AdapterName = "claude-code" | "codex" | "gemini" | "openclaw-acp" | (string & {});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One OpenClaw gateway profile. Referenced by `RouteRule.gateway` and
|
|
20
|
+
* `DaemonRouteDefault.gateway` (and `StoredBotCordCredentials.openclawGateway`)
|
|
21
|
+
* via `name`. `tokenFile` is `~`-expanded and read at `toGatewayConfig` time;
|
|
22
|
+
* read failures do not block boot — the gateway becomes unusable but other
|
|
23
|
+
* gateways still work.
|
|
24
|
+
*/
|
|
25
|
+
export interface OpenclawGatewayProfile {
|
|
26
|
+
name: string;
|
|
27
|
+
url: string;
|
|
28
|
+
/** Bearer token; mutually-exclusive priority is `token > tokenFile`. */
|
|
29
|
+
token?: string;
|
|
30
|
+
tokenFile?: string;
|
|
31
|
+
/** Default OpenClaw agent profile name when a route does not pin one. */
|
|
32
|
+
defaultAgent?: string;
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
/**
|
|
19
36
|
* Predicates selecting messages for a route. `roomId` / `roomPrefix` are
|
|
@@ -41,12 +58,23 @@ export interface RouteRule {
|
|
|
41
58
|
cwd: string;
|
|
42
59
|
/** Extra CLI flags appended to the adapter invocation. */
|
|
43
60
|
extraArgs?: string[];
|
|
61
|
+
/**
|
|
62
|
+
* Required when `adapter === "openclaw-acp"`: name of an entry in
|
|
63
|
+
* `DaemonConfig.openclawGateways[]`.
|
|
64
|
+
*/
|
|
65
|
+
gateway?: string;
|
|
66
|
+
/** Overrides `OpenclawGatewayProfile.defaultAgent` when set. */
|
|
67
|
+
openclawAgent?: string;
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
export interface DaemonRouteDefault {
|
|
47
71
|
adapter: AdapterName;
|
|
48
72
|
cwd: string;
|
|
49
73
|
extraArgs?: string[];
|
|
74
|
+
/** Same semantics as `RouteRule.gateway`. */
|
|
75
|
+
gateway?: string;
|
|
76
|
+
/** Same semantics as `RouteRule.openclawAgent`. */
|
|
77
|
+
openclawAgent?: string;
|
|
50
78
|
}
|
|
51
79
|
|
|
52
80
|
/**
|
|
@@ -60,6 +88,17 @@ export interface AgentDiscoveryConfig {
|
|
|
60
88
|
credentialsDir?: string;
|
|
61
89
|
}
|
|
62
90
|
|
|
91
|
+
export interface OpenclawDiscoveryConfig {
|
|
92
|
+
/** Defaults to true. */
|
|
93
|
+
enabled?: boolean;
|
|
94
|
+
/** Overrides the local config-file search roots. */
|
|
95
|
+
searchPaths?: string[];
|
|
96
|
+
/** Overrides the local loopback ports to probe. */
|
|
97
|
+
defaultPorts?: number[];
|
|
98
|
+
/** Defaults to true. When false, discovery only persists gateways. */
|
|
99
|
+
autoProvision?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
63
102
|
export interface DaemonConfig {
|
|
64
103
|
/**
|
|
65
104
|
* @deprecated Kept for backward compatibility with pre-multi-agent configs.
|
|
@@ -90,6 +129,34 @@ export interface DaemonConfig {
|
|
|
90
129
|
routes: RouteRule[];
|
|
91
130
|
/** If true, stream blocks (only meaningful for rm_oc_* rooms). */
|
|
92
131
|
streamBlocks: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Persistent transcript-logging settings (design §3 / §6). Defaults to
|
|
134
|
+
* disabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
|
|
135
|
+
*/
|
|
136
|
+
transcript?: TranscriptConfig;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Optional registry of OpenClaw gateway endpoints. Routes / managed routes
|
|
140
|
+
* with `adapter === "openclaw-acp"` reference these by `name`. Resolution
|
|
141
|
+
* to {@link ResolvedOpenclawGateway} happens eagerly in `toGatewayConfig`
|
|
142
|
+
* so the dispatcher never re-queries this list.
|
|
143
|
+
*/
|
|
144
|
+
openclawGateways?: OpenclawGatewayProfile[];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Daemon-side local OpenClaw discovery. Omitted means enabled with default
|
|
148
|
+
* search paths/ports and automatic adoption of discovered agents.
|
|
149
|
+
*/
|
|
150
|
+
openclawDiscovery?: OpenclawDiscoveryConfig;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Persistent transcript settings (design §6). Default-off — `botcord-daemon
|
|
155
|
+
* transcript enable` flips `enabled` and `transcript disable` flips it back.
|
|
156
|
+
* The env var `BOTCORD_TRANSCRIPT` can override at boot.
|
|
157
|
+
*/
|
|
158
|
+
export interface TranscriptConfig {
|
|
159
|
+
enabled?: boolean;
|
|
93
160
|
}
|
|
94
161
|
|
|
95
162
|
/**
|
|
@@ -160,11 +227,15 @@ function ensureDir(): void {
|
|
|
160
227
|
}
|
|
161
228
|
}
|
|
162
229
|
|
|
230
|
+
export const CONFIG_MISSING = "CONFIG_MISSING";
|
|
231
|
+
|
|
163
232
|
export function loadConfig(): DaemonConfig {
|
|
164
233
|
if (!existsSync(CONFIG_PATH)) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
234
|
+
const err = new Error(`daemon config not found at ${CONFIG_PATH}`) as Error & {
|
|
235
|
+
code?: string;
|
|
236
|
+
};
|
|
237
|
+
err.code = CONFIG_MISSING;
|
|
238
|
+
throw err;
|
|
168
239
|
}
|
|
169
240
|
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
170
241
|
const parsed = JSON.parse(raw) as Partial<DaemonConfig>;
|
|
@@ -196,6 +267,65 @@ export function loadConfig(): DaemonConfig {
|
|
|
196
267
|
}
|
|
197
268
|
validateAdapter(parsed.defaultRoute.adapter, "defaultRoute.adapter");
|
|
198
269
|
|
|
270
|
+
const gatewaysRaw = (parsed as Partial<DaemonConfig>).openclawGateways;
|
|
271
|
+
const gatewayNames = new Set<string>();
|
|
272
|
+
if (gatewaysRaw !== undefined) {
|
|
273
|
+
if (!Array.isArray(gatewaysRaw)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`daemon config "openclawGateways" must be an array (${CONFIG_PATH})`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
for (const [i, g] of gatewaysRaw.entries()) {
|
|
279
|
+
if (!g || typeof g !== "object") {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`daemon config openclawGateways[${i}] is not an object (${CONFIG_PATH})`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const gg = g as Partial<OpenclawGatewayProfile>;
|
|
285
|
+
if (typeof gg.name !== "string" || gg.name.length === 0) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`daemon config openclawGateways[${i}].name must be a non-empty string (${CONFIG_PATH})`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (typeof gg.url !== "string" || gg.url.length === 0) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`daemon config openclawGateways[${i}].url must be a non-empty string (${CONFIG_PATH})`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (gatewayNames.has(gg.name)) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`daemon config openclawGateways[${i}].name "${gg.name}" duplicated (${CONFIG_PATH})`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
gatewayNames.add(gg.name);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const validateGatewayRef = (
|
|
305
|
+
adapter: string,
|
|
306
|
+
gateway: unknown,
|
|
307
|
+
where: string,
|
|
308
|
+
): void => {
|
|
309
|
+
if (adapter === "openclaw-acp") {
|
|
310
|
+
if (typeof gateway !== "string" || gateway.length === 0) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`daemon config ${where} adapter "openclaw-acp" requires a "gateway" name (${CONFIG_PATH})`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (!gatewayNames.has(gateway)) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`daemon config ${where}.gateway "${gateway}" not in openclawGateways (${CONFIG_PATH})`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
validateGatewayRef(
|
|
324
|
+
parsed.defaultRoute.adapter,
|
|
325
|
+
(parsed.defaultRoute as DaemonRouteDefault).gateway,
|
|
326
|
+
"defaultRoute",
|
|
327
|
+
);
|
|
328
|
+
|
|
199
329
|
const routesRaw = parsed.routes ?? [];
|
|
200
330
|
if (!Array.isArray(routesRaw)) {
|
|
201
331
|
throw new Error(`daemon config "routes" must be an array (${CONFIG_PATH})`);
|
|
@@ -210,6 +340,7 @@ export function loadConfig(): DaemonConfig {
|
|
|
210
340
|
);
|
|
211
341
|
}
|
|
212
342
|
validateAdapter(r.adapter, `routes[${i}].adapter`);
|
|
343
|
+
validateGatewayRef(r.adapter, (r as RouteRule).gateway, `routes[${i}]`);
|
|
213
344
|
}
|
|
214
345
|
// Preserve the on-disk shape as-is so `config` prints what the user wrote.
|
|
215
346
|
// Resolution of agents vs agentId happens at the consumption boundary
|
|
@@ -219,6 +350,20 @@ export function loadConfig(): DaemonConfig {
|
|
|
219
350
|
routes: routesRaw,
|
|
220
351
|
streamBlocks: parsed.streamBlocks ?? true,
|
|
221
352
|
};
|
|
353
|
+
if (parsed.transcript && typeof parsed.transcript === "object") {
|
|
354
|
+
const t: TranscriptConfig = {};
|
|
355
|
+
if (typeof parsed.transcript.enabled === "boolean") t.enabled = parsed.transcript.enabled;
|
|
356
|
+
out.transcript = t;
|
|
357
|
+
}
|
|
358
|
+
if (gatewaysRaw && Array.isArray(gatewaysRaw)) {
|
|
359
|
+
out.openclawGateways = (gatewaysRaw as OpenclawGatewayProfile[]).map((g) => {
|
|
360
|
+
const copy: OpenclawGatewayProfile = { name: g.name, url: g.url };
|
|
361
|
+
if (typeof g.token === "string") copy.token = g.token;
|
|
362
|
+
if (typeof g.tokenFile === "string") copy.tokenFile = g.tokenFile;
|
|
363
|
+
if (typeof g.defaultAgent === "string") copy.defaultAgent = g.defaultAgent;
|
|
364
|
+
return copy;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
222
367
|
if (hasAgents) out.agents = (parsed.agents as string[]).slice();
|
|
223
368
|
if (hasLegacy) out.agentId = parsed.agentId;
|
|
224
369
|
if (discovery && typeof discovery === "object") {
|
|
@@ -229,6 +374,25 @@ export function loadConfig(): DaemonConfig {
|
|
|
229
374
|
}
|
|
230
375
|
out.agentDiscovery = copy;
|
|
231
376
|
}
|
|
377
|
+
const openclawDiscovery = parsed.openclawDiscovery;
|
|
378
|
+
if (openclawDiscovery && typeof openclawDiscovery === "object") {
|
|
379
|
+
const copy: OpenclawDiscoveryConfig = {};
|
|
380
|
+
if (typeof openclawDiscovery.enabled === "boolean") copy.enabled = openclawDiscovery.enabled;
|
|
381
|
+
if (Array.isArray(openclawDiscovery.searchPaths)) {
|
|
382
|
+
copy.searchPaths = openclawDiscovery.searchPaths.filter(
|
|
383
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (Array.isArray(openclawDiscovery.defaultPorts)) {
|
|
387
|
+
copy.defaultPorts = openclawDiscovery.defaultPorts.filter(
|
|
388
|
+
(p): p is number => Number.isInteger(p) && p > 0 && p < 65536,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (typeof openclawDiscovery.autoProvision === "boolean") {
|
|
392
|
+
copy.autoProvision = openclawDiscovery.autoProvision;
|
|
393
|
+
}
|
|
394
|
+
out.openclawDiscovery = copy;
|
|
395
|
+
}
|
|
232
396
|
return out;
|
|
233
397
|
}
|
|
234
398
|
|
package/src/daemon-config-map.ts
CHANGED
|
@@ -1,15 +1,109 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import type {
|
|
2
5
|
GatewayChannelConfig,
|
|
3
6
|
GatewayConfig,
|
|
4
7
|
GatewayRoute,
|
|
8
|
+
ResolvedOpenclawGateway,
|
|
5
9
|
RouteMatch,
|
|
6
10
|
TrustLevel as GatewayTrustLevel,
|
|
7
11
|
} from "./gateway/index.js";
|
|
8
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
DaemonConfig,
|
|
14
|
+
DaemonRouteDefault,
|
|
15
|
+
OpenclawGatewayProfile,
|
|
16
|
+
RouteRule,
|
|
17
|
+
} from "./config.js";
|
|
9
18
|
import { resolveAgentIds } from "./config.js";
|
|
10
19
|
import { agentWorkspaceDir } from "./agent-workspace.js";
|
|
11
20
|
import { log as daemonLog } from "./log.js";
|
|
12
21
|
|
|
22
|
+
/** Per-agent metadata cached from credentials, used by `buildManagedRoutes`. */
|
|
23
|
+
export interface AgentRuntimeMeta {
|
|
24
|
+
runtime?: string;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
/** OpenClaw gateway profile name to lookup in the registry. */
|
|
27
|
+
openclawGateway?: string;
|
|
28
|
+
/** Optional override of the OpenClaw agent profile within the gateway. */
|
|
29
|
+
openclawAgent?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Profile + tokenFile-resolved bearer token. Exported so other module-boundary
|
|
33
|
+
* paths (runtime probing, post-provision hot-add) reuse the same resolver
|
|
34
|
+
* instead of duplicating tokenFile semantics. */
|
|
35
|
+
export interface PreparedGatewayProfile extends OpenclawGatewayProfile {
|
|
36
|
+
/** Token actually usable at dispatch time; empty when load failed. */
|
|
37
|
+
resolvedToken?: string;
|
|
38
|
+
/** Reason `resolvedToken` is empty, for logs. */
|
|
39
|
+
tokenError?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expandHome(p: string): string {
|
|
43
|
+
if (p === "~") return homedir();
|
|
44
|
+
if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
|
|
45
|
+
return p;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve one profile's token (inline > tokenFile). Failures are swallowed
|
|
49
|
+
* into `tokenError`; `resolvedToken` is left undefined. Logs at warn for ops
|
|
50
|
+
* visibility. */
|
|
51
|
+
export function prepareGatewayProfile(
|
|
52
|
+
p: OpenclawGatewayProfile,
|
|
53
|
+
): PreparedGatewayProfile {
|
|
54
|
+
const prepared: PreparedGatewayProfile = { ...p };
|
|
55
|
+
if (p.token && p.token.length > 0) {
|
|
56
|
+
prepared.resolvedToken = p.token;
|
|
57
|
+
} else if (p.tokenFile && p.tokenFile.length > 0) {
|
|
58
|
+
try {
|
|
59
|
+
prepared.resolvedToken = readFileSync(expandHome(p.tokenFile), "utf8").trim();
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
prepared.tokenError = err?.message ?? String(err);
|
|
62
|
+
daemonLog.warn("daemon.config.openclaw.tokenfile_failed", {
|
|
63
|
+
gateway: p.name,
|
|
64
|
+
tokenFile: p.tokenFile,
|
|
65
|
+
error: prepared.tokenError,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return prepared;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build a name → prepared-profile map for a config's gateway registry. */
|
|
73
|
+
export function prepareGatewayProfiles(
|
|
74
|
+
profiles: OpenclawGatewayProfile[] | undefined,
|
|
75
|
+
): Map<string, PreparedGatewayProfile> {
|
|
76
|
+
const out = new Map<string, PreparedGatewayProfile>();
|
|
77
|
+
if (!profiles) return out;
|
|
78
|
+
for (const p of profiles) out.set(p.name, prepareGatewayProfile(p));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveGateway(
|
|
83
|
+
profiles: Map<string, PreparedGatewayProfile>,
|
|
84
|
+
gatewayName: string | undefined,
|
|
85
|
+
agentOverride: string | undefined,
|
|
86
|
+
where: string,
|
|
87
|
+
): ResolvedOpenclawGateway | undefined {
|
|
88
|
+
if (!gatewayName) {
|
|
89
|
+
daemonLog.warn("daemon.config.openclaw.missing_gateway", { where });
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const profile = profiles.get(gatewayName);
|
|
93
|
+
if (!profile) {
|
|
94
|
+
daemonLog.warn("daemon.config.openclaw.unknown_gateway", { where, gateway: gatewayName });
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const resolved: ResolvedOpenclawGateway = {
|
|
98
|
+
name: profile.name,
|
|
99
|
+
url: profile.url,
|
|
100
|
+
};
|
|
101
|
+
if (profile.resolvedToken) resolved.token = profile.resolvedToken;
|
|
102
|
+
const agent = agentOverride ?? profile.defaultAgent;
|
|
103
|
+
if (agent) resolved.openclawAgent = agent;
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
|
|
13
107
|
/** Options accepted by {@link toGatewayConfig}. */
|
|
14
108
|
export interface ToGatewayConfigOptions {
|
|
15
109
|
/**
|
|
@@ -24,7 +118,7 @@ export interface ToGatewayConfigOptions {
|
|
|
24
118
|
* turns to its runtime. Explicit `cfg.routes` entries still win because
|
|
25
119
|
* synthesized routes are appended after them.
|
|
26
120
|
*/
|
|
27
|
-
agentRuntimes?: Record<string,
|
|
121
|
+
agentRuntimes?: Record<string, AgentRuntimeMeta>;
|
|
28
122
|
}
|
|
29
123
|
|
|
30
124
|
/**
|
|
@@ -59,7 +153,11 @@ function mapTrustLevel(
|
|
|
59
153
|
* legacy alias and its canonical field are present, the canonical field
|
|
60
154
|
* wins and a warning is logged.
|
|
61
155
|
*/
|
|
62
|
-
function mapRoute(
|
|
156
|
+
function mapRoute(
|
|
157
|
+
r: RouteRule,
|
|
158
|
+
profiles: Map<string, PreparedGatewayProfile>,
|
|
159
|
+
index: number,
|
|
160
|
+
): GatewayRoute {
|
|
63
161
|
const match: RouteMatch = {};
|
|
64
162
|
if (r.match.channel) match.channel = r.match.channel;
|
|
65
163
|
if (r.match.accountId) match.accountId = r.match.accountId;
|
|
@@ -95,13 +193,22 @@ function mapRoute(r: RouteRule): GatewayRoute {
|
|
|
95
193
|
if (typeof r.match.mentioned === "boolean") match.mentioned = r.match.mentioned;
|
|
96
194
|
|
|
97
195
|
const rawTrust = (r as { trustLevel?: "owner" | "untrusted" }).trustLevel;
|
|
98
|
-
|
|
196
|
+
const out: GatewayRoute = {
|
|
99
197
|
match,
|
|
100
198
|
runtime: r.adapter,
|
|
101
199
|
cwd: r.cwd,
|
|
102
200
|
extraArgs: r.extraArgs,
|
|
103
201
|
trustLevel: mapTrustLevel(rawTrust),
|
|
104
202
|
};
|
|
203
|
+
if (r.adapter === "openclaw-acp") {
|
|
204
|
+
out.gateway = resolveGateway(
|
|
205
|
+
profiles,
|
|
206
|
+
r.gateway,
|
|
207
|
+
r.openclawAgent,
|
|
208
|
+
`routes[${index}]`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
105
212
|
}
|
|
106
213
|
|
|
107
214
|
/**
|
|
@@ -134,6 +241,8 @@ export function toGatewayConfig(
|
|
|
134
241
|
|
|
135
242
|
// DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
|
|
136
243
|
// defensively so future config extensions can propagate without a shape bump.
|
|
244
|
+
const profiles = prepareGatewayProfiles(cfg.openclawGateways);
|
|
245
|
+
|
|
137
246
|
const rawDefaultTrust = (cfg.defaultRoute as { trustLevel?: "owner" | "untrusted" })
|
|
138
247
|
.trustLevel;
|
|
139
248
|
const defaultRoute: GatewayRoute = {
|
|
@@ -144,8 +253,17 @@ export function toGatewayConfig(
|
|
|
144
253
|
// (direct → cancel-previous, group → serial).
|
|
145
254
|
trustLevel: mapTrustLevel(rawDefaultTrust),
|
|
146
255
|
};
|
|
256
|
+
if (cfg.defaultRoute.adapter === "openclaw-acp") {
|
|
257
|
+
const dr = cfg.defaultRoute as DaemonRouteDefault;
|
|
258
|
+
defaultRoute.gateway = resolveGateway(
|
|
259
|
+
profiles,
|
|
260
|
+
dr.gateway,
|
|
261
|
+
dr.openclawAgent,
|
|
262
|
+
"defaultRoute",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
147
265
|
|
|
148
|
-
const routes: GatewayRoute[] = (cfg.routes ?? []).map(mapRoute);
|
|
266
|
+
const routes: GatewayRoute[] = (cfg.routes ?? []).map((r, i) => mapRoute(r, profiles, i));
|
|
149
267
|
|
|
150
268
|
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
151
269
|
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
@@ -157,6 +275,7 @@ export function toGatewayConfig(
|
|
|
157
275
|
agentIds,
|
|
158
276
|
opts.agentRuntimes ?? {},
|
|
159
277
|
defaultRoute,
|
|
278
|
+
profiles,
|
|
160
279
|
);
|
|
161
280
|
|
|
162
281
|
return {
|
|
@@ -184,17 +303,43 @@ export function toGatewayConfig(
|
|
|
184
303
|
*/
|
|
185
304
|
export function buildManagedRoutes(
|
|
186
305
|
agentIds: string[],
|
|
187
|
-
agentRuntimes: Record<string,
|
|
306
|
+
agentRuntimes: Record<string, AgentRuntimeMeta>,
|
|
188
307
|
defaultRoute: GatewayRoute,
|
|
308
|
+
openclawProfiles?: Map<string, PreparedGatewayProfile>,
|
|
189
309
|
): Map<string, GatewayRoute> {
|
|
190
310
|
const out = new Map<string, GatewayRoute>();
|
|
311
|
+
// Lazy-build profile map when caller didn't pass one (legacy callers).
|
|
312
|
+
const profiles = openclawProfiles ?? new Map<string, PreparedGatewayProfile>();
|
|
191
313
|
for (const agentId of agentIds) {
|
|
192
314
|
const meta = agentRuntimes[agentId] ?? {};
|
|
193
|
-
|
|
315
|
+
const runtime = meta.runtime ?? defaultRoute.runtime;
|
|
316
|
+
const route: GatewayRoute = {
|
|
194
317
|
match: { accountId: agentId },
|
|
195
|
-
runtime
|
|
318
|
+
runtime,
|
|
196
319
|
cwd: meta.cwd || agentWorkspaceDir(agentId),
|
|
197
|
-
|
|
320
|
+
// Inherit defaultRoute's extraArgs so synthesized per-agent routes
|
|
321
|
+
// pick up operator-wide flags (e.g. `--permission-mode bypassPermissions`)
|
|
322
|
+
// that would otherwise apply only to agents listed in `cfg.routes[]`.
|
|
323
|
+
...(defaultRoute.extraArgs ? { extraArgs: defaultRoute.extraArgs.slice() } : {}),
|
|
324
|
+
};
|
|
325
|
+
if (runtime === "openclaw-acp") {
|
|
326
|
+
// Per RFC §3.4: prefer credentials, fall back to defaultRoute.gateway.
|
|
327
|
+
const gatewayName = meta.openclawGateway ?? defaultRoute.gateway?.name;
|
|
328
|
+
const agentOverride = meta.openclawAgent;
|
|
329
|
+
const resolved = gatewayName
|
|
330
|
+
? resolveGateway(profiles, gatewayName, agentOverride, `managedRoute[${agentId}]`)
|
|
331
|
+
: defaultRoute.gateway;
|
|
332
|
+
if (!resolved) {
|
|
333
|
+
// No usable gateway — skip the managed route so defaultRoute can take over.
|
|
334
|
+
daemonLog.warn("daemon.config.openclaw.managed_route_skipped", {
|
|
335
|
+
agentId,
|
|
336
|
+
gatewayName,
|
|
337
|
+
});
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
route.gateway = resolved;
|
|
341
|
+
}
|
|
342
|
+
out.set(agentId, route);
|
|
198
343
|
}
|
|
199
344
|
return out;
|
|
200
345
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CONTROL_FRAME_TYPES,
|
|
3
|
+
shouldWake,
|
|
4
|
+
type AttentionPolicy,
|
|
5
|
+
} from "@botcord/protocol-core";
|
|
2
6
|
import {
|
|
3
7
|
Gateway,
|
|
4
8
|
createBotCordChannel,
|
|
9
|
+
resolveTranscriptEnabled,
|
|
5
10
|
sanitizeUntrustedContent,
|
|
6
11
|
type ChannelAdapter,
|
|
7
12
|
type GatewayChannelConfig,
|
|
@@ -18,7 +23,12 @@ import { ensureAgentWorkspace } from "./agent-workspace.js";
|
|
|
18
23
|
import { ControlChannel } from "./control-channel.js";
|
|
19
24
|
import { toGatewayConfig } from "./daemon-config-map.js";
|
|
20
25
|
import { log as daemonLog } from "./log.js";
|
|
21
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
adoptDiscoveredOpenclawAgents,
|
|
28
|
+
collectRuntimeSnapshot,
|
|
29
|
+
createProvisioner,
|
|
30
|
+
} from "./provision.js";
|
|
31
|
+
import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
|
|
22
32
|
import { SnapshotWriter } from "./snapshot-writer.js";
|
|
23
33
|
import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
24
34
|
import { createRoomStaticContextBuilder } from "./room-context.js";
|
|
@@ -31,6 +41,8 @@ import {
|
|
|
31
41
|
} from "./loop-risk.js";
|
|
32
42
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
33
43
|
import { UserAuthManager } from "./user-auth.js";
|
|
44
|
+
import { PolicyResolver } from "./gateway/policy-resolver.js";
|
|
45
|
+
import { scanMention } from "./mention-scan.js";
|
|
34
46
|
|
|
35
47
|
/**
|
|
36
48
|
* Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
|
|
@@ -222,6 +234,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
222
234
|
|
|
223
235
|
const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
|
|
224
236
|
|
|
237
|
+
// Per-agent hub URL — read from each credential file at boot. Used to
|
|
238
|
+
// populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
|
|
239
|
+
// `botcord` CLI talks to the same hub the agent is registered against,
|
|
240
|
+
// even when a single daemon hosts agents from different hubs.
|
|
241
|
+
const hubUrlByAgentId = new Map<string, string>();
|
|
242
|
+
for (const a of boot.agents) {
|
|
243
|
+
if (a.hubUrl) hubUrlByAgentId.set(a.agentId, a.hubUrl);
|
|
244
|
+
}
|
|
245
|
+
const fallbackHubUrl = opts.hubBaseUrl;
|
|
246
|
+
const resolveHubUrl = (accountId: string): string | undefined =>
|
|
247
|
+
hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
|
|
248
|
+
|
|
225
249
|
// ActivityTracker lives at the daemon layer (not the gateway core). We
|
|
226
250
|
// expose it to the gateway via (a) the `buildSystemContext` hook so the
|
|
227
251
|
// cross-room digest reflects current activity, and (b) the `onInbound`
|
|
@@ -319,6 +343,40 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
319
343
|
});
|
|
320
344
|
};
|
|
321
345
|
|
|
346
|
+
// Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
|
|
347
|
+
// the optional `defaultAttention` / `attentionKeywords` carried by
|
|
348
|
+
// `provision_agent`, refreshed in-place by the `policy_updated` control
|
|
349
|
+
// frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
|
|
350
|
+
// leaves it absent so the resolver collapses to per-agent state.
|
|
351
|
+
const policyResolver = new PolicyResolver({
|
|
352
|
+
fetchGlobal: async (_agentId: string) => undefined,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Display-name lookup for the mention text-fallback. Populated from boot
|
|
356
|
+
// credentials; multi-agent daemons can reuse the same map via accountId.
|
|
357
|
+
const displayNameByAgent = new Map<string, string>();
|
|
358
|
+
for (const a of boot.agents) {
|
|
359
|
+
if (a.displayName) displayNameByAgent.set(a.agentId, a.displayName);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Attention gate: compose `messages.mentioned` (sender-supplied — distrust)
|
|
363
|
+
// with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
|
|
364
|
+
// effective policy, then defer to the protocol-core `shouldWake` decision.
|
|
365
|
+
const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
|
|
366
|
+
const policy: AttentionPolicy = await policyResolver.resolve(
|
|
367
|
+
msg.accountId,
|
|
368
|
+
msg.conversation.id,
|
|
369
|
+
);
|
|
370
|
+
const localMention = scanMention(msg.text, {
|
|
371
|
+
agentId: msg.accountId,
|
|
372
|
+
displayName: displayNameByAgent.get(msg.accountId),
|
|
373
|
+
});
|
|
374
|
+
return shouldWake(policy, {
|
|
375
|
+
mentioned: msg.mentioned === true || localMention,
|
|
376
|
+
text: msg.text,
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
|
|
322
380
|
const gateway = new Gateway({
|
|
323
381
|
config: gwConfig,
|
|
324
382
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
@@ -340,6 +398,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
340
398
|
onInbound,
|
|
341
399
|
onOutbound,
|
|
342
400
|
composeUserTurn: composeBotCordUserTurn,
|
|
401
|
+
attentionGate,
|
|
402
|
+
resolveHubUrl,
|
|
403
|
+
transcriptEnabled: resolveTranscriptEnabled(
|
|
404
|
+
process.env.BOTCORD_TRANSCRIPT,
|
|
405
|
+
opts.config.transcript?.enabled === true,
|
|
406
|
+
),
|
|
343
407
|
});
|
|
344
408
|
|
|
345
409
|
logger.info("daemon starting", {
|
|
@@ -356,13 +420,37 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
356
420
|
logger.warn("daemon starting with no channels", {
|
|
357
421
|
source: boot.source,
|
|
358
422
|
credentialsDir: boot.credentialsDir,
|
|
359
|
-
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon
|
|
423
|
+
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon start --agent <ag_xxx>` (only seeds config on first run)",
|
|
360
424
|
});
|
|
361
425
|
}
|
|
362
426
|
|
|
363
427
|
await gateway.start();
|
|
364
428
|
logger.info("daemon started", { agents: agentIds });
|
|
365
429
|
|
|
430
|
+
if (openclawAutoProvisionEnabled(opts.config)) {
|
|
431
|
+
try {
|
|
432
|
+
const adopted = await adoptDiscoveredOpenclawAgents({
|
|
433
|
+
gateway,
|
|
434
|
+
cfg: opts.config,
|
|
435
|
+
});
|
|
436
|
+
if (
|
|
437
|
+
adopted.adopted.length > 0 ||
|
|
438
|
+
adopted.failed.length > 0 ||
|
|
439
|
+
adopted.skipped.length > 0
|
|
440
|
+
) {
|
|
441
|
+
logger.info("openclaw auto-provision completed", {
|
|
442
|
+
adopted: adopted.adopted,
|
|
443
|
+
skipped: adopted.skipped,
|
|
444
|
+
failed: adopted.failed,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
} catch (err) {
|
|
448
|
+
logger.warn("openclaw auto-provision failed; continuing", {
|
|
449
|
+
error: err instanceof Error ? err.message : String(err),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
366
454
|
// Control channel is optional — daemon still runs (data-plane only)
|
|
367
455
|
// when user-auth hasn't been set up yet. Operators can `login` later
|
|
368
456
|
// without restarting, but for P0 we require a restart to pick it up.
|
|
@@ -376,7 +464,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
376
464
|
userId: userAuth.current.userId,
|
|
377
465
|
hubUrl: userAuth.current.hubUrl,
|
|
378
466
|
});
|
|
379
|
-
const provisioner = createProvisioner({ gateway });
|
|
467
|
+
const provisioner = createProvisioner({ gateway, policyResolver });
|
|
380
468
|
controlChannel = new ControlChannel({
|
|
381
469
|
auth: userAuth,
|
|
382
470
|
handle: provisioner,
|
|
@@ -443,7 +531,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
443
531
|
*/
|
|
444
532
|
export interface BootBackfillResult {
|
|
445
533
|
credentialPathByAgentId: Map<string, string>;
|
|
446
|
-
agentRuntimes: Record<string, { runtime?: string; cwd?: string }>;
|
|
534
|
+
agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }>;
|
|
447
535
|
}
|
|
448
536
|
|
|
449
537
|
/**
|
|
@@ -466,10 +554,12 @@ export function backfillBootAgents(
|
|
|
466
554
|
const failed: string[] = [];
|
|
467
555
|
for (const a of agents) {
|
|
468
556
|
if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
|
|
469
|
-
if (a.runtime || a.cwd) {
|
|
557
|
+
if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
|
|
470
558
|
agentRuntimes[a.agentId] = {
|
|
471
559
|
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
472
560
|
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
561
|
+
...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
|
|
562
|
+
...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
|
|
473
563
|
};
|
|
474
564
|
}
|
|
475
565
|
// Seed files are written only when missing (see `ensureAgentWorkspace`),
|