clay-server 4.0.0-beta.1 → 4.0.0-beta.11

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.
Files changed (99) hide show
  1. package/lib/background-task-timing.js +105 -0
  2. package/lib/config.js +25 -1
  3. package/lib/daemon.js +22 -0
  4. package/lib/git-cli.js +94 -1
  5. package/lib/knowledge-import.js +436 -0
  6. package/lib/knowledge-record-store.js +188 -0
  7. package/lib/knowledge-search.js +240 -0
  8. package/lib/mate-knowledge-mcp-server.js +151 -0
  9. package/lib/mate-knowledge-migration.js +283 -0
  10. package/lib/mate-knowledge-service.js +488 -0
  11. package/lib/mate-knowledge-sync.js +482 -0
  12. package/lib/notes-lifecycle.js +149 -0
  13. package/lib/notes.js +60 -3
  14. package/lib/project-connection.js +17 -0
  15. package/lib/project-http.js +15 -1
  16. package/lib/project-knowledge.js +22 -0
  17. package/lib/project-logs-comments.js +137 -0
  18. package/lib/project-logs-mcp-server.js +314 -0
  19. package/lib/project-logs-query.js +167 -0
  20. package/lib/project-logs-root.js +67 -0
  21. package/lib/project-logs-schema.js +259 -0
  22. package/lib/project-logs-service.js +357 -0
  23. package/lib/project-logs-snapshot.js +202 -0
  24. package/lib/project-logs-store.js +498 -0
  25. package/lib/project-logs-versioning.js +128 -0
  26. package/lib/project-logs.js +348 -0
  27. package/lib/project-mate-interaction.js +14 -0
  28. package/lib/project-mate-knowledge.js +110 -0
  29. package/lib/project-memory.js +22 -0
  30. package/lib/project-pair-lifecycle.js +461 -0
  31. package/lib/project-session-notes.js +64 -13
  32. package/lib/project-session-pair.js +124 -111
  33. package/lib/project-sessions.js +46 -6
  34. package/lib/project-user-message.js +41 -4
  35. package/lib/project-vendor-login.js +414 -0
  36. package/lib/project-worker-permission.js +429 -0
  37. package/lib/project-worker-proposal.js +16 -1
  38. package/lib/project.js +164 -4
  39. package/lib/public/app.js +49 -20
  40. package/lib/public/css/git-placard.css +154 -0
  41. package/lib/public/css/input.css +79 -25
  42. package/lib/public/css/notifications-center.css +67 -0
  43. package/lib/public/css/pane.css +52 -0
  44. package/lib/public/css/project-logs.css +234 -0
  45. package/lib/public/css/scheduler.css +1 -1
  46. package/lib/public/css/sticky-notes.css +231 -226
  47. package/lib/public/index.html +12 -0
  48. package/lib/public/modules/app-connection.js +90 -4
  49. package/lib/public/modules/app-messages.js +54 -19
  50. package/lib/public/modules/app-notifications.js +15 -124
  51. package/lib/public/modules/app-panels.js +43 -2
  52. package/lib/public/modules/app-projects.js +2 -2
  53. package/lib/public/modules/background-tasks-ui.js +139 -16
  54. package/lib/public/modules/git-agent-sessions.js +85 -0
  55. package/lib/public/modules/git-panel.js +111 -84
  56. package/lib/public/modules/git-placard.js +161 -0
  57. package/lib/public/modules/home-sidebar.js +7 -0
  58. package/lib/public/modules/mate-sidebar.js +0 -8
  59. package/lib/public/modules/pane-bridge.js +9 -0
  60. package/lib/public/modules/project-logs-ambient.js +238 -0
  61. package/lib/public/modules/project-logs-render.js +359 -0
  62. package/lib/public/modules/project-logs.js +329 -0
  63. package/lib/public/modules/scheduler.js +17 -3
  64. package/lib/public/modules/sidebar-mobile.js +5 -9
  65. package/lib/public/modules/sidebar-sessions.js +25 -1
  66. package/lib/public/modules/sidebar.js +17 -5
  67. package/lib/public/modules/split-view.js +7 -0
  68. package/lib/public/modules/sticky-notes-browser.js +324 -0
  69. package/lib/public/modules/sticky-notes-card.js +358 -0
  70. package/lib/public/modules/sticky-notes-editor.js +288 -0
  71. package/lib/public/modules/sticky-notes-shared.js +90 -0
  72. package/lib/public/modules/sticky-notes.js +168 -946
  73. package/lib/public/modules/tool-palette-order.js +143 -0
  74. package/lib/public/modules/tool-palette-overlays.js +159 -0
  75. package/lib/public/modules/tool-palette.js +21 -188
  76. package/lib/public/modules/tui-attention.js +10 -0
  77. package/lib/public/modules/update-snooze.js +169 -0
  78. package/lib/public/modules/vendor-login.js +287 -0
  79. package/lib/public/modules/worker-pane-lock.js +174 -0
  80. package/lib/public/style.css +2 -0
  81. package/lib/sdk-bridge.js +77 -10
  82. package/lib/sdk-message-processor.js +24 -7
  83. package/lib/server.js +61 -6
  84. package/lib/session-driver-eligibility.js +183 -0
  85. package/lib/session-notes-mcp-server.js +25 -5
  86. package/lib/session-pair-factory.js +233 -0
  87. package/lib/session-pair-mcp-server.js +45 -5
  88. package/lib/session-pair-prompts.js +60 -0
  89. package/lib/sessions.js +4 -1
  90. package/lib/tools-registry.js +11 -1
  91. package/lib/update-snooze.js +437 -0
  92. package/lib/workspace-query-access.js +102 -0
  93. package/lib/workspace-query-service.js +24 -17
  94. package/lib/ws-schema.js +28 -3
  95. package/lib/yoke/adapters/claude.js +69 -4
  96. package/lib/yoke/adapters/codex.js +42 -5
  97. package/lib/yoke/codex-app-server.js +20 -2
  98. package/lib/yoke/codex-background-tasks.js +8 -2
  99. package/package.json +3 -3
@@ -0,0 +1,437 @@
1
+ // Update-notification capability and snooze policy.
2
+ //
3
+ // Two concerns that belong together because both decide whether a given
4
+ // connection may be told that a new version exists:
5
+ //
6
+ // 1. Capability. Update version metadata and update actions are
7
+ // admin-grade. In multi-user mode that means an authenticated user whose
8
+ // role is "admin"; a connection with no identity is refused. A
9
+ // single-user install has no user records at all by design, and its sole
10
+ // local operator is the owner, so it is admin-grade. This is the one
11
+ // canonical check; do not re-derive it anywhere else.
12
+ //
13
+ // 2. Snooze. A per-user, per-version deadline stored on the server so it
14
+ // survives reloads and applies on every device. Snoozing 4.1.0 says
15
+ // nothing about 4.2.0, so a newer release notifies immediately.
16
+ //
17
+ // The store is a small JSON file under CONFIG_DIR rather than the users
18
+ // record, because a single-user install has no users record to write to and
19
+ // the same policy has to work in both modes.
20
+
21
+ var crypto = require("crypto");
22
+ var fs = require("fs");
23
+ var path = require("path");
24
+ var config = require("./config");
25
+
26
+ var STORE_VERSION = 1;
27
+ var STORE_FILE = "update-snooze.json";
28
+
29
+ // Requested durations are chosen from this allowlist by key. A duration is
30
+ // never read as a number from the payload, and a deadline is never read from
31
+ // the payload at all: the server computes every instant itself.
32
+ var SNOOZE_OPTIONS = {
33
+ "3h": { label: "3 hours", ms: 3 * 60 * 60 * 1000 },
34
+ "8h": { label: "8 hours", ms: 8 * 60 * 60 * 1000 },
35
+ "tomorrow": { label: "Tomorrow", tomorrow: true },
36
+ };
37
+
38
+ // "Tomorrow" means the next calendar day at 09:00 local time, so at 23:00 it
39
+ // is 10 hours away and at 00:30 it is 32.5 hours away. That upper bound plus
40
+ // headroom is the hard server-side maximum: whatever timezone a client claims,
41
+ // the stored deadline is clamped to it.
42
+ var TOMORROW_HOUR = 9;
43
+ var MAX_SNOOZE_MS = 36 * 60 * 60 * 1000;
44
+
45
+ // A UTC offset outside real-world bounds is refused rather than clamped, so a
46
+ // malformed hint falls back to server local time instead of silently skewing.
47
+ var MIN_TZ_OFFSET_MINUTES = -14 * 60;
48
+ var MAX_TZ_OFFSET_MINUTES = 14 * 60;
49
+
50
+ // The key a single-user install stores its snooze under. It is a server-side
51
+ // constant, never a value a client can supply.
52
+ var LOCAL_USER_KEY = "__local__";
53
+
54
+ function storePath() {
55
+ return path.join(config.CONFIG_DIR, STORE_FILE);
56
+ }
57
+
58
+ function emptyStore() {
59
+ return { version: STORE_VERSION, users: {} };
60
+ }
61
+
62
+ function loadStore() {
63
+ var raw;
64
+ try {
65
+ raw = fs.readFileSync(storePath(), "utf8");
66
+ } catch (e) {
67
+ return emptyStore();
68
+ }
69
+ var parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch (e) {
73
+ // A torn or hand-edited file must not break update notifications; it just
74
+ // means nothing is snoozed.
75
+ return emptyStore();
76
+ }
77
+ if (!parsed || typeof parsed !== "object" || !parsed.users || typeof parsed.users !== "object") {
78
+ return emptyStore();
79
+ }
80
+ return { version: STORE_VERSION, users: parsed.users };
81
+ }
82
+
83
+ // A per-writer temp name. A shared ".tmp" would let two concurrent writers
84
+ // scribble over each other's half-written file and then rename it into place,
85
+ // publishing a mix of both.
86
+ var tmpCounter = 0;
87
+
88
+ function saveStore(store) {
89
+ try {
90
+ config.ensureConfigDir();
91
+ } catch (e) {}
92
+ var target = storePath();
93
+ tmpCounter++;
94
+ var tmp = target + "." + process.pid + "-" + tmpCounter + ".tmp";
95
+ try {
96
+ fs.writeFileSync(tmp, JSON.stringify(store), { mode: 0o600 });
97
+ fs.renameSync(tmp, target);
98
+ return true;
99
+ } catch (e) {
100
+ try { fs.unlinkSync(tmp); } catch (e2) {}
101
+ return false;
102
+ }
103
+ }
104
+
105
+ // --- Write serialization --------------------------------------------------
106
+ //
107
+ // One user's snooze must never erase another's. Node's synchronous I/O already
108
+ // serializes writers inside a process, but two daemons can share a CLAY_HOME,
109
+ // so read-modify-write is done under an exclusive lock file and the store is
110
+ // re-read inside that lock. That makes each mutation a merge into whatever is
111
+ // currently on disk rather than an overwrite with a stale snapshot.
112
+ //
113
+ // Follows the `fs.openSync(path, "wx")` convention already used for the Mate
114
+ // knowledge migration lock, with a short bounded wait because these writes are
115
+ // tiny and rare (a person clicking Snooze).
116
+ //
117
+ // A live holder always wins, however old its lock is: a daemon paused under a
118
+ // debugger is still holding the store, and stealing from it would let two
119
+ // writers merge against the same snapshot. Age alone can only reclaim a lock
120
+ // whose holder is confirmed dead or cannot be identified at all.
121
+
122
+ var LOCK_RETRIES = 25;
123
+ var LOCK_WAIT_MS = 2;
124
+ var LOCK_STALE_MS = 5000;
125
+
126
+ function lockPath() {
127
+ return storePath() + ".lock";
128
+ }
129
+
130
+ function sleepBriefly(ms) {
131
+ try {
132
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
133
+ } catch (e) {
134
+ // Without SharedArrayBuffer the retry loop simply spins; it is bounded.
135
+ }
136
+ }
137
+
138
+ // Delegates to the shared helper, whose contract is exactly what this lock
139
+ // needs: a positive-integer pid, EPERM counted as alive (a second daemon run
140
+ // by another user still holds the store), ESRCH as the only dead, and any
141
+ // unanswerable error as alive. Erring towards "alive" is the safe direction
142
+ // here too: the worst case is refusing to write, never stealing a live lock.
143
+ function isHolderAlive(pid) {
144
+ return config.isPidAlive(pid);
145
+ }
146
+
147
+ function readHolder(target) {
148
+ var raw;
149
+ try {
150
+ raw = fs.readFileSync(target, "utf8");
151
+ } catch (e) {
152
+ return null;
153
+ }
154
+ var parsed;
155
+ try {
156
+ parsed = JSON.parse(raw);
157
+ } catch (e) {
158
+ return { pid: null, nonce: null, startedAt: 0 };
159
+ }
160
+ if (!parsed || typeof parsed !== "object") return { pid: null, nonce: null, startedAt: 0 };
161
+ return {
162
+ pid: typeof parsed.pid === "number" ? parsed.pid : null,
163
+ nonce: typeof parsed.nonce === "string" ? parsed.nonce : null,
164
+ startedAt: typeof parsed.startedAt === "number" ? parsed.startedAt : 0,
165
+ };
166
+ }
167
+
168
+ // The nonce is what makes release safe. A pid alone cannot distinguish our own
169
+ // lock from a successor's lock written by a recycled pid, so every acquisition
170
+ // stamps a fresh random nonce and release only unlinks a file that still
171
+ // carries it.
172
+ function writeLock(target) {
173
+ var nonce = crypto.randomBytes(12).toString("hex");
174
+ var payload = JSON.stringify({ pid: process.pid, nonce: nonce, startedAt: Date.now() }) + "\n";
175
+ var fd = fs.openSync(target, "wx");
176
+ try {
177
+ fs.writeSync(fd, Buffer.from(payload, "utf8"));
178
+ } finally {
179
+ fs.closeSync(fd);
180
+ }
181
+ return { path: target, pid: process.pid, nonce: nonce };
182
+ }
183
+
184
+ function acquireLock() {
185
+ var target = lockPath();
186
+ for (var attempt = 0; attempt < LOCK_RETRIES; attempt++) {
187
+ try {
188
+ return writeLock(target);
189
+ } catch (e) {
190
+ if (!e || e.code !== "EEXIST") return null;
191
+ }
192
+
193
+ var holder = readHolder(target);
194
+ if (!holder) continue; // vanished between open and read: try again
195
+
196
+ if (holder.pid !== null) {
197
+ // An identifiable holder is only ever reclaimed once it is confirmed
198
+ // dead. Age is irrelevant. A recycled pid can therefore only make us
199
+ // wait and ultimately refuse the write, never double-enter.
200
+ if (isHolderAlive(holder.pid)) {
201
+ sleepBriefly(LOCK_WAIT_MS);
202
+ continue;
203
+ }
204
+ } else {
205
+ // No identifiable holder: reclaim only once unambiguously abandoned.
206
+ // The recorded stamp is preferred over mtime, which anything can touch;
207
+ // an unparseable lock has no stamp, so mtime is the only clock left.
208
+ var stamp = holder.startedAt;
209
+ if (!stamp) {
210
+ try { stamp = fs.statSync(target).mtimeMs; } catch (e2) { stamp = 0; }
211
+ }
212
+ var age = Date.now() - stamp;
213
+ // A future stamp (clock skew, or a hostile value) must not wedge the
214
+ // lock forever; it simply starts ageing from now.
215
+ if (age < 0) age = 0;
216
+ if (age < LOCK_STALE_MS) {
217
+ sleepBriefly(LOCK_WAIT_MS);
218
+ continue;
219
+ }
220
+ }
221
+
222
+ // Reclaim, then loop so the exclusive create decides the winner rather
223
+ // than this process assuming it won the race.
224
+ try { fs.unlinkSync(target); } catch (e3) {}
225
+ }
226
+ return null;
227
+ }
228
+
229
+ // Only unlink a lock this call still owns. If the file now carries a different
230
+ // pid or nonce, ownership changed while we held it and the file belongs to a
231
+ // successor, so removing it would release someone else's lock.
232
+ function releaseLock(token) {
233
+ if (!token || !token.path) return;
234
+ var holder = readHolder(token.path);
235
+ if (!holder) return;
236
+ if (holder.pid !== token.pid || holder.nonce !== token.nonce) return;
237
+ try { fs.unlinkSync(token.path); } catch (e) {}
238
+ }
239
+
240
+ // Apply `mutator` to the store under the lock. The mutator receives the store
241
+ // as it exists on disk right now and mutates it in place; returning false means
242
+ // "nothing changed", so no write happens.
243
+ function mutate(mutator) {
244
+ try {
245
+ config.ensureConfigDir();
246
+ } catch (e) {}
247
+ var lock = acquireLock();
248
+ if (!lock) return false;
249
+ try {
250
+ var store = loadStore();
251
+ if (mutator(store) === false) return true;
252
+ return saveStore(store);
253
+ } catch (e) {
254
+ return false;
255
+ } finally {
256
+ releaseLock(lock);
257
+ }
258
+ }
259
+
260
+ // --- Capability -----------------------------------------------------------
261
+
262
+ // The canonical admin-grade check for everything update-related: delivery of
263
+ // version metadata, the snooze action, manual checks, channel changes, and
264
+ // installing.
265
+ //
266
+ // `conn._clayUser` is a snapshot taken when the socket was opened and can be
267
+ // arbitrarily stale: a user demoted or deleted mid-session still carries
268
+ // role "admin" on their live connection. So the snapshot is only used for its
269
+ // id, and the role is re-resolved from the authoritative users store on every
270
+ // delivery and every action. Both must independently say "admin"; a missing
271
+ // record, a throwing store, or any disagreement between the two is a denial.
272
+ function isUpdateAdmin(conn, usersModule) {
273
+ if (!conn) return false;
274
+ var multiUser = false;
275
+ try {
276
+ multiUser = !!(usersModule && typeof usersModule.isMultiUser === "function" && usersModule.isMultiUser());
277
+ } catch (e) {
278
+ // An unreadable users record must not be treated as "no restrictions".
279
+ return false;
280
+ }
281
+ if (!multiUser) return true;
282
+
283
+ var user = conn._clayUser;
284
+ if (!user || !user.id || user.role !== "admin") return false;
285
+ if (!usersModule || typeof usersModule.findUserById !== "function") return false;
286
+
287
+ var live;
288
+ try {
289
+ live = usersModule.findUserById(user.id);
290
+ } catch (e) {
291
+ return false;
292
+ }
293
+ // Deleted between connect and now, or demoted since.
294
+ if (!live || live.role !== "admin") return false;
295
+ return true;
296
+ }
297
+
298
+ // The storage key for a connection. Returns null when multi-user mode cannot
299
+ // attribute the connection, so callers persist nothing rather than guessing.
300
+ // Identity is always read from the connection, never from a message payload.
301
+ function userKeyFor(conn, usersModule) {
302
+ if (!isUpdateAdmin(conn, usersModule)) return null;
303
+ var multiUser = false;
304
+ try {
305
+ multiUser = !!(usersModule && typeof usersModule.isMultiUser === "function" && usersModule.isMultiUser());
306
+ } catch (e) {
307
+ return null;
308
+ }
309
+ if (!multiUser) return LOCAL_USER_KEY;
310
+ var user = conn._clayUser;
311
+ if (!user || !user.id) return null;
312
+ return String(user.id);
313
+ }
314
+
315
+ // --- Deadlines ------------------------------------------------------------
316
+
317
+ function isValidTzOffset(value) {
318
+ return typeof value === "number" && isFinite(value) &&
319
+ Math.floor(value) === value &&
320
+ value >= MIN_TZ_OFFSET_MINUTES && value <= MAX_TZ_OFFSET_MINUTES;
321
+ }
322
+
323
+ // Next calendar day at TOMORROW_HOUR, in the zone described by a UTC offset in
324
+ // minutes east of UTC, expressed as a UTC instant. With no usable offset the
325
+ // server's own local time is used, which is the safe default because it is the
326
+ // only clock the server can vouch for.
327
+ function nextLocalMorning(nowMs, tzOffsetMinutes) {
328
+ if (!isValidTzOffset(tzOffsetMinutes)) {
329
+ var local = new Date(nowMs);
330
+ var serverTarget = new Date(local.getFullYear(), local.getMonth(), local.getDate() + 1, TOMORROW_HOUR, 0, 0, 0);
331
+ return serverTarget.getTime();
332
+ }
333
+ var offsetMs = tzOffsetMinutes * 60 * 1000;
334
+ // Shift into the target zone, take the calendar date there, then shift back.
335
+ var shifted = new Date(nowMs + offsetMs);
336
+ var midnightNextDayUtc = Date.UTC(
337
+ shifted.getUTCFullYear(),
338
+ shifted.getUTCMonth(),
339
+ shifted.getUTCDate() + 1,
340
+ TOMORROW_HOUR, 0, 0, 0
341
+ );
342
+ return midnightNextDayUtc - offsetMs;
343
+ }
344
+
345
+ // Resolve an allowlisted duration key to a UTC deadline, clamped to the
346
+ // server-side maximum. Returns null for anything not on the allowlist.
347
+ function resolveDeadline(durationKey, nowMs, tzOffsetMinutes) {
348
+ if (typeof durationKey !== "string") return null;
349
+ var option = Object.prototype.hasOwnProperty.call(SNOOZE_OPTIONS, durationKey)
350
+ ? SNOOZE_OPTIONS[durationKey]
351
+ : null;
352
+ if (!option) return null;
353
+ var until = option.tomorrow
354
+ ? nextLocalMorning(nowMs, tzOffsetMinutes)
355
+ : nowMs + option.ms;
356
+ if (!isFinite(until)) return null;
357
+ if (until <= nowMs) return null;
358
+ if (until > nowMs + MAX_SNOOZE_MS) until = nowMs + MAX_SNOOZE_MS;
359
+ return until;
360
+ }
361
+
362
+ // --- Store operations ----------------------------------------------------
363
+
364
+ function readRecord(userKey) {
365
+ if (!userKey) return null;
366
+ var store = loadStore();
367
+ var record = store.users[userKey];
368
+ if (!record || typeof record !== "object") return null;
369
+ var until = typeof record.until === "number" && isFinite(record.until) ? record.until : 0;
370
+ var version = typeof record.version === "string" ? record.version : "";
371
+ if (!until || !version) return null;
372
+ return { version: version, until: until };
373
+ }
374
+
375
+ // Touches exactly one user's entry and leaves every other entry as found on
376
+ // disk, so two users snoozing at the same moment both survive.
377
+ function writeRecord(userKey, record) {
378
+ if (!userKey) return false;
379
+ return mutate(function (store) {
380
+ if (record) store.users[userKey] = { version: record.version, until: record.until };
381
+ else if (Object.prototype.hasOwnProperty.call(store.users, userKey)) delete store.users[userKey];
382
+ else return false;
383
+ });
384
+ }
385
+
386
+ // Whether this user is currently snoozing exactly this version. A snooze for a
387
+ // different version never suppresses a newer one, and an expired snooze never
388
+ // suppresses anything.
389
+ function isSnoozed(userKey, version, nowMs) {
390
+ if (!userKey || !version) return false;
391
+ var record = readRecord(userKey);
392
+ if (!record) return false;
393
+ if (record.version !== version) return false;
394
+ return record.until > nowMs;
395
+ }
396
+
397
+ // Record a snooze. `version` is the server's authoritative latest version; a
398
+ // version named in a client payload is never used.
399
+ function snooze(userKey, version, durationKey, nowMs, tzOffsetMinutes) {
400
+ if (!userKey) return { ok: false, error: "no_identity" };
401
+ if (typeof version !== "string" || !version) return { ok: false, error: "no_update" };
402
+ var until = resolveDeadline(durationKey, nowMs, tzOffsetMinutes);
403
+ if (!until) return { ok: false, error: "invalid_duration" };
404
+ if (!writeRecord(userKey, { version: version, until: until })) {
405
+ return { ok: false, error: "not_persisted" };
406
+ }
407
+ return { ok: true, version: version, until: until, duration: durationKey };
408
+ }
409
+
410
+ // Drop any snooze for this user. Used when the user re-engages deliberately:
411
+ // a manual update check, or installing.
412
+ function clearSnooze(userKey) {
413
+ if (!userKey) return false;
414
+ // Delegates straight to the locked mutation rather than checking first and
415
+ // writing after; the check-then-write pair was itself a race.
416
+ return writeRecord(userKey, null);
417
+ }
418
+
419
+ module.exports = {
420
+ LOCAL_USER_KEY: LOCAL_USER_KEY,
421
+ LOCK_STALE_MS: LOCK_STALE_MS,
422
+ acquireLock: acquireLock,
423
+ isHolderAlive: isHolderAlive,
424
+ lockPath: lockPath,
425
+ releaseLock: releaseLock,
426
+ MAX_SNOOZE_MS: MAX_SNOOZE_MS,
427
+ SNOOZE_OPTIONS: SNOOZE_OPTIONS,
428
+ TOMORROW_HOUR: TOMORROW_HOUR,
429
+ clearSnooze: clearSnooze,
430
+ isSnoozed: isSnoozed,
431
+ isUpdateAdmin: isUpdateAdmin,
432
+ readRecord: readRecord,
433
+ resolveDeadline: resolveDeadline,
434
+ snooze: snooze,
435
+ storePath: storePath,
436
+ userKeyFor: userKeyFor,
437
+ };
@@ -0,0 +1,102 @@
1
+ // Authoritative project access for workspace queries.
2
+ //
3
+ // getStatus() reports slug, path, title, and owner, but carries no visibility
4
+ // and no allowedUsers. Rebuilding an access record from it therefore defaults
5
+ // every project to public, and users-permissions treats a record with no
6
+ // visibility as public, which authorizes any authenticated user against any
7
+ // project. Access is resolved here from the record onGetProjectAccess()
8
+ // returns, and every failure path denies.
9
+ //
10
+ // The record is re-read on every evaluation rather than captured at bind time,
11
+ // so a project turning private, changing owner, or dropping a user from
12
+ // allowedUsers takes effect on that caller's next query.
13
+
14
+ function attachWorkspaceQueryAccess(deps) {
15
+ var settings = deps || {};
16
+ var isMultiUser = settings.isMultiUser;
17
+ var resolveMate = settings.resolveMate;
18
+ var onGetProjectAccess = settings.onGetProjectAccess;
19
+ var canAccessProject = settings.canAccessProject;
20
+
21
+ // The authoritative record for a project, or null. A missing slug, a missing
22
+ // resolver, a throwing resolver, a non-record return, and an error record all
23
+ // resolve to null, which denies.
24
+ function record(status) {
25
+ if (!status || typeof status.slug !== "string" || !status.slug) return null;
26
+ if (typeof onGetProjectAccess !== "function") return null;
27
+ var access;
28
+ try {
29
+ access = onGetProjectAccess(status.slug);
30
+ } catch (e) {
31
+ return null;
32
+ }
33
+ if (!access || typeof access !== "object" || Array.isArray(access)) return null;
34
+ if (access.error) return null;
35
+ return access;
36
+ }
37
+
38
+ // The permission predicate is only ever consulted with a real record, and
39
+ // anything other than an explicit true denies.
40
+ function permitted(userId, access) {
41
+ if (!userId || !access) return false;
42
+ if (typeof canAccessProject !== "function") return false;
43
+ try {
44
+ return canAccessProject(userId, access) === true;
45
+ } catch (e) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ // Single-user ownership. There are no visibility rules and no access records
51
+ // to consult in this mode, so the project's own owner field is authoritative.
52
+ // Unchanged from the behaviour this module replaced.
53
+ function ownsSingleUser(principal, status) {
54
+ if (!status.projectOwnerId) return true;
55
+ return !!principal.singleUserOwnerId && status.projectOwnerId === principal.singleUserOwnerId;
56
+ }
57
+
58
+ // A Mate project belongs to whoever the Mate registry says created the Mate.
59
+ //
60
+ // This is not a convenience: Mate projects are registered directly on the
61
+ // running server and are never written to the persisted project list, so
62
+ // onGetProjectAccess() has no record for them at all. The registry is the
63
+ // authority for Mate ownership, and resolveMate is already scoped to the
64
+ // asking user, so this path can only ever grant a Mate its own project.
65
+ function ownsMateProject(principal, status) {
66
+ if (!status || status.isMate !== true || !status.mateId) return false;
67
+ if (!principal.userId || typeof resolveMate !== "function") return false;
68
+ var mate;
69
+ try {
70
+ mate = resolveMate(principal.userId, status.mateId);
71
+ } catch (e) {
72
+ return false;
73
+ }
74
+ if (!mate || mate.id !== status.mateId) return false;
75
+ return !!mate.createdBy && mate.createdBy === principal.userId;
76
+ }
77
+
78
+ // `accessible` decides whether the container is visible at all; `owned`
79
+ // drives the separate rule that an accessible container holding no session of
80
+ // the caller's own is omitted. Worktrees are never surfaced directly.
81
+ function evaluate(principal, status) {
82
+ var denied = { owned: false, accessible: false };
83
+ if (!principal || !status || status.isWorktree) return denied;
84
+ if (typeof isMultiUser !== "function" || !isMultiUser()) {
85
+ var owned = ownsSingleUser(principal, status);
86
+ return { owned: owned, accessible: owned };
87
+ }
88
+ if (!principal.userId) return denied;
89
+ if (ownsMateProject(principal, status)) return { owned: true, accessible: true };
90
+ // status.projectOwnerId is never trusted on its own in multi-user mode: it
91
+ // is a snapshot that can outlive a transfer or a revocation, so treating it
92
+ // as ownership would let a stale status skip the authoritative record.
93
+ var access = record(status);
94
+ if (!access) return denied;
95
+ if (access.ownerId && access.ownerId === principal.userId) return { owned: true, accessible: true };
96
+ return { owned: false, accessible: permitted(principal.userId, access) };
97
+ }
98
+
99
+ return { evaluate: evaluate, getProjectAccess: record };
100
+ }
101
+
102
+ module.exports = { attachWorkspaceQueryAccess: attachWorkspaceQueryAccess };
@@ -1,5 +1,6 @@
1
1
  var sessionSearch = require("./session-search");
2
2
  var builtinMates = require("./builtin-mates");
3
+ var workspaceQueryAccess = require("./workspace-query-access");
3
4
  var crypto = require("crypto");
4
5
 
5
6
  var MAX_PAGE = 50;
@@ -100,7 +101,13 @@ function attachWorkspaceQueryService(ctx) {
100
101
  var getProjects = ctx.getProjects;
101
102
  var isMultiUser = ctx.isMultiUser;
102
103
  var resolveMate = ctx.resolveMate;
103
- var canAccessProject = ctx.canAccessProject;
104
+ // Ownership and access both come from here, never from getStatus().
105
+ var projectAccess = workspaceQueryAccess.attachWorkspaceQueryAccess({
106
+ isMultiUser: isMultiUser,
107
+ resolveMate: resolveMate,
108
+ onGetProjectAccess: ctx.onGetProjectAccess,
109
+ canAccessProject: ctx.canAccessProject,
110
+ });
104
111
 
105
112
  function authoritativeMate(source) {
106
113
  if (!source || source.isMate !== true || !source.mateId || typeof resolveMate !== "function") return null;
@@ -164,12 +171,6 @@ function attachWorkspaceQueryService(ctx) {
164
171
  });
165
172
  }
166
173
 
167
- function ownsProject(principal, status) {
168
- if (!status || status.isWorktree) return false;
169
- if (isMultiUser()) return !!principal.userId && status.projectOwnerId === principal.userId;
170
- return !status.projectOwnerId || (!!principal.singleUserOwnerId && status.projectOwnerId === principal.singleUserOwnerId);
171
- }
172
-
173
174
  function ownsSession(principal, session) {
174
175
  if (!session || session.hidden) return false;
175
176
  if (isMultiUser()) return !!principal.userId && session.ownerId === principal.userId;
@@ -180,12 +181,12 @@ function attachWorkspaceQueryService(ctx) {
180
181
  var projects = [];
181
182
  getProjects().forEach(function (project, slug) {
182
183
  var status = project.getStatus();
183
- var projectOwned = ownsProject(principal, status);
184
- var projectAccessible = projectOwned;
185
- if (!projectAccessible && isMultiUser() && typeof canAccessProject === "function") {
186
- projectAccessible = canAccessProject(principal.userId, status) === true;
187
- }
188
- if (!projectAccessible || (status && status.isWorktree)) return;
184
+ if (!status || status.isWorktree) return;
185
+ // Authorized here rather than at bind time, so an already-created tool
186
+ // handler loses a project the moment its access record changes.
187
+ var verdict = projectAccess.evaluate(principal, status);
188
+ if (!verdict.accessible) return;
189
+ var projectOwned = verdict.owned;
189
190
  var manager = project.getSessionManager ? project.getSessionManager() : project.sm;
190
191
  if (!manager || !manager.sessions) return;
191
192
  var sessions = [];
@@ -305,14 +306,20 @@ function attachWorkspaceQueryService(ctx) {
305
306
  function searchHistory(principal, projectSlug, args) {
306
307
  var query = cleanText(args && args.query || "", 500);
307
308
  if (!query) throw new Error("A search query is required.");
308
- var projects;
309
+ // One authorization snapshot for the whole call. Resolving hits back to
310
+ // their project through findProject() would re-authorize every project once
311
+ // per hit, so the visible set is captured once and indexed.
312
+ var visible;
309
313
  if (projectSlug) {
310
314
  var project = findProject(principal, projectSlug);
311
315
  if (!project) throw new Error("Project not found in the owned workspace.");
312
- projects = [sanitizedProject(project)];
316
+ visible = [project];
313
317
  } else {
314
- projects = collect(principal).map(sanitizedProject);
318
+ visible = collect(principal);
315
319
  }
320
+ var bySlug = {};
321
+ for (var v = 0; v < visible.length; v++) bySlug[visible[v].projectSlug] = visible[v];
322
+ var projects = visible.map(sanitizedProject);
316
323
  var max = Math.min(100, decodeCursor(args && args.cursor) + parseLimit(args && args.limit, 20) + 20);
317
324
  var ranked = sessionSearch.searchPalette(projects, query, { maxResults: max });
318
325
  var offset = decodeCursor(args && args.cursor);
@@ -321,7 +328,7 @@ function attachWorkspaceQueryService(ctx) {
321
328
  var results = [];
322
329
  for (var i = 0; i < selected.length; i++) {
323
330
  var hit = selected[i];
324
- var projectForHit = findProject(principal, hit.projectSlug);
331
+ var projectForHit = bySlug[hit.projectSlug] || null;
325
332
  var session = projectForHit ? findSession(projectForHit, String(hit.sessionId)) : null;
326
333
  if (!session) continue;
327
334
  results.push({