@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/agent-workspace.ts
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
* codex-home/ — per-agent CODEX_HOME used by the codex adapter so codex
|
|
8
8
|
* reads a daemon-written AGENTS.md (systemContext carrier)
|
|
9
9
|
* and stores its sessions/ without touching ~/.codex.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* hermes-home/ — per-agent HERMES_HOME used by the hermes-acp
|
|
11
|
+
* adapter (carries .env, state.db, skills/) so
|
|
12
|
+
* hermes-acp's per-user state stays isolated.
|
|
13
|
+
* hermes-workspace/ — per-agent runtime cwd for hermes-acp; the adapter
|
|
14
|
+
* writes systemContext into AGENTS.md here every turn.
|
|
15
|
+
* Kept separate from `workspace/` so daemon-written
|
|
16
|
+
* systemContext does not clobber the user/agent-
|
|
17
|
+
* editable workspace AGENTS.md.
|
|
12
18
|
*/
|
|
13
19
|
import {
|
|
14
20
|
chmodSync,
|
|
@@ -16,6 +22,7 @@ import {
|
|
|
16
22
|
existsSync,
|
|
17
23
|
lstatSync,
|
|
18
24
|
mkdirSync,
|
|
25
|
+
readFileSync,
|
|
19
26
|
readlinkSync,
|
|
20
27
|
symlinkSync,
|
|
21
28
|
unlinkSync,
|
|
@@ -60,6 +67,26 @@ export function agentCodexHomeDir(agentId: string): string {
|
|
|
60
67
|
return path.join(agentHomeDir(agentId), "codex-home");
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Per-agent HERMES_HOME. Carries the hermes-acp `.env`, `state.db`, and
|
|
72
|
+
* `skills/` so each daemon-managed agent has an isolated hermes config
|
|
73
|
+
* tree and never reads/writes the user's `~/.hermes`.
|
|
74
|
+
*/
|
|
75
|
+
export function agentHermesHomeDir(agentId: string): string {
|
|
76
|
+
return path.join(agentHomeDir(agentId), "hermes-home");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per-agent runtime cwd for hermes-acp. Distinct from `workspace/` so the
|
|
81
|
+
* adapter can rewrite `AGENTS.md` here every turn (carrying the dynamic
|
|
82
|
+
* systemContext) without clobbering the user/agent-editable workspace
|
|
83
|
+
* `AGENTS.md`. hermes discovers `AGENTS.md` from cwd upward, so the file
|
|
84
|
+
* must live alongside the spawn cwd.
|
|
85
|
+
*/
|
|
86
|
+
export function agentHermesWorkspaceDir(agentId: string): string {
|
|
87
|
+
return path.join(agentHomeDir(agentId), "hermes-workspace");
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
export interface WorkspaceSeed {
|
|
64
91
|
displayName?: string;
|
|
65
92
|
bio?: string;
|
|
@@ -89,10 +116,14 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
|
|
|
89
116
|
|
|
90
117
|
## How to use this
|
|
91
118
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
- \`identity.md\` is **auto-loaded** by the daemon and injected into every turn's
|
|
120
|
+
system context as the \`[BotCord Identity]\` block. Edits to this file (yours,
|
|
121
|
+
the dashboard's via \`applyAgentIdentity\`, or a hello-snapshot reapply) take
|
|
122
|
+
effect on the next turn — no restart needed.
|
|
123
|
+
- \`memory.md\` and \`task.md\` are **convention, not mechanism**. The daemon does
|
|
124
|
+
not auto-load them; you are instructed to skim them before responding and to
|
|
125
|
+
write back what changed after meaningful turns. Keep them tight enough to be
|
|
126
|
+
worth re-reading.
|
|
96
127
|
`;
|
|
97
128
|
|
|
98
129
|
const MEMORY_MD = `# Memory
|
|
@@ -100,9 +131,9 @@ const MEMORY_MD = `# Memory
|
|
|
100
131
|
<!--
|
|
101
132
|
Long-lived facts about the user, past decisions, and preferences that should
|
|
102
133
|
survive across conversations. Organize by topic. Keep entries short. Prune
|
|
103
|
-
regularly — AGENTS.md instructs
|
|
104
|
-
response, but nothing loads it automatically; keep it
|
|
105
|
-
worth re-reading.
|
|
134
|
+
regularly — AGENTS.md instructs you to consult this file before each
|
|
135
|
+
response, but nothing loads it automatically (unlike identity.md); keep it
|
|
136
|
+
short enough to be worth re-reading.
|
|
106
137
|
-->
|
|
107
138
|
`;
|
|
108
139
|
|
|
@@ -217,6 +248,28 @@ export function ensureAgentCodexHome(agentId: string): string {
|
|
|
217
248
|
return dir;
|
|
218
249
|
}
|
|
219
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Idempotently create the per-agent HERMES_HOME and HERMES workspace
|
|
253
|
+
* directories. Writes a stub `.env` inside HERMES_HOME so hermes-acp's
|
|
254
|
+
* `_load_env` does not log "No .env found" on every spawn; users can edit
|
|
255
|
+
* this file to add API keys / model overrides.
|
|
256
|
+
*/
|
|
257
|
+
export function ensureAgentHermesWorkspace(agentId: string): {
|
|
258
|
+
hermesHome: string;
|
|
259
|
+
hermesWorkspace: string;
|
|
260
|
+
} {
|
|
261
|
+
const hermesHome = agentHermesHomeDir(agentId);
|
|
262
|
+
const hermesWorkspace = agentHermesWorkspaceDir(agentId);
|
|
263
|
+
mkdirTolerant(hermesHome);
|
|
264
|
+
mkdirTolerant(hermesWorkspace);
|
|
265
|
+
writeIfMissing(
|
|
266
|
+
path.join(hermesHome, ".env"),
|
|
267
|
+
"# hermes-agent environment overrides for this BotCord agent.\n" +
|
|
268
|
+
"# Add e.g. HERMES_INFERENCE_PROVIDER=openrouter, OPENROUTER_API_KEY=...\n",
|
|
269
|
+
);
|
|
270
|
+
return { hermesHome, hermesWorkspace };
|
|
271
|
+
}
|
|
272
|
+
|
|
220
273
|
/**
|
|
221
274
|
* Idempotently create the agent's home / workspace / state directories and
|
|
222
275
|
* seed the workspace Markdown files. Existing files are never overwritten —
|
|
@@ -235,6 +288,7 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
|
|
|
235
288
|
mkdirTolerant(notes);
|
|
236
289
|
mkdirTolerant(state);
|
|
237
290
|
ensureAgentCodexHome(agentId);
|
|
291
|
+
ensureAgentHermesWorkspace(agentId);
|
|
238
292
|
|
|
239
293
|
const agentsMdPath = path.join(workspace, "AGENTS.md");
|
|
240
294
|
const claudeMdPath = path.join(workspace, "CLAUDE.md");
|
|
@@ -245,3 +299,113 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
|
|
|
245
299
|
writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
|
|
246
300
|
writeIfMissing(path.join(notes, ".gitkeep"), "");
|
|
247
301
|
}
|
|
302
|
+
|
|
303
|
+
/** Patch fields accepted by {@link applyAgentIdentity}. `bio = null` clears it. */
|
|
304
|
+
export interface AgentIdentityPatch {
|
|
305
|
+
displayName?: string;
|
|
306
|
+
bio?: string | null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Result of applying an identity patch. `changed` is true only when the
|
|
311
|
+
* file was rewritten on disk; `skipped` reports why (no-op vs. unable).
|
|
312
|
+
*/
|
|
313
|
+
export interface AgentIdentityApplyResult {
|
|
314
|
+
changed: boolean;
|
|
315
|
+
skipped?: "missing-file" | "no-change" | "unparseable";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const DISPLAY_NAME_LINE = /^- \*\*Display name\*\*: .*$/m;
|
|
319
|
+
// Match the Bio section's body. Anchor on the next `##` heading when one
|
|
320
|
+
// exists, otherwise consume to end-of-file — keeps the rewrite working when
|
|
321
|
+
// the user has stripped Role/Boundaries sections.
|
|
322
|
+
const BIO_SECTION = /(## Bio\n\n)([\s\S]*?)(\n+##\s|$)/;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Surgically rewrite the `Display name` and `Bio` fields inside an existing
|
|
326
|
+
* `identity.md`, preserving anything the user has authored elsewhere
|
|
327
|
+
* (Role / Boundaries / arbitrary new sections). No-op when the file is
|
|
328
|
+
* missing — provisioning will create it with the correct values, and
|
|
329
|
+
* subsequent hello snapshots simply reapply the dashboard truth.
|
|
330
|
+
*
|
|
331
|
+
* The identity.md template carries `Role` / `Boundaries` headings after
|
|
332
|
+
* `## Bio`; we anchor the Bio rewrite on "next `##`" so user-added
|
|
333
|
+
* paragraphs inside Bio are replaced wholesale (the dashboard is the
|
|
334
|
+
* source of truth) without disturbing siblings.
|
|
335
|
+
*/
|
|
336
|
+
export function applyAgentIdentity(
|
|
337
|
+
agentId: string,
|
|
338
|
+
patch: AgentIdentityPatch,
|
|
339
|
+
): AgentIdentityApplyResult {
|
|
340
|
+
assertSafeAgentId(agentId);
|
|
341
|
+
const file = path.join(agentWorkspaceDir(agentId), "identity.md");
|
|
342
|
+
if (!existsSync(file)) {
|
|
343
|
+
return { changed: false, skipped: "missing-file" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let text: string;
|
|
347
|
+
try {
|
|
348
|
+
text = readFileSync(file, "utf8");
|
|
349
|
+
} catch {
|
|
350
|
+
return { changed: false, skipped: "missing-file" };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const original = text;
|
|
354
|
+
let touched = false;
|
|
355
|
+
|
|
356
|
+
if (typeof patch.displayName === "string") {
|
|
357
|
+
const value = patch.displayName.length > 0 ? patch.displayName : FIELD_PLACEHOLDER;
|
|
358
|
+
if (DISPLAY_NAME_LINE.test(text)) {
|
|
359
|
+
// Use a function replacer so `$1`, `$&` etc. inside the value are
|
|
360
|
+
// treated literally rather than as backreferences.
|
|
361
|
+
text = text.replace(DISPLAY_NAME_LINE, () => `- **Display name**: ${value}`);
|
|
362
|
+
touched = true;
|
|
363
|
+
} else {
|
|
364
|
+
// Heavily-edited file without the canonical metadata block — bail
|
|
365
|
+
// out rather than guess where to splice.
|
|
366
|
+
return { changed: false, skipped: "unparseable" };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (patch.bio !== undefined) {
|
|
371
|
+
const bioText =
|
|
372
|
+
patch.bio !== null && patch.bio.trim().length > 0
|
|
373
|
+
? patch.bio.trim()
|
|
374
|
+
: BIO_PLACEHOLDER;
|
|
375
|
+
if (BIO_SECTION.test(text)) {
|
|
376
|
+
text = text.replace(BIO_SECTION, (_match, head, _body, tail) => `${head}${bioText}${tail}`);
|
|
377
|
+
touched = true;
|
|
378
|
+
} else {
|
|
379
|
+
return { changed: false, skipped: "unparseable" };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!touched || text === original) {
|
|
384
|
+
return { changed: false, skipped: "no-change" };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
388
|
+
return { changed: true };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Read the agent's `identity.md` verbatim, if it exists. Returns the raw
|
|
393
|
+
* contents (including the leading `# Identity` heading) so callers can
|
|
394
|
+
* splice it into the system context. Returns `null` when the workspace
|
|
395
|
+
* has not been provisioned yet, the file is empty, or the read fails.
|
|
396
|
+
*
|
|
397
|
+
* Each call hits disk — same contract as `readWorkingMemory`, so a
|
|
398
|
+
* dashboard-driven edit (`applyAgentIdentity` from a control frame, or
|
|
399
|
+
* a hello-snapshot reapply, or the agent's own self-edit) is visible
|
|
400
|
+
* on the very next turn without restarting the gateway.
|
|
401
|
+
*/
|
|
402
|
+
export function readIdentity(agentId: string): string | null {
|
|
403
|
+
assertSafeAgentId(agentId);
|
|
404
|
+
const file = path.join(agentWorkspaceDir(agentId), "identity.md");
|
|
405
|
+
try {
|
|
406
|
+
const raw = readFileSync(file, "utf8");
|
|
407
|
+
return raw.trim().length > 0 ? raw : null;
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
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/control-channel.ts
CHANGED
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
* Independent from the agent data-plane WS: different auth (user access
|
|
6
6
|
* token vs agent JWT), different endpoint (`/daemon/ws`), different
|
|
7
7
|
* lifecycle (alive even when zero agents are bound).
|
|
8
|
-
*
|
|
9
|
-
* See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
|
|
10
8
|
*/
|
|
11
9
|
import WebSocket from "ws";
|
|
12
10
|
import {
|
|
@@ -31,8 +29,7 @@ const REPLAY_DEDUPE_CAP = 256;
|
|
|
31
29
|
|
|
32
30
|
/**
|
|
33
31
|
* Build the canonical signing input for a control frame: RFC 8785 (JCS)
|
|
34
|
-
* canonicalization of `{id, type, params, ts}`.
|
|
35
|
-
* `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
|
|
32
|
+
* canonicalization of `{id, type, params, ts}`. The Hub uses Python
|
|
36
33
|
* `jcs.canonicalize` over the same object before signing.
|
|
37
34
|
*
|
|
38
35
|
* Excludes `sig` by definition. `params` defaults to `{}` (empty 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
|
/**
|
|
@@ -19,13 +113,12 @@ export interface ToGatewayConfigOptions {
|
|
|
19
113
|
*/
|
|
20
114
|
agentIds?: string[];
|
|
21
115
|
/**
|
|
22
|
-
* Per-agent runtime/cwd cached from credentials
|
|
23
|
-
* `
|
|
24
|
-
* `toGatewayConfig` synthesizes a terminal route pinning that agent's
|
|
116
|
+
* Per-agent runtime/cwd cached from credentials. When present for an agent
|
|
117
|
+
* id, `toGatewayConfig` synthesizes a terminal route pinning that agent's
|
|
25
118
|
* turns to its runtime. Explicit `cfg.routes` entries still win because
|
|
26
119
|
* synthesized routes are appended after them.
|
|
27
120
|
*/
|
|
28
|
-
agentRuntimes?: Record<string,
|
|
121
|
+
agentRuntimes?: Record<string, AgentRuntimeMeta>;
|
|
29
122
|
}
|
|
30
123
|
|
|
31
124
|
/**
|
|
@@ -60,7 +153,11 @@ function mapTrustLevel(
|
|
|
60
153
|
* legacy alias and its canonical field are present, the canonical field
|
|
61
154
|
* wins and a warning is logged.
|
|
62
155
|
*/
|
|
63
|
-
function mapRoute(
|
|
156
|
+
function mapRoute(
|
|
157
|
+
r: RouteRule,
|
|
158
|
+
profiles: Map<string, PreparedGatewayProfile>,
|
|
159
|
+
index: number,
|
|
160
|
+
): GatewayRoute {
|
|
64
161
|
const match: RouteMatch = {};
|
|
65
162
|
if (r.match.channel) match.channel = r.match.channel;
|
|
66
163
|
if (r.match.accountId) match.accountId = r.match.accountId;
|
|
@@ -96,13 +193,22 @@ function mapRoute(r: RouteRule): GatewayRoute {
|
|
|
96
193
|
if (typeof r.match.mentioned === "boolean") match.mentioned = r.match.mentioned;
|
|
97
194
|
|
|
98
195
|
const rawTrust = (r as { trustLevel?: "owner" | "untrusted" }).trustLevel;
|
|
99
|
-
|
|
196
|
+
const out: GatewayRoute = {
|
|
100
197
|
match,
|
|
101
198
|
runtime: r.adapter,
|
|
102
199
|
cwd: r.cwd,
|
|
103
200
|
extraArgs: r.extraArgs,
|
|
104
201
|
trustLevel: mapTrustLevel(rawTrust),
|
|
105
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;
|
|
106
212
|
}
|
|
107
213
|
|
|
108
214
|
/**
|
|
@@ -135,6 +241,8 @@ export function toGatewayConfig(
|
|
|
135
241
|
|
|
136
242
|
// DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
|
|
137
243
|
// defensively so future config extensions can propagate without a shape bump.
|
|
244
|
+
const profiles = prepareGatewayProfiles(cfg.openclawGateways);
|
|
245
|
+
|
|
138
246
|
const rawDefaultTrust = (cfg.defaultRoute as { trustLevel?: "owner" | "untrusted" })
|
|
139
247
|
.trustLevel;
|
|
140
248
|
const defaultRoute: GatewayRoute = {
|
|
@@ -145,8 +253,17 @@ export function toGatewayConfig(
|
|
|
145
253
|
// (direct → cancel-previous, group → serial).
|
|
146
254
|
trustLevel: mapTrustLevel(rawDefaultTrust),
|
|
147
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
|
+
}
|
|
148
265
|
|
|
149
|
-
const routes: GatewayRoute[] = (cfg.routes ?? []).map(mapRoute);
|
|
266
|
+
const routes: GatewayRoute[] = (cfg.routes ?? []).map((r, i) => mapRoute(r, profiles, i));
|
|
150
267
|
|
|
151
268
|
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
152
269
|
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
@@ -158,6 +275,7 @@ export function toGatewayConfig(
|
|
|
158
275
|
agentIds,
|
|
159
276
|
opts.agentRuntimes ?? {},
|
|
160
277
|
defaultRoute,
|
|
278
|
+
profiles,
|
|
161
279
|
);
|
|
162
280
|
|
|
163
281
|
return {
|
|
@@ -185,17 +303,43 @@ export function toGatewayConfig(
|
|
|
185
303
|
*/
|
|
186
304
|
export function buildManagedRoutes(
|
|
187
305
|
agentIds: string[],
|
|
188
|
-
agentRuntimes: Record<string,
|
|
306
|
+
agentRuntimes: Record<string, AgentRuntimeMeta>,
|
|
189
307
|
defaultRoute: GatewayRoute,
|
|
308
|
+
openclawProfiles?: Map<string, PreparedGatewayProfile>,
|
|
190
309
|
): Map<string, GatewayRoute> {
|
|
191
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>();
|
|
192
313
|
for (const agentId of agentIds) {
|
|
193
314
|
const meta = agentRuntimes[agentId] ?? {};
|
|
194
|
-
|
|
315
|
+
const runtime = meta.runtime ?? defaultRoute.runtime;
|
|
316
|
+
const route: GatewayRoute = {
|
|
195
317
|
match: { accountId: agentId },
|
|
196
|
-
runtime
|
|
318
|
+
runtime,
|
|
197
319
|
cwd: meta.cwd || agentWorkspaceDir(agentId),
|
|
198
|
-
|
|
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);
|
|
199
343
|
}
|
|
200
344
|
return out;
|
|
201
345
|
}
|