@cueloop/daemon 0.1.0-alpha.4 → 0.1.0-alpha.41

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/src/server.ts CHANGED
@@ -1,19 +1,31 @@
1
1
  /**
2
2
  * The socket server: binds DaemonCore's methods to the unix socket, owns the
3
- * pidfile, stale-socket cleanup, and the idle-exit timer (#14). File
3
+ * pidfile, stale-socket cleanup, and the idle-exit timer. File
4
4
  * permissions are the local auth: the socket and state dir are 0700/0600.
5
5
  */
6
6
 
7
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import {
8
+ chmodSync,
9
+ closeSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
8
17
  import { DaemonCore, type DaemonEvent } from "./api";
9
18
  import { DaemonError } from "./errors";
19
+ import { roleAllowsMethod, type DaemonRole } from "./capabilities";
10
20
  import { isKnownMethod, parseParams } from "./validate";
11
- import { LineBuffer, type Request } from "./protocol";
21
+ import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
12
22
  import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
13
23
 
14
- interface Conn {
24
+ interface Connection {
15
25
  write(data: string): void;
16
26
  subscribed: boolean;
27
+ /** Capability role for this connection; the owner until a daemon.hello caps it. */
28
+ role: DaemonRole;
17
29
  }
18
30
 
19
31
  export interface DaemonOptions {
@@ -33,20 +45,20 @@ const HELD_HOMES = new Set<string>();
33
45
  export class DaemonServer {
34
46
  readonly core: DaemonCore;
35
47
  readonly home: string;
36
- private conns = new Set<Conn>();
48
+ private connections = new Set<Connection>();
37
49
  private server: ReturnType<typeof Bun.listen> | null = null;
38
50
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
51
  private lockFd: number | null = null;
40
52
  private readonly idleExitMs: number;
41
53
  private readonly onIdleExit: () => void;
42
54
 
43
- constructor(opts: DaemonOptions = {}) {
44
- this.home = opts.home ?? cueloopHome();
45
- this.idleExitMs = opts.idleExitMs ?? 15 * 60 * 1000;
46
- this.onIdleExit = opts.onIdleExit ?? (() => process.exit(0));
55
+ constructor(options: DaemonOptions = {}) {
56
+ this.home = options.home ?? cueloopHome();
57
+ this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
58
+ this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
47
59
  mkdirSync(this.home, { recursive: true, mode: 0o700 });
48
60
  this.core = new DaemonCore(this.home);
49
- this.core.onEvent((e) => this.broadcast(e));
61
+ this.core.onEvent((event) => this.broadcast(event));
50
62
  }
51
63
 
52
64
  /**
@@ -118,24 +130,37 @@ export class DaemonServer {
118
130
  // safe now: holding the lock means no live daemon owns this home, so any
119
131
  // socket file left behind is stale
120
132
  if (existsSync(path)) rmSync(path, { force: true });
121
- const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
133
+ this.server = Bun.listen<{
134
+ buffer: LineBuffer;
135
+ connection: Connection;
136
+ writer: BackpressureWriter;
137
+ }>({
123
138
  unix: path,
124
139
  socket: {
125
- open(socket) {
126
- const conn: Conn = { write: (d) => socket.write(d), subscribed: false };
127
- socket.data = { buffer: new LineBuffer(), conn };
128
- self.conns.add(conn);
129
- self.scheduleIdleCheck();
140
+ open: (socket) => {
141
+ const writer = new BackpressureWriter(socket);
142
+ const connection: Connection = {
143
+ write: (data) => writer.write(data),
144
+ subscribed: false,
145
+ role: "owner",
146
+ };
147
+ socket.data = { buffer: new LineBuffer(), connection, writer };
148
+ this.connections.add(connection);
149
+ this.scheduleIdleCheck();
130
150
  },
131
- data(socket, data) {
151
+ data: (socket, data) => {
132
152
  socket.data.buffer.push(data.toString(), (line) => {
133
- void self.handleLine(socket.data.conn, line);
153
+ void this.respondToRequestLine(socket.data.connection, line);
134
154
  });
135
155
  },
136
- close(socket) {
137
- self.conns.delete(socket.data.conn);
138
- self.scheduleIdleCheck();
156
+ drain(socket) {
157
+ // drain can fire before open has attached data on some platforms;
158
+ // a throw here would take the whole daemon down with the socket
159
+ socket.data?.writer.drain();
160
+ },
161
+ close: (socket) => {
162
+ this.connections.delete(socket.data.connection);
163
+ this.scheduleIdleCheck();
139
164
  },
140
165
  error() {},
141
166
  },
@@ -155,9 +180,9 @@ export class DaemonServer {
155
180
  this.releaseLock();
156
181
  }
157
182
 
158
- private broadcast(e: DaemonEvent): void {
159
- const frame = JSON.stringify(e) + "\n";
160
- for (const c of this.conns) if (c.subscribed) c.write(frame);
183
+ private broadcast(event: DaemonEvent): void {
184
+ const frame = JSON.stringify(event) + "\n";
185
+ for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
161
186
  this.scheduleIdleCheck();
162
187
  }
163
188
 
@@ -166,7 +191,7 @@ export class DaemonServer {
166
191
  if (this.idleExitMs <= 0) return;
167
192
  if (this.idleTimer) clearTimeout(this.idleTimer);
168
193
  this.idleTimer = setTimeout(() => {
169
- if (this.conns.size === 0 && !this.core.hasPendingSessions()) {
194
+ if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
170
195
  this.stop();
171
196
  this.onIdleExit();
172
197
  } else {
@@ -175,33 +200,44 @@ export class DaemonServer {
175
200
  }, this.idleExitMs);
176
201
  }
177
202
 
178
- private async handleLine(conn: Conn, line: string): Promise<void> {
179
- let req: Request;
203
+ private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
204
+ let request: Request;
180
205
  try {
181
- req = JSON.parse(line) as Request;
206
+ request = JSON.parse(line) as Request;
182
207
  } catch {
183
- conn.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
208
+ connection.write(
209
+ JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
210
+ "\n",
211
+ );
184
212
  return;
185
213
  }
186
214
  try {
187
- const result = await this.dispatch(conn, req);
188
- conn.write(JSON.stringify({ id: req.id, result }) + "\n");
215
+ const result = await this.dispatch(connection, request);
216
+ connection.write(JSON.stringify({ id: request.id, result }) + "\n");
189
217
  } catch (err) {
190
218
  const code = err instanceof DaemonError ? err.code : "internal";
191
219
  const message = err instanceof Error ? err.message : String(err);
192
- conn.write(JSON.stringify({ id: req.id, error: { code, message } }) + "\n");
220
+ connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
193
221
  }
194
222
  }
195
223
 
196
- private async dispatch(conn: Conn, req: Request): Promise<unknown> {
224
+ private async dispatch(connection: Connection, request: Request): Promise<unknown> {
197
225
  // The wire is untrusted JSON: validate before DaemonCore sees anything.
198
- if (typeof req.method !== "string" || !isKnownMethod(req.method)) {
199
- throw new DaemonError("unknown_method", `unknown method ${String(req.method)}`);
226
+ if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
227
+ throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
228
+ }
229
+ // Capability gate: a capped role (a review-side agent) cannot escalate past
230
+ // read + annotate, whatever verb it sends.
231
+ if (!roleAllowsMethod(connection.role, request.method)) {
232
+ throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
200
233
  }
201
234
  const core = this.core;
202
- switch (req.method) {
235
+ switch (request.method) {
203
236
  case "daemon.ping":
204
237
  return { pid: process.pid };
238
+ case "daemon.hello":
239
+ connection.role = parseParams("daemon.hello", request.params).role;
240
+ return {};
205
241
  case "daemon.shutdown":
206
242
  setTimeout(() => {
207
243
  this.stop();
@@ -209,42 +245,68 @@ export class DaemonServer {
209
245
  }, 10);
210
246
  return {};
211
247
  case "events.subscribe":
212
- conn.subscribed = true;
248
+ connection.subscribed = true;
213
249
  return {};
214
250
  case "session.create": {
215
- const p = parseParams("session.create", req.params);
216
- return core.sessionCreate({ workspace: p.workspace, artifact: p.artifact });
251
+ const params = parseParams("session.create", request.params);
252
+ return core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
217
253
  }
218
254
  case "session.get":
219
- return core.sessionGet(parseParams("session.get", req.params).id);
255
+ return core.sessionGet(parseParams("session.get", request.params).id);
220
256
  case "session.list":
221
- return core.sessionList(parseParams("session.list", req.params).filter);
257
+ return core.sessionList(parseParams("session.list", request.params).filter);
222
258
  case "session.wait": {
223
- const p = parseParams("session.wait", req.params);
224
- return core.sessionWait(p.id, p.timeoutMs);
259
+ const params = parseParams("session.wait", request.params);
260
+ return core.sessionWait(params.id, params.timeoutMs);
225
261
  }
226
262
  case "session.annotate": {
227
- const p = parseParams("session.annotate", req.params);
228
- return core.sessionAnnotate(p.id, p.annotation);
263
+ const params = parseParams("session.annotate", request.params);
264
+ return core.sessionAnnotate(params.id, params.annotation, params.authorName);
229
265
  }
230
266
  case "session.removeAnnotation": {
231
- const p = parseParams("session.removeAnnotation", req.params);
232
- return core.sessionRemoveAnnotation(p.id, p.annotationId);
267
+ const params = parseParams("session.removeAnnotation", request.params);
268
+ return core.sessionRemoveAnnotation(params.id, params.annotationId);
233
269
  }
234
270
  case "session.setWorkingCopy": {
235
- const p = parseParams("session.setWorkingCopy", req.params);
236
- return core.sessionSetWorkingCopy(p.id, p.workingCopy);
271
+ const params = parseParams("session.setWorkingCopy", request.params);
272
+ return core.sessionSetWorkingCopy(params.id, params.workingCopy);
273
+ }
274
+ case "session.setViewed": {
275
+ const params = parseParams("session.setViewed", request.params);
276
+ return core.sessionSetViewed(params.id, params.viewedPaths);
277
+ }
278
+ case "session.setShareId": {
279
+ const params = parseParams("session.setShareId", request.params);
280
+ return core.sessionSetShareId(params.id, params.shareId);
281
+ }
282
+ case "session.delete": {
283
+ core.sessionDelete(parseParams("session.delete", request.params).id);
284
+ return {};
285
+ }
286
+ case "session.mergeShared": {
287
+ const params = parseParams("session.mergeShared", request.params);
288
+ return core.sessionMergeShared(params.id, {
289
+ annotations: params.annotations,
290
+ participants: params.participants,
291
+ });
237
292
  }
238
293
  case "session.resolve": {
239
- const p = parseParams("session.resolve", req.params);
240
- return core.sessionResolve(p.id, p.verdictKind, p.summary);
294
+ const params = parseParams("session.resolve", request.params);
295
+ return core.sessionResolve(params.id, params.verdictKind, params.summary);
241
296
  }
242
297
  case "session.submitRevision": {
243
- const p = parseParams("session.submitRevision", req.params);
244
- return core.sessionSubmitRevision(p.id, p.content);
298
+ const params = parseParams("session.submitRevision", request.params);
299
+ return core.sessionSubmitRevision(params.id, params.content, params.addressedAnnotationIds);
300
+ }
301
+ case "herdr.getTab":
302
+ return core.herdrGetTab(parseParams("herdr.getTab", request.params).id);
303
+ case "herdr.setTab": {
304
+ const params = parseParams("herdr.setTab", request.params);
305
+ core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
306
+ return {};
245
307
  }
246
308
  default:
247
- throw new DaemonError("unknown_method", `unknown method ${req.method}`);
309
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
248
310
  }
249
311
  }
250
312
  }
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { gzipSync } from "node:zlib";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { Artifact, WorkspaceKey } from "@cueloop/schema";
7
+ import { DaemonCore } from "./api";
8
+ import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
9
+
10
+ const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
11
+ const PLAN: Artifact = { type: "plan", content: "# Plan\n\nDo the thing.\n", meta: {} };
12
+
13
+ let home: string;
14
+ let core: DaemonCore;
15
+
16
+ beforeEach(() => {
17
+ home = mkdtempSync(join(tmpdir(), "cueloop-blob-"));
18
+ core = new DaemonCore(home);
19
+ });
20
+ afterEach(() => rmSync(home, { recursive: true, force: true }));
21
+
22
+ describe("share blob roundtrip", () => {
23
+ test("pack then unpack restores the session with its annotations", () => {
24
+ // Arrange
25
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
26
+ const annotated = core.sessionAnnotate(session.id, {
27
+ id: "a_1",
28
+ kind: "comment",
29
+ anchor: { quote: "the thing", prefix: "", suffix: "" },
30
+ body: "why this?",
31
+ });
32
+
33
+ // Act
34
+ const restored = unpackSessionBlob(packSessionBlob(annotated));
35
+
36
+ // Assert
37
+ expect(restored).toEqual(annotated);
38
+ });
39
+ });
40
+
41
+ describe("unpack rejects bad input", () => {
42
+ test("caps the decompressed size, so a compression bomb throws", () => {
43
+ // Arrange: tiny gzip, huge inflation - the decompression-bomb shape
44
+ const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
45
+
46
+ // Act / Assert
47
+ expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
48
+ });
49
+
50
+ test("throws on bytes that are not gzip", () => {
51
+ // Act / Assert
52
+ expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
53
+ });
54
+
55
+ test("throws on a shape that is not a session", () => {
56
+ // Arrange
57
+ const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
58
+
59
+ // Act / Assert
60
+ expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
61
+ });
62
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The share-blob wire format: how a ReviewSession travels from `cueloop share`
3
+ * to the gateway. The planner's CLI packs (this is the only thing it can do -
4
+ * it holds no encryption key); the gateway unpacks, then encrypts for R2.
5
+ *
6
+ * Format is gzip(JSON). Unpack is the untrusted boundary - the bytes come off
7
+ * an SSH channel from anyone who can reach the `share` user - so it caps the
8
+ * decompressed size (a decompression-bomb guard) and runs the same record
9
+ * validator the daemon store uses, never trusting the shape.
10
+ */
11
+
12
+ import { gzipSync, gunzipSync } from "node:zlib";
13
+ import type { ReviewSession } from "@cueloop/schema";
14
+ import { validateSessionRecord } from "./validate";
15
+
16
+ /** Decompressed ceiling for one shared session (ADR 0004's payload cap). */
17
+ export const MAX_BLOB_BYTES = 1024 * 1024;
18
+
19
+ /** The SSH username `cueloop share` connects as to upload; the gateway routes on it. */
20
+ export const SHARE_UPLOAD_USER = "share";
21
+
22
+ /** Where shares live, in one place: the client uploads here, the gateway prints it. */
23
+ export const DEFAULT_SHARE_HOST = "cueloop.dev";
24
+ export const DEFAULT_SHARE_PORT = 22;
25
+
26
+ /** Serialise + compress a session for upload. */
27
+ export function packSessionBlob(session: ReviewSession): Buffer {
28
+ return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
29
+ }
30
+
31
+ /** Decompress + validate an uploaded blob, or throw a precise reason. */
32
+ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
33
+ let json: string;
34
+ try {
35
+ json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
36
+ } catch (err) {
37
+ throw new Error(
38
+ `blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`,
39
+ { cause: err },
40
+ );
41
+ }
42
+ let raw: unknown;
43
+ try {
44
+ raw = JSON.parse(json);
45
+ } catch {
46
+ throw new Error("blob is not valid JSON");
47
+ }
48
+ const parsed = validateSessionRecord(raw);
49
+ if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
50
+ return parsed.value as ReviewSession;
51
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Single-instance guarantee (#14): one daemon per home. Two daemons over one
2
+ * Single-instance guarantee: one daemon per home. Two daemons over one
3
3
  * state directory means divergent in-memory sessions and clients seeing
4
4
  * whichever half they connect to.
5
5
  */
@@ -23,33 +23,39 @@ beforeEach(() => {
23
23
  home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
24
24
  });
25
25
  afterEach(() => {
26
- for (const s of servers.splice(0)) s.stop();
26
+ for (const runningServer of servers.splice(0)) runningServer.stop();
27
27
  rmSync(home, { recursive: true, force: true });
28
28
  });
29
29
 
30
30
  function server(): DaemonServer {
31
- const s = new DaemonServer({ home, idleExitMs: 0 });
32
- servers.push(s);
33
- return s;
31
+ const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
32
+ servers.push(daemonServer);
33
+ return daemonServer;
34
34
  }
35
35
 
36
36
  describe("one daemon per home", () => {
37
37
  test("the second start is refused instead of stealing the socket", () => {
38
+ // Arrange
38
39
  const first = server();
39
40
  const path = first.start();
40
41
  expect(path).toBe(socketPath(home));
41
42
  const second = server();
43
+
44
+ // Assert
42
45
  expect(second.start()).toBeNull();
43
46
  // the winner's socket survives untouched
44
47
  expect(existsSync(path!)).toBe(true);
45
48
  });
46
49
 
47
50
  test("clients keep talking to the one live daemon after a refused start", async () => {
51
+ // Arrange
48
52
  const first = server();
49
53
  first.start();
50
54
  const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
51
55
  server().start(); // refused
52
56
  const client = await DaemonClient.connect({ home });
57
+
58
+ // Assert
53
59
  try {
54
60
  // no split brain: the session created on the winner is visible
55
61
  expect((await client.sessionGet(created.id)).id).toBe(created.id);
@@ -60,36 +66,47 @@ describe("one daemon per home", () => {
60
66
  });
61
67
 
62
68
  test("a stale lock from a crashed daemon is reclaimed", () => {
69
+ // Arrange
63
70
  // a pid that cannot be running (init is never a cueloop daemon, and this
64
71
  // simulates the record a crashed process leaves behind)
65
72
  writeFileSync(lockPath(home), "999999");
66
- const s = server();
67
- expect(s.start()).toBe(socketPath(home));
73
+ const daemonServer = server();
74
+
75
+ // Assert
76
+ expect(daemonServer.start()).toBe(socketPath(home));
68
77
  });
69
78
 
70
79
  test("stopping releases the lock so a restart works", () => {
80
+ // Arrange
71
81
  const first = server();
72
82
  first.start();
83
+
84
+ // Act
73
85
  first.stop();
86
+
87
+ // Assert
74
88
  expect(existsSync(lockPath(home))).toBe(false);
75
89
  const second = server();
76
90
  expect(second.start()).toBe(socketPath(home));
77
91
  });
78
92
 
79
93
  test("concurrent autostarts converge on a single daemon", async () => {
94
+ // Act
80
95
  // several clients race to autostart; all must end up on the same daemon
81
96
  const clients = await Promise.all([
82
97
  DaemonClient.connect({ home, autostart: true }),
83
98
  DaemonClient.connect({ home, autostart: true }),
84
99
  DaemonClient.connect({ home, autostart: true }),
85
100
  ]);
101
+
102
+ // Assert
86
103
  try {
87
- const pids = await Promise.all(clients.map((c) => c.ping()));
88
- const unique = new Set(pids.map((p) => p.pid));
104
+ const pids = await Promise.all(clients.map((client) => client.ping()));
105
+ const unique = new Set(pids.map((ping) => ping.pid));
89
106
  expect(unique.size).toBe(1);
90
107
  // and state is shared: one client's session is visible to the others
91
- const s = await clients[0]!.sessionCreate(WS, PLAN);
92
- expect((await clients[2]!.sessionGet(s.id)).id).toBe(s.id);
108
+ const session = await clients[0]!.sessionCreate(WS, PLAN);
109
+ expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
93
110
  } finally {
94
111
  for (const c of clients) c.close();
95
112
  // the autostarted daemon is not one of `servers` - shut it down
package/src/store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session persistence (#14): one JSON document per session, every write
2
+ * Session persistence: one JSON document per session, every write
3
3
  * through a temp file + atomic rename so a crash mid-write can never leave
4
4
  * a corrupt record. Recovery is a read-only scan; records that fail to
5
5
  * parse are skipped and reported, never deleted.
@@ -49,15 +49,17 @@ export class SessionStore {
49
49
  }
50
50
 
51
51
  list(): ReviewSession[] {
52
- return [...this.sessions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
52
+ return [...this.sessions.values()].sort((left, right) =>
53
+ left.createdAt.localeCompare(right.createdAt),
54
+ );
53
55
  }
54
56
 
55
57
  upsert(session: ReviewSession): void {
56
58
  this.sessions.set(session.id, session);
57
59
  const path = join(this.dir, `${session.id}.json`);
58
- const tmp = path + ".tmp";
59
- writeFileSync(tmp, JSON.stringify(session, null, 2));
60
- renameSync(tmp, path);
60
+ const tempPath = path + ".tmp";
61
+ writeFileSync(tempPath, JSON.stringify(session, null, 2));
62
+ renameSync(tempPath, path);
61
63
  }
62
64
 
63
65
  delete(id: string): boolean {