@botcord/daemon 0.2.27 → 0.2.29
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 +2 -0
- package/dist/agent-discovery.js +2 -0
- package/dist/agent-workspace.d.ts +11 -1
- package/dist/agent-workspace.js +20 -2
- package/dist/daemon-config-map.d.ts +2 -0
- package/dist/daemon-config-map.js +3 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +2 -1
- package/dist/gateway/dispatcher.js +1 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +35 -0
- package/dist/gateway/runtimes/hermes-agent.js +135 -4
- package/dist/gateway/runtimes/openclaw-acp.d.ts +6 -3
- package/dist/gateway/runtimes/openclaw-acp.js +75 -9
- package/dist/gateway/types.d.ts +17 -0
- package/dist/openclaw-discovery.d.ts +3 -1
- package/dist/openclaw-discovery.js +176 -2
- package/dist/provision.d.ts +12 -8
- package/dist/provision.js +198 -4
- package/package.json +1 -1
- package/src/__tests__/agent-workspace.test.ts +18 -0
- package/src/__tests__/openclaw-acp.test.ts +172 -0
- package/src/__tests__/openclaw-discovery.test.ts +64 -0
- package/src/__tests__/provision.test.ts +164 -0
- package/src/agent-discovery.ts +3 -0
- package/src/agent-workspace.ts +24 -2
- package/src/daemon-config-map.ts +5 -0
- package/src/daemon.ts +3 -2
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +87 -0
- package/src/gateway/dispatcher.ts +1 -0
- package/src/gateway/runtimes/hermes-agent.ts +151 -3
- package/src/gateway/runtimes/openclaw-acp.ts +82 -9
- package/src/gateway/types.ts +17 -0
- package/src/openclaw-discovery.ts +180 -3
- package/src/provision.ts +221 -6
|
@@ -224,6 +224,8 @@ describe("OpenclawAcpAdapter.run", () => {
|
|
|
224
224
|
const frame = JSON.parse(line);
|
|
225
225
|
if (frame.method === "initialize") {
|
|
226
226
|
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
227
|
+
} else if (frame.method === "session/load") {
|
|
228
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: frame.params.sessionId } }) + "\n");
|
|
227
229
|
} else if (frame.method === "session/new") {
|
|
228
230
|
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "s1" } }) + "\n");
|
|
229
231
|
} else if (frame.method === "session/prompt") {
|
|
@@ -245,4 +247,174 @@ describe("OpenclawAcpAdapter.run", () => {
|
|
|
245
247
|
await adapter.run({ ...opts, sessionId: "s1" });
|
|
246
248
|
expect(spawnFn).toHaveBeenCalledTimes(1);
|
|
247
249
|
});
|
|
250
|
+
|
|
251
|
+
it("loads a cached ACP session id with the stable sessionKey before prompting", async () => {
|
|
252
|
+
const child = new FakeChild();
|
|
253
|
+
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
254
|
+
const gateway: ResolvedOpenclawGateway = {
|
|
255
|
+
name: "local",
|
|
256
|
+
url: "ws://127.0.0.1:1",
|
|
257
|
+
openclawAgent: "swe",
|
|
258
|
+
};
|
|
259
|
+
const seen: any[] = [];
|
|
260
|
+
|
|
261
|
+
child.stdin.on("data", (chunk: Buffer) => {
|
|
262
|
+
for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
|
|
263
|
+
const frame = JSON.parse(line);
|
|
264
|
+
seen.push(frame);
|
|
265
|
+
if (frame.method === "initialize") {
|
|
266
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
267
|
+
} else if (frame.method === "session/load") {
|
|
268
|
+
expect(frame.params.sessionId).toBe("cached-id");
|
|
269
|
+
expect(frame.params._meta.sessionKey).toBe(
|
|
270
|
+
"agent:swe:ag_337518f31844:direct:rm_oc_owner",
|
|
271
|
+
);
|
|
272
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "cached-id" } }) + "\n");
|
|
273
|
+
} else if (frame.method === "session/prompt") {
|
|
274
|
+
expect(frame.params.sessionId).toBe("cached-id");
|
|
275
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "loaded ok" } }) + "\n");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const res = await adapter.run({
|
|
281
|
+
text: "hi",
|
|
282
|
+
sessionId: "cached-id",
|
|
283
|
+
cwd: "/tmp",
|
|
284
|
+
accountId: "ag_337518f31844",
|
|
285
|
+
signal: new AbortController().signal,
|
|
286
|
+
trustLevel: "owner",
|
|
287
|
+
gateway,
|
|
288
|
+
context: { conversationKey: "direct:rm_oc_owner" },
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(res.error).toBeUndefined();
|
|
292
|
+
expect(res.text).toBe("loaded ok");
|
|
293
|
+
expect(res.newSessionId).toBe("cached-id");
|
|
294
|
+
expect(seen.map((f) => f.method)).toEqual(["initialize", "session/load", "session/prompt"]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("discards a cached ACP session id when session/load reports not found", async () => {
|
|
298
|
+
const child = new FakeChild();
|
|
299
|
+
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
300
|
+
const gateway: ResolvedOpenclawGateway = {
|
|
301
|
+
name: "local",
|
|
302
|
+
url: "ws://127.0.0.1:1",
|
|
303
|
+
openclawAgent: "swe",
|
|
304
|
+
};
|
|
305
|
+
const seen: any[] = [];
|
|
306
|
+
|
|
307
|
+
child.stdin.on("data", (chunk: Buffer) => {
|
|
308
|
+
for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
|
|
309
|
+
const frame = JSON.parse(line);
|
|
310
|
+
seen.push(frame);
|
|
311
|
+
if (frame.method === "initialize") {
|
|
312
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
313
|
+
} else if (frame.method === "session/load") {
|
|
314
|
+
child.stdout.write(
|
|
315
|
+
JSON.stringify({
|
|
316
|
+
jsonrpc: "2.0",
|
|
317
|
+
id: frame.id,
|
|
318
|
+
error: {
|
|
319
|
+
code: -32603,
|
|
320
|
+
message: "Internal error",
|
|
321
|
+
data: { details: "Session cached-id not found" },
|
|
322
|
+
},
|
|
323
|
+
}) + "\n",
|
|
324
|
+
);
|
|
325
|
+
} else if (frame.method === "session/new") {
|
|
326
|
+
expect(frame.params._meta.sessionKey).toBe(
|
|
327
|
+
"agent:swe:ag_337518f31844:direct:rm_oc_owner",
|
|
328
|
+
);
|
|
329
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "fresh-id" } }) + "\n");
|
|
330
|
+
} else if (frame.method === "session/prompt") {
|
|
331
|
+
expect(frame.params.sessionId).toBe("fresh-id");
|
|
332
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "fresh ok" } }) + "\n");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const res = await adapter.run({
|
|
338
|
+
text: "hi",
|
|
339
|
+
sessionId: "cached-id",
|
|
340
|
+
cwd: "/tmp",
|
|
341
|
+
accountId: "ag_337518f31844",
|
|
342
|
+
signal: new AbortController().signal,
|
|
343
|
+
trustLevel: "owner",
|
|
344
|
+
gateway,
|
|
345
|
+
context: { conversationKey: "direct:rm_oc_owner" },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(res.error).toBeUndefined();
|
|
349
|
+
expect(res.text).toBe("fresh ok");
|
|
350
|
+
expect(res.newSessionId).toBe("fresh-id");
|
|
351
|
+
expect(seen.map((f) => f.method)).toEqual([
|
|
352
|
+
"initialize",
|
|
353
|
+
"session/load",
|
|
354
|
+
"session/new",
|
|
355
|
+
"session/prompt",
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("recreates the ACP session and retries once when prompt reports not found", async () => {
|
|
360
|
+
const child = new FakeChild();
|
|
361
|
+
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
362
|
+
const gateway: ResolvedOpenclawGateway = {
|
|
363
|
+
name: "local",
|
|
364
|
+
url: "ws://127.0.0.1:1",
|
|
365
|
+
openclawAgent: "swe",
|
|
366
|
+
};
|
|
367
|
+
const seen: any[] = [];
|
|
368
|
+
|
|
369
|
+
child.stdin.on("data", (chunk: Buffer) => {
|
|
370
|
+
for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
|
|
371
|
+
const frame = JSON.parse(line);
|
|
372
|
+
seen.push(frame);
|
|
373
|
+
if (frame.method === "initialize") {
|
|
374
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
375
|
+
} else if (frame.method === "session/load") {
|
|
376
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "cached-id" } }) + "\n");
|
|
377
|
+
} else if (frame.method === "session/new") {
|
|
378
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "fresh-id" } }) + "\n");
|
|
379
|
+
} else if (frame.method === "session/prompt" && frame.params.sessionId === "cached-id") {
|
|
380
|
+
child.stdout.write(
|
|
381
|
+
JSON.stringify({
|
|
382
|
+
jsonrpc: "2.0",
|
|
383
|
+
id: frame.id,
|
|
384
|
+
error: {
|
|
385
|
+
code: -32603,
|
|
386
|
+
message: "Internal error",
|
|
387
|
+
data: { details: "Session cached-id not found" },
|
|
388
|
+
},
|
|
389
|
+
}) + "\n",
|
|
390
|
+
);
|
|
391
|
+
} else if (frame.method === "session/prompt") {
|
|
392
|
+
expect(frame.params.sessionId).toBe("fresh-id");
|
|
393
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "retry ok" } }) + "\n");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const res = await adapter.run({
|
|
399
|
+
text: "hi",
|
|
400
|
+
sessionId: "cached-id",
|
|
401
|
+
cwd: "/tmp",
|
|
402
|
+
accountId: "ag_337518f31844",
|
|
403
|
+
signal: new AbortController().signal,
|
|
404
|
+
trustLevel: "owner",
|
|
405
|
+
gateway,
|
|
406
|
+
context: { conversationKey: "direct:rm_oc_owner" },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(res.error).toBeUndefined();
|
|
410
|
+
expect(res.text).toBe("retry ok");
|
|
411
|
+
expect(res.newSessionId).toBe("fresh-id");
|
|
412
|
+
expect(seen.map((f) => f.method)).toEqual([
|
|
413
|
+
"initialize",
|
|
414
|
+
"session/load",
|
|
415
|
+
"session/prompt",
|
|
416
|
+
"session/new",
|
|
417
|
+
"session/prompt",
|
|
418
|
+
]);
|
|
419
|
+
});
|
|
248
420
|
});
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import {
|
|
6
6
|
defaultOpenclawDiscoveryPorts,
|
|
7
|
+
defaultOpenclawDiscoverySystemdUnitPaths,
|
|
7
8
|
defaultOpenclawDiscoveryTokenFilePaths,
|
|
8
9
|
discoverLocalOpenclawGateways,
|
|
9
10
|
mergeOpenclawGateways,
|
|
@@ -148,6 +149,69 @@ describe("discoverLocalOpenclawGateways", () => {
|
|
|
148
149
|
]);
|
|
149
150
|
});
|
|
150
151
|
|
|
152
|
+
it("discovers gateway port and token from a systemd OpenClaw unit", async () => {
|
|
153
|
+
const dir = tempDir();
|
|
154
|
+
const unit = path.join(dir, "openclaw.service");
|
|
155
|
+
writeFileSync(
|
|
156
|
+
unit,
|
|
157
|
+
[
|
|
158
|
+
"[Service]",
|
|
159
|
+
"User=openclaw",
|
|
160
|
+
'Environment="OPENCLAW_GATEWAY_TOKEN=systemd-token"',
|
|
161
|
+
"ExecStart=/usr/bin/openclaw gateway --bind lan --port 16200 --allow-unconfigured",
|
|
162
|
+
].join("\n"),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const found = await discoverLocalOpenclawGateways({
|
|
166
|
+
searchPaths: [],
|
|
167
|
+
defaultPorts: [],
|
|
168
|
+
systemdUnitPaths: [unit],
|
|
169
|
+
env: {},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(defaultOpenclawDiscoverySystemdUnitPaths()).toEqual(
|
|
173
|
+
expect.arrayContaining(["/etc/systemd/system/openclaw.service"]),
|
|
174
|
+
);
|
|
175
|
+
expect(found).toEqual([
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
url: "ws://127.0.0.1:16200",
|
|
178
|
+
token: "systemd-token",
|
|
179
|
+
source: "systemd-unit",
|
|
180
|
+
}),
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("discovers gateway token from a systemd EnvironmentFile", async () => {
|
|
185
|
+
const dir = tempDir();
|
|
186
|
+
const unit = path.join(dir, "openclaw.service");
|
|
187
|
+
const envFile = path.join(dir, "openclaw.env");
|
|
188
|
+
writeFileSync(envFile, 'OPENCLAW_GATEWAY_TOKEN="file-token"\n');
|
|
189
|
+
writeFileSync(
|
|
190
|
+
unit,
|
|
191
|
+
[
|
|
192
|
+
"[Service]",
|
|
193
|
+
`EnvironmentFile=${envFile}`,
|
|
194
|
+
"Environment=OPENCLAW_GATEWAY_PORT=16200",
|
|
195
|
+
"ExecStart=/usr/bin/openclaw gateway --bind lan --allow-unconfigured",
|
|
196
|
+
].join("\n"),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const found = await discoverLocalOpenclawGateways({
|
|
200
|
+
searchPaths: [],
|
|
201
|
+
defaultPorts: [],
|
|
202
|
+
systemdUnitPaths: [unit],
|
|
203
|
+
env: {},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(found).toEqual([
|
|
207
|
+
expect.objectContaining({
|
|
208
|
+
url: "ws://127.0.0.1:16200",
|
|
209
|
+
token: "file-token",
|
|
210
|
+
source: "systemd-unit",
|
|
211
|
+
}),
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
151
215
|
it("prefers OPENCLAW_ACP env vars over OPENCLAW_GATEWAY env vars", async () => {
|
|
152
216
|
const found = await discoverLocalOpenclawGateways({
|
|
153
217
|
searchPaths: [],
|
|
@@ -1421,3 +1421,167 @@ describe("update_agent handler", () => {
|
|
|
1421
1421
|
expect(ack.error?.code).toBe("bad_params");
|
|
1422
1422
|
});
|
|
1423
1423
|
});
|
|
1424
|
+
|
|
1425
|
+
describe("provision_agent hermes profile attach", () => {
|
|
1426
|
+
it("rejects invalid hermes profile names before resolving HERMES_HOME", async () => {
|
|
1427
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1428
|
+
fs.mkdirSync(nodePath.join(tmp, ".hermes", "profiles"), {
|
|
1429
|
+
recursive: true,
|
|
1430
|
+
});
|
|
1431
|
+
fs.mkdirSync(nodePath.join(tmp, "outside"), { recursive: true });
|
|
1432
|
+
const gw = makeFakeGateway();
|
|
1433
|
+
const provisioner = createProvisioner({
|
|
1434
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1435
|
+
});
|
|
1436
|
+
const privateKey = Buffer.alloc(32, 23).toString("base64");
|
|
1437
|
+
const ack = await provisioner({
|
|
1438
|
+
id: "req_hp_invalid",
|
|
1439
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
1440
|
+
params: {
|
|
1441
|
+
runtime: "hermes-agent",
|
|
1442
|
+
hermes: { profile: "../../outside" },
|
|
1443
|
+
credentials: {
|
|
1444
|
+
agentId: "ag_hp_invalid",
|
|
1445
|
+
keyId: "k_hp_invalid",
|
|
1446
|
+
privateKey,
|
|
1447
|
+
hubUrl: "https://hub.example",
|
|
1448
|
+
runtime: "hermes-agent",
|
|
1449
|
+
},
|
|
1450
|
+
},
|
|
1451
|
+
});
|
|
1452
|
+
expect(ack.ok).toBe(false);
|
|
1453
|
+
expect(ack.error?.code).toBe("hermes_profile_invalid");
|
|
1454
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
1455
|
+
});
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("rejects with hermes_profile_not_found when the profile does not exist", async () => {
|
|
1459
|
+
await withSandboxHome(async ({ tmp: _tmp }) => {
|
|
1460
|
+
const gw = makeFakeGateway();
|
|
1461
|
+
const provisioner = createProvisioner({
|
|
1462
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1463
|
+
});
|
|
1464
|
+
const privateKey = Buffer.alloc(32, 11).toString("base64");
|
|
1465
|
+
const ack = await provisioner({
|
|
1466
|
+
id: "req_hp_missing",
|
|
1467
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
1468
|
+
params: {
|
|
1469
|
+
runtime: "hermes-agent",
|
|
1470
|
+
hermes: { profile: "ghost" },
|
|
1471
|
+
credentials: {
|
|
1472
|
+
agentId: "ag_hp_missing",
|
|
1473
|
+
keyId: "k_hp",
|
|
1474
|
+
privateKey,
|
|
1475
|
+
hubUrl: "https://hub.example",
|
|
1476
|
+
runtime: "hermes-agent",
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
});
|
|
1480
|
+
expect(ack.ok).toBe(false);
|
|
1481
|
+
expect(ack.error?.code).toBe("hermes_profile_not_found");
|
|
1482
|
+
expect(ack.error?.profile).toBe("ghost");
|
|
1483
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
1484
|
+
});
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
it("persists hermesProfile to credentials when the profile exists and is free", async () => {
|
|
1488
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1489
|
+
// Lay down ~/.hermes/profiles/coder so validateHermesProfileForProvision
|
|
1490
|
+
// sees it.
|
|
1491
|
+
fs.mkdirSync(nodePath.join(tmp, ".hermes", "profiles", "coder"), {
|
|
1492
|
+
recursive: true,
|
|
1493
|
+
});
|
|
1494
|
+
const gw = makeFakeGateway();
|
|
1495
|
+
const provisioner = createProvisioner({
|
|
1496
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1497
|
+
});
|
|
1498
|
+
const privateKey = Buffer.alloc(32, 13).toString("base64");
|
|
1499
|
+
const ack = await provisioner({
|
|
1500
|
+
id: "req_hp_ok",
|
|
1501
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
1502
|
+
params: {
|
|
1503
|
+
runtime: "hermes-agent",
|
|
1504
|
+
hermes: { profile: "coder" },
|
|
1505
|
+
credentials: {
|
|
1506
|
+
agentId: "ag_hp_ok",
|
|
1507
|
+
keyId: "k_hp",
|
|
1508
|
+
privateKey,
|
|
1509
|
+
hubUrl: "https://hub.example",
|
|
1510
|
+
displayName: "coder agent",
|
|
1511
|
+
runtime: "hermes-agent",
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
expect(ack.ok).toBe(true);
|
|
1516
|
+
expect(gw.addChannel).toHaveBeenCalledOnce();
|
|
1517
|
+
|
|
1518
|
+
const credFile = nodePath.join(
|
|
1519
|
+
tmp,
|
|
1520
|
+
".botcord",
|
|
1521
|
+
"credentials",
|
|
1522
|
+
"ag_hp_ok.json",
|
|
1523
|
+
);
|
|
1524
|
+
const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<
|
|
1525
|
+
string,
|
|
1526
|
+
unknown
|
|
1527
|
+
>;
|
|
1528
|
+
expect(saved.hermesProfile).toBe("coder");
|
|
1529
|
+
expect(saved.runtime).toBe("hermes-agent");
|
|
1530
|
+
expect(
|
|
1531
|
+
fs.existsSync(
|
|
1532
|
+
nodePath.join(tmp, ".hermes", "profiles", "coder", "skills", "botcord", "SKILL.md"),
|
|
1533
|
+
),
|
|
1534
|
+
).toBe(true);
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
it("rejects with hermes_profile_occupied when another agent already binds the profile", async () => {
|
|
1539
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1540
|
+
fs.mkdirSync(nodePath.join(tmp, ".hermes", "profiles", "coder"), {
|
|
1541
|
+
recursive: true,
|
|
1542
|
+
});
|
|
1543
|
+
const gw = makeFakeGateway();
|
|
1544
|
+
const provisioner = createProvisioner({
|
|
1545
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
const privateKey = Buffer.alloc(32, 17).toString("base64");
|
|
1549
|
+
const okAck = await provisioner({
|
|
1550
|
+
id: "req_first",
|
|
1551
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
1552
|
+
params: {
|
|
1553
|
+
runtime: "hermes-agent",
|
|
1554
|
+
hermes: { profile: "coder" },
|
|
1555
|
+
credentials: {
|
|
1556
|
+
agentId: "ag_first",
|
|
1557
|
+
keyId: "k_first",
|
|
1558
|
+
privateKey,
|
|
1559
|
+
hubUrl: "https://hub.example",
|
|
1560
|
+
runtime: "hermes-agent",
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
});
|
|
1564
|
+
expect(okAck.ok).toBe(true);
|
|
1565
|
+
|
|
1566
|
+
const privateKey2 = Buffer.alloc(32, 19).toString("base64");
|
|
1567
|
+
const ack = await provisioner({
|
|
1568
|
+
id: "req_second",
|
|
1569
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
1570
|
+
params: {
|
|
1571
|
+
runtime: "hermes-agent",
|
|
1572
|
+
hermes: { profile: "coder" },
|
|
1573
|
+
credentials: {
|
|
1574
|
+
agentId: "ag_second",
|
|
1575
|
+
keyId: "k_second",
|
|
1576
|
+
privateKey: privateKey2,
|
|
1577
|
+
hubUrl: "https://hub.example",
|
|
1578
|
+
runtime: "hermes-agent",
|
|
1579
|
+
},
|
|
1580
|
+
},
|
|
1581
|
+
});
|
|
1582
|
+
expect(ack.ok).toBe(false);
|
|
1583
|
+
expect(ack.error?.code).toBe("hermes_profile_occupied");
|
|
1584
|
+
expect(ack.error?.occupiedBy).toBe("ag_first");
|
|
1585
|
+
});
|
|
1586
|
+
});
|
|
1587
|
+
});
|
package/src/agent-discovery.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface DiscoveredAgentCredential {
|
|
|
42
42
|
openclawGateway?: string;
|
|
43
43
|
/** OpenClaw agent profile override from credentials. */
|
|
44
44
|
openclawAgent?: string;
|
|
45
|
+
/** Hermes profile name from credentials (only meaningful for hermes-agent). */
|
|
46
|
+
hermesProfile?: string;
|
|
45
47
|
/** Key id from the credentials file — surfaced so boot-time workspace
|
|
46
48
|
* seeding (see daemon-agent-workspace-plan.md §9) can render identity.md
|
|
47
49
|
* without re-reading the file. */
|
|
@@ -182,6 +184,7 @@ export function discoverAgentCredentials(
|
|
|
182
184
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
183
185
|
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
184
186
|
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
187
|
+
if (creds.hermesProfile) entry.hermesProfile = creds.hermesProfile;
|
|
185
188
|
if (creds.keyId) entry.keyId = creds.keyId;
|
|
186
189
|
if (creds.savedAt) entry.savedAt = creds.savedAt;
|
|
187
190
|
agents.push(entry);
|
package/src/agent-workspace.ts
CHANGED
|
@@ -357,14 +357,25 @@ export function ensureAgentCodexHome(agentId: string): string {
|
|
|
357
357
|
* loader (which only sees this isolated HERMES_HOME, not `~/.hermes`)
|
|
358
358
|
* can discover them.
|
|
359
359
|
*/
|
|
360
|
-
export function ensureAgentHermesWorkspace(
|
|
360
|
+
export function ensureAgentHermesWorkspace(
|
|
361
|
+
agentId: string,
|
|
362
|
+
opts: { attached?: boolean } = {},
|
|
363
|
+
): {
|
|
361
364
|
hermesHome: string;
|
|
362
365
|
hermesWorkspace: string;
|
|
363
366
|
} {
|
|
364
367
|
const hermesHome = agentHermesHomeDir(agentId);
|
|
365
368
|
const hermesWorkspace = agentHermesWorkspaceDir(agentId);
|
|
366
|
-
mkdirTolerant(hermesHome);
|
|
367
369
|
mkdirTolerant(hermesWorkspace);
|
|
370
|
+
// Attach mode: HERMES_HOME points at the user's `~/.hermes/profiles/<n>/`
|
|
371
|
+
// so we MUST NOT touch the per-agent isolated home. The cwd
|
|
372
|
+
// (`hermesWorkspace`) is still ours and `prepareTurn` writes AGENTS.md
|
|
373
|
+
// there. Profile-owned skill seeding is handled separately by
|
|
374
|
+
// `ensureAttachedHermesProfileSkills`.
|
|
375
|
+
if (opts.attached) {
|
|
376
|
+
return { hermesHome, hermesWorkspace };
|
|
377
|
+
}
|
|
378
|
+
mkdirTolerant(hermesHome);
|
|
368
379
|
writeIfMissing(
|
|
369
380
|
path.join(hermesHome, ".env"),
|
|
370
381
|
"# hermes-agent environment overrides for this BotCord agent.\n" +
|
|
@@ -451,6 +462,17 @@ function seedHermesAgentSkills(hermesHome: string): void {
|
|
|
451
462
|
copyBundledSkills(path.join(hermesHome, "skills"));
|
|
452
463
|
}
|
|
453
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Seed BotCord's bundled Hermes skills into a user-owned Hermes profile used
|
|
467
|
+
* by attach mode. Unlike `ensureAgentHermesWorkspace({ attached: true })`,
|
|
468
|
+
* this intentionally writes only the managed `botcord*` skill directories
|
|
469
|
+
* under the profile's `skills/` directory; it does not touch `.env`,
|
|
470
|
+
* `config.yaml`, sessions, or any user-authored skills.
|
|
471
|
+
*/
|
|
472
|
+
export function ensureAttachedHermesProfileSkills(profileHome: string): void {
|
|
473
|
+
seedHermesAgentSkills(profileHome);
|
|
474
|
+
}
|
|
475
|
+
|
|
454
476
|
/**
|
|
455
477
|
* Idempotently create the agent's home / workspace / state directories and
|
|
456
478
|
* seed the workspace Markdown files. Existing files are never overwritten —
|
package/src/daemon-config-map.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface AgentRuntimeMeta {
|
|
|
27
27
|
openclawGateway?: string;
|
|
28
28
|
/** Optional override of the OpenClaw agent profile within the gateway. */
|
|
29
29
|
openclawAgent?: string;
|
|
30
|
+
/** Hermes profile name to attach to (`runtime === "hermes-agent"` only). */
|
|
31
|
+
hermesProfile?: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** Profile + tokenFile-resolved bearer token. Exported so other module-boundary
|
|
@@ -339,6 +341,9 @@ export function buildManagedRoutes(
|
|
|
339
341
|
}
|
|
340
342
|
route.gateway = resolved;
|
|
341
343
|
}
|
|
344
|
+
if (runtime === "hermes-agent" && meta.hermesProfile) {
|
|
345
|
+
route.hermesProfile = meta.hermesProfile;
|
|
346
|
+
}
|
|
342
347
|
out.set(agentId, route);
|
|
343
348
|
}
|
|
344
349
|
return out;
|
package/src/daemon.ts
CHANGED
|
@@ -562,7 +562,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
562
562
|
*/
|
|
563
563
|
export interface BootBackfillResult {
|
|
564
564
|
credentialPathByAgentId: Map<string, string>;
|
|
565
|
-
agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }>;
|
|
565
|
+
agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }>;
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
/**
|
|
@@ -585,12 +585,13 @@ export function backfillBootAgents(
|
|
|
585
585
|
const failed: string[] = [];
|
|
586
586
|
for (const a of agents) {
|
|
587
587
|
if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
|
|
588
|
-
if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
|
|
588
|
+
if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent || a.hermesProfile) {
|
|
589
589
|
agentRuntimes[a.agentId] = {
|
|
590
590
|
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
591
591
|
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
592
592
|
...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
|
|
593
593
|
...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
|
|
594
|
+
...(a.hermesProfile ? { hermesProfile: a.hermesProfile } : {}),
|
|
594
595
|
};
|
|
595
596
|
}
|
|
596
597
|
// Seed files are written only when missing (see `ensureAgentWorkspace`),
|
|
@@ -12,6 +12,8 @@ import os from "node:os";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import {
|
|
14
14
|
HermesAgentAdapter,
|
|
15
|
+
hermesProfileHomeDir,
|
|
16
|
+
listHermesProfiles,
|
|
15
17
|
resolveHermesAcpCommand,
|
|
16
18
|
} from "../runtimes/hermes-agent.js";
|
|
17
19
|
import { agentHermesWorkspaceDir } from "../../agent-workspace.js";
|
|
@@ -367,3 +369,88 @@ describe("HermesAgentAdapter", () => {
|
|
|
367
369
|
expect(status.some((s) => s.phase === "stopped")).toBe(true);
|
|
368
370
|
});
|
|
369
371
|
});
|
|
372
|
+
|
|
373
|
+
describe("listHermesProfiles", () => {
|
|
374
|
+
it("returns the synthetic default entry when only ~/.hermes exists", () => {
|
|
375
|
+
// beforeAll set HOME to an empty tmp dir; create just ~/.hermes.
|
|
376
|
+
const root = path.join(agentHomeRoot, ".hermes");
|
|
377
|
+
mkdirSync(root, { recursive: true });
|
|
378
|
+
// Drop a config.yaml to exercise the optional model snapshot.
|
|
379
|
+
writeFileSync(
|
|
380
|
+
path.join(root, "config.yaml"),
|
|
381
|
+
"model:\n default: anthropic/claude-opus-4.6\n provider: custom\n",
|
|
382
|
+
);
|
|
383
|
+
writeFileSync(path.join(root, "SOUL.md"), "test soul");
|
|
384
|
+
mkdirSync(path.join(root, "sessions"), { recursive: true });
|
|
385
|
+
writeFileSync(path.join(root, "sessions", "20260101_abc.jsonl"), "{}\n");
|
|
386
|
+
|
|
387
|
+
const profiles = listHermesProfiles();
|
|
388
|
+
const def = profiles.find((p) => p.name === "default");
|
|
389
|
+
expect(def).toBeDefined();
|
|
390
|
+
expect(def?.isDefault).toBe(true);
|
|
391
|
+
expect(def?.home).toBe(root);
|
|
392
|
+
expect(def?.modelName).toBe("anthropic/claude-opus-4.6");
|
|
393
|
+
expect(def?.hasSoul).toBe(true);
|
|
394
|
+
expect(def?.sessionsCount).toBe(1);
|
|
395
|
+
|
|
396
|
+
rmSync(root, { recursive: true, force: true });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("enumerates named profiles and honours active_profile", () => {
|
|
400
|
+
const root = path.join(agentHomeRoot, ".hermes");
|
|
401
|
+
mkdirSync(path.join(root, "profiles", "coder"), { recursive: true });
|
|
402
|
+
mkdirSync(path.join(root, "profiles", "writer"), { recursive: true });
|
|
403
|
+
// Garbage / invalid profile names should be skipped.
|
|
404
|
+
mkdirSync(path.join(root, "profiles", "BadName"), { recursive: true });
|
|
405
|
+
writeFileSync(path.join(root, "active_profile"), "writer\n");
|
|
406
|
+
|
|
407
|
+
const profiles = listHermesProfiles();
|
|
408
|
+
const names = profiles.map((p) => p.name).sort();
|
|
409
|
+
expect(names).toEqual(["coder", "default", "writer"]);
|
|
410
|
+
const writer = profiles.find((p) => p.name === "writer");
|
|
411
|
+
expect(writer?.isActive).toBe(true);
|
|
412
|
+
expect(writer?.home).toBe(hermesProfileHomeDir("writer"));
|
|
413
|
+
|
|
414
|
+
rmSync(root, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("HermesAgentAdapter.spawnEnv attach mode", () => {
|
|
419
|
+
it("points HERMES_HOME at the user profile when hermesProfile is set", () => {
|
|
420
|
+
const adapter = new HermesAgentAdapter({ binary: "/dev/null" });
|
|
421
|
+
type EnvProbe = { HERMES_HOME?: string };
|
|
422
|
+
const env = (
|
|
423
|
+
adapter as unknown as {
|
|
424
|
+
spawnEnv: (opts: unknown) => EnvProbe;
|
|
425
|
+
}
|
|
426
|
+
).spawnEnv({
|
|
427
|
+
text: "",
|
|
428
|
+
sessionId: null,
|
|
429
|
+
cwd: tmpRoot,
|
|
430
|
+
accountId: "ag_attach",
|
|
431
|
+
signal: new AbortController().signal,
|
|
432
|
+
trustLevel: "owner",
|
|
433
|
+
hermesProfile: "coder",
|
|
434
|
+
});
|
|
435
|
+
expect(env.HERMES_HOME).toBe(hermesProfileHomeDir("coder"));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("falls back to per-agent isolated home when hermesProfile is unset", () => {
|
|
439
|
+
const adapter = new HermesAgentAdapter({ binary: "/dev/null" });
|
|
440
|
+
type EnvProbe = { HERMES_HOME?: string };
|
|
441
|
+
const env = (
|
|
442
|
+
adapter as unknown as {
|
|
443
|
+
spawnEnv: (opts: unknown) => EnvProbe;
|
|
444
|
+
}
|
|
445
|
+
).spawnEnv({
|
|
446
|
+
text: "",
|
|
447
|
+
sessionId: null,
|
|
448
|
+
cwd: tmpRoot,
|
|
449
|
+
accountId: "ag_isolated",
|
|
450
|
+
signal: new AbortController().signal,
|
|
451
|
+
trustLevel: "owner",
|
|
452
|
+
});
|
|
453
|
+
expect(env.HERMES_HOME).toContain(path.join(".botcord", "agents", "ag_isolated"));
|
|
454
|
+
expect(env.HERMES_HOME).not.toBe(hermesProfileHomeDir("default"));
|
|
455
|
+
});
|
|
456
|
+
});
|