trekoon 0.4.2 → 0.4.3

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.
@@ -1,6 +1,6 @@
1
- import { chmodSync, existsSync, mkdirSync, unlinkSync, statSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, realpathSync, unlinkSync, statSync } from "node:fs";
2
2
  import { connect, createServer, type Server, type Socket } from "node:net";
3
- import { dirname } from "node:path";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  import { redactStack, safeErrorMessage } from "../commands/error-utils";
5
5
  import { closeCachedDatabases } from "../storage/database";
6
6
  import { resolveStoragePaths } from "../storage/path";
@@ -46,6 +46,7 @@ const MAX_TOTAL_BUFFERED_BYTES = 8 * MAX_REQUEST_BYTES;
46
46
  const DEFAULT_MAX_CONNECTIONS = 32;
47
47
  /** Idle/incomplete-request timeout for a server-side socket. */
48
48
  const SERVER_SOCKET_IDLE_MS = 5_000;
49
+ const OWNER_ONLY_MASK = 0o077;
49
50
 
50
51
  /**
51
52
  * Pre-write transport failure: the daemon socket was unreachable or the
@@ -121,6 +122,34 @@ function debugLog(prefix: string, payload: unknown): void {
121
122
  console.error(prefix);
122
123
  }
123
124
 
125
+ function formatMode(mode: number): string {
126
+ // eslint-disable-next-line no-bitwise
127
+ return `0o${(mode & 0o777).toString(8).padStart(3, "0")}`;
128
+ }
129
+
130
+ function assertOwnerOnlyMode(path: string, label: string): void {
131
+ const stats = statSync(path);
132
+ // eslint-disable-next-line no-bitwise
133
+ if ((stats.mode & OWNER_ONLY_MASK) !== 0) {
134
+ throw new Error(`${label} at ${path} must be owner-only; got ${formatMode(stats.mode)}`);
135
+ }
136
+ }
137
+
138
+ export function __assertOwnerOnlyModeForTests(path: string, label: string): void {
139
+ assertOwnerOnlyMode(path, label);
140
+ }
141
+
142
+ function isPathWithin(candidatePath: string, rootPath: string): boolean {
143
+ const candidate: string = realpathSync(resolve(candidatePath));
144
+ const root: string = realpathSync(resolve(rootPath));
145
+ const pathToCandidate: string = relative(root, candidate);
146
+ return pathToCandidate === "" || (!pathToCandidate.startsWith("..") && !isAbsolute(pathToCandidate));
147
+ }
148
+
149
+ function isAllowedRequestCwd(cwd: string, allowedRoots: readonly string[]): boolean {
150
+ return allowedRoots.some((root: string): boolean => isPathWithin(cwd, root));
151
+ }
152
+
124
153
  /**
125
154
  * Execute a single daemon request through the in-process CLI shell.
126
155
  * Exported for direct unit testing without a socket round-trip.
@@ -202,16 +231,18 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
202
231
  const socketPath: string = options.socketPath ?? resolveDaemonSocketPath(cwd);
203
232
  const socketDir: string = dirname(socketPath);
204
233
  const maxConnections: number = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
234
+ const daemonStoragePaths = resolveStoragePaths(cwd);
235
+ const allowedRequestRoots: readonly string[] = [
236
+ daemonStoragePaths.worktreeRoot,
237
+ daemonStoragePaths.storageDir,
238
+ ];
205
239
 
206
240
  if (!existsSync(socketDir)) {
207
241
  mkdirSync(socketDir, { recursive: true, mode: 0o700 });
208
242
  }
209
243
  // Tighten parent dir perms even if it pre-existed.
210
- try {
211
- chmodSync(socketDir, 0o700);
212
- } catch {
213
- // best effort; not all filesystems honour this
214
- }
244
+ chmodSync(socketDir, 0o700);
245
+ assertOwnerOnlyMode(socketDir, "daemon socket directory");
215
246
 
216
247
  // Stale socket from a previous crashed run.
217
248
  safeUnlink(socketPath);
@@ -220,23 +251,20 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
220
251
  // buffered. Used to enforce both the per-server connection cap and a
221
252
  // global upper bound on memory pressure across all in-flight requests.
222
253
  const liveSockets: Set<Socket> = new Set<Socket>();
254
+ const processingSockets: Set<Socket> = new Set<Socket>();
255
+ const inFlightRequests: Set<Promise<void>> = new Set<Promise<void>>();
223
256
  let totalBufferedBytes = 0;
224
257
 
225
258
  const server: Server = createServer((socket: Socket): void => {
226
259
  if (liveSockets.size >= maxConnections) {
227
- try {
228
- socket.write(
229
- `${JSON.stringify({
230
- stdout: "",
231
- stderr: "Daemon: daemon_busy (too many concurrent connections)\n",
232
- exitCode: 1,
233
- })}\n`,
234
- );
235
- } catch {
236
- // best effort
237
- }
238
- socket.end();
239
- socket.destroy();
260
+ const busyEnvelope: string = `${JSON.stringify({
261
+ stdout: "",
262
+ stderr: "Daemon: daemon_busy (too many concurrent connections)\n",
263
+ exitCode: 1,
264
+ })}\n`;
265
+ socket.write(busyEnvelope, (): void => {
266
+ socket.end();
267
+ });
240
268
  return;
241
269
  }
242
270
 
@@ -322,7 +350,12 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
322
350
  // meaningful; the dispatcher controls the lifetime from here.
323
351
  socket.setTimeout(0);
324
352
 
325
- void handlePayload(payload, socket);
353
+ processingSockets.add(socket);
354
+ const requestPromise = handlePayload(payload, socket, allowedRequestRoots).finally((): void => {
355
+ processingSockets.delete(socket);
356
+ inFlightRequests.delete(requestPromise);
357
+ });
358
+ inFlightRequests.add(requestPromise);
326
359
  });
327
360
 
328
361
  socket.on("error", (error: Error): void => {
@@ -354,8 +387,13 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
354
387
  // race when other code allocates fds during startup.
355
388
  try {
356
389
  chmodSync(socketPath, 0o600);
357
- } catch {
358
- // best effort
390
+ assertOwnerOnlyMode(socketPath, "daemon socket");
391
+ } catch (error: unknown) {
392
+ await new Promise<void>((resolve): void => {
393
+ server.close((): void => resolve());
394
+ });
395
+ safeUnlink(socketPath);
396
+ throw error;
359
397
  }
360
398
 
361
399
  if (!options.silent) {
@@ -365,32 +403,48 @@ export async function startDaemonServer(options: StartDaemonOptions = {}): Promi
365
403
  const handle: DaemonServerHandle = {
366
404
  socketPath,
367
405
  server,
368
- close: (): Promise<void> =>
369
- new Promise<void>((resolve): void => {
370
- // Force-close any sockets still parked on the connection cap path so
371
- // server.close() resolves promptly during test teardown.
372
- for (const sock of liveSockets) {
373
- try {
374
- sock.destroy();
375
- } catch {
376
- // best effort
377
- }
406
+ close: async (): Promise<void> => {
407
+ const serverClosed = new Promise<void>((resolve): void => {
408
+ server.close((): void => resolve());
409
+ });
410
+ // Force-close idle sockets so server.close() resolves promptly during
411
+ // test teardown, but let in-flight dispatches finish before DB shutdown.
412
+ for (const sock of liveSockets) {
413
+ if (processingSockets.has(sock)) {
414
+ continue;
378
415
  }
379
- liveSockets.clear();
380
- totalBufferedBytes = 0;
381
- server.close((): void => {
382
- safeUnlink(socketPath);
383
- closeCachedDatabases();
384
- delete process.env.TREKOON_DAEMON_INPROCESS;
385
- resolve();
386
- });
387
- }),
416
+ try {
417
+ sock.destroy();
418
+ } catch {
419
+ // best effort
420
+ }
421
+ }
422
+ await Promise.allSettled([...inFlightRequests]);
423
+ for (const sock of liveSockets) {
424
+ try {
425
+ sock.destroy();
426
+ } catch {
427
+ // best effort
428
+ }
429
+ }
430
+ await serverClosed;
431
+ liveSockets.clear();
432
+ processingSockets.clear();
433
+ totalBufferedBytes = 0;
434
+ safeUnlink(socketPath);
435
+ closeCachedDatabases();
436
+ delete process.env.TREKOON_DAEMON_INPROCESS;
437
+ },
388
438
  };
389
439
 
390
440
  return handle;
391
441
  }
392
442
 
393
- async function handlePayload(payload: string, socket: Socket): Promise<void> {
443
+ async function handlePayload(
444
+ payload: string,
445
+ socket: Socket,
446
+ allowedRequestRoots: readonly string[],
447
+ ): Promise<void> {
394
448
  let response: DaemonResponse;
395
449
  try {
396
450
  const parsed: unknown = JSON.parse(payload);
@@ -400,6 +454,12 @@ async function handlePayload(payload: string, socket: Socket): Promise<void> {
400
454
  stderr: "Daemon: invalid request payload\n",
401
455
  exitCode: 1,
402
456
  };
457
+ } else if (!isAllowedRequestCwd(parsed.cwd, allowedRequestRoots)) {
458
+ response = {
459
+ stdout: "",
460
+ stderr: "Daemon: request cwd is outside the daemon worktree/storage scope\n",
461
+ exitCode: 1,
462
+ };
403
463
  } else {
404
464
  response = await executeDaemonRequest(parsed);
405
465
  }
@@ -472,7 +532,7 @@ export async function sendDaemonRequest(
472
532
  // socket buffer. Any subsequent error/timeout MUST surface as
473
533
  // PostWriteError because the daemon may have already executed the
474
534
  // mutation.
475
- let postWrite = false;
535
+ let writeAttempted = false;
476
536
 
477
537
  const fail = (error: unknown): void => {
478
538
  if (settled) {
@@ -485,7 +545,7 @@ export async function sendDaemonRequest(
485
545
  } catch {
486
546
  // best effort
487
547
  }
488
- if (postWrite) {
548
+ if (writeAttempted) {
489
549
  const cause: unknown = error;
490
550
  const message: string = error instanceof Error ? error.message : String(error);
491
551
  reject(new PostWriteError(`daemon may have committed; do not retry: ${message}`, cause));
@@ -517,15 +577,16 @@ export async function sendDaemonRequest(
517
577
 
518
578
  socket.on("connect", (): void => {
519
579
  const wireBytes: string = `${JSON.stringify(request)}${REQUEST_TERMINATOR}`;
580
+ writeAttempted = true;
520
581
  socket.write(wireBytes, (writeError?: Error | null): void => {
521
582
  if (writeError) {
522
583
  fail(writeError);
523
584
  return;
524
585
  }
525
- // The bytes are buffered into the kernel socket; from this point
526
- // on the daemon may execute the request. Any error/timeout that
527
- // follows must be classified as PostWriteError.
528
- postWrite = true;
586
+ // Callback confirmation is intentionally retained as a second signal
587
+ // for future diagnostics; classification flips synchronously before
588
+ // socket.write can return so write-then-error races are post-write.
589
+ writeAttempted = true;
529
590
  });
530
591
  });
531
592
 
@@ -304,10 +304,17 @@ function releaseCachedDatabase(key: string): void {
304
304
  }
305
305
 
306
306
  export function closeCachedDatabases(): void {
307
- for (const entry of cachedDatabases.values()) {
307
+ for (const [key, entry] of cachedDatabases) {
308
+ if (entry.refcount > 0) {
309
+ // eslint-disable-next-line no-console
310
+ console.warn(
311
+ `[trekoon daemon] leaving cached database open during shutdown because it is still borrowed: ${key}`,
312
+ );
313
+ continue;
314
+ }
308
315
  closeCachedHandle(entry.handle);
316
+ cachedDatabases.delete(key);
309
317
  }
310
- cachedDatabases.clear();
311
318
  warnedOnTransientOvergrowth = false;
312
319
  }
313
320
 
@@ -538,7 +538,20 @@ function validateMigrationPlan(): void {
538
538
  }
539
539
  }
540
540
 
541
- function runExclusive<T>(db: Database, operation: () => T): T {
541
+ function attachRollbackFailure(error: unknown, rollbackError: unknown): unknown {
542
+ if (error instanceof Error) {
543
+ Object.defineProperty(error, "cause", {
544
+ value: rollbackError,
545
+ configurable: true,
546
+ writable: true,
547
+ });
548
+ return error;
549
+ }
550
+
551
+ return new Error("Migration operation failed and rollback also failed.", { cause: rollbackError });
552
+ }
553
+
554
+ export function runExclusive<T>(db: Database, operation: () => T): T {
542
555
  db.exec("BEGIN EXCLUSIVE TRANSACTION;");
543
556
 
544
557
  try {
@@ -546,7 +559,11 @@ function runExclusive<T>(db: Database, operation: () => T): T {
546
559
  db.exec("COMMIT;");
547
560
  return result;
548
561
  } catch (error: unknown) {
549
- db.exec("ROLLBACK;");
562
+ try {
563
+ db.exec("ROLLBACK;");
564
+ } catch (rollbackError: unknown) {
565
+ throw attachRollbackFailure(error, rollbackError);
566
+ }
550
567
  throw error;
551
568
  }
552
569
  }
@@ -950,24 +950,28 @@ function removeConflictsForEntityIds(
950
950
  if (entityIds.length === 0) {
951
951
  return;
952
952
  }
953
- const placeholders = entityIds.map(() => "?").join(", ");
954
- if (excludeConflictId !== undefined) {
955
- db.query(
956
- `DELETE FROM sync_conflicts
957
- WHERE entity_kind = ?
958
- AND entity_id IN (${placeholders})
959
- AND worktree_path = ?
960
- AND current_branch = ?
961
- AND id != ?;`,
962
- ).run(entityKind, ...entityIds, scope.worktreePath, scope.currentBranch, excludeConflictId);
963
- } else {
964
- db.query(
965
- `DELETE FROM sync_conflicts
966
- WHERE entity_kind = ?
967
- AND entity_id IN (${placeholders})
968
- AND worktree_path = ?
969
- AND current_branch = ?;`,
970
- ).run(entityKind, ...entityIds, scope.worktreePath, scope.currentBranch);
953
+
954
+ for (let offset = 0; offset < entityIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
955
+ const chunkIds = entityIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
956
+ const placeholders = chunkIds.map(() => "?").join(", ");
957
+ if (excludeConflictId !== undefined) {
958
+ db.query(
959
+ `DELETE FROM sync_conflicts
960
+ WHERE entity_kind = ?
961
+ AND entity_id IN (${placeholders})
962
+ AND worktree_path = ?
963
+ AND current_branch = ?
964
+ AND id != ?;`,
965
+ ).run(entityKind, ...chunkIds, scope.worktreePath, scope.currentBranch, excludeConflictId);
966
+ } else {
967
+ db.query(
968
+ `DELETE FROM sync_conflicts
969
+ WHERE entity_kind = ?
970
+ AND entity_id IN (${placeholders})
971
+ AND worktree_path = ?
972
+ AND current_branch = ?;`,
973
+ ).run(entityKind, ...chunkIds, scope.worktreePath, scope.currentBranch);
974
+ }
971
975
  }
972
976
  }
973
977
 
@@ -2176,6 +2180,8 @@ export function listSyncConflicts(cwd: string, mode: SyncConflictMode): SyncConf
2176
2180
 
2177
2181
  export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDetail {
2178
2182
  const storage = openTrekoonDatabase(cwd);
2183
+ const git = resolveGitContext(cwd);
2184
+ const scope: ConflictScope = scopeFromGitContext(git);
2179
2185
 
2180
2186
  try {
2181
2187
  const conflict = storage.db
@@ -2184,10 +2190,12 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
2184
2190
  SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
2185
2191
  FROM sync_conflicts
2186
2192
  WHERE id = ?
2193
+ AND worktree_path = ?
2194
+ AND current_branch = ?
2187
2195
  LIMIT 1;
2188
2196
  `,
2189
2197
  )
2190
- .get(conflictId) as ConflictRow | null;
2198
+ .get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
2191
2199
 
2192
2200
  if (!conflict) {
2193
2201
  throw new Error(`Conflict '${conflictId}' not found.`);
@@ -2231,17 +2239,19 @@ export function getSyncConflict(cwd: string, conflictId: string): SyncConflictDe
2231
2239
  }
2232
2240
  }
2233
2241
 
2234
- function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
2242
+ function lookupPendingConflict(db: Database, conflictId: string, scope: ConflictScope): ConflictRow {
2235
2243
  const conflict = db
2236
2244
  .query(
2237
2245
  `
2238
2246
  SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
2239
2247
  FROM sync_conflicts
2240
2248
  WHERE id = ?
2249
+ AND worktree_path = ?
2250
+ AND current_branch = ?
2241
2251
  LIMIT 1;
2242
2252
  `,
2243
2253
  )
2244
- .get(conflictId) as ConflictRow | null;
2254
+ .get(conflictId, scope.worktreePath, scope.currentBranch) as ConflictRow | null;
2245
2255
 
2246
2256
  if (!conflict) {
2247
2257
  throw new Error(`Conflict '${conflictId}' not found.`);
@@ -2257,6 +2267,7 @@ function lookupPendingConflict(db: Database, conflictId: string): ConflictRow {
2257
2267
  export function syncResolve(cwd: string, conflictId: string, resolution: SyncResolution): ResolveSummary {
2258
2268
  const storage = openTrekoonDatabase(cwd);
2259
2269
  const git = resolveGitContext(cwd);
2270
+ const scope: ConflictScope = scopeFromGitContext(git);
2260
2271
 
2261
2272
  try {
2262
2273
  persistGitContext(storage.db, git);
@@ -2266,7 +2277,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
2266
2277
  // atomic. Without this, two concurrent resolves could both pass
2267
2278
  // the check and double-resolve the same conflict.
2268
2279
  const conflict = writeTransaction(storage.db, (): ConflictRow => {
2269
- const row = lookupPendingConflict(storage.db, conflictId);
2280
+ const row = lookupPendingConflict(storage.db, conflictId, scope);
2270
2281
  resolveConflictRow(storage.db, row, resolution, git);
2271
2282
  return row;
2272
2283
  });
@@ -2286,9 +2297,11 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
2286
2297
  // Preview is read-only — no git context persistence needed.
2287
2298
  export function syncResolvePreview(cwd: string, conflictId: string, resolution: SyncResolution): ResolvePreviewSummary {
2288
2299
  const storage = openTrekoonDatabase(cwd);
2300
+ const git = resolveGitContext(cwd);
2301
+ const scope: ConflictScope = scopeFromGitContext(git);
2289
2302
 
2290
2303
  try {
2291
- const conflict = lookupPendingConflict(storage.db, conflictId);
2304
+ const conflict = lookupPendingConflict(storage.db, conflictId, scope);
2292
2305
 
2293
2306
  const oursValue: unknown = parseConflictValue(conflict.ours_value);
2294
2307
  const theirsValue: unknown = parseConflictValue(conflict.theirs_value);
@@ -2343,7 +2356,11 @@ function queryPendingConflictIds(
2343
2356
  return (db.query(sql).all(...params) as ConflictOrderRow[]).map((row) => row.id);
2344
2357
  }
2345
2358
 
2346
- function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]): readonly ConflictRow[] {
2359
+ function queryPendingConflictsByIds(
2360
+ db: Database,
2361
+ conflictIds: readonly string[],
2362
+ scope: ConflictScope,
2363
+ ): readonly ConflictRow[] {
2347
2364
  if (conflictIds.length === 0) {
2348
2365
  return [];
2349
2366
  }
@@ -2354,10 +2371,13 @@ function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]
2354
2371
  `
2355
2372
  SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at, worktree_path, current_branch
2356
2373
  FROM sync_conflicts
2357
- WHERE resolution = 'pending' AND id IN (${placeholders});
2374
+ WHERE resolution = 'pending'
2375
+ AND id IN (${placeholders})
2376
+ AND worktree_path = ?
2377
+ AND current_branch = ?;
2358
2378
  `,
2359
2379
  )
2360
- .all(...conflictIds) as ConflictRow[];
2380
+ .all(...conflictIds, scope.worktreePath, scope.currentBranch) as ConflictRow[];
2361
2381
 
2362
2382
  const rowById = new Map(rows.map((row) => [row.id, row]));
2363
2383
 
@@ -2397,7 +2417,7 @@ export function syncResolveAll(
2397
2417
 
2398
2418
  for (let offset = 0; offset < orderedConflictIds.length; offset += RESOLVE_ALL_CHUNK_SIZE) {
2399
2419
  const chunkIds = orderedConflictIds.slice(offset, offset + RESOLVE_ALL_CHUNK_SIZE);
2400
- const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds);
2420
+ const chunkConflicts = queryPendingConflictsByIds(storage.db, chunkIds, scope);
2401
2421
 
2402
2422
  if (chunkConflicts.length !== chunkIds.length) {
2403
2423
  throw new DomainError({