@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
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Hoisted mock for `../config.js` so reloadConfig / setRoute don't read the
|
|
4
|
+
// real `~/.botcord/daemon/config.json`. Bound to `mockState` so each test
|
|
5
|
+
// can rewrite the in-memory config and observe saves.
|
|
6
|
+
const mockState = {
|
|
7
|
+
cfg: {
|
|
8
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
9
|
+
routes: [],
|
|
10
|
+
streamBlocks: true,
|
|
11
|
+
} as Record<string, unknown>,
|
|
12
|
+
saved: [] as Array<Record<string, unknown>>,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
vi.mock("../config.js", async () => {
|
|
16
|
+
const actual = await vi.importActual<typeof import("../config.js")>("../config.js");
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
loadConfig: () => JSON.parse(JSON.stringify(mockState.cfg)) as Record<string, unknown>,
|
|
20
|
+
saveConfig: (next: Record<string, unknown>) => {
|
|
21
|
+
mockState.cfg = JSON.parse(JSON.stringify(next)) as Record<string, unknown>;
|
|
22
|
+
mockState.saved.push(JSON.parse(JSON.stringify(next)) as Record<string, unknown>);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
addAgentToConfig,
|
|
29
|
+
removeAgentFromConfig,
|
|
30
|
+
reloadConfig,
|
|
31
|
+
setRoute,
|
|
32
|
+
createProvisioner,
|
|
33
|
+
} = await import("../provision.js");
|
|
34
|
+
const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
|
|
35
|
+
import type { DaemonConfig } from "../config.js";
|
|
36
|
+
import type {
|
|
37
|
+
GatewayChannelConfig,
|
|
38
|
+
GatewayRoute,
|
|
39
|
+
GatewayRuntimeSnapshot,
|
|
40
|
+
} from "../gateway/index.js";
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
mockState.cfg = {
|
|
44
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
45
|
+
routes: [],
|
|
46
|
+
streamBlocks: true,
|
|
47
|
+
};
|
|
48
|
+
mockState.saved = [];
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function baseConfig(overrides: Partial<DaemonConfig> = {}): DaemonConfig {
|
|
52
|
+
return {
|
|
53
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
54
|
+
routes: [],
|
|
55
|
+
streamBlocks: true,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("addAgentToConfig / removeAgentFromConfig", () => {
|
|
61
|
+
it("adds a new agent to an empty config", () => {
|
|
62
|
+
const cfg = baseConfig();
|
|
63
|
+
const next = addAgentToConfig(cfg, "ag_a");
|
|
64
|
+
expect(next?.agents).toEqual(["ag_a"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("no-ops when the agent is already present", () => {
|
|
68
|
+
const cfg = baseConfig({ agents: ["ag_a"] });
|
|
69
|
+
expect(addAgentToConfig(cfg, "ag_a")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("absorbs a legacy scalar agentId into the new agents array", () => {
|
|
73
|
+
const cfg = baseConfig({ agentId: "ag_legacy" });
|
|
74
|
+
const next = addAgentToConfig(cfg, "ag_new");
|
|
75
|
+
expect(next?.agents).toEqual(["ag_legacy", "ag_new"]);
|
|
76
|
+
expect(next?.agentId).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("removes an agent from the list", () => {
|
|
80
|
+
const cfg = baseConfig({ agents: ["ag_a", "ag_b"] });
|
|
81
|
+
const next = removeAgentFromConfig(cfg, "ag_a");
|
|
82
|
+
expect(next?.agents).toEqual(["ag_b"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("drops the legacy scalar if it matches the removed agent", () => {
|
|
86
|
+
const cfg = baseConfig({ agentId: "ag_legacy" });
|
|
87
|
+
const next = removeAgentFromConfig(cfg, "ag_legacy");
|
|
88
|
+
expect(next?.agentId).toBeUndefined();
|
|
89
|
+
expect(next?.agents).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("no-ops when the agent isn't configured", () => {
|
|
93
|
+
const cfg = baseConfig({ agents: ["ag_a"] });
|
|
94
|
+
expect(removeAgentFromConfig(cfg, "ag_nope")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
interface FakeGateway {
|
|
99
|
+
addChannel: ReturnType<typeof vi.fn>;
|
|
100
|
+
removeChannel: ReturnType<typeof vi.fn>;
|
|
101
|
+
upsertManagedRoute: ReturnType<typeof vi.fn>;
|
|
102
|
+
removeManagedRoute: ReturnType<typeof vi.fn>;
|
|
103
|
+
replaceManagedRoutes: ReturnType<typeof vi.fn>;
|
|
104
|
+
listManagedRoutes: () => GatewayRoute[];
|
|
105
|
+
snapshot: () => GatewayRuntimeSnapshot;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeFakeGateway(initialChannelIds: string[] = []): FakeGateway {
|
|
109
|
+
const channels = new Set(initialChannelIds);
|
|
110
|
+
const managed = new Map<string, GatewayRoute>();
|
|
111
|
+
return {
|
|
112
|
+
addChannel: vi.fn(async (cfg: GatewayChannelConfig) => {
|
|
113
|
+
channels.add(cfg.id);
|
|
114
|
+
}),
|
|
115
|
+
removeChannel: vi.fn(async (id: string) => {
|
|
116
|
+
channels.delete(id);
|
|
117
|
+
}),
|
|
118
|
+
upsertManagedRoute: vi.fn((accountId: string, route: GatewayRoute) => {
|
|
119
|
+
managed.set(accountId, route);
|
|
120
|
+
}),
|
|
121
|
+
removeManagedRoute: vi.fn((accountId: string) => {
|
|
122
|
+
managed.delete(accountId);
|
|
123
|
+
}),
|
|
124
|
+
replaceManagedRoutes: vi.fn((routes: Map<string, GatewayRoute>) => {
|
|
125
|
+
managed.clear();
|
|
126
|
+
for (const [id, route] of routes) managed.set(id, route);
|
|
127
|
+
}),
|
|
128
|
+
listManagedRoutes: (): GatewayRoute[] => Array.from(managed.values()),
|
|
129
|
+
snapshot: (): GatewayRuntimeSnapshot => ({
|
|
130
|
+
channels: Object.fromEntries(
|
|
131
|
+
[...channels].map((id) => [
|
|
132
|
+
id,
|
|
133
|
+
{
|
|
134
|
+
channel: id,
|
|
135
|
+
accountId: id,
|
|
136
|
+
running: true,
|
|
137
|
+
connected: true,
|
|
138
|
+
lastStartAt: 1700000000000,
|
|
139
|
+
},
|
|
140
|
+
]),
|
|
141
|
+
),
|
|
142
|
+
turns: {},
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("reload_config handler", () => {
|
|
148
|
+
it("adds agents listed in config but missing from gateway", async () => {
|
|
149
|
+
mockState.cfg = {
|
|
150
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
151
|
+
routes: [],
|
|
152
|
+
streamBlocks: true,
|
|
153
|
+
agents: ["ag_a", "ag_b"],
|
|
154
|
+
};
|
|
155
|
+
const gw = makeFakeGateway(["ag_a"]);
|
|
156
|
+
const res = await reloadConfig({ gateway: gw as unknown as Parameters<typeof reloadConfig>[0]["gateway"] });
|
|
157
|
+
expect(res.added).toEqual(["ag_b"]);
|
|
158
|
+
expect(res.removed).toEqual([]);
|
|
159
|
+
expect(gw.addChannel).toHaveBeenCalledOnce();
|
|
160
|
+
const addArg = gw.addChannel.mock.calls[0][0] as GatewayChannelConfig;
|
|
161
|
+
expect(addArg.id).toBe("ag_b");
|
|
162
|
+
expect(addArg.type).toBe("botcord");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("removes channels not listed in config", async () => {
|
|
166
|
+
mockState.cfg = {
|
|
167
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
168
|
+
routes: [],
|
|
169
|
+
streamBlocks: true,
|
|
170
|
+
agents: ["ag_a"],
|
|
171
|
+
};
|
|
172
|
+
const gw = makeFakeGateway(["ag_a", "ag_stale"]);
|
|
173
|
+
const res = await reloadConfig({ gateway: gw as unknown as Parameters<typeof reloadConfig>[0]["gateway"] });
|
|
174
|
+
expect(res.removed).toEqual(["ag_stale"]);
|
|
175
|
+
expect(res.added).toEqual([]);
|
|
176
|
+
expect(gw.removeChannel).toHaveBeenCalledWith("ag_stale", "reload_config");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns reloaded:true with empty diffs when in sync", async () => {
|
|
180
|
+
mockState.cfg = {
|
|
181
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
182
|
+
routes: [],
|
|
183
|
+
streamBlocks: true,
|
|
184
|
+
agents: ["ag_a"],
|
|
185
|
+
};
|
|
186
|
+
const gw = makeFakeGateway(["ag_a"]);
|
|
187
|
+
const res = await reloadConfig({ gateway: gw as unknown as Parameters<typeof reloadConfig>[0]["gateway"] });
|
|
188
|
+
expect(res).toEqual({ reloaded: true, added: [], removed: [] });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("rebuilds managed routes — legacy credential without cwd gets workspace fallback", async () => {
|
|
192
|
+
const os = await import("node:os");
|
|
193
|
+
const fs = await import("node:fs");
|
|
194
|
+
const nodePath = await import("node:path");
|
|
195
|
+
const { agentWorkspaceDir } = await import("../agent-workspace.js");
|
|
196
|
+
|
|
197
|
+
const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-reload-"));
|
|
198
|
+
const prevHome = process.env.HOME;
|
|
199
|
+
process.env.HOME = tmp;
|
|
200
|
+
try {
|
|
201
|
+
// Plant a legacy credentials file with NO `cwd` field — mimics an
|
|
202
|
+
// agent provisioned before the per-agent-workspace feature shipped.
|
|
203
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
204
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
205
|
+
fs.writeFileSync(
|
|
206
|
+
nodePath.join(credDir, "ag_legacy.json"),
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
agentId: "ag_legacy",
|
|
209
|
+
keyId: "k_legacy",
|
|
210
|
+
privateKey: Buffer.alloc(32, 3).toString("base64"),
|
|
211
|
+
hubUrl: "https://hub.example",
|
|
212
|
+
// Deliberately omit `runtime` + `cwd` — the fallback path is
|
|
213
|
+
// what we're exercising.
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
mockState.cfg = {
|
|
218
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
219
|
+
routes: [
|
|
220
|
+
// Operator-authored route for a different accountId — must
|
|
221
|
+
// survive reload untouched (plan §10.5 property).
|
|
222
|
+
{ match: { accountId: "ag_user_pinned" }, adapter: "codex", cwd: "/src" },
|
|
223
|
+
],
|
|
224
|
+
streamBlocks: true,
|
|
225
|
+
agents: ["ag_legacy"],
|
|
226
|
+
};
|
|
227
|
+
const gw = makeFakeGateway(["ag_legacy"]);
|
|
228
|
+
const res = await reloadConfig({
|
|
229
|
+
gateway: gw as unknown as Parameters<typeof reloadConfig>[0]["gateway"],
|
|
230
|
+
});
|
|
231
|
+
expect(res.reloaded).toBe(true);
|
|
232
|
+
|
|
233
|
+
expect(gw.replaceManagedRoutes).toHaveBeenCalledOnce();
|
|
234
|
+
const passed = gw.replaceManagedRoutes.mock.calls[0][0] as Map<string, GatewayRoute>;
|
|
235
|
+
const legacy = passed.get("ag_legacy");
|
|
236
|
+
expect(legacy).toBeDefined();
|
|
237
|
+
expect(legacy!.cwd).toBe(agentWorkspaceDir("ag_legacy"));
|
|
238
|
+
expect(legacy!.runtime).toBe("claude-code"); // falls back to defaultRoute.runtime
|
|
239
|
+
// The user-authored route still lives in cfg.routes[] — not duplicated
|
|
240
|
+
// into the managed bucket even if accountIds overlapped.
|
|
241
|
+
expect(passed.has("ag_user_pinned")).toBe(false);
|
|
242
|
+
} finally {
|
|
243
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
244
|
+
else process.env.HOME = prevHome;
|
|
245
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("list_agents handler", () => {
|
|
251
|
+
it("returns running channels with status + lastMessageAt", async () => {
|
|
252
|
+
const gw = makeFakeGateway(["ag_a", "ag_b"]);
|
|
253
|
+
const provisioner = createProvisioner({
|
|
254
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
255
|
+
});
|
|
256
|
+
const ack = await provisioner({
|
|
257
|
+
id: "req_1",
|
|
258
|
+
type: CONTROL_FRAME_TYPES.LIST_AGENTS,
|
|
259
|
+
});
|
|
260
|
+
expect(ack.ok).toBe(true);
|
|
261
|
+
const result = ack.result as {
|
|
262
|
+
agents: Array<{ id: string; name: string; online: boolean; status: string }>;
|
|
263
|
+
};
|
|
264
|
+
const ids = result.agents.map((a) => a.id).sort();
|
|
265
|
+
expect(ids).toEqual(["ag_a", "ag_b"]);
|
|
266
|
+
for (const a of result.agents) {
|
|
267
|
+
expect(a.status).toBe("running");
|
|
268
|
+
expect(a.online).toBe(true);
|
|
269
|
+
expect(a.name).toBe(a.id);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("set_route handler", () => {
|
|
275
|
+
it("appends a new route pinned to the agent", () => {
|
|
276
|
+
const res = setRoute({
|
|
277
|
+
agentId: "ag_a",
|
|
278
|
+
route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp" },
|
|
279
|
+
});
|
|
280
|
+
expect(res.ok).toBe(true);
|
|
281
|
+
expect(res.inserted).toBe(true);
|
|
282
|
+
expect(mockState.saved.length).toBeGreaterThan(0);
|
|
283
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
284
|
+
expect(saved.routes).toHaveLength(1);
|
|
285
|
+
expect(saved.routes[0].match.accountId).toBe("ag_a");
|
|
286
|
+
expect(saved.routes[0].adapter).toBe("claude-code");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("replaces the agent-only route in place on second call", () => {
|
|
290
|
+
setRoute({ agentId: "ag_a", route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp" } });
|
|
291
|
+
const res2 = setRoute({
|
|
292
|
+
agentId: "ag_a",
|
|
293
|
+
route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp", extraArgs: ["--debug"] },
|
|
294
|
+
});
|
|
295
|
+
expect(res2.inserted).toBe(false);
|
|
296
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
297
|
+
expect(saved.routes).toHaveLength(1);
|
|
298
|
+
expect(saved.routes[0].extraArgs).toEqual(["--debug"]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("forces match.accountId to the agentId even when callers omit it", () => {
|
|
302
|
+
setRoute({
|
|
303
|
+
agentId: "ag_x",
|
|
304
|
+
route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp", match: { conversationPrefix: "rm_oc_" } },
|
|
305
|
+
});
|
|
306
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
307
|
+
expect(saved.routes[0].match.accountId).toBe("ag_x");
|
|
308
|
+
expect(saved.routes[0].match.conversationPrefix).toBe("rm_oc_");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("rejects routes whose cwd is outside $HOME", () => {
|
|
312
|
+
expect(() =>
|
|
313
|
+
setRoute({ agentId: "ag_a", route: { adapter: "claude-code", cwd: "/etc/passwd-dir" } }),
|
|
314
|
+
).toThrow(/outside the user home/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("rejects unknown adapters", () => {
|
|
318
|
+
expect(() =>
|
|
319
|
+
setRoute({ agentId: "ag_a", route: { adapter: "totally-fake", cwd: process.env.HOME ?? "/tmp" } }),
|
|
320
|
+
).toThrow(/unknown adapter/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("requires agentId and route", () => {
|
|
324
|
+
expect(() => setRoute({})).toThrow(/agentId/);
|
|
325
|
+
expect(() => setRoute({ agentId: "ag_a" })).toThrow(/route/);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("accepts the contract's {agentId, pattern} shape", () => {
|
|
329
|
+
// Use the daemon's default cwd ($HOME) so safe-cwd validation passes.
|
|
330
|
+
mockState.cfg = {
|
|
331
|
+
defaultRoute: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp" },
|
|
332
|
+
routes: [],
|
|
333
|
+
streamBlocks: true,
|
|
334
|
+
};
|
|
335
|
+
const res = setRoute({ agentId: "ag_a", pattern: "rm_oc_" });
|
|
336
|
+
expect(res.ok).toBe(true);
|
|
337
|
+
expect(res.inserted).toBe(true);
|
|
338
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
339
|
+
expect(saved.routes[0].match.accountId).toBe("ag_a");
|
|
340
|
+
expect(saved.routes[0].match.conversationPrefix).toBe("rm_oc_");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// provision_agent runtime / cwd persistence
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe("provision_agent handler writes runtime + cwd", () => {
|
|
349
|
+
it("persists runtime and cwd from the credentials envelope to the credentials file", async () => {
|
|
350
|
+
const os = await import("node:os");
|
|
351
|
+
const fs = await import("node:fs");
|
|
352
|
+
const nodePath = await import("node:path");
|
|
353
|
+
|
|
354
|
+
// Redirect $HOME so writeCredentialsFile lands in a sandbox.
|
|
355
|
+
const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-provision-"));
|
|
356
|
+
const prevHome = process.env.HOME;
|
|
357
|
+
process.env.HOME = tmp;
|
|
358
|
+
try {
|
|
359
|
+
const gw = makeFakeGateway();
|
|
360
|
+
const provisioner = createProvisioner({
|
|
361
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// A valid 32-byte Ed25519 seed → deterministic keypair. Any fresh
|
|
365
|
+
// 32-byte b64 works; writeCredentialsFile cross-checks publicKey if
|
|
366
|
+
// provided. Here we let the provisioner derive it from privateKey.
|
|
367
|
+
const privateKey = Buffer.alloc(32, 7).toString("base64");
|
|
368
|
+
|
|
369
|
+
const ack = await provisioner({
|
|
370
|
+
id: "req_prov",
|
|
371
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
372
|
+
params: {
|
|
373
|
+
runtime: "claude-code",
|
|
374
|
+
cwd: tmp,
|
|
375
|
+
credentials: {
|
|
376
|
+
agentId: "ag_runtime",
|
|
377
|
+
keyId: "k_runtime",
|
|
378
|
+
privateKey,
|
|
379
|
+
hubUrl: "https://hub.example",
|
|
380
|
+
displayName: "writer",
|
|
381
|
+
runtime: "claude-code",
|
|
382
|
+
cwd: tmp,
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
expect(ack.ok).toBe(true);
|
|
388
|
+
expect(gw.addChannel).toHaveBeenCalledOnce();
|
|
389
|
+
|
|
390
|
+
// File written with runtime + cwd fields preserved.
|
|
391
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_runtime.json");
|
|
392
|
+
const raw = fs.readFileSync(credFile, "utf8");
|
|
393
|
+
const saved = JSON.parse(raw) as Record<string, unknown>;
|
|
394
|
+
expect(saved.runtime).toBe("claude-code");
|
|
395
|
+
expect(saved.cwd).toBe(tmp);
|
|
396
|
+
expect(saved.hubUrl).toBe("https://hub.example");
|
|
397
|
+
} finally {
|
|
398
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
399
|
+
else process.env.HOME = prevHome;
|
|
400
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("rejects unknown runtime ids before touching disk", async () => {
|
|
405
|
+
const gw = makeFakeGateway();
|
|
406
|
+
const provisioner = createProvisioner({
|
|
407
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
408
|
+
});
|
|
409
|
+
const privateKey = Buffer.alloc(32, 9).toString("base64");
|
|
410
|
+
await expect(
|
|
411
|
+
provisioner({
|
|
412
|
+
id: "req_bad",
|
|
413
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
414
|
+
params: {
|
|
415
|
+
runtime: "totally-fake",
|
|
416
|
+
credentials: {
|
|
417
|
+
agentId: "ag_bad",
|
|
418
|
+
keyId: "k_bad",
|
|
419
|
+
privateKey,
|
|
420
|
+
hubUrl: "https://hub.example",
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
}),
|
|
424
|
+
).rejects.toThrow(/unknown runtime/);
|
|
425
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("accepts the deprecated `adapter` alias from older Hub builds", async () => {
|
|
429
|
+
const os = await import("node:os");
|
|
430
|
+
const fs = await import("node:fs");
|
|
431
|
+
const nodePath = await import("node:path");
|
|
432
|
+
const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-provision-"));
|
|
433
|
+
const prevHome = process.env.HOME;
|
|
434
|
+
process.env.HOME = tmp;
|
|
435
|
+
try {
|
|
436
|
+
const gw = makeFakeGateway();
|
|
437
|
+
const provisioner = createProvisioner({
|
|
438
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
439
|
+
});
|
|
440
|
+
const privateKey = Buffer.alloc(32, 11).toString("base64");
|
|
441
|
+
const ack = await provisioner({
|
|
442
|
+
id: "req_alias",
|
|
443
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
444
|
+
params: {
|
|
445
|
+
adapter: "claude-code",
|
|
446
|
+
credentials: {
|
|
447
|
+
agentId: "ag_alias",
|
|
448
|
+
keyId: "k_alias",
|
|
449
|
+
privateKey,
|
|
450
|
+
hubUrl: "https://hub.example",
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
expect(ack.ok).toBe(true);
|
|
455
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_alias.json");
|
|
456
|
+
const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
|
|
457
|
+
expect(saved.runtime).toBe("claude-code");
|
|
458
|
+
} finally {
|
|
459
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
460
|
+
else process.env.HOME = prevHome;
|
|
461
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// provision_agent workspace seeding + managed-route hot-add (plan §7, §10.3)
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
interface SandboxFixture {
|
|
471
|
+
tmp: string;
|
|
472
|
+
prevHome: string | undefined;
|
|
473
|
+
fs: typeof import("node:fs");
|
|
474
|
+
path: typeof import("node:path");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function withSandboxHome<T>(run: (sbx: SandboxFixture) => Promise<T>): Promise<T> {
|
|
478
|
+
const os = await import("node:os");
|
|
479
|
+
const fs = await import("node:fs");
|
|
480
|
+
const nodePath = await import("node:path");
|
|
481
|
+
const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-provision-"));
|
|
482
|
+
const prevHome = process.env.HOME;
|
|
483
|
+
process.env.HOME = tmp;
|
|
484
|
+
try {
|
|
485
|
+
return await run({ tmp, prevHome, fs, path: nodePath });
|
|
486
|
+
} finally {
|
|
487
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
488
|
+
else process.env.HOME = prevHome;
|
|
489
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const SEED_FILES = ["AGENTS.md", "CLAUDE.md", "identity.md", "memory.md", "task.md"];
|
|
494
|
+
|
|
495
|
+
describe("provision_agent seeds workspace + hot-adds managed route", () => {
|
|
496
|
+
it("defaults cwd to agentWorkspaceDir on the fast path (Hub-supplied credentials)", async () => {
|
|
497
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
498
|
+
const gw = makeFakeGateway();
|
|
499
|
+
const provisioner = createProvisioner({
|
|
500
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
501
|
+
});
|
|
502
|
+
const privateKey = Buffer.alloc(32, 13).toString("base64");
|
|
503
|
+
const ack = await provisioner({
|
|
504
|
+
id: "req_fast",
|
|
505
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
506
|
+
params: {
|
|
507
|
+
credentials: {
|
|
508
|
+
agentId: "ag_fast",
|
|
509
|
+
keyId: "k_fast",
|
|
510
|
+
privateKey,
|
|
511
|
+
hubUrl: "https://hub.example",
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
expect(ack.ok).toBe(true);
|
|
516
|
+
const expectedCwd = nodePath.join(tmp, ".botcord", "agents", "ag_fast", "workspace");
|
|
517
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_fast.json");
|
|
518
|
+
const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
|
|
519
|
+
expect(saved.cwd).toBe(expectedCwd);
|
|
520
|
+
expect(fs.existsSync(expectedCwd)).toBe(true);
|
|
521
|
+
for (const f of SEED_FILES) {
|
|
522
|
+
expect(fs.existsSync(nodePath.join(expectedCwd, f))).toBe(true);
|
|
523
|
+
}
|
|
524
|
+
// State dir and notes/.gitkeep also created.
|
|
525
|
+
expect(fs.existsSync(nodePath.join(tmp, ".botcord", "agents", "ag_fast", "state"))).toBe(true);
|
|
526
|
+
expect(fs.existsSync(nodePath.join(expectedCwd, "notes", ".gitkeep"))).toBe(true);
|
|
527
|
+
// Managed route hot-added.
|
|
528
|
+
const routes = gw.listManagedRoutes();
|
|
529
|
+
expect(routes).toHaveLength(1);
|
|
530
|
+
expect(routes[0].match?.accountId).toBe("ag_fast");
|
|
531
|
+
expect(routes[0].cwd).toBe(expectedCwd);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("defaults cwd to agentWorkspaceDir on the slow path (daemon register)", async () => {
|
|
536
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
537
|
+
// Seed an existing credential so `inferHubUrl` finds a hubUrl.
|
|
538
|
+
// Omit publicKey — loadStoredCredentials derives it and cross-checks
|
|
539
|
+
// when present. Supplying a mismatched pair would make inferHubUrl
|
|
540
|
+
// silently skip the file.
|
|
541
|
+
const existingCreds = {
|
|
542
|
+
version: 1,
|
|
543
|
+
hubUrl: "https://hub.example",
|
|
544
|
+
agentId: "ag_existing",
|
|
545
|
+
keyId: "k_e",
|
|
546
|
+
privateKey: Buffer.alloc(32, 3).toString("base64"),
|
|
547
|
+
savedAt: new Date().toISOString(),
|
|
548
|
+
};
|
|
549
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
550
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
551
|
+
fs.writeFileSync(
|
|
552
|
+
nodePath.join(credDir, "ag_existing.json"),
|
|
553
|
+
JSON.stringify(existingCreds),
|
|
554
|
+
);
|
|
555
|
+
mockState.cfg = {
|
|
556
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
557
|
+
routes: [],
|
|
558
|
+
streamBlocks: true,
|
|
559
|
+
agents: ["ag_existing"],
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const registered = {
|
|
563
|
+
agentId: "ag_slow",
|
|
564
|
+
keyId: "k_slow",
|
|
565
|
+
privateKey: Buffer.alloc(32, 21).toString("base64"),
|
|
566
|
+
publicKey: Buffer.alloc(32, 22).toString("base64"),
|
|
567
|
+
hubUrl: "https://hub.example",
|
|
568
|
+
token: "tok",
|
|
569
|
+
expiresAt: Date.now() + 60_000,
|
|
570
|
+
};
|
|
571
|
+
const register = vi.fn(async () => registered);
|
|
572
|
+
|
|
573
|
+
const gw = makeFakeGateway();
|
|
574
|
+
const provisioner = createProvisioner({
|
|
575
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
576
|
+
register: register as unknown as Parameters<typeof createProvisioner>[0]["register"],
|
|
577
|
+
});
|
|
578
|
+
const ack = await provisioner({
|
|
579
|
+
id: "req_slow",
|
|
580
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
581
|
+
params: { name: "slow-agent" },
|
|
582
|
+
});
|
|
583
|
+
expect(ack.ok).toBe(true);
|
|
584
|
+
const expectedCwd = nodePath.join(tmp, ".botcord", "agents", "ag_slow", "workspace");
|
|
585
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_slow.json");
|
|
586
|
+
const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
|
|
587
|
+
expect(saved.cwd).toBe(expectedCwd);
|
|
588
|
+
for (const f of SEED_FILES) {
|
|
589
|
+
expect(fs.existsSync(nodePath.join(expectedCwd, f))).toBe(true);
|
|
590
|
+
}
|
|
591
|
+
const routes = gw.listManagedRoutes();
|
|
592
|
+
expect(routes).toHaveLength(1);
|
|
593
|
+
expect(routes[0].cwd).toBe(expectedCwd);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("honors an explicit params.cwd override while still seeding the workspace", async () => {
|
|
598
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
599
|
+
const override = nodePath.join(tmp, "project-dir");
|
|
600
|
+
fs.mkdirSync(override, { recursive: true });
|
|
601
|
+
const gw = makeFakeGateway();
|
|
602
|
+
const provisioner = createProvisioner({
|
|
603
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
604
|
+
});
|
|
605
|
+
const privateKey = Buffer.alloc(32, 17).toString("base64");
|
|
606
|
+
const ack = await provisioner({
|
|
607
|
+
id: "req_override",
|
|
608
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
609
|
+
params: {
|
|
610
|
+
cwd: override,
|
|
611
|
+
credentials: {
|
|
612
|
+
agentId: "ag_override",
|
|
613
|
+
keyId: "k_o",
|
|
614
|
+
privateKey,
|
|
615
|
+
hubUrl: "https://hub.example",
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
expect(ack.ok).toBe(true);
|
|
620
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_override.json");
|
|
621
|
+
const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
|
|
622
|
+
expect(saved.cwd).toBe(override);
|
|
623
|
+
// Workspace still exists even when runtime cwd points elsewhere.
|
|
624
|
+
const wsDir = nodePath.join(tmp, ".botcord", "agents", "ag_override", "workspace");
|
|
625
|
+
for (const f of SEED_FILES) {
|
|
626
|
+
expect(fs.existsSync(nodePath.join(wsDir, f))).toBe(true);
|
|
627
|
+
}
|
|
628
|
+
const routes = gw.listManagedRoutes();
|
|
629
|
+
expect(routes[0].cwd).toBe(override);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("rejects params.credentials.cwd outside $HOME before any disk write", async () => {
|
|
634
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
635
|
+
const gw = makeFakeGateway();
|
|
636
|
+
const provisioner = createProvisioner({
|
|
637
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
638
|
+
});
|
|
639
|
+
const privateKey = Buffer.alloc(32, 19).toString("base64");
|
|
640
|
+
await expect(
|
|
641
|
+
provisioner({
|
|
642
|
+
id: "req_smuggle",
|
|
643
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
644
|
+
params: {
|
|
645
|
+
credentials: {
|
|
646
|
+
agentId: "ag_smuggle",
|
|
647
|
+
keyId: "k_s",
|
|
648
|
+
privateKey,
|
|
649
|
+
hubUrl: "https://hub.example",
|
|
650
|
+
cwd: "/etc",
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
}),
|
|
654
|
+
).rejects.toThrow(/outside the user home/);
|
|
655
|
+
// Credentials file must not have been written.
|
|
656
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_smuggle.json");
|
|
657
|
+
expect(fs.existsSync(credFile)).toBe(false);
|
|
658
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
659
|
+
expect(gw.listManagedRoutes()).toHaveLength(0);
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("unlinks the credentials file when ensureAgentWorkspace fails", async () => {
|
|
664
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
665
|
+
// Pre-create a FILE at the workspace dir path so mkdirSync(recursive)
|
|
666
|
+
// throws ENOTDIR — forcing ensureAgentWorkspace to propagate.
|
|
667
|
+
const agentDir = nodePath.join(tmp, ".botcord", "agents", "ag_blocked");
|
|
668
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
669
|
+
fs.writeFileSync(nodePath.join(agentDir, "workspace"), "blocker");
|
|
670
|
+
|
|
671
|
+
const gw = makeFakeGateway();
|
|
672
|
+
const provisioner = createProvisioner({
|
|
673
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
674
|
+
});
|
|
675
|
+
const privateKey = Buffer.alloc(32, 23).toString("base64");
|
|
676
|
+
await expect(
|
|
677
|
+
provisioner({
|
|
678
|
+
id: "req_rollback",
|
|
679
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
680
|
+
params: {
|
|
681
|
+
credentials: {
|
|
682
|
+
agentId: "ag_blocked",
|
|
683
|
+
keyId: "k_b",
|
|
684
|
+
privateKey,
|
|
685
|
+
hubUrl: "https://hub.example",
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
}),
|
|
689
|
+
).rejects.toThrow();
|
|
690
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_blocked.json");
|
|
691
|
+
expect(fs.existsSync(credFile)).toBe(false);
|
|
692
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
693
|
+
expect(gw.listManagedRoutes()).toHaveLength(0);
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("rolls back the managed route when addChannel fails", async () => {
|
|
698
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
699
|
+
const gw = makeFakeGateway();
|
|
700
|
+
gw.addChannel.mockImplementationOnce(async () => {
|
|
701
|
+
throw new Error("channel boom");
|
|
702
|
+
});
|
|
703
|
+
const provisioner = createProvisioner({
|
|
704
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
705
|
+
});
|
|
706
|
+
const privateKey = Buffer.alloc(32, 29).toString("base64");
|
|
707
|
+
await expect(
|
|
708
|
+
provisioner({
|
|
709
|
+
id: "req_ch_fail",
|
|
710
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
711
|
+
params: {
|
|
712
|
+
credentials: {
|
|
713
|
+
agentId: "ag_chfail",
|
|
714
|
+
keyId: "k_c",
|
|
715
|
+
privateKey,
|
|
716
|
+
hubUrl: "https://hub.example",
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
}),
|
|
720
|
+
).rejects.toThrow(/channel boom/);
|
|
721
|
+
// Credentials unlinked; managed route never added (addChannel threw
|
|
722
|
+
// before the upsert step).
|
|
723
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_chfail.json");
|
|
724
|
+
expect(fs.existsSync(credFile)).toBe(false);
|
|
725
|
+
expect(gw.listManagedRoutes()).toHaveLength(0);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
// revoke_agent — new flag semantics (plan §11.3)
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
function seedAgentOnDisk(
|
|
735
|
+
fs: typeof import("node:fs"),
|
|
736
|
+
nodePath: typeof import("node:path"),
|
|
737
|
+
tmp: string,
|
|
738
|
+
agentId: string,
|
|
739
|
+
): { credFile: string; workspaceDir: string; stateDir: string; homeDir: string } {
|
|
740
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
741
|
+
const homeDir = nodePath.join(tmp, ".botcord", "agents", agentId);
|
|
742
|
+
const workspaceDir = nodePath.join(homeDir, "workspace");
|
|
743
|
+
const stateDir = nodePath.join(homeDir, "state");
|
|
744
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
745
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
746
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
747
|
+
const credFile = nodePath.join(credDir, `${agentId}.json`);
|
|
748
|
+
fs.writeFileSync(
|
|
749
|
+
credFile,
|
|
750
|
+
JSON.stringify({
|
|
751
|
+
version: 1,
|
|
752
|
+
hubUrl: "https://hub.example",
|
|
753
|
+
agentId,
|
|
754
|
+
keyId: `k_${agentId}`,
|
|
755
|
+
privateKey: Buffer.alloc(32, 41).toString("base64"),
|
|
756
|
+
publicKey: Buffer.alloc(32, 42).toString("base64"),
|
|
757
|
+
savedAt: new Date().toISOString(),
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
760
|
+
fs.writeFileSync(nodePath.join(workspaceDir, "memory.md"), "# Memory\nprecious\n");
|
|
761
|
+
fs.writeFileSync(nodePath.join(stateDir, "working-memory.json"), "{}");
|
|
762
|
+
return { credFile, workspaceDir, stateDir, homeDir };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
describe("revoke_agent respects deleteState / deleteWorkspace flags", () => {
|
|
766
|
+
it("default flags: credentials deleted, state deleted, workspace preserved", async () => {
|
|
767
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
768
|
+
const { credFile, workspaceDir, stateDir } = seedAgentOnDisk(
|
|
769
|
+
fs,
|
|
770
|
+
nodePath,
|
|
771
|
+
tmp,
|
|
772
|
+
"ag_default",
|
|
773
|
+
);
|
|
774
|
+
const gw = makeFakeGateway(["ag_default"]);
|
|
775
|
+
const provisioner = createProvisioner({
|
|
776
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
777
|
+
});
|
|
778
|
+
const ack = await provisioner({
|
|
779
|
+
id: "req_rev_default",
|
|
780
|
+
type: CONTROL_FRAME_TYPES.REVOKE_AGENT,
|
|
781
|
+
params: { agentId: "ag_default" },
|
|
782
|
+
});
|
|
783
|
+
expect(ack.ok).toBe(true);
|
|
784
|
+
const result = ack.result as {
|
|
785
|
+
agentId: string;
|
|
786
|
+
credentialsDeleted: boolean;
|
|
787
|
+
stateDeleted: boolean;
|
|
788
|
+
workspaceDeleted: boolean;
|
|
789
|
+
};
|
|
790
|
+
expect(result.credentialsDeleted).toBe(true);
|
|
791
|
+
expect(result.stateDeleted).toBe(true);
|
|
792
|
+
expect(result.workspaceDeleted).toBe(false);
|
|
793
|
+
expect(fs.existsSync(credFile)).toBe(false);
|
|
794
|
+
expect(fs.existsSync(stateDir)).toBe(false);
|
|
795
|
+
expect(fs.existsSync(workspaceDir)).toBe(true);
|
|
796
|
+
expect(gw.removeManagedRoute).toHaveBeenCalledWith("ag_default");
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("deleteCredentials:false keeps everything on disk but still revokes the channel + managed route", async () => {
|
|
801
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
802
|
+
const { credFile, workspaceDir, stateDir } = seedAgentOnDisk(
|
|
803
|
+
fs,
|
|
804
|
+
nodePath,
|
|
805
|
+
tmp,
|
|
806
|
+
"ag_keep",
|
|
807
|
+
);
|
|
808
|
+
const gw = makeFakeGateway(["ag_keep"]);
|
|
809
|
+
const provisioner = createProvisioner({
|
|
810
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
811
|
+
});
|
|
812
|
+
const ack = await provisioner({
|
|
813
|
+
id: "req_rev_keep",
|
|
814
|
+
type: CONTROL_FRAME_TYPES.REVOKE_AGENT,
|
|
815
|
+
params: { agentId: "ag_keep", deleteCredentials: false },
|
|
816
|
+
});
|
|
817
|
+
expect(ack.ok).toBe(true);
|
|
818
|
+
const result = ack.result as {
|
|
819
|
+
credentialsDeleted: boolean;
|
|
820
|
+
stateDeleted: boolean;
|
|
821
|
+
workspaceDeleted: boolean;
|
|
822
|
+
};
|
|
823
|
+
expect(result.credentialsDeleted).toBe(false);
|
|
824
|
+
expect(result.stateDeleted).toBe(false);
|
|
825
|
+
expect(result.workspaceDeleted).toBe(false);
|
|
826
|
+
expect(fs.existsSync(credFile)).toBe(true);
|
|
827
|
+
expect(fs.existsSync(stateDir)).toBe(true);
|
|
828
|
+
expect(fs.existsSync(workspaceDir)).toBe(true);
|
|
829
|
+
// Channel + managed route cleanup still runs unconditionally.
|
|
830
|
+
expect(gw.removeChannel).toHaveBeenCalledWith("ag_keep", "revoked by hub");
|
|
831
|
+
expect(gw.removeManagedRoute).toHaveBeenCalledWith("ag_keep");
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("deleteWorkspace:true removes the entire agent home (subsumes state)", async () => {
|
|
836
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
837
|
+
const { credFile, homeDir } = seedAgentOnDisk(fs, nodePath, tmp, "ag_wipe");
|
|
838
|
+
const gw = makeFakeGateway(["ag_wipe"]);
|
|
839
|
+
const provisioner = createProvisioner({
|
|
840
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
841
|
+
});
|
|
842
|
+
const ack = await provisioner({
|
|
843
|
+
id: "req_rev_wipe",
|
|
844
|
+
type: CONTROL_FRAME_TYPES.REVOKE_AGENT,
|
|
845
|
+
params: { agentId: "ag_wipe", deleteWorkspace: true },
|
|
846
|
+
});
|
|
847
|
+
expect(ack.ok).toBe(true);
|
|
848
|
+
const result = ack.result as {
|
|
849
|
+
credentialsDeleted: boolean;
|
|
850
|
+
stateDeleted: boolean;
|
|
851
|
+
workspaceDeleted: boolean;
|
|
852
|
+
};
|
|
853
|
+
expect(result.credentialsDeleted).toBe(true);
|
|
854
|
+
expect(result.stateDeleted).toBe(true);
|
|
855
|
+
expect(result.workspaceDeleted).toBe(true);
|
|
856
|
+
expect(fs.existsSync(credFile)).toBe(false);
|
|
857
|
+
expect(fs.existsSync(homeDir)).toBe(false);
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("deleteState:false with deleteCredentials:true keeps state + workspace", async () => {
|
|
862
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
863
|
+
const { credFile, workspaceDir, stateDir } = seedAgentOnDisk(
|
|
864
|
+
fs,
|
|
865
|
+
nodePath,
|
|
866
|
+
tmp,
|
|
867
|
+
"ag_keepstate",
|
|
868
|
+
);
|
|
869
|
+
const gw = makeFakeGateway(["ag_keepstate"]);
|
|
870
|
+
const provisioner = createProvisioner({
|
|
871
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
872
|
+
});
|
|
873
|
+
const ack = await provisioner({
|
|
874
|
+
id: "req_rev_keepstate",
|
|
875
|
+
type: CONTROL_FRAME_TYPES.REVOKE_AGENT,
|
|
876
|
+
params: { agentId: "ag_keepstate", deleteCredentials: true, deleteState: false },
|
|
877
|
+
});
|
|
878
|
+
expect(ack.ok).toBe(true);
|
|
879
|
+
const result = ack.result as {
|
|
880
|
+
credentialsDeleted: boolean;
|
|
881
|
+
stateDeleted: boolean;
|
|
882
|
+
workspaceDeleted: boolean;
|
|
883
|
+
};
|
|
884
|
+
expect(result.credentialsDeleted).toBe(true);
|
|
885
|
+
expect(result.stateDeleted).toBe(false);
|
|
886
|
+
expect(result.workspaceDeleted).toBe(false);
|
|
887
|
+
expect(fs.existsSync(credFile)).toBe(false);
|
|
888
|
+
expect(fs.existsSync(stateDir)).toBe(true);
|
|
889
|
+
expect(fs.existsSync(workspaceDir)).toBe(true);
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it("preserves an operator-authored cfg.routes[] entry with the same accountId", async () => {
|
|
894
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
895
|
+
seedAgentOnDisk(fs, nodePath, tmp, "ag_opkept");
|
|
896
|
+
mockState.cfg = {
|
|
897
|
+
defaultRoute: { adapter: "claude-code", cwd: tmp },
|
|
898
|
+
routes: [
|
|
899
|
+
{
|
|
900
|
+
match: { accountId: "ag_opkept" },
|
|
901
|
+
adapter: "claude-code",
|
|
902
|
+
cwd: tmp,
|
|
903
|
+
},
|
|
904
|
+
],
|
|
905
|
+
streamBlocks: true,
|
|
906
|
+
agents: ["ag_opkept"],
|
|
907
|
+
};
|
|
908
|
+
const gw = makeFakeGateway(["ag_opkept"]);
|
|
909
|
+
const provisioner = createProvisioner({
|
|
910
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
911
|
+
});
|
|
912
|
+
await provisioner({
|
|
913
|
+
id: "req_rev_op",
|
|
914
|
+
type: CONTROL_FRAME_TYPES.REVOKE_AGENT,
|
|
915
|
+
params: { agentId: "ag_opkept" },
|
|
916
|
+
});
|
|
917
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
918
|
+
expect(saved.routes).toHaveLength(1);
|
|
919
|
+
expect(saved.routes[0].match.accountId).toBe("ag_opkept");
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
});
|