@botcord/daemon 0.1.1
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/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
package/src/provision.ts
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business logic triggered by control-plane frames. The channel dispatches
|
|
3
|
+
* to this module with the parsed {@link ControlFrame}; we execute the
|
|
4
|
+
* side effects (register agent, write credentials, load route, add/remove
|
|
5
|
+
* gateway channel) and return an ack payload.
|
|
6
|
+
*
|
|
7
|
+
* See `docs/daemon-control-plane-plan.md` §4.3, §5.3, §8.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
BotCordClient,
|
|
14
|
+
CONTROL_FRAME_TYPES,
|
|
15
|
+
defaultCredentialsFile,
|
|
16
|
+
derivePublicKey,
|
|
17
|
+
loadStoredCredentials,
|
|
18
|
+
writeCredentialsFile,
|
|
19
|
+
type ControlAck,
|
|
20
|
+
type ControlFrame,
|
|
21
|
+
type ListRuntimesResult,
|
|
22
|
+
type ProvisionAgentParams,
|
|
23
|
+
type RevokeAgentParams,
|
|
24
|
+
type RevokeAgentResult,
|
|
25
|
+
type RuntimeProbeResult,
|
|
26
|
+
type StoredBotCordCredentials,
|
|
27
|
+
} from "@botcord/protocol-core";
|
|
28
|
+
import type { Gateway } from "./gateway/index.js";
|
|
29
|
+
import type {
|
|
30
|
+
GatewayChannelConfig,
|
|
31
|
+
GatewayRuntimeSnapshot,
|
|
32
|
+
} from "./gateway/index.js";
|
|
33
|
+
import {
|
|
34
|
+
loadConfig,
|
|
35
|
+
resolveConfiguredAgentIds,
|
|
36
|
+
saveConfig,
|
|
37
|
+
type DaemonConfig,
|
|
38
|
+
type RouteRule,
|
|
39
|
+
type RouteRuleMatch,
|
|
40
|
+
} from "./config.js";
|
|
41
|
+
import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes } from "./daemon-config-map.js";
|
|
42
|
+
import {
|
|
43
|
+
agentHomeDir,
|
|
44
|
+
agentStateDir,
|
|
45
|
+
agentWorkspaceDir,
|
|
46
|
+
ensureAgentWorkspace,
|
|
47
|
+
} from "./agent-workspace.js";
|
|
48
|
+
import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
|
|
49
|
+
import { log as daemonLog } from "./log.js";
|
|
50
|
+
|
|
51
|
+
/** Options accepted by {@link createProvisioner}. */
|
|
52
|
+
export interface ProvisionerOptions {
|
|
53
|
+
/** Live gateway handle used to hot-plug channels. */
|
|
54
|
+
gateway: Gateway;
|
|
55
|
+
/**
|
|
56
|
+
* Override for `BotCordClient.register` — tests inject a stub so they can
|
|
57
|
+
* run without a real Hub.
|
|
58
|
+
*/
|
|
59
|
+
register?: typeof BotCordClient.register;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The value a frame handler returns (minus the `id` which the channel fills in). */
|
|
63
|
+
type AckBody = Omit<ControlAck, "id">;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a dispatcher function that routes a `ControlFrame` to the right
|
|
67
|
+
* handler. Returned function signature matches
|
|
68
|
+
* `ControlChannelOptions.handle`.
|
|
69
|
+
*/
|
|
70
|
+
export function createProvisioner(opts: ProvisionerOptions): (
|
|
71
|
+
frame: ControlFrame,
|
|
72
|
+
) => Promise<AckBody> {
|
|
73
|
+
const gateway = opts.gateway;
|
|
74
|
+
const register = opts.register ?? BotCordClient.register;
|
|
75
|
+
|
|
76
|
+
return async (frame: ControlFrame): Promise<AckBody> => {
|
|
77
|
+
daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
|
|
78
|
+
switch (frame.type) {
|
|
79
|
+
case CONTROL_FRAME_TYPES.PING:
|
|
80
|
+
return { ok: true, result: { pong: true, ts: Date.now() } };
|
|
81
|
+
|
|
82
|
+
case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
|
|
83
|
+
const params = (frame.params ?? {}) as unknown as ProvisionAgentParams;
|
|
84
|
+
daemonLog.info("provision_agent: start", {
|
|
85
|
+
frameId: frame.id,
|
|
86
|
+
hasCredentials: !!params.credentials,
|
|
87
|
+
runtime: pickRuntime(params) ?? null,
|
|
88
|
+
name: params.name ?? null,
|
|
89
|
+
});
|
|
90
|
+
const agent = await provisionAgent(params, { gateway, register });
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
result: {
|
|
94
|
+
agentId: agent.agentId,
|
|
95
|
+
hubUrl: agent.hubUrl,
|
|
96
|
+
credentialsFile: agent.credentialsFile,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case CONTROL_FRAME_TYPES.REVOKE_AGENT: {
|
|
102
|
+
const params = (frame.params ?? {}) as unknown as RevokeAgentParams;
|
|
103
|
+
daemonLog.info("revoke_agent: start", {
|
|
104
|
+
frameId: frame.id,
|
|
105
|
+
agentId: params.agentId,
|
|
106
|
+
deleteCredentials: params.deleteCredentials !== false,
|
|
107
|
+
});
|
|
108
|
+
const res = await revokeAgent(params, { gateway });
|
|
109
|
+
return { ok: true, result: res };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case CONTROL_FRAME_TYPES.LIST_AGENTS: {
|
|
113
|
+
const agents = listAgentsFromGateway(gateway);
|
|
114
|
+
daemonLog.debug("list_agents", { count: agents.length });
|
|
115
|
+
return { ok: true, result: { agents } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case CONTROL_FRAME_TYPES.RELOAD_CONFIG: {
|
|
119
|
+
daemonLog.info("reload_config: start", { frameId: frame.id });
|
|
120
|
+
const res = await reloadConfig({ gateway });
|
|
121
|
+
return { ok: true, result: res };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case CONTROL_FRAME_TYPES.SET_ROUTE: {
|
|
125
|
+
daemonLog.info("set_route: start", { frameId: frame.id });
|
|
126
|
+
const res = setRoute(frame.params ?? {});
|
|
127
|
+
return { ok: true, result: res };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
|
|
131
|
+
const snapshot = collectRuntimeSnapshot();
|
|
132
|
+
daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
|
|
133
|
+
return { ok: true, result: snapshot };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
default:
|
|
137
|
+
daemonLog.warn("provision.dispatch: unknown frame type", {
|
|
138
|
+
type: frame.type,
|
|
139
|
+
id: frame.id,
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: { code: "unknown_type", message: `unknown control frame type "${frame.type}"` },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface ProvisionedAgent {
|
|
150
|
+
agentId: string;
|
|
151
|
+
hubUrl: string;
|
|
152
|
+
credentialsFile: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface ProvisionCtx {
|
|
156
|
+
gateway: Gateway;
|
|
157
|
+
register: typeof BotCordClient.register;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function provisionAgent(
|
|
161
|
+
params: ProvisionAgentParams,
|
|
162
|
+
ctx: ProvisionCtx,
|
|
163
|
+
): Promise<ProvisionedAgent> {
|
|
164
|
+
// Validate both caller-supplied cwd sources up front. Previously only
|
|
165
|
+
// `params.cwd` was checked, so `params.credentials.cwd` could smuggle an
|
|
166
|
+
// arbitrary path (e.g. `/etc`) into the credentials file; plan §7 closes
|
|
167
|
+
// that hole by moving the check to the union of both.
|
|
168
|
+
const explicitCwd = params.credentials?.cwd ?? params.cwd;
|
|
169
|
+
assertSafeCwd(explicitCwd);
|
|
170
|
+
|
|
171
|
+
const cfg = loadConfig();
|
|
172
|
+
const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
|
|
173
|
+
daemonLog.debug("provision: credentials materialized", {
|
|
174
|
+
agentId: credentials.agentId,
|
|
175
|
+
hubUrl: credentials.hubUrl,
|
|
176
|
+
runtime: credentials.runtime ?? null,
|
|
177
|
+
source: params.credentials ? "hub-supplied" : "registered",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const credentialsFile = writeCredentialsFile(
|
|
181
|
+
defaultCredentialsFile(credentials.agentId),
|
|
182
|
+
credentials,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Seed the per-agent workspace directory. On failure, unlink the fresh
|
|
186
|
+
// credentials file but do NOT `rm -rf` the agent dir — partial contents
|
|
187
|
+
// may belong to a pre-existing workspace we must not touch.
|
|
188
|
+
try {
|
|
189
|
+
ensureAgentWorkspace(credentials.agentId, {
|
|
190
|
+
displayName: credentials.displayName,
|
|
191
|
+
bio: params.bio,
|
|
192
|
+
runtime: credentials.runtime,
|
|
193
|
+
keyId: credentials.keyId,
|
|
194
|
+
savedAt: credentials.savedAt,
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(credentialsFile);
|
|
199
|
+
} catch {
|
|
200
|
+
// best-effort
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const updated = addAgentToConfig(cfg, credentials.agentId);
|
|
207
|
+
if (updated) saveConfig(updated);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// Rollback the credentials file if we can't persist config — the
|
|
210
|
+
// daemon should stay in sync or not at all. `addChannel` below would
|
|
211
|
+
// otherwise succeed against a config that doesn't list the agent.
|
|
212
|
+
try {
|
|
213
|
+
unlinkSync(credentialsFile);
|
|
214
|
+
} catch {
|
|
215
|
+
// best-effort
|
|
216
|
+
}
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
await ctx.gateway.addChannel({
|
|
222
|
+
id: credentials.agentId,
|
|
223
|
+
type: BOTCORD_CHANNEL_TYPE,
|
|
224
|
+
accountId: credentials.agentId,
|
|
225
|
+
agentId: credentials.agentId,
|
|
226
|
+
});
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// Best-effort rollback: drop the new agent from config and remove the
|
|
229
|
+
// credentials file. Log loudly so operators notice the partial state.
|
|
230
|
+
daemonLog.error("provision.addChannel failed, rolling back", {
|
|
231
|
+
agentId: credentials.agentId,
|
|
232
|
+
error: err instanceof Error ? err.message : String(err),
|
|
233
|
+
});
|
|
234
|
+
try {
|
|
235
|
+
const revertCfg = removeAgentFromConfig(loadConfig(), credentials.agentId);
|
|
236
|
+
if (revertCfg) saveConfig(revertCfg);
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
unlinkSync(credentialsFile);
|
|
242
|
+
} catch {
|
|
243
|
+
// ignore
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Hot-add the synthesized per-agent managed route so the next turn picks
|
|
249
|
+
// the agent's runtime + workspace cwd without waiting for reload_config.
|
|
250
|
+
try {
|
|
251
|
+
ctx.gateway.upsertManagedRoute(credentials.agentId, {
|
|
252
|
+
match: { accountId: credentials.agentId },
|
|
253
|
+
runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
|
|
254
|
+
cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Rollback the channel + config + credentials on managed-route failure
|
|
258
|
+
// (shouldn't happen — pure map op — but keeps the invariant tight).
|
|
259
|
+
daemonLog.error("provision.upsertManagedRoute failed, rolling back", {
|
|
260
|
+
agentId: credentials.agentId,
|
|
261
|
+
error: err instanceof Error ? err.message : String(err),
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
await ctx.gateway.removeChannel(credentials.agentId, "provision rollback");
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const revertCfg = removeAgentFromConfig(loadConfig(), credentials.agentId);
|
|
270
|
+
if (revertCfg) saveConfig(revertCfg);
|
|
271
|
+
} catch {
|
|
272
|
+
// ignore
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
unlinkSync(credentialsFile);
|
|
276
|
+
} catch {
|
|
277
|
+
// ignore
|
|
278
|
+
}
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
daemonLog.info("agent provisioned", {
|
|
283
|
+
agentId: credentials.agentId,
|
|
284
|
+
credentialsFile,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
agentId: credentials.agentId,
|
|
289
|
+
hubUrl: credentials.hubUrl,
|
|
290
|
+
credentialsFile,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function materializeCredentials(
|
|
295
|
+
params: ProvisionAgentParams,
|
|
296
|
+
cfg: DaemonConfig,
|
|
297
|
+
ctx: ProvisionCtx,
|
|
298
|
+
explicitCwd: string | undefined,
|
|
299
|
+
): Promise<StoredBotCordCredentials> {
|
|
300
|
+
// Runtime is an agent property (docs/agent-runtime-property-plan.md §4.1).
|
|
301
|
+
// Hub is authoritative; top-level `runtime` wins, `adapter` is a one-release
|
|
302
|
+
// alias, and `credentials.runtime` is the per-agent cached copy.
|
|
303
|
+
const runtime = pickRuntime(params);
|
|
304
|
+
if (runtime) assertKnownRuntime(runtime);
|
|
305
|
+
|
|
306
|
+
// Fast path: Hub handed us the credential envelope directly.
|
|
307
|
+
if (params.credentials) {
|
|
308
|
+
const c = params.credentials;
|
|
309
|
+
if (!c.agentId || !c.keyId || !c.privateKey) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"provision_agent.credentials missing required fields (agentId, keyId, privateKey)",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const derivedPub = derivePublicKey(c.privateKey);
|
|
315
|
+
if (c.publicKey && c.publicKey !== derivedPub) {
|
|
316
|
+
throw new Error("provision_agent.credentials publicKey does not match privateKey");
|
|
317
|
+
}
|
|
318
|
+
const hubUrl = c.hubUrl;
|
|
319
|
+
if (!hubUrl) {
|
|
320
|
+
throw new Error("provision_agent.credentials missing hubUrl");
|
|
321
|
+
}
|
|
322
|
+
const cwd = explicitCwd ?? agentWorkspaceDir(c.agentId);
|
|
323
|
+
const record: StoredBotCordCredentials = {
|
|
324
|
+
version: 1,
|
|
325
|
+
hubUrl,
|
|
326
|
+
agentId: c.agentId,
|
|
327
|
+
keyId: c.keyId,
|
|
328
|
+
privateKey: c.privateKey,
|
|
329
|
+
publicKey: c.publicKey ?? derivedPub,
|
|
330
|
+
savedAt: new Date().toISOString(),
|
|
331
|
+
};
|
|
332
|
+
if (c.displayName) record.displayName = c.displayName;
|
|
333
|
+
if (c.token) record.token = c.token;
|
|
334
|
+
if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
|
|
335
|
+
if (runtime) record.runtime = runtime;
|
|
336
|
+
record.cwd = cwd;
|
|
337
|
+
return record;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Slow path: daemon registers a fresh identity against Hub. We need a
|
|
341
|
+
// hubUrl — but `DaemonConfig` doesn't persist one, so fall back to a
|
|
342
|
+
// sibling credentials file if any agent is already bound.
|
|
343
|
+
const hubUrl = inferHubUrl(cfg);
|
|
344
|
+
if (!hubUrl) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
"provision_agent: cannot register without a known hubUrl — include `credentials.hubUrl` in the frame",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const name = params.name || `agent-${Date.now()}`;
|
|
350
|
+
const reg = await ctx.register(hubUrl, name, params.bio);
|
|
351
|
+
const cwd = explicitCwd ?? agentWorkspaceDir(reg.agentId);
|
|
352
|
+
const record: StoredBotCordCredentials = {
|
|
353
|
+
version: 1,
|
|
354
|
+
hubUrl: reg.hubUrl,
|
|
355
|
+
agentId: reg.agentId,
|
|
356
|
+
keyId: reg.keyId,
|
|
357
|
+
privateKey: reg.privateKey,
|
|
358
|
+
publicKey: reg.publicKey,
|
|
359
|
+
savedAt: new Date().toISOString(),
|
|
360
|
+
displayName: name,
|
|
361
|
+
token: reg.token,
|
|
362
|
+
tokenExpiresAt: reg.expiresAt,
|
|
363
|
+
};
|
|
364
|
+
if (runtime) record.runtime = runtime;
|
|
365
|
+
record.cwd = cwd;
|
|
366
|
+
return record;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function revokeAgent(
|
|
370
|
+
params: RevokeAgentParams,
|
|
371
|
+
ctx: { gateway: Gateway },
|
|
372
|
+
): Promise<RevokeAgentResult> {
|
|
373
|
+
if (!params.agentId) {
|
|
374
|
+
throw new Error("revoke_agent requires params.agentId");
|
|
375
|
+
}
|
|
376
|
+
const agentId = params.agentId;
|
|
377
|
+
const deleteCreds = params.deleteCredentials !== false;
|
|
378
|
+
// `deleteState` defaults to whatever `deleteCredentials` resolves to —
|
|
379
|
+
// vanilla revoke wipes runtime state, but explicit `deleteCredentials:false`
|
|
380
|
+
// (keep-creds) also implies keep-state unless the caller says otherwise.
|
|
381
|
+
const deleteState = params.deleteState ?? deleteCreds;
|
|
382
|
+
// Workspace is precious (user-authored memory/notes); require explicit opt-in.
|
|
383
|
+
const deleteWorkspace = params.deleteWorkspace === true;
|
|
384
|
+
|
|
385
|
+
// In-memory gateway ops run first so any in-flight turn is aborted before
|
|
386
|
+
// disk state changes. Both run unconditionally — the channel is revoked
|
|
387
|
+
// regardless of whether disk state survives, and the synthesized managed
|
|
388
|
+
// route is now dangling.
|
|
389
|
+
try {
|
|
390
|
+
await ctx.gateway.removeChannel(agentId, "revoked by hub");
|
|
391
|
+
} catch (err) {
|
|
392
|
+
daemonLog.warn("revoke.removeChannel failed", {
|
|
393
|
+
agentId,
|
|
394
|
+
error: err instanceof Error ? err.message : String(err),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
ctx.gateway.removeManagedRoute(agentId);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
daemonLog.warn("revoke.removeManagedRoute failed", {
|
|
401
|
+
agentId,
|
|
402
|
+
error: err instanceof Error ? err.message : String(err),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let removed = false;
|
|
407
|
+
try {
|
|
408
|
+
const cfg = loadConfig();
|
|
409
|
+
const next = removeAgentFromConfig(cfg, agentId);
|
|
410
|
+
if (next) {
|
|
411
|
+
saveConfig(next);
|
|
412
|
+
removed = true;
|
|
413
|
+
}
|
|
414
|
+
} catch (err) {
|
|
415
|
+
daemonLog.warn("revoke.saveConfig failed", {
|
|
416
|
+
agentId,
|
|
417
|
+
error: err instanceof Error ? err.message : String(err),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let credentialsDeleted = false;
|
|
422
|
+
if (deleteCreds) {
|
|
423
|
+
const file = defaultCredentialsFile(agentId);
|
|
424
|
+
try {
|
|
425
|
+
if (existsSync(file)) {
|
|
426
|
+
unlinkSync(file);
|
|
427
|
+
credentialsDeleted = true;
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
daemonLog.warn("revoke.unlink failed", {
|
|
431
|
+
agentId,
|
|
432
|
+
file,
|
|
433
|
+
error: err instanceof Error ? err.message : String(err),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Disk steps are independent and best-effort: a failure at one step logs a
|
|
439
|
+
// warning but does not prevent the next (matches `deleteCredentials`).
|
|
440
|
+
let stateDeleted = false;
|
|
441
|
+
let workspaceDeleted = false;
|
|
442
|
+
if (deleteWorkspace) {
|
|
443
|
+
// Workspace deletion subsumes state — remove the whole agent home.
|
|
444
|
+
const home = agentHomeDir(agentId);
|
|
445
|
+
try {
|
|
446
|
+
if (existsSync(home)) {
|
|
447
|
+
rmSync(home, { recursive: true, force: true });
|
|
448
|
+
workspaceDeleted = true;
|
|
449
|
+
stateDeleted = true;
|
|
450
|
+
}
|
|
451
|
+
} catch (err) {
|
|
452
|
+
daemonLog.warn("revoke.rmWorkspace failed", {
|
|
453
|
+
agentId,
|
|
454
|
+
path: home,
|
|
455
|
+
error: err instanceof Error ? err.message : String(err),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
} else if (deleteState) {
|
|
459
|
+
const state = agentStateDir(agentId);
|
|
460
|
+
try {
|
|
461
|
+
if (existsSync(state)) {
|
|
462
|
+
rmSync(state, { recursive: true, force: true });
|
|
463
|
+
stateDeleted = true;
|
|
464
|
+
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
daemonLog.warn("revoke.rmState failed", {
|
|
467
|
+
agentId,
|
|
468
|
+
path: state,
|
|
469
|
+
error: err instanceof Error ? err.message : String(err),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
daemonLog.info("agent revoked", {
|
|
475
|
+
agentId,
|
|
476
|
+
removed,
|
|
477
|
+
credentialsDeleted,
|
|
478
|
+
stateDeleted,
|
|
479
|
+
workspaceDeleted,
|
|
480
|
+
});
|
|
481
|
+
return { agentId, removed, credentialsDeleted, stateDeleted, workspaceDeleted };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Reject paths outside the operator's home directory (plan §8.3). */
|
|
485
|
+
function assertSafeCwd(cwd: string | undefined): void {
|
|
486
|
+
if (!cwd) return;
|
|
487
|
+
const home = homedir();
|
|
488
|
+
const abs = path.resolve(cwd);
|
|
489
|
+
const rel = path.relative(home, abs);
|
|
490
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
491
|
+
throw new Error(`provision_agent.cwd "${cwd}" is outside the user home directory`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Append `agentId` to the daemon config if not already present. Returns a
|
|
497
|
+
* new config object or `null` if nothing changed (so callers can skip the
|
|
498
|
+
* disk write).
|
|
499
|
+
*/
|
|
500
|
+
export function addAgentToConfig(cfg: DaemonConfig, agentId: string): DaemonConfig | null {
|
|
501
|
+
const list = Array.isArray(cfg.agents) ? cfg.agents.slice() : [];
|
|
502
|
+
if (cfg.agentId && !list.includes(cfg.agentId)) list.push(cfg.agentId);
|
|
503
|
+
if (list.includes(agentId)) return null;
|
|
504
|
+
list.push(agentId);
|
|
505
|
+
const next: DaemonConfig = { ...cfg, agents: list };
|
|
506
|
+
// Once `agents` exists explicitly, the legacy scalar becomes redundant.
|
|
507
|
+
delete next.agentId;
|
|
508
|
+
return next;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Inverse of {@link addAgentToConfig}. Returns `null` on no-op. */
|
|
512
|
+
export function removeAgentFromConfig(
|
|
513
|
+
cfg: DaemonConfig,
|
|
514
|
+
agentId: string,
|
|
515
|
+
): DaemonConfig | null {
|
|
516
|
+
const list = Array.isArray(cfg.agents) ? cfg.agents.slice() : [];
|
|
517
|
+
if (cfg.agentId && !list.includes(cfg.agentId)) list.push(cfg.agentId);
|
|
518
|
+
const before = list.length;
|
|
519
|
+
const filtered = list.filter((a) => a !== agentId);
|
|
520
|
+
const legacyMatched = cfg.agentId === agentId;
|
|
521
|
+
if (filtered.length === before && !legacyMatched) return null;
|
|
522
|
+
const next: DaemonConfig = { ...cfg, agents: filtered };
|
|
523
|
+
if (legacyMatched) delete next.agentId;
|
|
524
|
+
return next;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// runtime-discovery snapshot (plan §8.5)
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Probe every registered adapter and shape the result as the wire-level
|
|
533
|
+
* {@link ListRuntimesResult} — used by both the `list_runtimes` ack path and
|
|
534
|
+
* the daemon-side first-connect `runtime_snapshot` push in `daemon.ts`.
|
|
535
|
+
*
|
|
536
|
+
* Kept pure: the only side effects are `detectRuntimes()` itself (which the
|
|
537
|
+
* gateway already isolates from throwing) and reading the wall clock.
|
|
538
|
+
*/
|
|
539
|
+
export function collectRuntimeSnapshot(): ListRuntimesResult {
|
|
540
|
+
const entries = detectRuntimes();
|
|
541
|
+
const runtimes: RuntimeProbeResult[] = entries.map((entry) => {
|
|
542
|
+
const record: RuntimeProbeResult = {
|
|
543
|
+
id: entry.id,
|
|
544
|
+
available: entry.result.available,
|
|
545
|
+
};
|
|
546
|
+
// Only attach optional fields when present so the wire frame doesn't
|
|
547
|
+
// carry explicit `undefined`s — mirrors the credential-materialization
|
|
548
|
+
// style used above.
|
|
549
|
+
if (entry.result.version) record.version = entry.result.version;
|
|
550
|
+
if (entry.result.path) record.path = entry.result.path;
|
|
551
|
+
// Gateway's probe surface doesn't expose an `error` string today — it
|
|
552
|
+
// already swallows throws into `{available: false}`. We leave the wire
|
|
553
|
+
// field blank in that case and let callers treat `!available` as reason
|
|
554
|
+
// enough; filling a synthetic message would be misleading.
|
|
555
|
+
return record;
|
|
556
|
+
});
|
|
557
|
+
return { runtimes, probedAt: Date.now() };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// reload_config / list_agents / set_route handlers (P3)
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
interface ReloadResult {
|
|
565
|
+
reloaded: true;
|
|
566
|
+
added: string[];
|
|
567
|
+
removed: string[];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Re-read `config.json` and reconcile the running gateway against it. New
|
|
572
|
+
* agents in config but not in gateway snapshot → `addChannel`; agents in
|
|
573
|
+
* gateway but no longer in config → `removeChannel`. The agent's
|
|
574
|
+
* credentials must already exist on disk; we don't re-register identities
|
|
575
|
+
* here (that's `provision_agent`'s job).
|
|
576
|
+
*/
|
|
577
|
+
export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadResult> {
|
|
578
|
+
const cfg = loadConfig();
|
|
579
|
+
const desired = new Set(resolveConfiguredAgentIds(cfg) ?? []);
|
|
580
|
+
const current = new Set(Object.keys(ctx.gateway.snapshot().channels));
|
|
581
|
+
|
|
582
|
+
const added: string[] = [];
|
|
583
|
+
const removed: string[] = [];
|
|
584
|
+
|
|
585
|
+
for (const id of desired) {
|
|
586
|
+
if (current.has(id)) continue;
|
|
587
|
+
const channelCfg: GatewayChannelConfig = {
|
|
588
|
+
id,
|
|
589
|
+
type: BOTCORD_CHANNEL_TYPE,
|
|
590
|
+
accountId: id,
|
|
591
|
+
agentId: id,
|
|
592
|
+
};
|
|
593
|
+
try {
|
|
594
|
+
await ctx.gateway.addChannel(channelCfg);
|
|
595
|
+
added.push(id);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
daemonLog.warn("reload_config.addChannel failed", {
|
|
598
|
+
agentId: id,
|
|
599
|
+
error: err instanceof Error ? err.message : String(err),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
for (const id of current) {
|
|
604
|
+
if (desired.has(id)) continue;
|
|
605
|
+
try {
|
|
606
|
+
await ctx.gateway.removeChannel(id, "reload_config");
|
|
607
|
+
removed.push(id);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
daemonLog.warn("reload_config.removeChannel failed", {
|
|
610
|
+
agentId: id,
|
|
611
|
+
error: err instanceof Error ? err.message : String(err),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Re-synthesize managed routes so `set_route` + `reload_config` actually
|
|
617
|
+
// applies at runtime (plan §10.5). User-authored `cfg.routes[]` lives in a
|
|
618
|
+
// different bucket and is unaffected.
|
|
619
|
+
try {
|
|
620
|
+
const freshCfg = loadConfig();
|
|
621
|
+
const freshAgents = resolveConfiguredAgentIds(freshCfg) ?? [];
|
|
622
|
+
const agentRuntimes = readAgentRuntimesFromCredentials(freshAgents);
|
|
623
|
+
const freshDefault = {
|
|
624
|
+
runtime: freshCfg.defaultRoute.adapter,
|
|
625
|
+
cwd: freshCfg.defaultRoute.cwd,
|
|
626
|
+
};
|
|
627
|
+
const managed = buildManagedRoutes(freshAgents, agentRuntimes, freshDefault);
|
|
628
|
+
ctx.gateway.replaceManagedRoutes(managed);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
daemonLog.warn("reload_config.replaceManagedRoutes failed", {
|
|
631
|
+
error: err instanceof Error ? err.message : String(err),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
daemonLog.info("config reloaded", { added, removed });
|
|
636
|
+
return { reloaded: true, added, removed };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Read cached `runtime`/`cwd` from each agent's credentials file. Missing
|
|
641
|
+
* files or malformed entries are skipped silently — callers fall back to
|
|
642
|
+
* the daemon's `defaultRoute` for those agents.
|
|
643
|
+
*/
|
|
644
|
+
function readAgentRuntimesFromCredentials(
|
|
645
|
+
agentIds: string[],
|
|
646
|
+
): Record<string, { runtime?: string; cwd?: string }> {
|
|
647
|
+
const out: Record<string, { runtime?: string; cwd?: string }> = {};
|
|
648
|
+
for (const id of agentIds) {
|
|
649
|
+
const file = defaultCredentialsFile(id);
|
|
650
|
+
try {
|
|
651
|
+
if (!existsSync(file)) continue;
|
|
652
|
+
const creds = loadStoredCredentials(file);
|
|
653
|
+
const entry: { runtime?: string; cwd?: string } = {};
|
|
654
|
+
if (creds.runtime) entry.runtime = creds.runtime;
|
|
655
|
+
if (creds.cwd) entry.cwd = creds.cwd;
|
|
656
|
+
if (entry.runtime || entry.cwd) out[id] = entry;
|
|
657
|
+
} catch {
|
|
658
|
+
// best-effort — skip agents with unreadable credentials
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Per-agent entry returned by `list_agents`. Shape follows
|
|
666
|
+
* `docs/daemon-control-plane-api-contract.md` §3.2 — `{id, name, online}`.
|
|
667
|
+
* `status` and `lastMessageAt` are extra daemon-only fields the dashboard
|
|
668
|
+
* may ignore; kept so future contract revisions can promote them without
|
|
669
|
+
* breaking the wire.
|
|
670
|
+
*/
|
|
671
|
+
export interface AgentListEntry {
|
|
672
|
+
id: string;
|
|
673
|
+
/** Display name from credentials, when known. Falls back to the agent id. */
|
|
674
|
+
name: string;
|
|
675
|
+
/** True when the gateway channel is currently running + connected. */
|
|
676
|
+
online: boolean;
|
|
677
|
+
status: "running" | "stopped" | "unknown";
|
|
678
|
+
lastMessageAt?: number;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function listAgentsFromGateway(gateway: Gateway): AgentListEntry[] {
|
|
682
|
+
const snap: GatewayRuntimeSnapshot = gateway.snapshot();
|
|
683
|
+
// Include any configured agents that the gateway may not have a status for
|
|
684
|
+
// yet (e.g. initial boot before first reconcile).
|
|
685
|
+
let configuredIds: string[] = [];
|
|
686
|
+
try {
|
|
687
|
+
configuredIds = resolveConfiguredAgentIds(loadConfig()) ?? [];
|
|
688
|
+
} catch {
|
|
689
|
+
configuredIds = [];
|
|
690
|
+
}
|
|
691
|
+
const ids = new Set<string>([...Object.keys(snap.channels), ...configuredIds]);
|
|
692
|
+
const out: AgentListEntry[] = [];
|
|
693
|
+
for (const id of ids) {
|
|
694
|
+
const ch = snap.channels[id];
|
|
695
|
+
let name = id;
|
|
696
|
+
try {
|
|
697
|
+
const file = defaultCredentialsFile(id);
|
|
698
|
+
if (existsSync(file)) {
|
|
699
|
+
const c = loadStoredCredentials(file);
|
|
700
|
+
if (c.displayName) name = c.displayName;
|
|
701
|
+
}
|
|
702
|
+
} catch {
|
|
703
|
+
// ignore — fall back to the id
|
|
704
|
+
}
|
|
705
|
+
const online = !!(ch && ch.running && ch.connected !== false);
|
|
706
|
+
const entry: AgentListEntry = {
|
|
707
|
+
id,
|
|
708
|
+
name,
|
|
709
|
+
online,
|
|
710
|
+
status: ch ? (ch.running ? "running" : "stopped") : "unknown",
|
|
711
|
+
};
|
|
712
|
+
if (ch?.lastStartAt) entry.lastMessageAt = ch.lastStartAt;
|
|
713
|
+
out.push(entry);
|
|
714
|
+
}
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
interface SetRouteResult {
|
|
719
|
+
ok: true;
|
|
720
|
+
agentId: string;
|
|
721
|
+
routeIndex: number;
|
|
722
|
+
inserted: boolean;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
interface SetRouteParams {
|
|
726
|
+
agentId?: string;
|
|
727
|
+
/**
|
|
728
|
+
* Contract shape (`docs/daemon-control-plane-api-contract.md` §3.2):
|
|
729
|
+
* `{pattern, agentId}`. `pattern` is treated as a conversation-id prefix
|
|
730
|
+
* (`rm_oc_*` etc.). When `route` is omitted, we synthesize a sensible
|
|
731
|
+
* default route record using the daemon's existing default adapter+cwd.
|
|
732
|
+
*/
|
|
733
|
+
pattern?: string;
|
|
734
|
+
/**
|
|
735
|
+
* Daemon-richer shape (back-compat). When provided, takes precedence
|
|
736
|
+
* over `pattern` since it can express more than just a prefix.
|
|
737
|
+
*/
|
|
738
|
+
route?: {
|
|
739
|
+
adapter?: string;
|
|
740
|
+
cwd?: string;
|
|
741
|
+
extraArgs?: string[];
|
|
742
|
+
match?: RouteRuleMatch;
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Persist a route in `config.json` for the given agent. If a route already
|
|
748
|
+
* matches `agentId` exactly (single-key match), it is replaced; otherwise a
|
|
749
|
+
* new entry is appended. `match.accountId` is forced to `agentId` so the
|
|
750
|
+
* route is always agent-scoped. The change is applied at next
|
|
751
|
+
* `reload_config` — it does not mutate the live router immediately.
|
|
752
|
+
*
|
|
753
|
+
* Accepts the contract's `{pattern, agentId}` shape (treats `pattern` as a
|
|
754
|
+
* conversation-id prefix) AND the richer `{agentId, route: {...}}` shape
|
|
755
|
+
* for daemon-side callers that need to set adapter/cwd explicitly.
|
|
756
|
+
*/
|
|
757
|
+
export function setRoute(params: unknown): SetRouteResult {
|
|
758
|
+
const p = (params ?? {}) as SetRouteParams;
|
|
759
|
+
const agentId = p.agentId;
|
|
760
|
+
if (!agentId || typeof agentId !== "string") {
|
|
761
|
+
throw new Error("set_route requires params.agentId");
|
|
762
|
+
}
|
|
763
|
+
const route = p.route;
|
|
764
|
+
if (!route && (!p.pattern || typeof p.pattern !== "string")) {
|
|
765
|
+
throw new Error("set_route requires either params.route or params.pattern");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Defaults used when only `pattern` is supplied.
|
|
769
|
+
const cfg = loadConfig();
|
|
770
|
+
const adapter = route?.adapter ?? cfg.defaultRoute.adapter;
|
|
771
|
+
if (!getAdapterModule(adapter)) {
|
|
772
|
+
throw new Error(`set_route: unknown adapter "${adapter}"`);
|
|
773
|
+
}
|
|
774
|
+
const cwd = route?.cwd ?? cfg.defaultRoute.cwd;
|
|
775
|
+
if (!cwd || typeof cwd !== "string") {
|
|
776
|
+
throw new Error("set_route: route.cwd is required");
|
|
777
|
+
}
|
|
778
|
+
assertSafeCwd(cwd);
|
|
779
|
+
|
|
780
|
+
// Build the canonical match — always pin accountId so the route can't
|
|
781
|
+
// accidentally bleed across agents.
|
|
782
|
+
const incomingMatch = (route?.match ?? {}) as RouteRuleMatch;
|
|
783
|
+
const match: RouteRuleMatch = { ...incomingMatch, accountId: agentId };
|
|
784
|
+
if (p.pattern && typeof p.pattern === "string" && !match.conversationPrefix) {
|
|
785
|
+
match.conversationPrefix = p.pattern;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const newRule: RouteRule = {
|
|
789
|
+
match,
|
|
790
|
+
adapter,
|
|
791
|
+
cwd,
|
|
792
|
+
...(Array.isArray(route?.extraArgs) ? { extraArgs: route!.extraArgs!.slice() } : {}),
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
|
|
796
|
+
// Replace an existing matching rule. We use the canonical signature
|
|
797
|
+
// (accountId + conversationPrefix or accountId-only) so successive
|
|
798
|
+
// `set_route` calls for the same agent+pattern overwrite in place.
|
|
799
|
+
const sameSignature = (m: RouteRuleMatch | undefined): boolean => {
|
|
800
|
+
if (!m) return false;
|
|
801
|
+
if (m.accountId !== agentId) return false;
|
|
802
|
+
const incomingPrefix = match.conversationPrefix ?? null;
|
|
803
|
+
const existingPrefix = m.conversationPrefix ?? m.roomPrefix ?? null;
|
|
804
|
+
if (incomingPrefix !== existingPrefix) return false;
|
|
805
|
+
if (incomingPrefix === null && hasNonAccountSelector(m)) return false;
|
|
806
|
+
return true;
|
|
807
|
+
};
|
|
808
|
+
const existingIdx = routes.findIndex((r) => sameSignature(r.match));
|
|
809
|
+
|
|
810
|
+
let inserted = false;
|
|
811
|
+
let routeIndex: number;
|
|
812
|
+
if (existingIdx >= 0) {
|
|
813
|
+
routes[existingIdx] = newRule;
|
|
814
|
+
routeIndex = existingIdx;
|
|
815
|
+
} else {
|
|
816
|
+
routes.push(newRule);
|
|
817
|
+
routeIndex = routes.length - 1;
|
|
818
|
+
inserted = true;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const next: DaemonConfig = { ...cfg, routes };
|
|
822
|
+
saveConfig(next);
|
|
823
|
+
daemonLog.info("route set", { agentId, routeIndex, inserted });
|
|
824
|
+
return { ok: true, agentId, routeIndex, inserted };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function hasNonAccountSelector(m: RouteRuleMatch | undefined): boolean {
|
|
828
|
+
if (!m) return false;
|
|
829
|
+
return !!(
|
|
830
|
+
m.channel ||
|
|
831
|
+
m.conversationId ||
|
|
832
|
+
m.conversationPrefix ||
|
|
833
|
+
m.conversationKind ||
|
|
834
|
+
m.senderId ||
|
|
835
|
+
m.roomId ||
|
|
836
|
+
m.roomPrefix ||
|
|
837
|
+
typeof m.mentioned === "boolean"
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Resolve the runtime id the frame asks for. Prefers the canonical
|
|
843
|
+
* `runtime` field; falls back to the deprecated `adapter` alias and finally
|
|
844
|
+
* to `credentials.runtime` for Hub builds that ship the envelope-only form.
|
|
845
|
+
*/
|
|
846
|
+
function pickRuntime(params: ProvisionAgentParams): string | undefined {
|
|
847
|
+
const candidates = [params.runtime, params.adapter, params.credentials?.runtime];
|
|
848
|
+
for (const c of candidates) {
|
|
849
|
+
if (typeof c === "string" && c.length > 0) return c;
|
|
850
|
+
}
|
|
851
|
+
return undefined;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function assertKnownRuntime(runtime: string): void {
|
|
855
|
+
const mod = getAdapterModule(runtime);
|
|
856
|
+
if (!mod) {
|
|
857
|
+
throw new Error(`provision_agent: unknown runtime "${runtime}"`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Pull a hubUrl out of an existing credentials file, if the daemon is
|
|
863
|
+
* already bound to at least one agent. Used as a fallback when
|
|
864
|
+
* `provision_agent` doesn't carry an explicit `credentials.hubUrl`.
|
|
865
|
+
*/
|
|
866
|
+
function inferHubUrl(cfg: DaemonConfig): string | null {
|
|
867
|
+
const ids = resolveConfiguredAgentIds(cfg) ?? [];
|
|
868
|
+
for (const id of ids) {
|
|
869
|
+
const file = defaultCredentialsFile(id);
|
|
870
|
+
try {
|
|
871
|
+
if (!existsSync(file)) continue;
|
|
872
|
+
const creds = loadStoredCredentials(file);
|
|
873
|
+
if (creds.hubUrl) return creds.hubUrl;
|
|
874
|
+
} catch {
|
|
875
|
+
// skip
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return null;
|
|
879
|
+
}
|