@botcord/daemon 0.2.89 → 0.2.91
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/cloud-daemon.js +18 -4
- package/dist/daemon.d.ts +2 -3
- package/dist/daemon.js +21 -6
- package/dist/gateway/channels/botcord.js +35 -22
- package/dist/provision.d.ts +1 -0
- package/dist/provision.js +103 -9
- package/dist/runtime-models.js +46 -0
- package/dist/self-restart.d.ts +29 -0
- package/dist/self-restart.js +172 -0
- package/dist/skill-index.d.ts +14 -2
- package/dist/skill-index.js +109 -41
- package/dist/skill-installer.d.ts +61 -0
- package/dist/skill-installer.js +340 -0
- package/dist/system-context.d.ts +6 -0
- package/dist/system-context.js +1 -1
- package/package.json +3 -3
- package/src/__tests__/provision.test.ts +23 -0
- package/src/__tests__/runtime-discovery.test.ts +6 -3
- package/src/__tests__/runtime-models.test.ts +53 -0
- package/src/__tests__/self-restart.test.ts +57 -0
- package/src/__tests__/skill-index.test.ts +130 -13
- package/src/__tests__/skill-installer.test.ts +224 -0
- package/src/cloud-daemon.ts +17 -4
- package/src/daemon.ts +23 -8
- package/src/gateway/__tests__/botcord-channel.test.ts +38 -0
- package/src/gateway/channels/botcord.ts +41 -22
- package/src/provision.ts +111 -14
- package/src/runtime-models.ts +47 -0
- package/src/self-restart.ts +218 -0
- package/src/skill-index.ts +130 -53
- package/src/skill-installer.ts +472 -0
- package/src/system-context.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.91",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
25
25
|
"ws": "^8.20.1",
|
|
26
|
-
"@botcord/
|
|
27
|
-
"@botcord/
|
|
26
|
+
"@botcord/protocol-core": "^0.2.13",
|
|
27
|
+
"@botcord/cli": "^0.1.19"
|
|
28
28
|
},
|
|
29
29
|
"overrides": {
|
|
30
30
|
"axios": "^1.15.2"
|
|
@@ -336,6 +336,29 @@ describe("wake_agent handler", () => {
|
|
|
336
336
|
});
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
it("acks wake_agent after queueing instead of waiting for the turn to finish", async () => {
|
|
340
|
+
const gw = makeFakeGateway(["ag_wake"]);
|
|
341
|
+
gw.injectInbound.mockImplementation(() => new Promise(() => undefined));
|
|
342
|
+
const handler = createProvisioner({ gateway: gw as any });
|
|
343
|
+
|
|
344
|
+
const res = await Promise.race([
|
|
345
|
+
handler({
|
|
346
|
+
id: "req_wake_pending",
|
|
347
|
+
type: "wake_agent",
|
|
348
|
+
params: {
|
|
349
|
+
agent_id: "ag_wake",
|
|
350
|
+
message: "tick",
|
|
351
|
+
run_id: "sr_pending",
|
|
352
|
+
},
|
|
353
|
+
}),
|
|
354
|
+
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 20)),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
expect(res).not.toBe("timeout");
|
|
358
|
+
expect(res).toMatchObject({ ok: true, result: { agent_id: "ag_wake", queued: true } });
|
|
359
|
+
expect(gw.injectInbound).toHaveBeenCalledTimes(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
339
362
|
it("rejects wake_agent for an unloaded agent", async () => {
|
|
340
363
|
const gw = makeFakeGateway(["ag_loaded"]);
|
|
341
364
|
const handler = createProvisioner({ gateway: gw as any });
|
|
@@ -147,11 +147,14 @@ describe("collectRuntimeSnapshot", () => {
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
it("omits optional fields rather than emitting explicit undefineds", () => {
|
|
150
|
+
// Use a synthetic runtime id with no catalog strategy so the snapshot
|
|
151
|
+
// doesn't pick up a `models` field. Switching to "gemini" here would
|
|
152
|
+
// attach the built-in gemini model list.
|
|
150
153
|
setRuntimes([
|
|
151
154
|
{
|
|
152
|
-
id: "
|
|
153
|
-
displayName: "
|
|
154
|
-
binary: "
|
|
155
|
+
id: "unknown-runtime",
|
|
156
|
+
displayName: "Unknown",
|
|
157
|
+
binary: "unknown",
|
|
155
158
|
supportsRun: true,
|
|
156
159
|
result: { available: true },
|
|
157
160
|
},
|
|
@@ -380,4 +380,57 @@ describe("runtime model discovery parsers", () => {
|
|
|
380
380
|
},
|
|
381
381
|
]);
|
|
382
382
|
});
|
|
383
|
+
|
|
384
|
+
it("returns the built-in Gemini catalog and caches it under gemini.json", () => {
|
|
385
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "daemon-gemini-catalog-"));
|
|
386
|
+
const prevHome = process.env.HOME;
|
|
387
|
+
const prevCacheDir = process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
|
|
388
|
+
try {
|
|
389
|
+
const home = path.join(tmp, "home");
|
|
390
|
+
const cacheDir = path.join(tmp, "catalog-cache");
|
|
391
|
+
mkdirSync(home, { recursive: true });
|
|
392
|
+
process.env.HOME = home;
|
|
393
|
+
process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = cacheDir;
|
|
394
|
+
|
|
395
|
+
const catalog = discoverRuntimeModelCatalog({
|
|
396
|
+
id: "gemini",
|
|
397
|
+
displayName: "Gemini CLI",
|
|
398
|
+
binary: "gemini",
|
|
399
|
+
supportsRun: true,
|
|
400
|
+
result: { available: true, path: path.join(tmp, "missing-gemini") },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const ids = catalog.models?.map((m) => m.id) ?? [];
|
|
404
|
+
// `auto` is the default — wizard should pre-select it.
|
|
405
|
+
expect(catalog.models?.find((m) => m.isDefault)?.id).toBe("auto");
|
|
406
|
+
// Spot-check the documented family is present.
|
|
407
|
+
expect(ids).toEqual(
|
|
408
|
+
expect.arrayContaining([
|
|
409
|
+
"auto",
|
|
410
|
+
"pro",
|
|
411
|
+
"flash",
|
|
412
|
+
"flash-lite",
|
|
413
|
+
"gemini-2.5-pro",
|
|
414
|
+
"gemini-2.5-flash",
|
|
415
|
+
"gemini-3-pro-preview",
|
|
416
|
+
"gemini-3.1-pro-preview",
|
|
417
|
+
]),
|
|
418
|
+
);
|
|
419
|
+
// No per-turn parameters yet (thinkingLevel / thinkingBudget aren't
|
|
420
|
+
// exposed as CLI flags; we can't safely route them through `extraArgs`).
|
|
421
|
+
expect(catalog.parameters ?? []).toEqual([]);
|
|
422
|
+
|
|
423
|
+
// Cache persisted so subsequent calls don't re-touch settings.json.
|
|
424
|
+
expect(readdirSync(cacheDir)).toEqual(["gemini.json"]);
|
|
425
|
+
const payload = JSON.parse(readFileSync(path.join(cacheDir, "gemini.json"), "utf8"));
|
|
426
|
+
expect(payload.runtimeId).toBe("gemini");
|
|
427
|
+
expect(payload.catalog.models.map((m: { id: string }) => m.id)).toContain("gemini-2.5-flash");
|
|
428
|
+
} finally {
|
|
429
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
430
|
+
else process.env.HOME = prevHome;
|
|
431
|
+
if (prevCacheDir === undefined) delete process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
|
|
432
|
+
else process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = prevCacheDir;
|
|
433
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
383
436
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
realpathSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
symlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import { findDaemonInstallPrefix } from "../self-restart.js";
|
|
13
|
+
|
|
14
|
+
describe("self restart install prefix detection", () => {
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "botcord-self-restart-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("finds the npm install prefix for a managed @botcord/daemon entrypoint", () => {
|
|
26
|
+
const prefix = path.join(tmpDir, ".botcord", "daemon");
|
|
27
|
+
const packageRoot = path.join(prefix, "node_modules", "@botcord", "daemon");
|
|
28
|
+
const entrypoint = path.join(packageRoot, "dist", "index.js");
|
|
29
|
+
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
|
30
|
+
writeFileSync(path.join(packageRoot, "package.json"), '{"name":"@botcord/daemon"}');
|
|
31
|
+
writeFileSync(entrypoint, "");
|
|
32
|
+
|
|
33
|
+
expect(findDaemonInstallPrefix(entrypoint)).toBe(prefix);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resolves npm .bin symlinks before looking for the package root", () => {
|
|
37
|
+
const prefix = path.join(tmpDir, ".botcord", "daemon");
|
|
38
|
+
const packageRoot = path.join(prefix, "node_modules", "@botcord", "daemon");
|
|
39
|
+
const entrypoint = path.join(packageRoot, "dist", "index.js");
|
|
40
|
+
const bin = path.join(prefix, "node_modules", ".bin", "botcord-daemon");
|
|
41
|
+
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
|
42
|
+
mkdirSync(path.dirname(bin), { recursive: true });
|
|
43
|
+
writeFileSync(path.join(packageRoot, "package.json"), '{"name":"@botcord/daemon"}');
|
|
44
|
+
writeFileSync(entrypoint, "");
|
|
45
|
+
symlinkSync(entrypoint, bin);
|
|
46
|
+
|
|
47
|
+
expect(findDaemonInstallPrefix(bin)).toBe(realpathSync(prefix));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not treat a monorepo development entrypoint as self-updatable", () => {
|
|
51
|
+
const entrypoint = path.join(tmpDir, "packages", "daemon", "dist", "index.js");
|
|
52
|
+
mkdirSync(path.dirname(entrypoint), { recursive: true });
|
|
53
|
+
writeFileSync(entrypoint, "");
|
|
54
|
+
|
|
55
|
+
expect(findDaemonInstallPrefix(entrypoint)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -4,8 +4,10 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
agentCodexHomeDir,
|
|
7
|
+
agentHermesHomeDir,
|
|
7
8
|
agentWorkspaceDir,
|
|
8
9
|
} from "../agent-workspace.js";
|
|
10
|
+
import { hermesProfileHomeDir } from "../gateway/runtimes/hermes-agent.js";
|
|
9
11
|
import {
|
|
10
12
|
buildSoftSkillIndexPrompt,
|
|
11
13
|
collectAgentSkillSnapshot,
|
|
@@ -37,27 +39,40 @@ afterEach(() => {
|
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
describe("skill snapshots", () => {
|
|
40
|
-
it("scans
|
|
42
|
+
it("scopes scans to the selected runtime and maps UI source buckets", () => {
|
|
41
43
|
const agentId = "ag_skilltest";
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
writeSkill(
|
|
44
|
+
const claudePath = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|
|
45
|
+
const codexPath = path.join(agentCodexHomeDir(agentId), "skills");
|
|
46
|
+
writeSkill(claudePath, "claude-skill", "Claude skill");
|
|
47
|
+
writeSkill(codexPath, "codex-skill", "Codex skill");
|
|
48
|
+
writeSkill(path.join(tmpDir, ".claude", "skills"), "global-claude", "Global Claude");
|
|
49
|
+
writeSkill(path.join(tmpDir, ".codex", "skills"), "global-codex", "Global Codex");
|
|
45
50
|
|
|
46
|
-
const
|
|
47
|
-
expect(
|
|
51
|
+
const claudeScanned = scanSoftSkills(agentId, { runtime: "claude-code" });
|
|
52
|
+
expect(claudeScanned.map((s) => s.name).sort()).toEqual([
|
|
53
|
+
"claude-skill",
|
|
54
|
+
"global-claude",
|
|
55
|
+
]);
|
|
56
|
+
expect(claudeScanned.every((s) => s.runtime === "claude-code")).toBe(true);
|
|
57
|
+
|
|
58
|
+
const codexScanned = scanSoftSkills(agentId, { runtime: "codex" });
|
|
59
|
+
expect(codexScanned.map((s) => s.name).sort()).toEqual([
|
|
48
60
|
"codex-skill",
|
|
49
|
-
"global-
|
|
50
|
-
"workspace-skill",
|
|
61
|
+
"global-codex",
|
|
51
62
|
]);
|
|
63
|
+
expect(codexScanned.every((s) => s.runtime === "codex")).toBe(true);
|
|
52
64
|
|
|
53
|
-
const snapshot = collectAgentSkillSnapshot(agentId);
|
|
65
|
+
const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "codex" });
|
|
54
66
|
expect(snapshot.agentId).toBe(agentId);
|
|
55
|
-
expect(snapshot.
|
|
56
|
-
expect(snapshot.skills.
|
|
57
|
-
.toBe("workspace");
|
|
67
|
+
expect(snapshot.runtime).toBe("codex");
|
|
68
|
+
expect(snapshot.skills).toHaveLength(2);
|
|
58
69
|
expect(snapshot.skills.find((s) => s.name === "codex-skill")?.source)
|
|
59
70
|
.toBe("workspace");
|
|
60
|
-
expect(snapshot.skills.find((s) => s.name === "
|
|
71
|
+
expect(snapshot.skills.find((s) => s.name === "codex-skill")?.sourceDetail)
|
|
72
|
+
.toBe("agent-codex");
|
|
73
|
+
expect(snapshot.skills.find((s) => s.name === "codex-skill")?.path)
|
|
74
|
+
.toBe(path.join(codexPath, "codex-skill", "SKILL.md"));
|
|
75
|
+
expect(snapshot.skills.find((s) => s.name === "global-codex")?.source)
|
|
61
76
|
.toBe("runtime-global");
|
|
62
77
|
expect(snapshot.probedAt).toBeGreaterThan(0);
|
|
63
78
|
});
|
|
@@ -101,6 +116,108 @@ describe("skill snapshots", () => {
|
|
|
101
116
|
.toBe("runtime-global");
|
|
102
117
|
});
|
|
103
118
|
|
|
119
|
+
it("scans Hermes home/profile skills without mixing Claude or Codex dirs", () => {
|
|
120
|
+
const agentId = "ag_hermes_skills";
|
|
121
|
+
writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "claude-only", "Claude only");
|
|
122
|
+
writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-only", "Codex only");
|
|
123
|
+
writeSkill(path.join(agentHermesHomeDir(agentId), "skills"), "hermes-only", "Hermes only");
|
|
124
|
+
|
|
125
|
+
const isolated = scanSoftSkills(agentId, { runtime: "hermes-agent" });
|
|
126
|
+
expect(isolated.map((s) => s.name)).toEqual(["hermes-only"]);
|
|
127
|
+
expect(isolated[0]).toMatchObject({
|
|
128
|
+
source: "agent-hermes",
|
|
129
|
+
runtime: "hermes-agent",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const profileAgentId = "ag_hermes_profile";
|
|
133
|
+
writeSkill(
|
|
134
|
+
path.join(hermesProfileHomeDir("writer"), "skills"),
|
|
135
|
+
"profile-skill",
|
|
136
|
+
"Hermes profile skill",
|
|
137
|
+
);
|
|
138
|
+
const profile = collectAgentSkillSnapshot(profileAgentId, {
|
|
139
|
+
runtime: "hermes-agent",
|
|
140
|
+
hermesProfile: "writer",
|
|
141
|
+
});
|
|
142
|
+
expect(profile.skills).toHaveLength(1);
|
|
143
|
+
expect(profile.skills[0]).toMatchObject({
|
|
144
|
+
name: "profile-skill",
|
|
145
|
+
source: "workspace",
|
|
146
|
+
sourceDetail: "agent-hermes-profile",
|
|
147
|
+
runtime: "hermes-agent",
|
|
148
|
+
profile: "writer",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("scans Gemini workspace and user skill roots without mixing Claude or Codex dirs", () => {
|
|
153
|
+
const agentId = "ag_gemini_skills";
|
|
154
|
+
const workspaceGemini = path.join(agentWorkspaceDir(agentId), ".gemini", "skills");
|
|
155
|
+
const workspaceAgents = path.join(agentWorkspaceDir(agentId), ".agents", "skills");
|
|
156
|
+
writeSkill(workspaceGemini, "workspace-gemini", "Workspace Gemini skill");
|
|
157
|
+
writeSkill(workspaceAgents, "workspace-agent", "Workspace shared-agent skill");
|
|
158
|
+
writeSkill(path.join(tmpDir, ".gemini", "skills"), "global-gemini", "Global Gemini skill");
|
|
159
|
+
writeSkill(path.join(tmpDir, ".agents", "skills"), "global-agent", "Global shared-agent skill");
|
|
160
|
+
writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "claude-only", "Claude only");
|
|
161
|
+
writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-only", "Codex only");
|
|
162
|
+
|
|
163
|
+
const geminiScanned = scanSoftSkills(agentId, { runtime: "gemini" });
|
|
164
|
+
expect(geminiScanned.map((s) => s.name)).toEqual([
|
|
165
|
+
"global-agent",
|
|
166
|
+
"global-gemini",
|
|
167
|
+
"workspace-agent",
|
|
168
|
+
"workspace-gemini",
|
|
169
|
+
]);
|
|
170
|
+
expect(geminiScanned.every((s) => s.runtime === "gemini")).toBe(true);
|
|
171
|
+
|
|
172
|
+
const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "gemini" });
|
|
173
|
+
expect(snapshot.runtime).toBe("gemini");
|
|
174
|
+
expect(snapshot.skills.find((s) => s.name === "workspace-gemini"))
|
|
175
|
+
.toMatchObject({
|
|
176
|
+
source: "workspace",
|
|
177
|
+
sourceDetail: "agent-gemini",
|
|
178
|
+
runtime: "gemini",
|
|
179
|
+
path: path.join(workspaceGemini, "workspace-gemini", "SKILL.md"),
|
|
180
|
+
});
|
|
181
|
+
expect(snapshot.skills.find((s) => s.name === "workspace-agent"))
|
|
182
|
+
.toMatchObject({
|
|
183
|
+
source: "workspace",
|
|
184
|
+
sourceDetail: "agent-agents",
|
|
185
|
+
});
|
|
186
|
+
expect(snapshot.skills.find((s) => s.name === "global-agent"))
|
|
187
|
+
.toMatchObject({
|
|
188
|
+
source: "runtime-global",
|
|
189
|
+
sourceDetail: "global-agents",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps same-device workspace skills scoped by agent id", () => {
|
|
194
|
+
writeSkill(
|
|
195
|
+
path.join(agentWorkspaceDir("ag_workspace_a"), ".claude", "skills"),
|
|
196
|
+
"agent-local",
|
|
197
|
+
"Skill for agent A",
|
|
198
|
+
);
|
|
199
|
+
writeSkill(
|
|
200
|
+
path.join(agentWorkspaceDir("ag_workspace_b"), ".claude", "skills"),
|
|
201
|
+
"agent-local",
|
|
202
|
+
"Skill for agent B",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(scanSoftSkills("ag_workspace_a", { runtime: "claude-code" })).toEqual([
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
name: "agent-local",
|
|
208
|
+
description: "Skill for agent A",
|
|
209
|
+
source: "agent-claude",
|
|
210
|
+
}),
|
|
211
|
+
]);
|
|
212
|
+
expect(scanSoftSkills("ag_workspace_b", { runtime: "claude-code" })).toEqual([
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
name: "agent-local",
|
|
215
|
+
description: "Skill for agent B",
|
|
216
|
+
source: "agent-claude",
|
|
217
|
+
}),
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
|
|
104
221
|
it("returns complete snapshots while keeping the prompt soft index capped", () => {
|
|
105
222
|
const agentId = "ag_manyskills";
|
|
106
223
|
const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
symlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
agentCodexHomeDir,
|
|
15
|
+
agentWorkspaceDir,
|
|
16
|
+
} from "../agent-workspace.js";
|
|
17
|
+
import {
|
|
18
|
+
buildVercelSkillsArgs,
|
|
19
|
+
installAgentSkillManifest,
|
|
20
|
+
installBotLearnArchiveManifest,
|
|
21
|
+
installVercelSkillsForAgent,
|
|
22
|
+
} from "../skill-installer.js";
|
|
23
|
+
|
|
24
|
+
let tmpHome = "";
|
|
25
|
+
let prevHome: string | undefined;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tmpHome = mkdtempSync(path.join(tmpdir(), "skill-installer-"));
|
|
29
|
+
prevHome = process.env.HOME;
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
35
|
+
else process.env.HOME = prevHome;
|
|
36
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("skill installer", () => {
|
|
40
|
+
it("installs a manifest into the loaded agent runtime path and returns a refreshed snapshot", () => {
|
|
41
|
+
const result = installAgentSkillManifest(
|
|
42
|
+
"ag_manifest",
|
|
43
|
+
{
|
|
44
|
+
name: "review-helper",
|
|
45
|
+
description: "Review pull requests",
|
|
46
|
+
files: [{ path: "references/checklist.md", content: "Check tests\n" }],
|
|
47
|
+
},
|
|
48
|
+
{ runtime: "codex" },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const skillDir = path.join(agentCodexHomeDir("ag_manifest"), "skills", "review-helper");
|
|
52
|
+
expect(readFileSync(path.join(skillDir, "SKILL.md"), "utf8")).toContain("name: review-helper");
|
|
53
|
+
expect(readFileSync(path.join(skillDir, "references", "checklist.md"), "utf8")).toBe("Check tests\n");
|
|
54
|
+
expect(result.installed).toEqual([
|
|
55
|
+
{
|
|
56
|
+
name: "review-helper",
|
|
57
|
+
targets: ["codex"],
|
|
58
|
+
paths: [skillDir],
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
expect(result.snapshot.skills.map((s) => s.name)).toContain("review-helper");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("installs BotLearn archive-style manifests containing multiple skills", () => {
|
|
65
|
+
const result = installBotLearnArchiveManifest(
|
|
66
|
+
"ag_archive",
|
|
67
|
+
{
|
|
68
|
+
targetRuntimes: ["claude-code"],
|
|
69
|
+
skills: [
|
|
70
|
+
{
|
|
71
|
+
id: "planner",
|
|
72
|
+
description: "Plan work",
|
|
73
|
+
skillMd: "---\nname: planner\ndescription: Plan work\n---\n\n# Planner\n",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "tester",
|
|
77
|
+
markdown: "---\nname: tester\n---\n\n# Tester\n",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{ runtime: "claude-code" },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const skillsRoot = path.join(agentWorkspaceDir("ag_archive"), ".claude", "skills");
|
|
85
|
+
expect(existsSync(path.join(skillsRoot, "planner", "SKILL.md"))).toBe(true);
|
|
86
|
+
expect(existsSync(path.join(skillsRoot, "tester", "SKILL.md"))).toBe(true);
|
|
87
|
+
expect(result.installed.map((skill) => skill.name)).toEqual(["planner", "tester"]);
|
|
88
|
+
expect(result.snapshot.skills.map((skill) => skill.name).sort()).toEqual(["planner", "tester"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects unsafe names and file paths before writing", () => {
|
|
92
|
+
expect(() => installAgentSkillManifest("ag_unsafe_name", {
|
|
93
|
+
name: "../bad",
|
|
94
|
+
})).toThrow(/unsafe skill name/);
|
|
95
|
+
|
|
96
|
+
expect(() => installAgentSkillManifest("ag_unsafe_file", {
|
|
97
|
+
name: "safe",
|
|
98
|
+
files: [{ path: "../escape.txt", content: "no" }],
|
|
99
|
+
})).toThrow(/unsafe skill file path/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not leave a partial skill dir when source file validation fails", () => {
|
|
103
|
+
const sourceRoot = mkdtempSync(path.join(tmpdir(), "skill-source-"));
|
|
104
|
+
try {
|
|
105
|
+
expect(() => installAgentSkillManifest("ag_missing_source", {
|
|
106
|
+
name: "missing-source",
|
|
107
|
+
files: [{ path: "references/missing.md", sourcePath: "missing.md" }],
|
|
108
|
+
}, {
|
|
109
|
+
runtime: "codex",
|
|
110
|
+
sourceRoot,
|
|
111
|
+
})).toThrow();
|
|
112
|
+
|
|
113
|
+
const skillDir = path.join(agentCodexHomeDir("ag_missing_source"), "skills", "missing-source");
|
|
114
|
+
expect(existsSync(skillDir)).toBe(false);
|
|
115
|
+
} finally {
|
|
116
|
+
rmSync(sourceRoot, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not leave a partial skill dir when an inline file is oversized", () => {
|
|
121
|
+
const oversized = "x".repeat((256 * 1024) + 1);
|
|
122
|
+
|
|
123
|
+
expect(() => installAgentSkillManifest("ag_oversized", {
|
|
124
|
+
name: "oversized",
|
|
125
|
+
files: [{ path: "references/large.md", content: oversized }],
|
|
126
|
+
}, {
|
|
127
|
+
runtime: "codex",
|
|
128
|
+
})).toThrow(/skill file too large/);
|
|
129
|
+
|
|
130
|
+
const skillDir = path.join(agentCodexHomeDir("ag_oversized"), "skills", "oversized");
|
|
131
|
+
expect(existsSync(skillDir)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("builds a non-interactive vercel-labs/skills command for injected execution", () => {
|
|
135
|
+
expect(buildVercelSkillsArgs("https://github.com/vercel-labs/skills", ["frontend-design"], ["codex"]))
|
|
136
|
+
.toEqual([
|
|
137
|
+
"--yes",
|
|
138
|
+
"skills",
|
|
139
|
+
"add",
|
|
140
|
+
"https://github.com/vercel-labs/skills",
|
|
141
|
+
"--global",
|
|
142
|
+
"--copy",
|
|
143
|
+
"--yes",
|
|
144
|
+
"--skill",
|
|
145
|
+
"frontend-design",
|
|
146
|
+
"--agent",
|
|
147
|
+
"codex",
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("imports skills produced by an injected vercel-labs/skills executor without network", async () => {
|
|
152
|
+
const executor = vi.fn(async (_command, _args, options) => {
|
|
153
|
+
const home = options.env?.HOME as string;
|
|
154
|
+
const namespaced = path.join(home, ".codex", "skills", "vercel-labs", "find-skills");
|
|
155
|
+
mkdirSync(namespaced, { recursive: true });
|
|
156
|
+
writeFileSync(
|
|
157
|
+
path.join(namespaced, "SKILL.md"),
|
|
158
|
+
"---\nname: find-skills\ndescription: Find skills\n---\n\n# Find Skills\n",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = await installVercelSkillsForAgent({
|
|
163
|
+
agentId: "ag_vercel",
|
|
164
|
+
packageSpec: "https://github.com/vercel-labs/skills",
|
|
165
|
+
skills: ["find-skills"],
|
|
166
|
+
runtime: "codex",
|
|
167
|
+
executor,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const installed = path.join(agentCodexHomeDir("ag_vercel"), "skills", "find-skills", "SKILL.md");
|
|
171
|
+
expect(existsSync(installed)).toBe(true);
|
|
172
|
+
expect(result.installed).toEqual([
|
|
173
|
+
{
|
|
174
|
+
name: "find-skills",
|
|
175
|
+
targets: ["codex"],
|
|
176
|
+
paths: [path.dirname(installed)],
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
expect(result.snapshot.skills.map((skill) => skill.name)).toContain("find-skills");
|
|
180
|
+
expect(executor).toHaveBeenCalledWith(
|
|
181
|
+
"npx",
|
|
182
|
+
expect.arrayContaining(["skills", "add", "https://github.com/vercel-labs/skills"]),
|
|
183
|
+
expect.objectContaining({ cwd: agentWorkspaceDir("ag_vercel") }),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rejects untrusted vercel package specs", async () => {
|
|
188
|
+
await expect(installVercelSkillsForAgent({
|
|
189
|
+
agentId: "ag_untrusted_vercel",
|
|
190
|
+
packageSpec: "attacker/skills",
|
|
191
|
+
runtime: "codex",
|
|
192
|
+
executor: vi.fn(),
|
|
193
|
+
})).rejects.toThrow(/unsupported vercel skills packageSpec/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("rejects symlinked files from vercel skill import without installing a skill dir", async () => {
|
|
197
|
+
const outside = mkdtempSync(path.join(tmpdir(), "skill-outside-"));
|
|
198
|
+
try {
|
|
199
|
+
writeFileSync(path.join(outside, "secret.txt"), "do not copy");
|
|
200
|
+
const executor = vi.fn(async (_command, _args, options) => {
|
|
201
|
+
const home = options.env?.HOME as string;
|
|
202
|
+
const skillDir = path.join(home, ".codex", "skills", "vercel-labs", "linked-skill");
|
|
203
|
+
mkdirSync(skillDir, { recursive: true });
|
|
204
|
+
writeFileSync(
|
|
205
|
+
path.join(skillDir, "SKILL.md"),
|
|
206
|
+
"---\nname: linked-skill\n---\n\n# Linked Skill\n",
|
|
207
|
+
);
|
|
208
|
+
symlinkSync(path.join(outside, "secret.txt"), path.join(skillDir, "secret.txt"));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await expect(installVercelSkillsForAgent({
|
|
212
|
+
agentId: "ag_vercel_symlink",
|
|
213
|
+
packageSpec: "https://github.com/vercel-labs/skills",
|
|
214
|
+
runtime: "codex",
|
|
215
|
+
executor,
|
|
216
|
+
})).rejects.toThrow(/rejects symlink/);
|
|
217
|
+
|
|
218
|
+
const skillDir = path.join(agentCodexHomeDir("ag_vercel_symlink"), "skills", "linked-skill");
|
|
219
|
+
expect(existsSync(skillDir)).toBe(false);
|
|
220
|
+
} finally {
|
|
221
|
+
rmSync(outside, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/cloud-daemon.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { CloudAuthManager, asUserAuthManager } from "./cloud-auth.js";
|
|
|
42
42
|
import type { CloudModeConfig } from "./cloud-mode.js";
|
|
43
43
|
import { buildCloudRunSettleHook } from "./cloud-settle.js";
|
|
44
44
|
import type { InstalledAgentInfo, OnAgentInstalledHook } from "./provision.js";
|
|
45
|
+
import type { SkillIndexOptions } from "./skill-index.js";
|
|
45
46
|
|
|
46
47
|
// Cloud daemons follow the same cadence as local — keeps dashboard
|
|
47
48
|
// "runtimes last detected" behavior identical across both kinds.
|
|
@@ -125,6 +126,15 @@ export async function startCloudDaemon(
|
|
|
125
126
|
const credentialPathByAgentId = new Map<string, string>();
|
|
126
127
|
const hubUrlByAgentId = new Map<string, string>();
|
|
127
128
|
const displayNameByAgent = new Map<string, string>();
|
|
129
|
+
const runtimeByAgentId = new Map<string, string>();
|
|
130
|
+
const hermesProfileByAgentId = new Map<string, string>();
|
|
131
|
+
const skillIndexOptionsForAgent = (agentId: string): SkillIndexOptions => {
|
|
132
|
+
const hermesProfile = hermesProfileByAgentId.get(agentId);
|
|
133
|
+
return {
|
|
134
|
+
runtime: runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter,
|
|
135
|
+
...(hermesProfile ? { hermesProfile } : {}),
|
|
136
|
+
};
|
|
137
|
+
};
|
|
128
138
|
// Seed each per-agent hub URL with the cloud-mode value so that even
|
|
129
139
|
// before the first credential file is written the room-context fetcher
|
|
130
140
|
// has somewhere sensible to point.
|
|
@@ -235,15 +245,15 @@ export async function startCloudDaemon(
|
|
|
235
245
|
};
|
|
236
246
|
|
|
237
247
|
const installedAgentIds = new Set<string>();
|
|
238
|
-
const runtimeByAgentId = new Map<string, string>();
|
|
239
248
|
let controlChannel: ControlChannel | null = null;
|
|
240
249
|
const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
|
|
241
250
|
if (!controlChannel) return;
|
|
242
|
-
const
|
|
243
|
-
const pushed = pushAgentSkillSnapshot(controlChannel, agentId,
|
|
251
|
+
const skillIndexOptions = skillIndexOptionsForAgent(agentId);
|
|
252
|
+
const pushed = pushAgentSkillSnapshot(controlChannel, agentId, skillIndexOptions);
|
|
244
253
|
logger.info("cloud control-channel: agent_skill_snapshot pushed", {
|
|
245
254
|
agentId,
|
|
246
|
-
runtime,
|
|
255
|
+
runtime: skillIndexOptions.runtime,
|
|
256
|
+
hermesProfile: skillIndexOptions.hermesProfile ?? null,
|
|
247
257
|
reason,
|
|
248
258
|
ok: pushed,
|
|
249
259
|
});
|
|
@@ -252,6 +262,8 @@ export async function startCloudDaemon(
|
|
|
252
262
|
const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
|
|
253
263
|
installedAgentIds.add(info.agentId);
|
|
254
264
|
if (info.runtime) runtimeByAgentId.set(info.agentId, info.runtime);
|
|
265
|
+
if (info.hermesProfile) hermesProfileByAgentId.set(info.agentId, info.hermesProfile);
|
|
266
|
+
else if (info.runtime) hermesProfileByAgentId.delete(info.agentId);
|
|
255
267
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
256
268
|
if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
257
269
|
if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
|
|
@@ -262,6 +274,7 @@ export async function startCloudDaemon(
|
|
|
262
274
|
agentId: info.agentId,
|
|
263
275
|
activityTracker,
|
|
264
276
|
roomContextBuilder,
|
|
277
|
+
skillIndexOptions: () => skillIndexOptionsForAgent(info.agentId),
|
|
265
278
|
// Cloud daemons run isolated — no loop-risk guard wired in PR1;
|
|
266
279
|
// the runtime adapter's wall-time budget enforces the equivalent.
|
|
267
280
|
loopRiskBuilder: () => null,
|