@cueloop/daemon 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +6 -3
- package/src/api.test.ts +479 -1
- package/src/api.ts +453 -28
- package/src/capabilities.test.ts +28 -9
- package/src/capabilities.ts +58 -22
- package/src/client.ts +183 -43
- package/src/curate.ts +109 -0
- package/src/diff-hot-reload.test.ts +16 -0
- package/src/diff-watcher.ts +1 -4
- package/src/herdr-pane.test.ts +4 -7
- package/src/herdr-pane.ts +20 -7
- package/src/herdr-split.test.ts +1 -1
- package/src/herdr-split.ts +8 -4
- package/src/herdr-tab-store.ts +10 -13
- package/src/paths.ts +5 -0
- package/src/protocol.ts +28 -0
- package/src/retention.test.ts +3 -3
- package/src/retention.ts +1 -1
- package/src/review.ts +1 -1
- package/src/server.test.ts +258 -54
- package/src/server.ts +102 -13
- package/src/share-blob.test.ts +51 -1
- package/src/share-blob.ts +1 -1
- package/src/store.conformance.test.ts +41 -0
- package/src/store.ts +89 -12
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +60 -3
- package/src/validate.ts +104 -3
package/src/diff-watcher.ts
CHANGED
|
@@ -64,10 +64,7 @@ export class DiffWatcher {
|
|
|
64
64
|
}
|
|
65
65
|
// a watcher error (the root is deleted mid-review) must not take down the
|
|
66
66
|
// daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
|
|
67
|
-
|
|
68
|
-
"error",
|
|
69
|
-
() => {},
|
|
70
|
-
);
|
|
67
|
+
handle.on("error", () => {});
|
|
71
68
|
this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
|
|
72
69
|
}
|
|
73
70
|
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
|
|
|
16
16
|
* pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
|
|
17
17
|
* per `paneAlive` so the liveness branch can be exercised.
|
|
18
18
|
*/
|
|
19
|
-
function makeStub(name: string, paneAlive = false)
|
|
19
|
+
function makeStub(name: string, paneAlive = false) {
|
|
20
20
|
const logPath = join(dir, `${name}.log`);
|
|
21
21
|
const binPath = join(dir, `${name}.sh`);
|
|
22
22
|
const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
|
|
@@ -44,16 +44,13 @@ function readLines(logPath: string): string[] {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** In-memory persistence double: records what openHerdrPaneForReview saves. */
|
|
47
|
-
function fakePersistence(initial: HerdrTabHandle | null = null)
|
|
48
|
-
persistence: HerdrTabPersistence;
|
|
49
|
-
saved: () => HerdrTabHandle | null;
|
|
50
|
-
} {
|
|
47
|
+
function fakePersistence(initial: HerdrTabHandle | null = null) {
|
|
51
48
|
let stored = initial;
|
|
52
49
|
|
|
53
50
|
return {
|
|
54
51
|
persistence: {
|
|
55
52
|
herdrGetTab: async () => stored,
|
|
56
|
-
herdrSetTab: async (_sessionId, handle) => {
|
|
53
|
+
herdrSetTab: async (_sessionId: string, handle: HerdrTabHandle) => {
|
|
57
54
|
stored = handle;
|
|
58
55
|
},
|
|
59
56
|
},
|
|
@@ -199,7 +196,7 @@ describe("openHerdrPaneForReview", () => {
|
|
|
199
196
|
});
|
|
200
197
|
|
|
201
198
|
test("a recall failure (stale daemon) still opens a fresh tab", async () => {
|
|
202
|
-
// Arrange - a daemon predating the herdr-tab
|
|
199
|
+
// Arrange - a daemon predating the herdr-tab primitives rejects the recall
|
|
203
200
|
const stub = makeStub("gated-recall-fail");
|
|
204
201
|
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
205
202
|
const persistence: HerdrTabPersistence = {
|
package/src/herdr-pane.ts
CHANGED
|
@@ -7,9 +7,22 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { detectHerdr, type HerdrEnv, type ReviewSession } from "@cueloop/schema";
|
|
10
|
+
import * as v from "valibot";
|
|
10
11
|
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
11
12
|
|
|
12
13
|
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
14
|
+
const CreatedTabSchema = v.object({
|
|
15
|
+
result: v.optional(
|
|
16
|
+
v.object({
|
|
17
|
+
root_pane: v.optional(
|
|
18
|
+
v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
|
|
19
|
+
),
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
});
|
|
23
|
+
const PaneResultSchema = v.object({
|
|
24
|
+
result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
|
|
25
|
+
});
|
|
13
26
|
|
|
14
27
|
export interface OpenHerdrPaneOptions {
|
|
15
28
|
sessionId: string;
|
|
@@ -40,11 +53,11 @@ export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | n
|
|
|
40
53
|
);
|
|
41
54
|
|
|
42
55
|
if (created.exitCode !== 0) return null;
|
|
43
|
-
const parsed = JSON.parse(created.stdout.toString())
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const paneId = parsed.result?.root_pane?.pane_id;
|
|
47
|
-
const tabId = parsed.result?.root_pane?.tab_id;
|
|
56
|
+
const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
|
|
57
|
+
|
|
58
|
+
if (!parsed.success) return null;
|
|
59
|
+
const paneId = parsed.output.result?.root_pane?.pane_id;
|
|
60
|
+
const tabId = parsed.output.result?.root_pane?.tab_id;
|
|
48
61
|
|
|
49
62
|
if (!paneId || !tabId) return null;
|
|
50
63
|
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
|
@@ -77,9 +90,9 @@ function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
|
77
90
|
});
|
|
78
91
|
|
|
79
92
|
if (got.exitCode !== 0) return false;
|
|
80
|
-
const parsed = JSON.parse(got.stdout.toString())
|
|
93
|
+
const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
|
|
81
94
|
|
|
82
|
-
return parsed.result?.pane != null;
|
|
95
|
+
return parsed.success && parsed.output.result?.pane != null;
|
|
83
96
|
} catch {
|
|
84
97
|
return false;
|
|
85
98
|
}
|
package/src/herdr-split.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { launchHarnessInSplit } from "./herdr-split";
|
|
|
9
9
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-split-"));
|
|
10
10
|
|
|
11
11
|
/** A stub herdr binary that logs argv and prints the pane-split result (pane_id of the new split). */
|
|
12
|
-
function makeStub(name: string, splitOk = true)
|
|
12
|
+
function makeStub(name: string, splitOk = true) {
|
|
13
13
|
const logPath = join(dir, `${name}.log`);
|
|
14
14
|
const binPath = join(dir, `${name}.sh`);
|
|
15
15
|
const splitResult = splitOk
|
package/src/herdr-split.ts
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
|
|
10
|
+
import * as v from "valibot";
|
|
10
11
|
|
|
11
12
|
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
13
|
+
const SplitPaneSchema = v.object({
|
|
14
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.optional(v.string()) })) })),
|
|
15
|
+
});
|
|
12
16
|
|
|
13
17
|
/** How much of the current pane the launched harness split takes (herdr --ratio). */
|
|
14
18
|
const HARNESS_SPLIT_RATIO = "0.4";
|
|
@@ -55,10 +59,10 @@ export function launchHarnessInSplit(
|
|
|
55
59
|
);
|
|
56
60
|
|
|
57
61
|
if (split.exitCode !== 0) return false;
|
|
58
|
-
const parsed = JSON.parse(split.stdout.toString())
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const paneId = parsed.result?.pane?.pane_id;
|
|
62
|
+
const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
|
|
63
|
+
|
|
64
|
+
if (!parsed.success) return false;
|
|
65
|
+
const paneId = parsed.output.result?.pane?.pane_id;
|
|
62
66
|
|
|
63
67
|
if (!paneId) return false;
|
|
64
68
|
sendText(herdr.binPath, paneId, options.command);
|
package/src/herdr-tab-store.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import * as v from "valibot";
|
|
9
10
|
import { herdrTabsPath } from "./paths";
|
|
10
11
|
|
|
11
12
|
/** The tab and its root pane: pane id checks liveness, tab id focuses it. */
|
|
@@ -14,15 +15,11 @@ export interface HerdrTabHandle {
|
|
|
14
15
|
paneId: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
typeof (value as HerdrTabHandle).tabId === "string" &&
|
|
23
|
-
typeof (value as HerdrTabHandle).paneId === "string"
|
|
24
|
-
);
|
|
25
|
-
}
|
|
18
|
+
const HerdrTabHandleSchema = v.object({
|
|
19
|
+
tabId: v.string(),
|
|
20
|
+
paneId: v.string(),
|
|
21
|
+
});
|
|
22
|
+
const HerdrTabsSchema = v.record(v.string(), v.unknown());
|
|
26
23
|
|
|
27
24
|
export class HerdrTabStore {
|
|
28
25
|
private tabs = new Map<string, HerdrTabHandle>();
|
|
@@ -35,12 +32,12 @@ export class HerdrTabStore {
|
|
|
35
32
|
|
|
36
33
|
private load(): void {
|
|
37
34
|
try {
|
|
38
|
-
const parsed
|
|
35
|
+
const parsed = v.parse(HerdrTabsSchema, JSON.parse(readFileSync(this.path, "utf8")));
|
|
39
36
|
|
|
40
|
-
if (typeof parsed !== "object" || parsed === null) return;
|
|
41
37
|
for (const [sessionId, value] of Object.entries(parsed)) {
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
const handle = v.safeParse(HerdrTabHandleSchema, value);
|
|
39
|
+
|
|
40
|
+
if (handle.success) this.tabs.set(sessionId, handle.output);
|
|
44
41
|
}
|
|
45
42
|
} catch {
|
|
46
43
|
// absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
|
package/src/paths.ts
CHANGED
|
@@ -30,6 +30,11 @@ export function lockPath(home = cueloopHome()): string {
|
|
|
30
30
|
return join(home, "cueloop.lock");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
|
|
34
|
+
export function ownerTokenPath(home = cueloopHome()): string {
|
|
35
|
+
return join(home, "owner.token");
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
|
|
34
39
|
export function herdrTabsPath(home = cueloopHome()): string {
|
|
35
40
|
return join(home, "herdr-tabs.json");
|
package/src/protocol.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Events (push, after events.subscribe): { event, sessionId }
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import * as v from "valibot";
|
|
8
|
+
|
|
7
9
|
export interface Request {
|
|
8
10
|
id: number;
|
|
9
11
|
method: string;
|
|
@@ -19,10 +21,36 @@ export interface Response {
|
|
|
19
21
|
export interface EventFrame {
|
|
20
22
|
event: string;
|
|
21
23
|
sessionId: string;
|
|
24
|
+
/** The history entry the change appended, when it appended one. */
|
|
25
|
+
entryId?: string;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export type Frame = Response | EventFrame;
|
|
25
29
|
|
|
30
|
+
const RequestSchema = v.object({
|
|
31
|
+
id: v.number(),
|
|
32
|
+
method: v.string(),
|
|
33
|
+
params: v.optional(v.unknown()),
|
|
34
|
+
});
|
|
35
|
+
const ResponseSchema = v.object({
|
|
36
|
+
id: v.number(),
|
|
37
|
+
result: v.optional(v.unknown()),
|
|
38
|
+
error: v.optional(v.object({ code: v.string(), message: v.string() })),
|
|
39
|
+
});
|
|
40
|
+
const EventFrameSchema = v.object({
|
|
41
|
+
event: v.string(),
|
|
42
|
+
sessionId: v.string(),
|
|
43
|
+
entryId: v.optional(v.string()),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export function parseRequestFrame(line: string): Request {
|
|
47
|
+
return v.parse(RequestSchema, JSON.parse(line));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseInboundFrame(line: string): Frame {
|
|
51
|
+
return v.parse(v.union([ResponseSchema, EventFrameSchema]), JSON.parse(line));
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
55
|
export class LineBuffer {
|
|
28
56
|
private buffered = "";
|
package/src/retention.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
48
48
|
const missing = join(tempDir("cueloop-retention-"), "config.toml");
|
|
49
49
|
|
|
50
50
|
// Act
|
|
51
|
-
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing }
|
|
51
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing });
|
|
52
52
|
|
|
53
53
|
// Assert
|
|
54
54
|
expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
|
|
@@ -61,7 +61,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
61
61
|
writeFileSync(path, "[cleanup]\nperiod_days = 7\n");
|
|
62
62
|
|
|
63
63
|
// Act
|
|
64
|
-
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path }
|
|
64
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
|
|
65
65
|
|
|
66
66
|
// Assert
|
|
67
67
|
expect(days).toBe(7);
|
|
@@ -74,7 +74,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
74
74
|
writeFileSync(path, '[cleanup]\nperiod_days = "soon"\n');
|
|
75
75
|
|
|
76
76
|
// Act
|
|
77
|
-
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path }
|
|
77
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
|
|
78
78
|
|
|
79
79
|
// Assert
|
|
80
80
|
expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
|
package/src/retention.ts
CHANGED
|
@@ -20,7 +20,7 @@ const RefineStateSchema = v.object({
|
|
|
20
20
|
|
|
21
21
|
export type RefineState = v.InferOutput<typeof RefineStateSchema>;
|
|
22
22
|
|
|
23
|
-
export function parseRefineState(raw:
|
|
23
|
+
export function parseRefineState(raw: Parameters<typeof v.safeParse>[1]): Map<string, string> {
|
|
24
24
|
const parsed = v.safeParse(RefineStateSchema, raw);
|
|
25
25
|
|
|
26
26
|
if (!parsed.success) return new Map();
|
package/src/review.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { verdictResponse } from "./api";
|
|
19
19
|
import type { DaemonClient } from "./client";
|
|
20
20
|
|
|
21
|
-
// Adapters and CLI
|
|
21
|
+
// Adapters and CLI primitives reach the verdict mapping through this module too,
|
|
22
22
|
// so a session obtained outside a ReviewHandle maps the same way.
|
|
23
23
|
export { verdictResponse };
|
|
24
24
|
|
package/src/server.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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";
|
|
7
8
|
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
@@ -54,12 +55,8 @@ describe("socket round-trip", () => {
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
test("errors carry codes across the wire", async () => {
|
|
57
|
-
expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
58
|
-
|
|
59
|
-
await client.sessionGet("nope");
|
|
60
|
-
} catch (error) {
|
|
61
|
-
expect((error as DaemonClientError).code).toBe("not_found");
|
|
62
|
-
}
|
|
58
|
+
await expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
59
|
+
await expect(client.sessionGet("nope")).rejects.toHaveProperty("code", "not_found");
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
test("events push to subscribed connections only", async () => {
|
|
@@ -83,40 +80,103 @@ describe("socket round-trip", () => {
|
|
|
83
80
|
observer.close();
|
|
84
81
|
});
|
|
85
82
|
|
|
83
|
+
test("an event names the history entry its change appended", async () => {
|
|
84
|
+
// Arrange
|
|
85
|
+
const observer = await DaemonClient.connect({ home });
|
|
86
|
+
const frames: Array<{ event: string; entryId?: string }> = [];
|
|
87
|
+
|
|
88
|
+
observer.onEvent((event) => frames.push(event));
|
|
89
|
+
await observer.subscribe();
|
|
90
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const annotated = await client.sessionAnnotate(session.id, {
|
|
94
|
+
id: "a_evt",
|
|
95
|
+
kind: "comment",
|
|
96
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
97
|
+
body: "hello",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await Bun.sleep(50);
|
|
101
|
+
|
|
102
|
+
// Assert: the update frame carries the comment entry's id
|
|
103
|
+
const updated = frames.find((frame) => frame.event === "session.updated");
|
|
104
|
+
|
|
105
|
+
expect(updated?.entryId).toBe(annotated.history!.entries.at(-1)!.id);
|
|
106
|
+
observer.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a non-owner removes only the comments of the author it acts as, and names itself", async () => {
|
|
110
|
+
// Arrange: the owner's and Ana's comments; an agent connection acting as Ana
|
|
111
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
112
|
+
|
|
113
|
+
await client.sessionAnnotate(session.id, {
|
|
114
|
+
id: "own",
|
|
115
|
+
kind: "comment",
|
|
116
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
117
|
+
body: "mine",
|
|
118
|
+
});
|
|
119
|
+
await client.sessionAnnotate(session.id, {
|
|
120
|
+
id: "anas",
|
|
121
|
+
kind: "comment",
|
|
122
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
123
|
+
body: "hers",
|
|
124
|
+
author: "SHA256:ana",
|
|
125
|
+
});
|
|
126
|
+
const anonymous = await DaemonClient.connect({ home, role: "agent" });
|
|
127
|
+
const agent = await DaemonClient.connect({ home, role: "agent", author: "SHA256:ana" });
|
|
128
|
+
|
|
129
|
+
// Act + Assert: an unbound connection removes nothing; a bound one never another author's
|
|
130
|
+
await expect(anonymous.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
|
|
131
|
+
"code",
|
|
132
|
+
"forbidden",
|
|
133
|
+
);
|
|
134
|
+
await expect(agent.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
|
|
135
|
+
"code",
|
|
136
|
+
"forbidden",
|
|
137
|
+
);
|
|
138
|
+
await expect(
|
|
139
|
+
agent.sessionSetParticipantName(session.id, "SHA256:bob", "Not Bob"),
|
|
140
|
+
).rejects.toHaveProperty("code", "forbidden");
|
|
141
|
+
|
|
142
|
+
// Act: her own comment goes, and she names herself
|
|
143
|
+
const removed = await agent.sessionRemoveAnnotation(session.id, "anas");
|
|
144
|
+
const named = await agent.sessionSetParticipantName(session.id, "SHA256:ana", "Ana");
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
expect(removed.annotations.map((annotation) => annotation.id)).toEqual(["own"]);
|
|
148
|
+
expect(removed.history!.entries.at(-1)).toMatchObject({
|
|
149
|
+
type: "comment-removed",
|
|
150
|
+
annotationId: "anas",
|
|
151
|
+
});
|
|
152
|
+
expect(named.participants).toContainEqual({ id: "SHA256:ana", provider: "ssh", name: "Ana" });
|
|
153
|
+
anonymous.close();
|
|
154
|
+
agent.close();
|
|
155
|
+
});
|
|
156
|
+
|
|
86
157
|
test("malformed requests get structured errors and never wedge the daemon", async () => {
|
|
87
158
|
// missing required params
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
} catch (error) {
|
|
92
|
-
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
93
|
-
}
|
|
159
|
+
await expect(
|
|
160
|
+
client.request("session.create", { artifact: { type: "plan", content: "x" } }, v.object({})),
|
|
161
|
+
).rejects.toHaveProperty("code", "invalid_params");
|
|
94
162
|
// wrong types
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
100
|
-
}
|
|
163
|
+
await expect(client.request("session.wait", { id: 42 }, v.object({}))).rejects.toHaveProperty(
|
|
164
|
+
"code",
|
|
165
|
+
"invalid_params",
|
|
166
|
+
);
|
|
101
167
|
// unknown method
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const result of prototypeMethods) {
|
|
115
|
-
expect(result.status).toBe("rejected");
|
|
116
|
-
expect(((result as PromiseRejectedResult).reason as DaemonClientError).code).toBe(
|
|
117
|
-
"unknown_method",
|
|
118
|
-
);
|
|
119
|
-
}
|
|
168
|
+
await expect(client.request("session.nuke", {}, v.object({}))).rejects.toHaveProperty(
|
|
169
|
+
"code",
|
|
170
|
+
"unknown_method",
|
|
171
|
+
);
|
|
172
|
+
await Promise.all(
|
|
173
|
+
["__proto__", "toString", "constructor"].map((method) =>
|
|
174
|
+
expect(client.request(method, {}, v.object({}))).rejects.toHaveProperty(
|
|
175
|
+
"code",
|
|
176
|
+
"unknown_method",
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
120
180
|
// the daemon is still fully alive afterwards
|
|
121
181
|
const session = await client.sessionCreate(WS, PLAN);
|
|
122
182
|
|
|
@@ -137,7 +197,7 @@ describe("socket round-trip", () => {
|
|
|
137
197
|
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
138
198
|
});
|
|
139
199
|
|
|
140
|
-
test("share-sync
|
|
200
|
+
test("share-sync primitives round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
|
|
141
201
|
// Given a shared session with one local note
|
|
142
202
|
const session = await client.sessionCreate(WS, PLAN);
|
|
143
203
|
const anchor = { quote: "Body text", prefix: "", suffix: "." };
|
|
@@ -181,15 +241,11 @@ describe("socket round-trip", () => {
|
|
|
181
241
|
// Given a session
|
|
182
242
|
const session = await client.sessionCreate(WS, PLAN);
|
|
183
243
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
} catch (error) {
|
|
190
|
-
// Then it is rejected at the wire boundary
|
|
191
|
-
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
192
|
-
}
|
|
244
|
+
// When a malformed annotation is merged, it is rejected at the wire boundary
|
|
245
|
+
// @ts-expect-error intentionally malformed annotation (empty id, missing fields)
|
|
246
|
+
const merge = client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
|
|
247
|
+
|
|
248
|
+
await expect(merge).rejects.toHaveProperty("code", "invalid_params");
|
|
193
249
|
});
|
|
194
250
|
|
|
195
251
|
test("session.delete removes a session over the wire", async () => {
|
|
@@ -200,13 +256,8 @@ describe("socket round-trip", () => {
|
|
|
200
256
|
await client.sessionDelete(session.id);
|
|
201
257
|
|
|
202
258
|
// Then it is gone, and deleting an unknown id is a not_found error
|
|
203
|
-
expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
204
|
-
|
|
205
|
-
await client.sessionDelete("ses_missing");
|
|
206
|
-
throw new Error("should have thrown");
|
|
207
|
-
} catch (error) {
|
|
208
|
-
expect((error as DaemonClientError).code).toBe("not_found");
|
|
209
|
-
}
|
|
259
|
+
await expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
260
|
+
await expect(client.sessionDelete("ses_missing")).rejects.toHaveProperty("code", "not_found");
|
|
210
261
|
});
|
|
211
262
|
|
|
212
263
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
@@ -223,6 +274,159 @@ describe("socket round-trip", () => {
|
|
|
223
274
|
});
|
|
224
275
|
});
|
|
225
276
|
|
|
277
|
+
describe("ownership is proven, never declared", () => {
|
|
278
|
+
/** A wire frame as the daemon answers it: a result or a coded error. */
|
|
279
|
+
interface WireReply {
|
|
280
|
+
result?: unknown;
|
|
281
|
+
error?: { code: string };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The request body a bare socket sends: the primitive's params as the wire carries them. */
|
|
285
|
+
type WireParams = Parameters<typeof JSON.stringify>[0];
|
|
286
|
+
|
|
287
|
+
interface RawConnection {
|
|
288
|
+
call: (method: string, params: WireParams) => Promise<WireReply>;
|
|
289
|
+
close: () => void;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A bare socket that speaks the wire protocol without the client's handshake. */
|
|
293
|
+
async function rawConnection(): Promise<RawConnection> {
|
|
294
|
+
const pending = new Map<number, (frame: WireReply) => void>();
|
|
295
|
+
let buffered = "";
|
|
296
|
+
let nextId = 1;
|
|
297
|
+
const socket = await Bun.connect({
|
|
298
|
+
unix: join(home, "cueloop.sock"),
|
|
299
|
+
socket: {
|
|
300
|
+
data: (_socket, data) => {
|
|
301
|
+
buffered += data.toString();
|
|
302
|
+
const lines = buffered.split("\n");
|
|
303
|
+
|
|
304
|
+
buffered = lines.pop() ?? "";
|
|
305
|
+
for (const line of lines) {
|
|
306
|
+
const frame = JSON.parse(line);
|
|
307
|
+
|
|
308
|
+
if ("id" in frame) pending.get(frame.id)?.(frame);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
call: (method, params) =>
|
|
316
|
+
new Promise((resolve) => {
|
|
317
|
+
const id = nextId++;
|
|
318
|
+
|
|
319
|
+
pending.set(id, resolve);
|
|
320
|
+
socket.write(JSON.stringify({ id, method, params }) + "\n");
|
|
321
|
+
}),
|
|
322
|
+
close: () => socket.end(),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
test("a connection that never proves ownership is a collaborator: it reads and comments, nothing more", async () => {
|
|
327
|
+
// Arrange
|
|
328
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
329
|
+
const raw = await rawConnection();
|
|
330
|
+
|
|
331
|
+
// Act + Assert
|
|
332
|
+
expect((await raw.call("session.get", { id: session.id })).error).toBeUndefined();
|
|
333
|
+
expect(
|
|
334
|
+
(
|
|
335
|
+
await raw.call("session.annotate", {
|
|
336
|
+
id: session.id,
|
|
337
|
+
annotation: {
|
|
338
|
+
id: "a_raw",
|
|
339
|
+
kind: "comment",
|
|
340
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
341
|
+
body: "from a bare socket",
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
).error,
|
|
345
|
+
).toBeUndefined();
|
|
346
|
+
expect(
|
|
347
|
+
(await raw.call("session.resolve", { id: session.id, verdictKind: "approve", summary: "" }))
|
|
348
|
+
.error?.code,
|
|
349
|
+
).toBe("forbidden");
|
|
350
|
+
expect((await raw.call("session.delete", { id: session.id })).error?.code).toBe("forbidden");
|
|
351
|
+
raw.close();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("claiming ownership without the token is refused; the token grants it", async () => {
|
|
355
|
+
// Arrange
|
|
356
|
+
const raw = await rawConnection();
|
|
357
|
+
|
|
358
|
+
// Act + Assert: a bare claim, then a wrong token, both refused
|
|
359
|
+
expect((await raw.call("daemon.hello", { role: "owner" })).error?.code).toBe("forbidden");
|
|
360
|
+
expect((await raw.call("daemon.hello", { role: "owner", token: "nope" })).error?.code).toBe(
|
|
361
|
+
"forbidden",
|
|
362
|
+
);
|
|
363
|
+
expect((await raw.call("daemon.shutdown", {})).error?.code).toBe("forbidden");
|
|
364
|
+
|
|
365
|
+
// Act: the token the daemon wrote into its home
|
|
366
|
+
const token = readFileSync(join(home, "owner.token"), "utf8").trim();
|
|
367
|
+
|
|
368
|
+
expect((await raw.call("daemon.hello", { role: "owner", token })).error).toBeUndefined();
|
|
369
|
+
|
|
370
|
+
// Assert: owner primitives open up
|
|
371
|
+
const created = await raw.call("session.create", { workspace: WS, artifact: PLAN });
|
|
372
|
+
|
|
373
|
+
expect(created.error).toBeUndefined();
|
|
374
|
+
raw.close();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a refused handshake reaches the caller and leaves the live daemon's socket alone", async () => {
|
|
378
|
+
// Arrange: a token file that is not what the daemon minted
|
|
379
|
+
const tokenPath = join(home, "owner.token");
|
|
380
|
+
const minted = readFileSync(tokenPath, "utf8");
|
|
381
|
+
|
|
382
|
+
writeFileSync(tokenPath, "0".repeat(64));
|
|
383
|
+
|
|
384
|
+
// Act + Assert: the client hears the refusal; autostart does not replace the daemon
|
|
385
|
+
await expect(DaemonClient.connect({ home, autostart: true })).rejects.toHaveProperty(
|
|
386
|
+
"code",
|
|
387
|
+
"forbidden",
|
|
388
|
+
);
|
|
389
|
+
expect(existsSync(join(home, "cueloop.sock"))).toBe(true);
|
|
390
|
+
expect((await client.sessionList()).length).toBe(0);
|
|
391
|
+
|
|
392
|
+
// Arrange: a token file that is not a token at all
|
|
393
|
+
writeFileSync(tokenPath, "not-a-token");
|
|
394
|
+
|
|
395
|
+
// Act + Assert
|
|
396
|
+
await expect(DaemonClient.connect({ home })).rejects.toHaveProperty(
|
|
397
|
+
"code",
|
|
398
|
+
"invalid_owner_token",
|
|
399
|
+
);
|
|
400
|
+
writeFileSync(tokenPath, minted);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("the token exists before the socket does", () => {
|
|
404
|
+
// Arrange: a second daemon in another home
|
|
405
|
+
const other = mkdtempSync(join(tmpdir(), "cueloop-token-"));
|
|
406
|
+
const daemon = new DaemonServer({ home: other, idleExitMs: 0 });
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
// Act
|
|
410
|
+
daemon.start();
|
|
411
|
+
|
|
412
|
+
// Assert: the token is on disk the moment the socket is
|
|
413
|
+
expect(existsSync(join(other, "owner.token"))).toBe(true);
|
|
414
|
+
expect(existsSync(join(other, "cueloop.sock"))).toBe(true);
|
|
415
|
+
} finally {
|
|
416
|
+
daemon.stop();
|
|
417
|
+
rmSync(other, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("the daemon client proves ownership on connect and the token is private to the home", async () => {
|
|
422
|
+
// Assert: the client in beforeEach connected as the owner through the token
|
|
423
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
424
|
+
|
|
425
|
+
expect((await client.sessionResolve(session.id, "approve", "")).status).toBe("resolved");
|
|
426
|
+
expect(statSync(join(home, "owner.token")).mode & 0o777).toBe(0o600);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
226
430
|
describe("large payloads survive socket backpressure", () => {
|
|
227
431
|
test("a session bigger than the kernel socket buffer round-trips", async () => {
|
|
228
432
|
// Regression: responses larger than one socket write used to truncate
|