@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.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/package.json +39 -22
- package/src/api.test.ts +1060 -65
- package/src/api.ts +1398 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +89 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +669 -86
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.ts +293 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +268 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +64 -0
- package/src/herdr-thread-surface.test.ts +411 -0
- package/src/herdr-thread-surface.ts +355 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +453 -42
- package/src/server.ts +433 -88
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +398 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +282 -37
- package/src/validate.ts +398 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
package/src/server.test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import * as v from "valibot";
|
|
5
6
|
import { DaemonServer } from "./server";
|
|
6
7
|
import { DaemonClient, DaemonClientError } from "./client";
|
|
8
|
+
import { DAEMON_VERSION } from "./version";
|
|
7
9
|
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
8
10
|
|
|
9
11
|
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
@@ -27,88 +29,497 @@ afterEach(() => {
|
|
|
27
29
|
|
|
28
30
|
describe("socket round-trip", () => {
|
|
29
31
|
test("ping and full session flow over the wire", async () => {
|
|
32
|
+
// Assert
|
|
30
33
|
expect((await client.ping()).pid).toBe(process.pid);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await client.
|
|
34
|
+
|
|
35
|
+
// Act
|
|
36
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
37
|
+
|
|
38
|
+
// Assert
|
|
39
|
+
expect(session.status).toBe("pending");
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
await client.sessionAnnotate(session.id, {
|
|
34
43
|
id: "a1",
|
|
35
44
|
kind: "comment",
|
|
36
45
|
anchor: { quote: "Body text", prefix: "", suffix: "." },
|
|
37
46
|
body: "More detail please.",
|
|
38
47
|
});
|
|
39
|
-
const wait = client.sessionWait(
|
|
40
|
-
|
|
48
|
+
const wait = client.sessionWait(session.id, 5_000);
|
|
49
|
+
|
|
50
|
+
await client.sessionSendMessage(session.id, "changes_requested", "Expand it.");
|
|
41
51
|
const resolved = (await wait)!;
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
|
|
53
|
+
// Assert
|
|
54
|
+
expect(resolved.message!.outcome).toBe("changes_requested");
|
|
55
|
+
expect(resolved.message!.body).toContain("More detail please.");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("harness binding and Message delivery round-trip over the wire", async () => {
|
|
59
|
+
const thread = await client.sessionCreate(WS, PLAN);
|
|
60
|
+
const binding = await client.harnessBind(thread.id, "fake", "fake_1");
|
|
61
|
+
|
|
62
|
+
expect(await client.harnessGetBinding(binding.id)).toEqual(binding);
|
|
63
|
+
expect(await client.harnessBindingsForSession("fake", "fake_1")).toEqual([binding]);
|
|
64
|
+
expect(await client.harnessBindingsForSession("fake", "different")).toEqual([]);
|
|
65
|
+
|
|
66
|
+
const sent = await client.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
67
|
+
const pending = await client.deliveryPending(binding.id);
|
|
68
|
+
|
|
69
|
+
expect(pending).toHaveLength(1);
|
|
70
|
+
expect(pending[0]!.message).toEqual(sent.message!);
|
|
71
|
+
expect(pending[0]!.delivery.status).toBe("pending");
|
|
72
|
+
|
|
73
|
+
const acknowledged = await client.deliveryAcknowledge(pending[0]!.delivery.id);
|
|
74
|
+
|
|
75
|
+
expect(acknowledged.status).toBe("acknowledged");
|
|
76
|
+
expect(await client.deliveryPending(binding.id)).toEqual([]);
|
|
77
|
+
expect(
|
|
78
|
+
await client.harnessConsumeApprovedRetry(binding.id, sent.message!.id, PLAN.content),
|
|
79
|
+
).toBe(true);
|
|
80
|
+
expect(
|
|
81
|
+
await client.harnessConsumeApprovedRetry(binding.id, sent.message!.id, PLAN.content),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("diff revision replaces full changed-file contents over the wire", async () => {
|
|
86
|
+
const files = [
|
|
87
|
+
{ path: "a.ts", status: "modified" as const, oldContents: "old", newContents: "first" },
|
|
88
|
+
];
|
|
89
|
+
const thread = await client.sessionCreate(WS, {
|
|
90
|
+
type: "diff",
|
|
91
|
+
content: "first patch",
|
|
92
|
+
files,
|
|
93
|
+
meta: {},
|
|
94
|
+
});
|
|
95
|
+
const revised = await client.sessionSubmitRevision(
|
|
96
|
+
thread.id,
|
|
97
|
+
"second patch",
|
|
98
|
+
[],
|
|
99
|
+
[{ ...files[0]!, newContents: "second" }],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(revised.artifact.files?.[0]?.newContents).toBe("second");
|
|
103
|
+
expect((await client.sessionGet(thread.id)).artifact.files?.[0]?.newContents).toBe("second");
|
|
44
104
|
});
|
|
45
105
|
|
|
46
106
|
test("errors carry codes across the wire", async () => {
|
|
47
|
-
expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
48
|
-
|
|
49
|
-
await client.sessionGet("nope");
|
|
50
|
-
} catch (e) {
|
|
51
|
-
expect((e as DaemonClientError).code).toBe("not_found");
|
|
52
|
-
}
|
|
107
|
+
await expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
108
|
+
await expect(client.sessionGet("nope")).rejects.toHaveProperty("code", "not_found");
|
|
53
109
|
});
|
|
54
110
|
|
|
55
111
|
test("events push to subscribed connections only", async () => {
|
|
112
|
+
// Arrange
|
|
56
113
|
const observer = await DaemonClient.connect({ home });
|
|
57
114
|
const seen: string[] = [];
|
|
58
|
-
|
|
115
|
+
|
|
116
|
+
observer.onEvent((event) => seen.push(event.event));
|
|
59
117
|
await observer.subscribe();
|
|
60
|
-
|
|
61
|
-
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
121
|
+
|
|
122
|
+
await client.sessionSendMessage(session.id, "approved", "");
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
62
125
|
// events are pushed async over the socket; give the loop a beat
|
|
63
126
|
await Bun.sleep(50);
|
|
64
127
|
expect(seen).toContain("session.created");
|
|
65
|
-
expect(seen).toContain("
|
|
128
|
+
expect(seen).toContain("message.sent");
|
|
66
129
|
observer.close();
|
|
67
130
|
});
|
|
68
131
|
|
|
132
|
+
test("an event names the history entry its change appended", async () => {
|
|
133
|
+
// Arrange
|
|
134
|
+
const observer = await DaemonClient.connect({ home });
|
|
135
|
+
const frames: Array<{ event: string; entryId?: string }> = [];
|
|
136
|
+
|
|
137
|
+
observer.onEvent((event) => frames.push(event));
|
|
138
|
+
await observer.subscribe();
|
|
139
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
140
|
+
|
|
141
|
+
// Act
|
|
142
|
+
const annotated = await client.sessionAnnotate(session.id, {
|
|
143
|
+
id: "a_evt",
|
|
144
|
+
kind: "comment",
|
|
145
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
146
|
+
body: "hello",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await Bun.sleep(50);
|
|
150
|
+
|
|
151
|
+
// Assert: the update frame carries the comment entry's id
|
|
152
|
+
const updated = frames.find((frame) => frame.event === "session.updated");
|
|
153
|
+
|
|
154
|
+
expect(updated?.entryId).toBe(annotated.history!.entries.at(-1)!.id);
|
|
155
|
+
observer.close();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a non-owner removes only the comments of the author it acts as, and names itself", async () => {
|
|
159
|
+
// Arrange: the owner's and Ana's comments; an agent connection acting as Ana
|
|
160
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
161
|
+
|
|
162
|
+
await client.sessionAnnotate(session.id, {
|
|
163
|
+
id: "own",
|
|
164
|
+
kind: "comment",
|
|
165
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
166
|
+
body: "mine",
|
|
167
|
+
});
|
|
168
|
+
await client.sessionAnnotate(session.id, {
|
|
169
|
+
id: "anas",
|
|
170
|
+
kind: "comment",
|
|
171
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
172
|
+
body: "hers",
|
|
173
|
+
author: "SHA256:ana",
|
|
174
|
+
});
|
|
175
|
+
const anonymous = await DaemonClient.connect({ home, role: "agent" });
|
|
176
|
+
const agent = await DaemonClient.connect({ home, role: "agent", author: "SHA256:ana" });
|
|
177
|
+
|
|
178
|
+
// Act + Assert: an unbound connection removes nothing; a bound one never another author's
|
|
179
|
+
await expect(anonymous.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
|
|
180
|
+
"code",
|
|
181
|
+
"forbidden",
|
|
182
|
+
);
|
|
183
|
+
await expect(agent.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
|
|
184
|
+
"code",
|
|
185
|
+
"forbidden",
|
|
186
|
+
);
|
|
187
|
+
await expect(
|
|
188
|
+
agent.sessionSetParticipantName(session.id, "SHA256:bob", "Not Bob"),
|
|
189
|
+
).rejects.toHaveProperty("code", "forbidden");
|
|
190
|
+
|
|
191
|
+
// Act: her own comment goes, and she names herself
|
|
192
|
+
const removed = await agent.sessionRemoveAnnotation(session.id, "anas");
|
|
193
|
+
const named = await agent.sessionSetParticipantName(session.id, "SHA256:ana", "Ana");
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect(removed.annotations.map((annotation) => annotation.id)).toEqual(["own"]);
|
|
197
|
+
expect(removed.history!.entries.at(-1)).toMatchObject({
|
|
198
|
+
type: "comment-removed",
|
|
199
|
+
annotationId: "anas",
|
|
200
|
+
});
|
|
201
|
+
expect(named.participants).toContainEqual({ id: "SHA256:ana", provider: "ssh", name: "Ana" });
|
|
202
|
+
anonymous.close();
|
|
203
|
+
agent.close();
|
|
204
|
+
});
|
|
205
|
+
|
|
69
206
|
test("malformed requests get structured errors and never wedge the daemon", async () => {
|
|
70
207
|
// missing required params
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} catch (e) {
|
|
75
|
-
expect((e as DaemonClientError).code).toBe("invalid_params");
|
|
76
|
-
}
|
|
208
|
+
await expect(
|
|
209
|
+
client.request("session.create", { artifact: { type: "plan", content: "x" } }, v.object({})),
|
|
210
|
+
).rejects.toHaveProperty("code", "invalid_params");
|
|
77
211
|
// wrong types
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect((e as DaemonClientError).code).toBe("invalid_params");
|
|
83
|
-
}
|
|
212
|
+
await expect(client.request("session.wait", { id: 42 }, v.object({}))).rejects.toHaveProperty(
|
|
213
|
+
"code",
|
|
214
|
+
"invalid_params",
|
|
215
|
+
);
|
|
84
216
|
// unknown method
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
217
|
+
await expect(client.request("session.nuke", {}, v.object({}))).rejects.toHaveProperty(
|
|
218
|
+
"code",
|
|
219
|
+
"unknown_method",
|
|
220
|
+
);
|
|
221
|
+
await Promise.all(
|
|
222
|
+
["__proto__", "toString", "constructor"].map((method) =>
|
|
223
|
+
expect(client.request(method, {}, v.object({}))).rejects.toHaveProperty(
|
|
224
|
+
"code",
|
|
225
|
+
"unknown_method",
|
|
226
|
+
),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
91
229
|
// the daemon is still fully alive afterwards
|
|
92
|
-
const
|
|
93
|
-
|
|
230
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
231
|
+
|
|
232
|
+
expect((await client.sessionGet(session.id)).id).toBe(session.id);
|
|
94
233
|
});
|
|
95
234
|
|
|
96
235
|
test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
|
|
97
|
-
|
|
236
|
+
// Act
|
|
237
|
+
const session = await client.sessionCreate(WS, {
|
|
98
238
|
type: "plan",
|
|
99
239
|
content: "# P",
|
|
100
240
|
meta: { agent: "claude-code", herdrPane: "%7" },
|
|
101
241
|
});
|
|
102
|
-
const got = await client.sessionGet(
|
|
242
|
+
const got = await client.sessionGet(session.id);
|
|
243
|
+
|
|
244
|
+
// Assert
|
|
103
245
|
expect(got.artifact.meta.herdrPane).toBe("%7");
|
|
104
246
|
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
105
247
|
});
|
|
106
248
|
|
|
249
|
+
test("share-sync primitives round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
|
|
250
|
+
// Given a shared session with one local note
|
|
251
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
252
|
+
const anchor = { quote: "Body text", prefix: "", suffix: "." };
|
|
253
|
+
|
|
254
|
+
await client.sessionSetShareId(session.id, "p_abc123xy");
|
|
255
|
+
await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
|
|
256
|
+
expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
|
|
257
|
+
|
|
258
|
+
// When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
|
|
259
|
+
const merged = await client.sessionMergeShared(session.id, {
|
|
260
|
+
annotations: [
|
|
261
|
+
{
|
|
262
|
+
id: "a1",
|
|
263
|
+
kind: "comment",
|
|
264
|
+
anchor,
|
|
265
|
+
body: "should not overwrite",
|
|
266
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "a2",
|
|
270
|
+
kind: "comment",
|
|
271
|
+
anchor,
|
|
272
|
+
body: "theirs",
|
|
273
|
+
author: "SHA256:mate",
|
|
274
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
|
|
281
|
+
expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
|
|
282
|
+
expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
|
|
283
|
+
expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
|
|
284
|
+
"SHA256:mate",
|
|
285
|
+
);
|
|
286
|
+
expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("mergeShared validates each annotation at the socket boundary", async () => {
|
|
290
|
+
// Given a session
|
|
291
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
292
|
+
|
|
293
|
+
// When a malformed annotation is merged, it is rejected at the wire boundary
|
|
294
|
+
// @ts-expect-error intentionally malformed annotation (empty id, missing fields)
|
|
295
|
+
const merge = client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
|
|
296
|
+
|
|
297
|
+
await expect(merge).rejects.toHaveProperty("code", "invalid_params");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("session.delete removes a session over the wire", async () => {
|
|
301
|
+
// Given a created session
|
|
302
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
303
|
+
|
|
304
|
+
// When it is deleted
|
|
305
|
+
await client.sessionDelete(session.id);
|
|
306
|
+
|
|
307
|
+
// Then it is gone, and deleting an unknown id is a not_found error
|
|
308
|
+
await expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
309
|
+
await expect(client.sessionDelete("ses_missing")).rejects.toHaveProperty("code", "not_found");
|
|
310
|
+
});
|
|
311
|
+
|
|
107
312
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
313
|
+
// Arrange
|
|
108
314
|
const second = await DaemonClient.connect({ home });
|
|
109
|
-
|
|
110
|
-
|
|
315
|
+
|
|
316
|
+
// Act
|
|
317
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
318
|
+
const fromSecond = await second.sessionGet(session.id);
|
|
319
|
+
|
|
320
|
+
// Assert
|
|
111
321
|
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
112
322
|
second.close();
|
|
113
323
|
});
|
|
114
324
|
});
|
|
325
|
+
|
|
326
|
+
describe("ownership is proven, never declared", () => {
|
|
327
|
+
/** A wire frame as the daemon answers it: a result or a coded error. */
|
|
328
|
+
interface WireReply {
|
|
329
|
+
result?: unknown;
|
|
330
|
+
error?: { code: string };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The request body a bare socket sends: the primitive's params as the wire carries them. */
|
|
334
|
+
type WireParams = Parameters<typeof JSON.stringify>[0];
|
|
335
|
+
|
|
336
|
+
interface RawConnection {
|
|
337
|
+
call: (method: string, params: WireParams) => Promise<WireReply>;
|
|
338
|
+
close: () => void;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A bare socket that speaks the wire protocol without the client's handshake. */
|
|
342
|
+
async function rawConnection(): Promise<RawConnection> {
|
|
343
|
+
const pending = new Map<number, (frame: WireReply) => void>();
|
|
344
|
+
let buffered = "";
|
|
345
|
+
let nextId = 1;
|
|
346
|
+
const socket = await Bun.connect({
|
|
347
|
+
unix: join(home, "cueloop.sock"),
|
|
348
|
+
socket: {
|
|
349
|
+
data: (_socket, data) => {
|
|
350
|
+
buffered += data.toString();
|
|
351
|
+
const lines = buffered.split("\n");
|
|
352
|
+
|
|
353
|
+
buffered = lines.pop() ?? "";
|
|
354
|
+
for (const line of lines) {
|
|
355
|
+
const frame = JSON.parse(line);
|
|
356
|
+
|
|
357
|
+
if ("id" in frame) pending.get(frame.id)?.(frame);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
call: (method, params) =>
|
|
365
|
+
new Promise((resolve) => {
|
|
366
|
+
const id = nextId++;
|
|
367
|
+
|
|
368
|
+
pending.set(id, resolve);
|
|
369
|
+
socket.write(JSON.stringify({ id, method, params }) + "\n");
|
|
370
|
+
}),
|
|
371
|
+
close: () => socket.end(),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
test("a connection that never proves ownership is a collaborator: it reads and comments, nothing more", async () => {
|
|
376
|
+
// Arrange
|
|
377
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
378
|
+
const raw = await rawConnection();
|
|
379
|
+
|
|
380
|
+
// Act + Assert
|
|
381
|
+
expect((await raw.call("session.get", { id: session.id })).error).toBeUndefined();
|
|
382
|
+
expect(
|
|
383
|
+
(
|
|
384
|
+
await raw.call("session.annotate", {
|
|
385
|
+
id: session.id,
|
|
386
|
+
annotation: {
|
|
387
|
+
id: "a_raw",
|
|
388
|
+
kind: "comment",
|
|
389
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
390
|
+
body: "from a bare socket",
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
).error,
|
|
394
|
+
).toBeUndefined();
|
|
395
|
+
expect(
|
|
396
|
+
(await raw.call("session.sendMessage", { id: session.id, outcome: "approved", summary: "" }))
|
|
397
|
+
.error?.code,
|
|
398
|
+
).toBe("forbidden");
|
|
399
|
+
expect((await raw.call("session.delete", { id: session.id })).error?.code).toBe("forbidden");
|
|
400
|
+
raw.close();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("claiming ownership without the token is refused; the token grants it", async () => {
|
|
404
|
+
// Arrange
|
|
405
|
+
const raw = await rawConnection();
|
|
406
|
+
|
|
407
|
+
// Act + Assert: a bare claim, then a wrong token, both refused
|
|
408
|
+
expect((await raw.call("daemon.hello", { role: "owner" })).error?.code).toBe("forbidden");
|
|
409
|
+
expect((await raw.call("daemon.hello", { role: "owner", token: "nope" })).error?.code).toBe(
|
|
410
|
+
"forbidden",
|
|
411
|
+
);
|
|
412
|
+
expect((await raw.call("daemon.shutdown", {})).error?.code).toBe("forbidden");
|
|
413
|
+
|
|
414
|
+
// Act: the token the daemon wrote into its home
|
|
415
|
+
const token = readFileSync(join(home, "owner.token"), "utf8").trim();
|
|
416
|
+
|
|
417
|
+
expect(
|
|
418
|
+
(
|
|
419
|
+
await raw.call("daemon.hello", {
|
|
420
|
+
role: "owner",
|
|
421
|
+
token,
|
|
422
|
+
clientVersion: DAEMON_VERSION,
|
|
423
|
+
})
|
|
424
|
+
).error,
|
|
425
|
+
).toBeUndefined();
|
|
426
|
+
|
|
427
|
+
// Assert: owner primitives open up
|
|
428
|
+
const created = await raw.call("session.create", { workspace: WS, artifact: PLAN });
|
|
429
|
+
|
|
430
|
+
expect(created.error).toBeUndefined();
|
|
431
|
+
raw.close();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("an older owner cannot shut down a newer daemon", async () => {
|
|
435
|
+
const raw = await rawConnection();
|
|
436
|
+
const token = readFileSync(join(home, "owner.token"), "utf8").trim();
|
|
437
|
+
|
|
438
|
+
expect((await raw.call("daemon.hello", { role: "owner", token })).error?.code).toBe(
|
|
439
|
+
"version_mismatch",
|
|
440
|
+
);
|
|
441
|
+
expect((await raw.call("daemon.shutdown", {})).error?.code).toBe("forbidden");
|
|
442
|
+
expect((await raw.call("daemon.ping", {})).error).toBeUndefined();
|
|
443
|
+
raw.close();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("a refused handshake reaches the caller and leaves the live daemon's socket alone", async () => {
|
|
447
|
+
// Arrange: a token file that is not what the daemon minted
|
|
448
|
+
const tokenPath = join(home, "owner.token");
|
|
449
|
+
const minted = readFileSync(tokenPath, "utf8");
|
|
450
|
+
|
|
451
|
+
writeFileSync(tokenPath, "0".repeat(64));
|
|
452
|
+
|
|
453
|
+
// Act + Assert: the client hears the refusal; autostart does not replace the daemon
|
|
454
|
+
await expect(DaemonClient.connect({ home, autostart: true })).rejects.toHaveProperty(
|
|
455
|
+
"code",
|
|
456
|
+
"forbidden",
|
|
457
|
+
);
|
|
458
|
+
expect(existsSync(join(home, "cueloop.sock"))).toBe(true);
|
|
459
|
+
expect((await client.sessionList()).length).toBe(0);
|
|
460
|
+
|
|
461
|
+
// Arrange: a token file that is not a token at all
|
|
462
|
+
writeFileSync(tokenPath, "not-a-token");
|
|
463
|
+
|
|
464
|
+
// Act + Assert
|
|
465
|
+
await expect(DaemonClient.connect({ home })).rejects.toHaveProperty(
|
|
466
|
+
"code",
|
|
467
|
+
"invalid_owner_token",
|
|
468
|
+
);
|
|
469
|
+
writeFileSync(tokenPath, minted);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("the token exists before the socket does", () => {
|
|
473
|
+
// Arrange: a second daemon in another home
|
|
474
|
+
const other = mkdtempSync(join(tmpdir(), "cueloop-token-"));
|
|
475
|
+
const daemon = new DaemonServer({ home: other, idleExitMs: 0 });
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
// Act
|
|
479
|
+
daemon.start();
|
|
480
|
+
|
|
481
|
+
// Assert: the token is on disk the moment the socket is
|
|
482
|
+
expect(existsSync(join(other, "owner.token"))).toBe(true);
|
|
483
|
+
expect(existsSync(join(other, "cueloop.sock"))).toBe(true);
|
|
484
|
+
} finally {
|
|
485
|
+
daemon.stop();
|
|
486
|
+
rmSync(other, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("the daemon client proves ownership on connect and the token is private to the home", async () => {
|
|
491
|
+
// Assert: the client in beforeEach connected as the owner through the token
|
|
492
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
493
|
+
|
|
494
|
+
expect((await client.sessionSendMessage(session.id, "approved", "")).status).toBe("resolved");
|
|
495
|
+
expect(statSync(join(home, "owner.token")).mode & 0o777).toBe(0o600);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe("large payloads survive socket backpressure", () => {
|
|
500
|
+
test("a session bigger than the kernel socket buffer round-trips", async () => {
|
|
501
|
+
// Regression: responses larger than one socket write used to truncate
|
|
502
|
+
// mid-line because the write return value was ignored, so the client
|
|
503
|
+
// never saw the frame's newline and every later request wedged too.
|
|
504
|
+
// Arrange
|
|
505
|
+
const bigContent =
|
|
506
|
+
"# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
|
|
507
|
+
|
|
508
|
+
// Act
|
|
509
|
+
const created = await client.sessionCreate(
|
|
510
|
+
{ repoRoot: "/repo", branch: "main" },
|
|
511
|
+
{ type: "plan", content: bigContent, meta: { title: "big" } },
|
|
512
|
+
);
|
|
513
|
+
const fetched = await client.sessionGet(created.id);
|
|
514
|
+
|
|
515
|
+
// Assert
|
|
516
|
+
expect(fetched.artifact.content).toBe(bigContent);
|
|
517
|
+
|
|
518
|
+
// Act
|
|
519
|
+
// the connection must still be usable for the next frame
|
|
520
|
+
const listed = await client.sessionList();
|
|
521
|
+
|
|
522
|
+
// Assert
|
|
523
|
+
expect(listed.some((session) => session.id === created.id)).toBe(true);
|
|
524
|
+
}, 15_000);
|
|
525
|
+
});
|