@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,300 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import type { GatewayInboundMessage, GatewayLogger } from "../gateway/index.js";
|
|
12
|
+
import {
|
|
13
|
+
backfillBootAgents,
|
|
14
|
+
classifyActivitySender,
|
|
15
|
+
createActivityRecorder,
|
|
16
|
+
} from "../daemon.js";
|
|
17
|
+
import type { DiscoveredAgentCredential } from "../agent-discovery.js";
|
|
18
|
+
import { agentWorkspaceDir } from "../agent-workspace.js";
|
|
19
|
+
|
|
20
|
+
function makeMsg(overrides: {
|
|
21
|
+
conversationId?: string;
|
|
22
|
+
senderKind?: "user" | "agent" | "system";
|
|
23
|
+
senderId?: string;
|
|
24
|
+
senderName?: string;
|
|
25
|
+
sourceType?: unknown;
|
|
26
|
+
}): GatewayInboundMessage {
|
|
27
|
+
return {
|
|
28
|
+
id: "m1",
|
|
29
|
+
channel: "botcord",
|
|
30
|
+
accountId: "acc",
|
|
31
|
+
conversation: {
|
|
32
|
+
id: overrides.conversationId ?? "rm_team",
|
|
33
|
+
kind: "group",
|
|
34
|
+
},
|
|
35
|
+
sender: {
|
|
36
|
+
id: overrides.senderId ?? "ag_peer",
|
|
37
|
+
kind: overrides.senderKind ?? "agent",
|
|
38
|
+
...(overrides.senderName ? { name: overrides.senderName } : {}),
|
|
39
|
+
},
|
|
40
|
+
text: "",
|
|
41
|
+
raw: overrides.sourceType !== undefined ? { source_type: overrides.sourceType } : {},
|
|
42
|
+
receivedAt: Date.now(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("classifyActivitySender", () => {
|
|
47
|
+
it("labels rm_oc_* rooms as owner regardless of sender.kind", () => {
|
|
48
|
+
const msg = makeMsg({
|
|
49
|
+
conversationId: "rm_oc_abc",
|
|
50
|
+
senderKind: "user",
|
|
51
|
+
senderId: "ag_self",
|
|
52
|
+
});
|
|
53
|
+
expect(classifyActivitySender(msg)).toEqual({ kind: "owner", label: "ag_self" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("labels source_type=dashboard_user_chat as owner even outside rm_oc_ rooms", () => {
|
|
57
|
+
const msg = makeMsg({
|
|
58
|
+
conversationId: "rm_team_42",
|
|
59
|
+
senderKind: "user",
|
|
60
|
+
senderId: "ag_owner",
|
|
61
|
+
sourceType: "dashboard_user_chat",
|
|
62
|
+
});
|
|
63
|
+
// Regression guard: the gateway channel collapses owner + human-room humans
|
|
64
|
+
// into sender.kind="user"; without the source_type peek the classifier would
|
|
65
|
+
// label this turn as "human" in the cross-room digest.
|
|
66
|
+
expect(classifyActivitySender(msg)).toEqual({ kind: "owner", label: "ag_owner" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("labels dashboard_human_room senders as human and uses source_user_name", () => {
|
|
70
|
+
const msg = makeMsg({
|
|
71
|
+
conversationId: "rm_team_42",
|
|
72
|
+
senderKind: "user",
|
|
73
|
+
senderName: "Alice",
|
|
74
|
+
senderId: "ag_bridge",
|
|
75
|
+
sourceType: "dashboard_human_room",
|
|
76
|
+
});
|
|
77
|
+
expect(classifyActivitySender(msg)).toEqual({ kind: "human", label: "Alice" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("labels A2A peer (sender.kind=agent) as agent", () => {
|
|
81
|
+
const msg = makeMsg({
|
|
82
|
+
conversationId: "rm_team_42",
|
|
83
|
+
senderKind: "agent",
|
|
84
|
+
senderId: "ag_peer",
|
|
85
|
+
});
|
|
86
|
+
expect(classifyActivitySender(msg)).toEqual({ kind: "agent", label: "ag_peer" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back cleanly when raw is a non-object (defensive path for non-BotCord channels)", () => {
|
|
90
|
+
const msg = makeMsg({ conversationId: "rm_team_42", senderKind: "agent" });
|
|
91
|
+
// Overwrite raw to a non-object; classifier should still return agent.
|
|
92
|
+
(msg as { raw: unknown }).raw = "not-an-object";
|
|
93
|
+
expect(classifyActivitySender(msg)).toEqual({ kind: "agent", label: "ag_peer" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses sender.id when the user sender has no name", () => {
|
|
97
|
+
const msg = makeMsg({
|
|
98
|
+
conversationId: "rm_other",
|
|
99
|
+
senderKind: "user",
|
|
100
|
+
senderId: "ag_anon",
|
|
101
|
+
});
|
|
102
|
+
expect(classifyActivitySender(msg)).toEqual({ kind: "human", label: "ag_anon" });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("createActivityRecorder", () => {
|
|
107
|
+
it("records inbound messages regardless of the channel id the gateway stamps", () => {
|
|
108
|
+
// Regression: pre-fix, the observer bailed out when msg.channel !== "botcord".
|
|
109
|
+
// After the agent-id channel migration, the gateway stamps the agentId
|
|
110
|
+
// (e.g. "ag_self"). The recorder must still fire — cross-room digest
|
|
111
|
+
// silently going empty was the original bug.
|
|
112
|
+
const record = vi.fn();
|
|
113
|
+
const onInbound = createActivityRecorder({
|
|
114
|
+
activityTracker: { record },
|
|
115
|
+
});
|
|
116
|
+
const msg: GatewayInboundMessage = {
|
|
117
|
+
...makeMsg({ conversationId: "rm_team_42", senderKind: "agent", senderId: "ag_peer" }),
|
|
118
|
+
accountId: "ag_self",
|
|
119
|
+
channel: "ag_self",
|
|
120
|
+
text: "hello there",
|
|
121
|
+
};
|
|
122
|
+
onInbound(msg);
|
|
123
|
+
expect(record).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(record).toHaveBeenCalledWith({
|
|
125
|
+
agentId: "ag_self",
|
|
126
|
+
roomId: "rm_team_42",
|
|
127
|
+
roomName: undefined,
|
|
128
|
+
topic: null,
|
|
129
|
+
lastInboundPreview: "hello there",
|
|
130
|
+
lastSenderKind: "agent",
|
|
131
|
+
lastSender: "ag_peer",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("derives the activity agentId from msg.accountId (multi-agent isolation)", () => {
|
|
136
|
+
// Multi-agent regression guard: two messages targeting different
|
|
137
|
+
// configured agents must land under distinct tracker keys, not a
|
|
138
|
+
// closed-over constant.
|
|
139
|
+
const record = vi.fn();
|
|
140
|
+
const onInbound = createActivityRecorder({
|
|
141
|
+
activityTracker: { record },
|
|
142
|
+
});
|
|
143
|
+
onInbound({
|
|
144
|
+
...makeMsg({ conversationId: "rm_a", senderKind: "agent", senderId: "ag_peer" }),
|
|
145
|
+
accountId: "ag_one",
|
|
146
|
+
channel: "ag_one",
|
|
147
|
+
text: "for agent one",
|
|
148
|
+
});
|
|
149
|
+
onInbound({
|
|
150
|
+
...makeMsg({ conversationId: "rm_b", senderKind: "agent", senderId: "ag_peer" }),
|
|
151
|
+
accountId: "ag_two",
|
|
152
|
+
channel: "ag_two",
|
|
153
|
+
text: "for agent two",
|
|
154
|
+
});
|
|
155
|
+
expect(record.mock.calls[0][0].agentId).toBe("ag_one");
|
|
156
|
+
expect(record.mock.calls[1][0].agentId).toBe("ag_two");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("falls back to fallbackAgentId when msg.accountId is empty", () => {
|
|
160
|
+
const record = vi.fn();
|
|
161
|
+
const onInbound = createActivityRecorder({
|
|
162
|
+
activityTracker: { record },
|
|
163
|
+
fallbackAgentId: "ag_fallback",
|
|
164
|
+
});
|
|
165
|
+
onInbound({
|
|
166
|
+
...makeMsg({ conversationId: "rm_x", senderKind: "agent", senderId: "ag_peer" }),
|
|
167
|
+
accountId: "",
|
|
168
|
+
channel: "ag_fallback",
|
|
169
|
+
});
|
|
170
|
+
expect(record.mock.calls[0][0].agentId).toBe("ag_fallback");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("passes owner text through verbatim and sanitizes non-owner text", () => {
|
|
174
|
+
const record = vi.fn();
|
|
175
|
+
const onInbound = createActivityRecorder({
|
|
176
|
+
activityTracker: { record },
|
|
177
|
+
});
|
|
178
|
+
onInbound({
|
|
179
|
+
...makeMsg({ conversationId: "rm_oc_abc", senderKind: "user", senderId: "ag_self" }),
|
|
180
|
+
accountId: "ag_self",
|
|
181
|
+
channel: "ag_self",
|
|
182
|
+
text: "raw owner text",
|
|
183
|
+
});
|
|
184
|
+
expect(record.mock.calls[0][0].lastInboundPreview).toBe("raw owner text");
|
|
185
|
+
expect(record.mock.calls[0][0].lastSenderKind).toBe("owner");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
function silentLogger(): GatewayLogger {
|
|
190
|
+
return {
|
|
191
|
+
info: () => {},
|
|
192
|
+
warn: () => {},
|
|
193
|
+
error: () => {},
|
|
194
|
+
debug: () => {},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function bootAgent(
|
|
199
|
+
agentId: string,
|
|
200
|
+
extra: Partial<DiscoveredAgentCredential> = {},
|
|
201
|
+
): DiscoveredAgentCredential {
|
|
202
|
+
return {
|
|
203
|
+
agentId,
|
|
204
|
+
credentialsFile: `/fake/${agentId}.json`,
|
|
205
|
+
hubUrl: "https://hub.example.com",
|
|
206
|
+
...extra,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
describe("backfillBootAgents", () => {
|
|
211
|
+
let tmpHome: string;
|
|
212
|
+
let origHome: string | undefined;
|
|
213
|
+
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
tmpHome = mkdtempSync(path.join(os.tmpdir(), "botcord-daemon-boot-"));
|
|
216
|
+
origHome = process.env.HOME;
|
|
217
|
+
process.env.HOME = tmpHome;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
afterEach(() => {
|
|
221
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
222
|
+
else process.env.HOME = origHome;
|
|
223
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("creates the per-agent workspace for a legacy discovered agent that has none", () => {
|
|
227
|
+
// Simulates plan §9's primary use case: an agent was provisioned before
|
|
228
|
+
// the workspace feature existed, so `~/.botcord/agents/{id}/` doesn't
|
|
229
|
+
// exist yet. Boot backfill should materialize it — but leave the
|
|
230
|
+
// credentials file alone (no-credential-mutation invariant).
|
|
231
|
+
const res = backfillBootAgents(
|
|
232
|
+
[
|
|
233
|
+
bootAgent("ag_legacy", {
|
|
234
|
+
displayName: "Legacy",
|
|
235
|
+
keyId: "k_42",
|
|
236
|
+
savedAt: "2026-04-23T00:00:00.000Z",
|
|
237
|
+
}),
|
|
238
|
+
],
|
|
239
|
+
{ logger: silentLogger() },
|
|
240
|
+
);
|
|
241
|
+
const ws = agentWorkspaceDir("ag_legacy");
|
|
242
|
+
expect(existsSync(path.join(ws, "AGENTS.md"))).toBe(true);
|
|
243
|
+
expect(existsSync(path.join(ws, "identity.md"))).toBe(true);
|
|
244
|
+
const identity = readFileSync(path.join(ws, "identity.md"), "utf8");
|
|
245
|
+
expect(identity).toContain("ag_legacy");
|
|
246
|
+
expect(identity).toContain("Legacy");
|
|
247
|
+
expect(identity).toContain("k_42");
|
|
248
|
+
// Maps still populated for downstream `toGatewayConfig` consumption.
|
|
249
|
+
expect(res.credentialPathByAgentId.get("ag_legacy")).toBe(
|
|
250
|
+
"/fake/ag_legacy.json",
|
|
251
|
+
);
|
|
252
|
+
// No runtime/cwd on the boot agent → no entry in the runtimes map.
|
|
253
|
+
expect(res.agentRuntimes.ag_legacy).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("is idempotent: a second call leaves user-edited files alone", () => {
|
|
257
|
+
backfillBootAgents([bootAgent("ag_one")], { logger: silentLogger() });
|
|
258
|
+
const memoryPath = path.join(agentWorkspaceDir("ag_one"), "memory.md");
|
|
259
|
+
const edited = "# My notes\n\nremembered thing\n";
|
|
260
|
+
// Simulate the LLM/user editing memory.md.
|
|
261
|
+
writeFileSync(memoryPath, edited);
|
|
262
|
+
backfillBootAgents([bootAgent("ag_one")], { logger: silentLogger() });
|
|
263
|
+
expect(readFileSync(memoryPath, "utf8")).toBe(edited);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("warns and continues when ensureAgentWorkspace throws for one agent", () => {
|
|
267
|
+
// One agent's broken workspace (permission denied, full disk, etc.)
|
|
268
|
+
// must not block the other agents from being brought up.
|
|
269
|
+
const warn = vi.fn();
|
|
270
|
+
const ensure = vi.fn((agentId: string) => {
|
|
271
|
+
if (agentId === "ag_bad") throw new Error("EACCES");
|
|
272
|
+
});
|
|
273
|
+
const logger: GatewayLogger = { ...silentLogger(), warn };
|
|
274
|
+
const res = backfillBootAgents(
|
|
275
|
+
[bootAgent("ag_bad"), bootAgent("ag_good", { runtime: "codex" })],
|
|
276
|
+
{ logger, ensure },
|
|
277
|
+
);
|
|
278
|
+
expect(ensure).toHaveBeenCalledTimes(2);
|
|
279
|
+
expect(warn).toHaveBeenCalledWith(
|
|
280
|
+
"ensureAgentWorkspace failed at boot; continuing",
|
|
281
|
+
expect.objectContaining({ agentId: "ag_bad" }),
|
|
282
|
+
);
|
|
283
|
+
// Both agents are still plumbed into the downstream caches — the peer
|
|
284
|
+
// agent's channel and route are not taken down by the sibling's error.
|
|
285
|
+
expect(res.credentialPathByAgentId.has("ag_bad")).toBe(true);
|
|
286
|
+
expect(res.credentialPathByAgentId.has("ag_good")).toBe(true);
|
|
287
|
+
expect(res.agentRuntimes.ag_good).toEqual({ runtime: "codex" });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("does not touch credential files (no-credential-mutation invariant)", () => {
|
|
291
|
+
// §9 "No credential mutation": boot backfill writes to the agent's
|
|
292
|
+
// workspace dir only; the credential file passed in via `credentialsFile`
|
|
293
|
+
// is not opened. We verify by using a path that doesn't exist on disk —
|
|
294
|
+
// if the backfill tried to read or rewrite it, we'd see an ENOENT.
|
|
295
|
+
expect(() =>
|
|
296
|
+
backfillBootAgents([bootAgent("ag_one")], { logger: silentLogger() }),
|
|
297
|
+
).not.toThrow();
|
|
298
|
+
expect(existsSync("/fake/ag_one.json")).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
pollDeviceToken,
|
|
4
|
+
requestDeviceCode,
|
|
5
|
+
} from "@botcord/protocol-core";
|
|
6
|
+
|
|
7
|
+
const HUB = "http://localhost:9000";
|
|
8
|
+
|
|
9
|
+
describe("requestDeviceCode", () => {
|
|
10
|
+
let originalFetch: typeof fetch;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
originalFetch = globalThis.fetch;
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
globalThis.fetch = originalFetch;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("parses snake_case fields and applies defaults", async () => {
|
|
19
|
+
globalThis.fetch = vi.fn(async () =>
|
|
20
|
+
new Response(
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
device_code: "dc_abc",
|
|
23
|
+
user_code: "ABCD-EFGH",
|
|
24
|
+
verification_uri: "https://app.botcord.dev/activate",
|
|
25
|
+
expires_in: 600,
|
|
26
|
+
interval: 5,
|
|
27
|
+
}),
|
|
28
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
29
|
+
),
|
|
30
|
+
) as unknown as typeof fetch;
|
|
31
|
+
|
|
32
|
+
const dc = await requestDeviceCode(HUB);
|
|
33
|
+
expect(dc.deviceCode).toBe("dc_abc");
|
|
34
|
+
expect(dc.userCode).toBe("ABCD-EFGH");
|
|
35
|
+
expect(dc.expiresIn).toBe(600);
|
|
36
|
+
expect(dc.interval).toBe(5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("forwards label in the request body when provided", async () => {
|
|
40
|
+
let captured: { url: string; init: RequestInit | undefined } | null = null;
|
|
41
|
+
globalThis.fetch = vi.fn(async (url: unknown, init: unknown) => {
|
|
42
|
+
captured = { url: String(url), init: init as RequestInit | undefined };
|
|
43
|
+
return new Response(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
device_code: "dc_x",
|
|
46
|
+
user_code: "XXXX-YYYY",
|
|
47
|
+
verification_uri: "https://app.botcord.dev/activate",
|
|
48
|
+
expires_in: 600,
|
|
49
|
+
interval: 5,
|
|
50
|
+
}),
|
|
51
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
52
|
+
);
|
|
53
|
+
}) as unknown as typeof fetch;
|
|
54
|
+
|
|
55
|
+
await requestDeviceCode(HUB, { label: "MacBook Pro" });
|
|
56
|
+
expect(captured).not.toBeNull();
|
|
57
|
+
const cap = captured as unknown as { url: string; init: { body?: string } };
|
|
58
|
+
const body = JSON.parse(cap.init.body as string) as { label?: string };
|
|
59
|
+
expect(body.label).toBe("MacBook Pro");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws on missing fields", async () => {
|
|
63
|
+
globalThis.fetch = vi.fn(async () =>
|
|
64
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
65
|
+
) as unknown as typeof fetch;
|
|
66
|
+
await expect(requestDeviceCode(HUB)).rejects.toThrow(/missing/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("pollDeviceToken", () => {
|
|
71
|
+
let originalFetch: typeof fetch;
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
originalFetch = globalThis.fetch;
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
globalThis.fetch = originalFetch;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns pending on a 200 envelope with status=pending", async () => {
|
|
80
|
+
globalThis.fetch = vi.fn(async () =>
|
|
81
|
+
new Response(JSON.stringify({ status: "pending" }), { status: 200 }),
|
|
82
|
+
) as unknown as typeof fetch;
|
|
83
|
+
const r = await pollDeviceToken(HUB, "dc_x");
|
|
84
|
+
expect(r.status).toBe("pending");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("translates 4xx authorization_pending into pending", async () => {
|
|
88
|
+
globalThis.fetch = vi.fn(async () =>
|
|
89
|
+
new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }),
|
|
90
|
+
) as unknown as typeof fetch;
|
|
91
|
+
const r = await pollDeviceToken(HUB, "dc_x");
|
|
92
|
+
expect(r.status).toBe("pending");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns slow_down with the suggested interval", async () => {
|
|
96
|
+
globalThis.fetch = vi.fn(async () =>
|
|
97
|
+
new Response(JSON.stringify({ status: "slow_down", interval: 12 }), {
|
|
98
|
+
status: 200,
|
|
99
|
+
}),
|
|
100
|
+
) as unknown as typeof fetch;
|
|
101
|
+
const r = await pollDeviceToken(HUB, "dc_x");
|
|
102
|
+
expect(r.status).toBe("slow_down");
|
|
103
|
+
if (r.status === "slow_down") expect(r.interval).toBe(12);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns the issued token envelope", async () => {
|
|
107
|
+
globalThis.fetch = vi.fn(async () =>
|
|
108
|
+
new Response(
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
status: "issued",
|
|
111
|
+
access_token: "at_1",
|
|
112
|
+
refresh_token: "rt_1",
|
|
113
|
+
expires_in: 3600,
|
|
114
|
+
user_id: "usr_1",
|
|
115
|
+
daemon_instance_id: "dm_1",
|
|
116
|
+
hub_url: HUB,
|
|
117
|
+
}),
|
|
118
|
+
{ status: 200 },
|
|
119
|
+
),
|
|
120
|
+
) as unknown as typeof fetch;
|
|
121
|
+
const r = await pollDeviceToken(HUB, "dc_x");
|
|
122
|
+
expect(r.status).toBe("issued");
|
|
123
|
+
if (r.status === "issued") {
|
|
124
|
+
expect(r.accessToken).toBe("at_1");
|
|
125
|
+
expect(r.refreshToken).toBe("rt_1");
|
|
126
|
+
expect(r.userId).toBe("usr_1");
|
|
127
|
+
expect(r.daemonInstanceId).toBe("dm_1");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("throws on unrecognized 4xx errors", async () => {
|
|
132
|
+
globalThis.fetch = vi.fn(async () =>
|
|
133
|
+
new Response(JSON.stringify({ error: "expired_token" }), { status: 400 }),
|
|
134
|
+
) as unknown as typeof fetch;
|
|
135
|
+
await expect(pollDeviceToken(HUB, "dc_x")).rejects.toThrow(/expired_token/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("forwards label on poll requests", async () => {
|
|
139
|
+
let body: { label?: string; device_code?: string } | null = null;
|
|
140
|
+
globalThis.fetch = vi.fn(async (_url: unknown, init: unknown) => {
|
|
141
|
+
body = JSON.parse((init as { body: string }).body) as {
|
|
142
|
+
label?: string;
|
|
143
|
+
device_code?: string;
|
|
144
|
+
};
|
|
145
|
+
return new Response(JSON.stringify({ status: "pending" }), { status: 200 });
|
|
146
|
+
}) as unknown as typeof fetch;
|
|
147
|
+
await pollDeviceToken(HUB, "dc_x", { label: "Studio" });
|
|
148
|
+
const cap = body as unknown as { label?: string; device_code?: string };
|
|
149
|
+
expect(cap.label).toBe("Studio");
|
|
150
|
+
expect(cap.device_code).toBe("dc_x");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
channelsFromDaemonConfig,
|
|
4
|
+
probeChannel,
|
|
5
|
+
probeChannels,
|
|
6
|
+
renderDoctor,
|
|
7
|
+
type ChannelProbeConfig,
|
|
8
|
+
type DoctorFileReader,
|
|
9
|
+
type DoctorHttpFetcher,
|
|
10
|
+
} from "../doctor.js";
|
|
11
|
+
import type { DaemonConfig } from "../config.js";
|
|
12
|
+
|
|
13
|
+
const okCreds = JSON.stringify({
|
|
14
|
+
token: "secret-token",
|
|
15
|
+
hubUrl: "https://hub.example.com",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function fileReader(files: Record<string, string | null>): DoctorFileReader {
|
|
19
|
+
return {
|
|
20
|
+
readFile(p) {
|
|
21
|
+
return p in files ? files[p] : null;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fetcher(result: { ok: boolean; status?: number; error?: string }): DoctorHttpFetcher {
|
|
27
|
+
return vi.fn(async () => result);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("probeChannel", () => {
|
|
31
|
+
const ch: ChannelProbeConfig = {
|
|
32
|
+
id: "botcord-main",
|
|
33
|
+
type: "botcord",
|
|
34
|
+
accountId: "ag_123",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
it("reports credentials and hub both ✓ when everything is healthy", async () => {
|
|
38
|
+
const credsPath = "/creds/ag_123.json";
|
|
39
|
+
const result = await probeChannel(ch, {
|
|
40
|
+
credentialsPath: () => credsPath,
|
|
41
|
+
fileReader: fileReader({ [credsPath]: okCreds }),
|
|
42
|
+
fetcher: fetcher({ ok: true, status: 200 }),
|
|
43
|
+
timeoutMs: 1000,
|
|
44
|
+
});
|
|
45
|
+
expect(result.credentialsOk).toBe(true);
|
|
46
|
+
expect(result.hubOk).toBe(true);
|
|
47
|
+
expect(result.hubUrl).toBe("https://hub.example.com");
|
|
48
|
+
expect(result.hubMessage).toContain("200");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reports missing credentials when the file cannot be read", async () => {
|
|
52
|
+
const result = await probeChannel(ch, {
|
|
53
|
+
credentialsPath: () => "/no/such/file.json",
|
|
54
|
+
fileReader: fileReader({}),
|
|
55
|
+
fetcher: fetcher({ ok: true, status: 200 }),
|
|
56
|
+
timeoutMs: 1000,
|
|
57
|
+
});
|
|
58
|
+
expect(result.credentialsOk).toBe(false);
|
|
59
|
+
expect(result.credentialsMessage).toContain("missing");
|
|
60
|
+
expect(result.hubOk).toBe(false);
|
|
61
|
+
expect(result.hubMessage).toContain("skipped");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reports invalid JSON when credentials fail to parse", async () => {
|
|
65
|
+
const credsPath = "/creds/bad.json";
|
|
66
|
+
const result = await probeChannel(ch, {
|
|
67
|
+
credentialsPath: () => credsPath,
|
|
68
|
+
fileReader: fileReader({ [credsPath]: "{not json" }),
|
|
69
|
+
fetcher: fetcher({ ok: true, status: 200 }),
|
|
70
|
+
timeoutMs: 1000,
|
|
71
|
+
});
|
|
72
|
+
expect(result.credentialsOk).toBe(false);
|
|
73
|
+
expect(result.credentialsMessage).toContain("invalid JSON");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("reports ✗ with timeout message when the fetcher times out", async () => {
|
|
77
|
+
const credsPath = "/creds/ag_123.json";
|
|
78
|
+
const result = await probeChannel(ch, {
|
|
79
|
+
credentialsPath: () => credsPath,
|
|
80
|
+
fileReader: fileReader({ [credsPath]: okCreds }),
|
|
81
|
+
fetcher: fetcher({ ok: false, error: "timeout" }),
|
|
82
|
+
timeoutMs: 1000,
|
|
83
|
+
});
|
|
84
|
+
expect(result.credentialsOk).toBe(true);
|
|
85
|
+
expect(result.hubOk).toBe(false);
|
|
86
|
+
expect(result.hubMessage).toBe("timeout");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("reports a non-2xx status when reachable but not ok", async () => {
|
|
90
|
+
const credsPath = "/creds/ag_123.json";
|
|
91
|
+
const result = await probeChannel(ch, {
|
|
92
|
+
credentialsPath: () => credsPath,
|
|
93
|
+
fileReader: fileReader({ [credsPath]: okCreds }),
|
|
94
|
+
fetcher: fetcher({ ok: false, status: 503 }),
|
|
95
|
+
timeoutMs: 1000,
|
|
96
|
+
});
|
|
97
|
+
expect(result.hubOk).toBe(false);
|
|
98
|
+
expect(result.hubMessage).toContain("503");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("probeChannels returns an entry per input channel", async () => {
|
|
102
|
+
const credsPath = "/creds/ag_123.json";
|
|
103
|
+
const results = await probeChannels({
|
|
104
|
+
channels: [ch, { ...ch, id: "botcord-alt", accountId: "ag_alt" }],
|
|
105
|
+
credentialsPath: (acct) => (acct === "ag_123" ? credsPath : "/missing"),
|
|
106
|
+
fileReader: fileReader({ [credsPath]: okCreds }),
|
|
107
|
+
fetcher: fetcher({ ok: true, status: 200 }),
|
|
108
|
+
timeoutMs: 1000,
|
|
109
|
+
});
|
|
110
|
+
expect(results).toHaveLength(2);
|
|
111
|
+
expect(results[0].credentialsOk).toBe(true);
|
|
112
|
+
expect(results[1].credentialsOk).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("probeChannel (with per-channel credentialsFile)", () => {
|
|
117
|
+
it("uses channel.credentialsFile instead of the default when set", async () => {
|
|
118
|
+
const explicit = "/override/path/ag.json";
|
|
119
|
+
const reader = fileReader({ [explicit]: okCreds });
|
|
120
|
+
const result = await probeChannel(
|
|
121
|
+
{
|
|
122
|
+
id: "ag_x",
|
|
123
|
+
type: "botcord",
|
|
124
|
+
accountId: "ag_x",
|
|
125
|
+
credentialsFile: explicit,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
credentialsPath: () => "/default/should-not-be-read.json",
|
|
129
|
+
fileReader: reader,
|
|
130
|
+
fetcher: fetcher({ ok: true, status: 200 }),
|
|
131
|
+
timeoutMs: 1000,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
expect(result.credentialsOk).toBe(true);
|
|
135
|
+
expect(result.credentialsMessage).toContain(explicit);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("channelsFromDaemonConfig (boot-agent aware)", () => {
|
|
140
|
+
it("returns channels derived from the explicit agents list", () => {
|
|
141
|
+
const cfg: DaemonConfig = {
|
|
142
|
+
agents: ["ag_one", "ag_two"],
|
|
143
|
+
defaultRoute: { adapter: "claude-code", cwd: "/w" },
|
|
144
|
+
routes: [],
|
|
145
|
+
streamBlocks: true,
|
|
146
|
+
};
|
|
147
|
+
const channels = channelsFromDaemonConfig(cfg);
|
|
148
|
+
expect(channels.map((c) => c.id)).toEqual(["ag_one", "ag_two"]);
|
|
149
|
+
// Explicit config channels use the default credential path (no override).
|
|
150
|
+
for (const c of channels) {
|
|
151
|
+
expect(c.credentialsFile).toMatch(/\.botcord\/credentials\//);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns an empty list when no agents are configured and discovery is unavailable", () => {
|
|
156
|
+
// Default discovery dir almost certainly has no credentials in test env.
|
|
157
|
+
// We rely on the fallback-to-empty behaviour, not a real scan — any scan
|
|
158
|
+
// that throws turns into [].
|
|
159
|
+
const cfg: DaemonConfig = {
|
|
160
|
+
defaultRoute: { adapter: "claude-code", cwd: "/w" },
|
|
161
|
+
routes: [],
|
|
162
|
+
streamBlocks: true,
|
|
163
|
+
};
|
|
164
|
+
const channels = channelsFromDaemonConfig(cfg);
|
|
165
|
+
expect(Array.isArray(channels)).toBe(true);
|
|
166
|
+
// Either empty or non-empty depending on environment; the important
|
|
167
|
+
// guarantee is that it does not throw.
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("renderDoctor", () => {
|
|
172
|
+
it("renders runtimes + channel section with markers", () => {
|
|
173
|
+
const out = renderDoctor({
|
|
174
|
+
runtimes: [
|
|
175
|
+
{
|
|
176
|
+
id: "claude-code",
|
|
177
|
+
displayName: "Claude Code",
|
|
178
|
+
binary: "claude",
|
|
179
|
+
supportsRun: true,
|
|
180
|
+
result: { available: true, version: "1.0.0", path: "/usr/bin/claude" },
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
channels: [
|
|
184
|
+
{
|
|
185
|
+
id: "botcord-main",
|
|
186
|
+
type: "botcord",
|
|
187
|
+
accountId: "ag_123",
|
|
188
|
+
credentialsOk: true,
|
|
189
|
+
credentialsMessage: "loaded",
|
|
190
|
+
hubUrl: "https://hub.example.com",
|
|
191
|
+
hubOk: true,
|
|
192
|
+
hubMessage: "reachable (HTTP 200)",
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
expect(out).toContain("claude-code");
|
|
197
|
+
expect(out).toContain("1/1 runtimes available");
|
|
198
|
+
expect(out).toContain("Channels:");
|
|
199
|
+
expect(out).toContain("botcord-main");
|
|
200
|
+
expect(out).toContain("✓");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("shows 'No channels configured.' when the channel list is empty", () => {
|
|
204
|
+
const out = renderDoctor({
|
|
205
|
+
runtimes: [
|
|
206
|
+
{
|
|
207
|
+
id: "claude-code",
|
|
208
|
+
displayName: "Claude Code",
|
|
209
|
+
binary: "claude",
|
|
210
|
+
supportsRun: true,
|
|
211
|
+
result: { available: false },
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
channels: [],
|
|
215
|
+
});
|
|
216
|
+
expect(out).toContain("No channels configured.");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
BotCordClient,
|
|
4
|
+
loadStoredCredentials,
|
|
5
|
+
buildHubWebSocketUrl,
|
|
6
|
+
} from "@botcord/protocol-core";
|
|
7
|
+
|
|
8
|
+
describe("@botcord/protocol-core re-exports", () => {
|
|
9
|
+
it("exposes BotCordClient as a class", () => {
|
|
10
|
+
expect(BotCordClient).toBeDefined();
|
|
11
|
+
expect(typeof BotCordClient).toBe("function");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("exposes loadStoredCredentials as a function", () => {
|
|
15
|
+
expect(typeof loadStoredCredentials).toBe("function");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("buildHubWebSocketUrl converts http → ws + appends /hub/ws", () => {
|
|
19
|
+
expect(typeof buildHubWebSocketUrl).toBe("function");
|
|
20
|
+
const u = buildHubWebSocketUrl("http://localhost:9000");
|
|
21
|
+
expect(u.startsWith("ws://")).toBe(true);
|
|
22
|
+
expect(u).toContain("/hub/ws");
|
|
23
|
+
});
|
|
24
|
+
});
|