@botcord/daemon 0.2.5 → 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 +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 +49 -1
- package/dist/config.js +57 -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 +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 +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 +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +72 -1
- package/dist/provision.js +370 -7
- 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 +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 +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +132 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +66 -5
- 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 +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +438 -9
- 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
|
/**
|
|
@@ -90,6 +118,28 @@ export interface DaemonConfig {
|
|
|
90
118
|
routes: RouteRule[];
|
|
91
119
|
/** If true, stream blocks (only meaningful for rm_oc_* rooms). */
|
|
92
120
|
streamBlocks: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Persistent transcript-logging settings (design §3 / §6). Defaults to
|
|
123
|
+
* disabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
|
|
124
|
+
*/
|
|
125
|
+
transcript?: TranscriptConfig;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Optional registry of OpenClaw gateway endpoints. Routes / managed routes
|
|
129
|
+
* with `adapter === "openclaw-acp"` reference these by `name`. Resolution
|
|
130
|
+
* to {@link ResolvedOpenclawGateway} happens eagerly in `toGatewayConfig`
|
|
131
|
+
* so the dispatcher never re-queries this list.
|
|
132
|
+
*/
|
|
133
|
+
openclawGateways?: OpenclawGatewayProfile[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persistent transcript settings (design §6). Default-off — `botcord-daemon
|
|
138
|
+
* transcript enable` flips `enabled` and `transcript disable` flips it back.
|
|
139
|
+
* The env var `BOTCORD_TRANSCRIPT` can override at boot.
|
|
140
|
+
*/
|
|
141
|
+
export interface TranscriptConfig {
|
|
142
|
+
enabled?: boolean;
|
|
93
143
|
}
|
|
94
144
|
|
|
95
145
|
/**
|
|
@@ -160,11 +210,15 @@ function ensureDir(): void {
|
|
|
160
210
|
}
|
|
161
211
|
}
|
|
162
212
|
|
|
213
|
+
export const CONFIG_MISSING = "CONFIG_MISSING";
|
|
214
|
+
|
|
163
215
|
export function loadConfig(): DaemonConfig {
|
|
164
216
|
if (!existsSync(CONFIG_PATH)) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
217
|
+
const err = new Error(`daemon config not found at ${CONFIG_PATH}`) as Error & {
|
|
218
|
+
code?: string;
|
|
219
|
+
};
|
|
220
|
+
err.code = CONFIG_MISSING;
|
|
221
|
+
throw err;
|
|
168
222
|
}
|
|
169
223
|
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
170
224
|
const parsed = JSON.parse(raw) as Partial<DaemonConfig>;
|
|
@@ -196,6 +250,65 @@ export function loadConfig(): DaemonConfig {
|
|
|
196
250
|
}
|
|
197
251
|
validateAdapter(parsed.defaultRoute.adapter, "defaultRoute.adapter");
|
|
198
252
|
|
|
253
|
+
const gatewaysRaw = (parsed as Partial<DaemonConfig>).openclawGateways;
|
|
254
|
+
const gatewayNames = new Set<string>();
|
|
255
|
+
if (gatewaysRaw !== undefined) {
|
|
256
|
+
if (!Array.isArray(gatewaysRaw)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`daemon config "openclawGateways" must be an array (${CONFIG_PATH})`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
for (const [i, g] of gatewaysRaw.entries()) {
|
|
262
|
+
if (!g || typeof g !== "object") {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`daemon config openclawGateways[${i}] is not an object (${CONFIG_PATH})`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const gg = g as Partial<OpenclawGatewayProfile>;
|
|
268
|
+
if (typeof gg.name !== "string" || gg.name.length === 0) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`daemon config openclawGateways[${i}].name must be a non-empty string (${CONFIG_PATH})`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (typeof gg.url !== "string" || gg.url.length === 0) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`daemon config openclawGateways[${i}].url must be a non-empty string (${CONFIG_PATH})`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (gatewayNames.has(gg.name)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`daemon config openclawGateways[${i}].name "${gg.name}" duplicated (${CONFIG_PATH})`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
gatewayNames.add(gg.name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const validateGatewayRef = (
|
|
288
|
+
adapter: string,
|
|
289
|
+
gateway: unknown,
|
|
290
|
+
where: string,
|
|
291
|
+
): void => {
|
|
292
|
+
if (adapter === "openclaw-acp") {
|
|
293
|
+
if (typeof gateway !== "string" || gateway.length === 0) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`daemon config ${where} adapter "openclaw-acp" requires a "gateway" name (${CONFIG_PATH})`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (!gatewayNames.has(gateway)) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`daemon config ${where}.gateway "${gateway}" not in openclawGateways (${CONFIG_PATH})`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
validateGatewayRef(
|
|
307
|
+
parsed.defaultRoute.adapter,
|
|
308
|
+
(parsed.defaultRoute as DaemonRouteDefault).gateway,
|
|
309
|
+
"defaultRoute",
|
|
310
|
+
);
|
|
311
|
+
|
|
199
312
|
const routesRaw = parsed.routes ?? [];
|
|
200
313
|
if (!Array.isArray(routesRaw)) {
|
|
201
314
|
throw new Error(`daemon config "routes" must be an array (${CONFIG_PATH})`);
|
|
@@ -210,6 +323,7 @@ export function loadConfig(): DaemonConfig {
|
|
|
210
323
|
);
|
|
211
324
|
}
|
|
212
325
|
validateAdapter(r.adapter, `routes[${i}].adapter`);
|
|
326
|
+
validateGatewayRef(r.adapter, (r as RouteRule).gateway, `routes[${i}]`);
|
|
213
327
|
}
|
|
214
328
|
// Preserve the on-disk shape as-is so `config` prints what the user wrote.
|
|
215
329
|
// Resolution of agents vs agentId happens at the consumption boundary
|
|
@@ -219,6 +333,20 @@ export function loadConfig(): DaemonConfig {
|
|
|
219
333
|
routes: routesRaw,
|
|
220
334
|
streamBlocks: parsed.streamBlocks ?? true,
|
|
221
335
|
};
|
|
336
|
+
if (parsed.transcript && typeof parsed.transcript === "object") {
|
|
337
|
+
const t: TranscriptConfig = {};
|
|
338
|
+
if (typeof parsed.transcript.enabled === "boolean") t.enabled = parsed.transcript.enabled;
|
|
339
|
+
out.transcript = t;
|
|
340
|
+
}
|
|
341
|
+
if (gatewaysRaw && Array.isArray(gatewaysRaw)) {
|
|
342
|
+
out.openclawGateways = (gatewaysRaw as OpenclawGatewayProfile[]).map((g) => {
|
|
343
|
+
const copy: OpenclawGatewayProfile = { name: g.name, url: g.url };
|
|
344
|
+
if (typeof g.token === "string") copy.token = g.token;
|
|
345
|
+
if (typeof g.tokenFile === "string") copy.tokenFile = g.tokenFile;
|
|
346
|
+
if (typeof g.defaultAgent === "string") copy.defaultAgent = g.defaultAgent;
|
|
347
|
+
return copy;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
222
350
|
if (hasAgents) out.agents = (parsed.agents as string[]).slice();
|
|
223
351
|
if (hasLegacy) out.agentId = parsed.agentId;
|
|
224
352
|
if (discovery && typeof discovery === "object") {
|
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,
|
|
@@ -31,6 +36,8 @@ import {
|
|
|
31
36
|
} from "./loop-risk.js";
|
|
32
37
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
33
38
|
import { UserAuthManager } from "./user-auth.js";
|
|
39
|
+
import { PolicyResolver } from "./gateway/policy-resolver.js";
|
|
40
|
+
import { scanMention } from "./mention-scan.js";
|
|
34
41
|
|
|
35
42
|
/**
|
|
36
43
|
* Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
|
|
@@ -222,6 +229,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
222
229
|
|
|
223
230
|
const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
|
|
224
231
|
|
|
232
|
+
// Per-agent hub URL — read from each credential file at boot. Used to
|
|
233
|
+
// populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
|
|
234
|
+
// `botcord` CLI talks to the same hub the agent is registered against,
|
|
235
|
+
// even when a single daemon hosts agents from different hubs.
|
|
236
|
+
const hubUrlByAgentId = new Map<string, string>();
|
|
237
|
+
for (const a of boot.agents) {
|
|
238
|
+
if (a.hubUrl) hubUrlByAgentId.set(a.agentId, a.hubUrl);
|
|
239
|
+
}
|
|
240
|
+
const fallbackHubUrl = opts.hubBaseUrl;
|
|
241
|
+
const resolveHubUrl = (accountId: string): string | undefined =>
|
|
242
|
+
hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
|
|
243
|
+
|
|
225
244
|
// ActivityTracker lives at the daemon layer (not the gateway core). We
|
|
226
245
|
// expose it to the gateway via (a) the `buildSystemContext` hook so the
|
|
227
246
|
// cross-room digest reflects current activity, and (b) the `onInbound`
|
|
@@ -319,6 +338,40 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
319
338
|
});
|
|
320
339
|
};
|
|
321
340
|
|
|
341
|
+
// Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
|
|
342
|
+
// the optional `defaultAttention` / `attentionKeywords` carried by
|
|
343
|
+
// `provision_agent`, refreshed in-place by the `policy_updated` control
|
|
344
|
+
// frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
|
|
345
|
+
// leaves it absent so the resolver collapses to per-agent state.
|
|
346
|
+
const policyResolver = new PolicyResolver({
|
|
347
|
+
fetchGlobal: async (_agentId: string) => undefined,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Display-name lookup for the mention text-fallback. Populated from boot
|
|
351
|
+
// credentials; multi-agent daemons can reuse the same map via accountId.
|
|
352
|
+
const displayNameByAgent = new Map<string, string>();
|
|
353
|
+
for (const a of boot.agents) {
|
|
354
|
+
if (a.displayName) displayNameByAgent.set(a.agentId, a.displayName);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Attention gate: compose `messages.mentioned` (sender-supplied — distrust)
|
|
358
|
+
// with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
|
|
359
|
+
// effective policy, then defer to the protocol-core `shouldWake` decision.
|
|
360
|
+
const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
|
|
361
|
+
const policy: AttentionPolicy = await policyResolver.resolve(
|
|
362
|
+
msg.accountId,
|
|
363
|
+
msg.conversation.id,
|
|
364
|
+
);
|
|
365
|
+
const localMention = scanMention(msg.text, {
|
|
366
|
+
agentId: msg.accountId,
|
|
367
|
+
displayName: displayNameByAgent.get(msg.accountId),
|
|
368
|
+
});
|
|
369
|
+
return shouldWake(policy, {
|
|
370
|
+
mentioned: msg.mentioned === true || localMention,
|
|
371
|
+
text: msg.text,
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
|
|
322
375
|
const gateway = new Gateway({
|
|
323
376
|
config: gwConfig,
|
|
324
377
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
@@ -340,6 +393,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
340
393
|
onInbound,
|
|
341
394
|
onOutbound,
|
|
342
395
|
composeUserTurn: composeBotCordUserTurn,
|
|
396
|
+
attentionGate,
|
|
397
|
+
resolveHubUrl,
|
|
398
|
+
transcriptEnabled: resolveTranscriptEnabled(
|
|
399
|
+
process.env.BOTCORD_TRANSCRIPT,
|
|
400
|
+
opts.config.transcript?.enabled === true,
|
|
401
|
+
),
|
|
343
402
|
});
|
|
344
403
|
|
|
345
404
|
logger.info("daemon starting", {
|
|
@@ -356,7 +415,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
356
415
|
logger.warn("daemon starting with no channels", {
|
|
357
416
|
source: boot.source,
|
|
358
417
|
credentialsDir: boot.credentialsDir,
|
|
359
|
-
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon
|
|
418
|
+
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
419
|
});
|
|
361
420
|
}
|
|
362
421
|
|
|
@@ -376,7 +435,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
376
435
|
userId: userAuth.current.userId,
|
|
377
436
|
hubUrl: userAuth.current.hubUrl,
|
|
378
437
|
});
|
|
379
|
-
const provisioner = createProvisioner({ gateway });
|
|
438
|
+
const provisioner = createProvisioner({ gateway, policyResolver });
|
|
380
439
|
controlChannel = new ControlChannel({
|
|
381
440
|
auth: userAuth,
|
|
382
441
|
handle: provisioner,
|
|
@@ -443,7 +502,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
443
502
|
*/
|
|
444
503
|
export interface BootBackfillResult {
|
|
445
504
|
credentialPathByAgentId: Map<string, string>;
|
|
446
|
-
agentRuntimes: Record<string, { runtime?: string; cwd?: string }>;
|
|
505
|
+
agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }>;
|
|
447
506
|
}
|
|
448
507
|
|
|
449
508
|
/**
|
|
@@ -466,10 +525,12 @@ export function backfillBootAgents(
|
|
|
466
525
|
const failed: string[] = [];
|
|
467
526
|
for (const a of agents) {
|
|
468
527
|
if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
|
|
469
|
-
if (a.runtime || a.cwd) {
|
|
528
|
+
if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
|
|
470
529
|
agentRuntimes[a.agentId] = {
|
|
471
530
|
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
472
531
|
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
532
|
+
...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
|
|
533
|
+
...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
|
|
473
534
|
};
|
|
474
535
|
}
|
|
475
536
|
// Seed files are written only when missing (see `ensureAgentWorkspace`),
|
package/src/doctor.ts
CHANGED
|
@@ -31,9 +31,34 @@ export interface DoctorHttpResult {
|
|
|
31
31
|
error?: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** One endpoint probe entry, mirrored from `RuntimeEndpointProbe`. */
|
|
35
|
+
export interface DoctorRuntimeEndpoint {
|
|
36
|
+
name: string;
|
|
37
|
+
url: string;
|
|
38
|
+
reachable: boolean;
|
|
39
|
+
version?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
agents?: Array<{
|
|
42
|
+
id: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
workspace?: string;
|
|
45
|
+
model?: { name?: string; provider?: string };
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* Optional warning surfaced by the doctor: e.g. botcord plugin loaded on
|
|
49
|
+
* the gateway (would form a daemon → openclaw → botcord → Hub loop).
|
|
50
|
+
*/
|
|
51
|
+
warnings?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Augmented runtime entry that may carry endpoint probe results. */
|
|
55
|
+
export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
|
|
56
|
+
endpoints?: DoctorRuntimeEndpoint[];
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
/** Input for the rendered doctor output. */
|
|
35
60
|
export interface DoctorInput {
|
|
36
|
-
runtimes:
|
|
61
|
+
runtimes: DoctorRuntimeEntry[];
|
|
37
62
|
channels: ChannelProbeResult[];
|
|
38
63
|
}
|
|
39
64
|
|
|
@@ -226,10 +251,32 @@ export function renderDoctor(input: DoctorInput): string {
|
|
|
226
251
|
lines.push(
|
|
227
252
|
`${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`,
|
|
228
253
|
);
|
|
229
|
-
for (
|
|
254
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
255
|
+
const r = rows[i];
|
|
256
|
+
const e = input.runtimes[i];
|
|
230
257
|
lines.push(
|
|
231
258
|
`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`,
|
|
232
259
|
);
|
|
260
|
+
if (e.endpoints && e.endpoints.length > 0) {
|
|
261
|
+
for (const ep of e.endpoints) {
|
|
262
|
+
const mark = ep.reachable ? "✓" : "✗";
|
|
263
|
+
const detail = ep.reachable
|
|
264
|
+
? ep.version ?? "ok"
|
|
265
|
+
: ep.error ?? "unreachable";
|
|
266
|
+
lines.push(` gateway ${pad(`"${ep.name}"`, 16)} ${pad(ep.url, 40)} ${mark} ${detail}`);
|
|
267
|
+
if (ep.agents && ep.agents.length > 0) {
|
|
268
|
+
// RFC §3.8.4: list by `id` (stable key); show display name when distinct.
|
|
269
|
+
lines.push(
|
|
270
|
+
` agents (id): ${ep.agents
|
|
271
|
+
.map((a) => (a.name && a.name !== a.id ? `${a.id} (${a.name})` : a.id))
|
|
272
|
+
.join(", ")}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (ep.warnings) {
|
|
276
|
+
for (const w of ep.warnings) lines.push(` WARN: ${w}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
233
280
|
}
|
|
234
281
|
const available = input.runtimes.filter((e) => e.result.available).length;
|
|
235
282
|
lines.push(`\n${available}/${input.runtimes.length} runtimes available`);
|