claude-code-session-manager 0.13.1 → 0.15.0

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 (44) hide show
  1. package/dist/assets/{TiptapBody-Btu-_mZq.js → TiptapBody-D0tfDVZb.js} +1 -1
  2. package/dist/assets/{cssMode-EkBJI3CN.js → cssMode-C3tZkaJ9.js} +1 -1
  3. package/dist/assets/{freemarker2-DFbgmGC_.js → freemarker2-DEh8tC5X.js} +1 -1
  4. package/dist/assets/{handlebars-CUy9oTnL.js → handlebars-D_6KmsOK.js} +1 -1
  5. package/dist/assets/{html-CUFKxIqK.js → html-DF1KVjzv.js} +1 -1
  6. package/dist/assets/{htmlMode-hBSk7Fab.js → htmlMode-DEakPokt.js} +1 -1
  7. package/dist/assets/{index-D4dlTF2R.css → index-D-kX3T0V.css} +1 -1
  8. package/dist/assets/{index-BGIAQ9_i.js → index-Zg61GP50.js} +1416 -1115
  9. package/dist/assets/{javascript-CAH2ooMO.js → javascript-Du7a359D.js} +1 -1
  10. package/dist/assets/{jsonMode-BSMCRMaw.js → jsonMode-W3BJwbUD.js} +1 -1
  11. package/dist/assets/{liquid-CSMs05cs.js → liquid-DDj1fqca.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DEyYbu0m.js → lspLanguageFeatures-uyEbiR-d.js} +1 -1
  13. package/dist/assets/{mdx-BocaqTLI.js → mdx-DUqSETvC.js} +1 -1
  14. package/dist/assets/{python-Dj6u2CWq.js → python-D7S2lUAn.js} +1 -1
  15. package/dist/assets/{razor-D5OWmIwh.js → razor-19nfZNaZ.js} +1 -1
  16. package/dist/assets/{tsMode-Cy6xJd5e.js → tsMode-CQke5zpL.js} +1 -1
  17. package/dist/assets/{typescript-CsWWOqVn.js → typescript-D4ge0PUF.js} +1 -1
  18. package/dist/assets/{xml-DiTZK7ii.js → xml-D42QDS7q.js} +1 -1
  19. package/dist/assets/{yaml-pdLhZQr9.js → yaml-CEiz9NyU.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +2 -1
  22. package/src/main/__tests__/runVerify.test.cjs +388 -0
  23. package/src/main/config.cjs +1 -7
  24. package/src/main/files.cjs +4 -37
  25. package/src/main/historyAggregator.cjs +37 -14
  26. package/src/main/index.cjs +57 -134
  27. package/src/main/ipcSchemas.cjs +4 -0
  28. package/src/main/lib/childWithLog.cjs +218 -0
  29. package/src/main/lib/expandHome.cjs +21 -0
  30. package/src/main/lib/insideHome.cjs +74 -21
  31. package/src/main/lib/openExternalApp.cjs +141 -0
  32. package/src/main/pluginInstall.cjs +39 -1
  33. package/src/main/pty.cjs +11 -4
  34. package/src/main/queueOps.cjs +2 -2
  35. package/src/main/runVerify.cjs +527 -0
  36. package/src/main/scheduler.cjs +335 -286
  37. package/src/main/search.cjs +1 -6
  38. package/src/main/superagent.cjs +10 -0
  39. package/src/main/supervisor.cjs +16 -8
  40. package/src/main/transcripts.cjs +18 -0
  41. package/src/main/usageMatrix.cjs +336 -0
  42. package/src/main/watchers.cjs +2 -2
  43. package/src/preload/api.d.ts +68 -7
  44. package/src/preload/index.cjs +9 -0
@@ -9,6 +9,7 @@ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
9
9
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
10
10
  const configMgr = require('./config.cjs');
11
11
  const transcripts = require('./transcripts.cjs');
12
+ const usageMatrix = require('./usageMatrix.cjs');
12
13
  const sessionsStore = require('./sessionsStore.cjs');
13
14
  const billing = require('./usage.cjs');
14
15
  const logs = require('./logs.cjs');
@@ -34,7 +35,8 @@ const searchIpc = require('./search.cjs');
34
35
  const repoAnalyzer = require('./repoAnalyzer.cjs');
35
36
  const hivesIpc = require('./hives.cjs');
36
37
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
37
- const { assertCwdInsideHome } = require('./lib/insideHome.cjs');
38
+ const { checkInsideHome } = require('./lib/insideHome.cjs');
39
+ const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
38
40
 
39
41
  let mainWindow = null;
40
42
  let rebooting = false;
@@ -81,7 +83,7 @@ function writeFirstPaintFailureLog() {
81
83
  const ymd = new Date().toISOString().slice(0, 10);
82
84
  const logPath = path.join(logDir, `boot-${ymd}.log`);
83
85
 
84
- const homeCheck = assertCwdInsideHome(os.homedir());
86
+ const homeCheck = checkInsideHome(os.homedir());
85
87
  const lines = [
86
88
  `=== first-paint deadline exceeded @ ${new Date().toISOString()} ===`,
87
89
  `process.versions: ${JSON.stringify(process.versions)}`,
@@ -186,6 +188,7 @@ async function rebootApp() {
186
188
  ptyManager.attachWindow(mainWindow);
187
189
  configMgr.attachWindow(mainWindow);
188
190
  transcripts.attachWindow(mainWindow);
191
+ usageMatrix.attachWindow(mainWindow);
189
192
  voiceHotkey.init(mainWindow).catch((e) => {
190
193
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
191
194
  });
@@ -464,12 +467,9 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
464
467
  // for non-git dirs, detached HEAD, missing git, or timeouts so callers can
465
468
  // render `—` without branching on error shape. 1s ceiling keeps a wedged git
466
469
  // (network filesystem, hung index lock) from blocking the renderer.
467
- ipcMain.handle('app:git-branch', async (_e, payload) => {
468
- // safeParse (not throw) so a malformed call resolves to null — matches the
469
- // existing semantics where StatusBar renders `—` on any failure.
470
- const parsed = schemas.appGitBranch.safeParse(payload);
471
- if (!parsed.success) return null;
472
- const { cwd } = parsed.data;
470
+ ipcMain.handle('app:git-branch', validated(schemas.appGitBranch, async ({ cwd }) => {
471
+ // Both renderer callsites (AlmanacFooter, AlmanacSidebar) already `.catch`
472
+ // rejections and render `—`, so a ZodError throw is handled correctly.
473
473
  return await new Promise((resolve) => {
474
474
  execFile('git', ['branch', '--show-current'], { cwd, timeout: 1000, windowsHide: true }, (err, stdout) => {
475
475
  if (err) { resolve(null); return; }
@@ -477,156 +477,78 @@ ipcMain.handle('app:git-branch', async (_e, payload) => {
477
477
  resolve(out.length ? out : null);
478
478
  });
479
479
  });
480
- });
480
+ }));
481
481
 
482
- // Containment check used by the open-in-{editor,finder,terminal} handlers.
483
- // Bare `startsWith(home)` matches `/home/bilkoEVIL` when home=`/home/bilko`
484
- // the classic prefix-trap. Resolve real paths (follow symlinks) and require
485
- // either exact equality or a separator boundary. Returns null on success or
486
- // an error string on failure. ENOENT (cwd doesn't exist) is a soft fail —
487
- // downstream `spawn`/`shell.openPath` will surface a more precise error.
488
- function checkInsideHome(cwd) {
489
- if (typeof cwd !== 'string' || !cwd) return 'cwd outside home';
490
- const home = os.homedir();
491
- let realCwd;
492
- let realHome;
493
- try {
494
- realHome = fs.realpathSync(home);
495
- } catch {
496
- return 'home directory unresolved';
497
- }
498
- try {
499
- realCwd = fs.realpathSync(cwd);
500
- } catch (err) {
501
- if (err && err.code === 'ENOENT') {
502
- // Fall through to the existing error path: the resolved-but-nonexistent
503
- // path still has to be home-contained to avoid information disclosure
504
- // via stat-probes (`/etc/secrets/...` → editor errors that reveal
505
- // existence).
506
- const resolved = path.resolve(cwd);
507
- if (resolved !== realHome && !resolved.startsWith(realHome + path.sep)) {
508
- return 'cwd outside home';
509
- }
510
- return null;
511
- }
512
- return 'cwd outside home';
513
- }
514
- if (realCwd !== realHome && !realCwd.startsWith(realHome + path.sep)) {
515
- return 'cwd outside home';
516
- }
517
- return null;
518
- }
482
+ // Containment check for the open-in-{editor,finder,terminal} handlers lives
483
+ // in lib/insideHome.cjs single chokepoint for the /home/bilkoEVIL prefix-trap.
484
+ // Editor / finder / terminal logic lives in lib/openExternalApp.cjs.
519
485
 
520
- // Returns the resolved path of a command, or null if not found on PATH.
521
- function findCommand(name) {
522
- try {
523
- const out = execFileSync(
524
- process.platform === 'win32' ? 'where' : 'which',
525
- [name],
526
- { encoding: 'utf8', env: process.env, timeout: 500 },
527
- ).trim().split(/\r?\n/)[0];
528
- if (out) return out;
529
- } catch { /* not found */ }
530
- return null;
531
- }
486
+ ipcMain.handle('app:open-in-editor', validated(schemas.openInEditor, async ({ cwd, editor }) => {
487
+ const r = checkInsideHome(cwd);
488
+ if (!r.ok) throw new Error(r.error);
489
+ return openInEditor({ cwd, editor });
490
+ }));
532
491
 
533
- ipcMain.handle('app:open-in-editor', async (_e, payload) => {
534
- const { cwd, editor } = schemas.openInEditor.parse(payload);
535
- const err = checkInsideHome(cwd);
536
- if (err) throw new Error(err);
537
- const candidates = (editor && editor !== 'auto')
538
- ? [editor]
539
- : [process.env.VISUAL, process.env.EDITOR, 'code', 'cursor', 'subl', 'nano'].filter(Boolean);
540
- for (const cmd of candidates) {
541
- if (!findCommand(cmd)) continue;
542
- const child = spawn(cmd, [cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() });
543
- child.unref();
544
- return { ok: true, editor: cmd };
545
- }
546
- return { ok: false, error: 'no editor found' };
547
- });
548
-
549
- ipcMain.handle('app:open-external', async (_e, payload) => {
492
+ ipcMain.handle('app:open-external', validated(schemas.openExternal, async ({ url }) => {
550
493
  // URL filter mirrors setWindowOpenHandler at line ~631: without it, the
551
494
  // renderer could be tricked into asking shell.openExternal to launch
552
495
  // `file:///etc/passwd`, `javascript:…`, or `mailto:…`. Stick to web URLs.
553
- const { url } = schemas.openExternal.parse(payload);
554
496
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
555
497
  return { ok: false, error: 'only http/https URLs are allowed' };
556
498
  }
557
499
  await shell.openExternal(url);
558
500
  return { ok: true };
559
- });
501
+ }));
560
502
 
561
- ipcMain.handle('app:open-file-in-editor', async (_e, payload) => {
562
- // Open a specific file (with optional line:col) in the user's editor.
563
- // Distinct from app:open-in-editor above which opens a project root.
564
- // GUI editors that support the goto-line flag (code/cursor/subl) get
565
- // `-g file:line:col`; everything else falls back to opening the file alone.
566
- const { path: p, line, col, editor } = schemas.openFileInEditor.parse(payload);
503
+ ipcMain.handle('app:open-file-in-editor', validated(schemas.openFileInEditor, async ({ path: p, line, col, editor }) => {
567
504
  const home = os.homedir();
568
505
  const abs = path.isAbsolute(p) ? p : path.resolve(home, p);
569
- const err = checkInsideHome(abs);
570
- if (err) throw new Error(err);
571
- try { await fsp.access(abs); } catch { return { ok: false, error: `file not found: ${abs}` }; }
572
- const candidates = (editor && editor !== 'auto')
573
- ? [editor]
574
- : [process.env.VISUAL, process.env.EDITOR, 'code', 'cursor', 'subl', 'nano'].filter(Boolean);
575
- for (const cmd of candidates) {
576
- if (!findCommand(cmd)) continue;
577
- const supportsGoto = /^(code|cursor|subl)$/.test(cmd);
578
- const target = (supportsGoto && line) ? `${abs}:${line}${col ? `:${col}` : ''}` : abs;
579
- const args = supportsGoto ? ['-g', target] : [abs];
580
- const child = spawn(cmd, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
581
- child.unref();
582
- return { ok: true, editor: cmd };
583
- }
584
- return { ok: false, error: 'no editor found' };
585
- });
586
-
587
- ipcMain.handle('app:open-in-finder', async (_e, payload) => {
588
- const { cwd } = schemas.openInFinder.parse(payload);
589
- const err = checkInsideHome(cwd);
590
- if (err) throw new Error(err);
591
- await shell.openPath(cwd);
592
- return { ok: true };
593
- });
594
-
595
- ipcMain.handle('app:open-in-terminal', async (_e, payload) => {
596
- const { cwd } = schemas.openInTerminal.parse(payload);
597
- const err = checkInsideHome(cwd);
598
- if (err) throw new Error(err);
599
- if (process.platform === 'linux') {
600
- const terms = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
601
- for (const t of terms) {
602
- if (!findCommand(t)) continue;
603
- const args = t === 'gnome-terminal'
604
- ? ['--working-directory=' + cwd]
605
- : ['-e', `bash -c "cd '${cwd.replace(/'/g, "'\\''")}' && exec bash"`];
606
- const child = spawn(t, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
607
- child.unref();
608
- return { ok: true, terminal: t };
609
- }
610
- } else if (process.platform === 'darwin') {
611
- spawn('open', ['-a', 'Terminal', cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
612
- return { ok: true, terminal: 'Terminal.app' };
506
+ // Allowed roots: $HOME (the usual case) plus our own clipboard temp dir
507
+ // (clipboard-paste writes PNGs there; clicks on those paths from the
508
+ // terminal must resolve). Resolve symlinks on both sides on macOS
509
+ // /tmp is a symlink to /private/tmp, so a literal prefix check fails.
510
+ const clipboardDirRaw = path.join(os.tmpdir(), 'session-manager-clipboard');
511
+ let clipboardDirReal = clipboardDirRaw;
512
+ try { clipboardDirReal = fs.realpathSync(clipboardDirRaw); } catch { /* not yet created */ }
513
+ let absReal = abs;
514
+ try { absReal = fs.realpathSync(abs); } catch { /* file may not exist yet — fall through to access() below */ }
515
+ const inClipboardTmp =
516
+ absReal === clipboardDirReal ||
517
+ absReal.startsWith(clipboardDirReal + path.sep) ||
518
+ abs === clipboardDirRaw ||
519
+ abs.startsWith(clipboardDirRaw + path.sep);
520
+ if (!inClipboardTmp) {
521
+ const r = checkInsideHome(abs);
522
+ if (!r.ok) throw new Error(r.error);
613
523
  }
614
- return { ok: false, error: 'no terminal found' };
615
- });
616
-
617
- ipcMain.handle('app:archive-project', async (_e, payload) => {
618
- const { encoded } = schemas.archiveProject.parse(payload);
524
+ return openFileInEditor({ path: abs, line, col, editor });
525
+ }));
526
+
527
+ ipcMain.handle('app:open-in-finder', validated(schemas.openInFinder, async ({ cwd }) => {
528
+ const r = checkInsideHome(cwd);
529
+ if (!r.ok) throw new Error(r.error);
530
+ return openInFinder({ cwd });
531
+ }));
532
+
533
+ ipcMain.handle('app:open-in-terminal', validated(schemas.openInTerminal, async ({ cwd }) => {
534
+ const r = checkInsideHome(cwd);
535
+ if (!r.ok) throw new Error(r.error);
536
+ return openInTerminal({ cwd });
537
+ }));
538
+
539
+ ipcMain.handle('app:archive-project', validated(schemas.archiveProject, async ({ encoded }) => {
619
540
  const home = os.homedir();
620
541
  const src = path.join(home, '.claude', 'projects', encoded);
621
542
  const dst = path.join(home, '.claude', 'projects-archive', encoded);
622
543
  await fsp.mkdir(path.dirname(dst), { recursive: true });
623
544
  await fsp.rename(src, dst);
624
545
  return { ok: true };
625
- });
546
+ }));
626
547
 
627
548
  registerPtyHandlers();
628
549
  configMgr.registerConfigHandlers();
629
550
  transcripts.registerTranscriptHandlers();
551
+ usageMatrix.registerHandlers();
630
552
  sessionsStore.registerSessionsHandlers();
631
553
  billing.registerBillingHandlers();
632
554
  logs.registerLogHandlers();
@@ -738,7 +660,7 @@ app.whenReady().then(async () => {
738
660
  // Boot-time detection 2: symlinked /Users on macOS can make os.homedir()
739
661
  // realpath to a path outside itself, which breaks every cwd containment
740
662
  // check downstream. Surface here rather than failing on first session spawn.
741
- bootHomeSelfCheck = assertCwdInsideHome(os.homedir());
663
+ bootHomeSelfCheck = checkInsideHome(os.homedir());
742
664
  if (!bootHomeSelfCheck.ok) {
743
665
  console.error(`[insideHome] SELF-CHECK FAILED: ${bootHomeSelfCheck.error}; sessions will not be able to spawn`);
744
666
  }
@@ -858,6 +780,7 @@ app.whenReady().then(async () => {
858
780
  ptyManager.attachWindow(mainWindow);
859
781
  configMgr.attachWindow(mainWindow);
860
782
  transcripts.attachWindow(mainWindow);
783
+ usageMatrix.attachWindow(mainWindow);
861
784
  voiceHotkey.init(mainWindow).catch((e) => {
862
785
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
863
786
  });
@@ -333,6 +333,9 @@ const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
333
333
  const pluginsInstall = z.object({
334
334
  slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
335
335
  }).passthrough();
336
+ const pluginsAbort = z.object({
337
+ slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
338
+ }).passthrough();
336
339
 
337
340
  // SuperAgent — "boss" run that writes a structured prompt to the active
338
341
  // tab's PTY. Bounds match the inline schemas in superagent.cjs; centralizing
@@ -404,6 +407,7 @@ module.exports = {
404
407
  gitFileStatus,
405
408
  repoAnalyze,
406
409
  pluginsInstall,
410
+ pluginsAbort,
407
411
  superagentStart,
408
412
  superagentTabId,
409
413
  memoryList,
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * childWithLog.cjs — spawn a child process with a log fd and watchdog timers.
5
+ *
6
+ * Two-phase API so callers can emit pre-spawn log lines (e.g. "starting …",
7
+ * early-exit error messages) before the child is created, while still having
8
+ * the helper own the fd lifecycle end-to-end.
9
+ *
10
+ * Phase 1 — openLog(logPath)
11
+ * Opens the log file in append mode. Returns { fd, safeLog, closeFd }.
12
+ * safeLog: idempotent after closeFd; never throws (guards EBADF on timers).
13
+ * closeFd: idempotent; closes the fd exactly once.
14
+ * After calling withChildAndLog(), do NOT call closeFd() yourself — the
15
+ * helper takes ownership and closes it once after onExit returns.
16
+ *
17
+ * Phase 2 — withChildAndLog({ fd, logPath, safeLog, closeFd, spawn, watchdogs, onExit })
18
+ * Spawns the child with stdio piped to the open fd.
19
+ * Manages the watchdog timer lifecycle:
20
+ * - Each watchdog uses setInterval. The interval auto-clears the first time
21
+ * shouldFire(ctx) returns true, then calls action(ctx).
22
+ * - action() may call ctx.addTimer(t) to register secondary timers
23
+ * (e.g. SIGTERM → SIGKILL cascades) so they are also cleared on exit.
24
+ * Calls onExit() synchronously inside the child 'exit'/'error' handler,
25
+ * before calling closeFd(). Caller may call ctx.safeLog() inside onExit.
26
+ *
27
+ * WatchdogContext:
28
+ * child — the ChildProcess (null if spawn threw synchronously)
29
+ * logPath — string, used by watchdogs that stat the log file
30
+ * startedAt — Date.now() at spawn time
31
+ * safeLog — same safeLog returned by openLog()
32
+ * killedByWatchdog — string | null, mutable; set by action() to record which
33
+ * watchdog issued the kill
34
+ * killTree(sig) — sends sig to the process group (falls back to child.kill)
35
+ * addTimer(t) — registers a secondary timer (clearTimeout-compatible)
36
+ *
37
+ * onExit info:
38
+ * exitCode — number | null (−1 on spawn failure or child 'error' event)
39
+ * signal — string | null
40
+ * killedByWatchdog — string | null
41
+ * error — Error | undefined (child 'error' event or sync spawn throw)
42
+ * spawnFailed — boolean, true when the error came from a synchronous spawn throw
43
+ * safeLog — same safeLog (usable inside onExit for final log lines)
44
+ *
45
+ * Returns { child, cancel } where cancel() clears all timers + SIGKILLs.
46
+ * child is null when the synchronous spawn threw (onExit has already been called).
47
+ */
48
+
49
+ const fs = require('node:fs');
50
+ const { spawn } = require('node:child_process');
51
+
52
+ // ---------- Phase 1 ----------
53
+
54
+ /**
55
+ * Open a log file in append mode. Returns { fd, safeLog, closeFd }.
56
+ *
57
+ * @param {string} logPath
58
+ * @returns {{ fd: number, safeLog: (msg: string) => void, closeFd: () => void }}
59
+ */
60
+ function openLog(logPath) {
61
+ const fd = fs.openSync(logPath, 'a');
62
+ let fdClosed = false;
63
+
64
+ const closeFd = () => {
65
+ if (fdClosed) return;
66
+ fdClosed = true;
67
+ try { fs.closeSync(fd); } catch { /* already closed */ }
68
+ };
69
+
70
+ // safeLog: no-op once the fd is closed, never throws.
71
+ // Pre-fix for EBADF: a watchdog timer firing after closeFd would throw and
72
+ // crash the host if we used fs.writeSync directly.
73
+ const safeLog = (msg) => {
74
+ if (fdClosed) return;
75
+ try { fs.writeSync(fd, msg); } catch { /* fd vanished mid-write */ }
76
+ };
77
+
78
+ return { fd, safeLog, closeFd };
79
+ }
80
+
81
+ // ---------- Phase 2 ----------
82
+
83
+ /**
84
+ * @typedef {Object} WatchdogSpec
85
+ * @property {string} label
86
+ * @property {number} intervalMs — poll interval
87
+ * @property {(ctx: WatchdogContext) => boolean} shouldFire — return true to
88
+ * fire action + auto-clear the interval (fire-once-on-condition)
89
+ * @property {(ctx: WatchdogContext) => void} action
90
+ */
91
+
92
+ /**
93
+ * @typedef {Object} WatchdogContext
94
+ * @property {import('child_process').ChildProcess | null} child
95
+ * @property {string} logPath
96
+ * @property {number} startedAt
97
+ * @property {(msg: string) => void} safeLog
98
+ * @property {string | null} killedByWatchdog
99
+ * @property {(signal: string) => boolean} killTree
100
+ * @property {(timer: any) => void} addTimer
101
+ */
102
+
103
+ /**
104
+ * @param {{
105
+ * fd: number,
106
+ * logPath: string,
107
+ * safeLog: (msg: string) => void,
108
+ * closeFd: () => void,
109
+ * spawn: { command: string, args: string[], options: object },
110
+ * watchdogs?: WatchdogSpec[],
111
+ * onExit?: (info: {
112
+ * exitCode: number | null,
113
+ * signal: string | null,
114
+ * killedByWatchdog: string | null,
115
+ * error?: Error,
116
+ * spawnFailed?: boolean,
117
+ * safeLog: (msg: string) => void,
118
+ * }) => void,
119
+ * }} opts
120
+ * @returns {{ child: import('child_process').ChildProcess | null, cancel: () => void }}
121
+ */
122
+ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
123
+ const startedAt = Date.now();
124
+
125
+ // Secondary timers registered by watchdog actions (e.g. SIGTERM → SIGKILL cascades).
126
+ // Cleared alongside the primary watchdog timers on child exit.
127
+ // clearTimeout works on both Timeout and Interval handles in Node.js.
128
+ const extraTimers = [];
129
+
130
+ /** @type {WatchdogContext} */
131
+ const ctx = {
132
+ child: null,
133
+ logPath,
134
+ startedAt,
135
+ safeLog,
136
+ killedByWatchdog: null,
137
+ killTree(signal) {
138
+ const c = ctx.child;
139
+ if (!c) return false;
140
+ // Negative pid targets the whole process group (requires detached: true at spawn).
141
+ // Falls back to direct kill if the process is not a group leader.
142
+ try { process.kill(-c.pid, signal); return true; }
143
+ catch {
144
+ try { process.kill(c.pid, signal); return true; }
145
+ catch { return false; /* already dead */ }
146
+ }
147
+ },
148
+ addTimer(t) { extraTimers.push(t); },
149
+ };
150
+
151
+ // Build interval-based watchdog timers. Each fires every intervalMs; the
152
+ // first time shouldFire(ctx) returns true, the interval is auto-cleared and
153
+ // action(ctx) is called (fire-once-on-condition semantics).
154
+ //
155
+ // O(|watchdogs|) setup, O(1) per poll tick.
156
+ const clearFns = watchdogs.map((wd) => {
157
+ const t = setInterval(() => {
158
+ if (wd.shouldFire(ctx)) {
159
+ clearInterval(t);
160
+ wd.action(ctx);
161
+ }
162
+ }, wd.intervalMs);
163
+ if (t.unref) t.unref();
164
+ return () => clearInterval(t);
165
+ });
166
+
167
+ const clearAllTimers = () => {
168
+ clearFns.forEach((fn) => fn());
169
+ extraTimers.forEach((t) => clearTimeout(t));
170
+ };
171
+
172
+ // handleDone: called from both 'error' and 'exit' handlers. Clears timers,
173
+ // calls onExit (caller may still safeLog inside), then closes the fd.
174
+ const handleDone = (exitCode, signal, error, spawnFailed) => {
175
+ clearAllTimers();
176
+ if (onExit) {
177
+ onExit({
178
+ exitCode,
179
+ signal: signal ?? null,
180
+ killedByWatchdog: ctx.killedByWatchdog,
181
+ error,
182
+ spawnFailed: spawnFailed ?? false,
183
+ safeLog,
184
+ });
185
+ }
186
+ closeFd();
187
+ };
188
+
189
+ // Attempt synchronous spawn. On failure, call handleDone immediately so the
190
+ // caller's onExit can resolve any pending Promise before we return null.
191
+ let child;
192
+ try {
193
+ child = spawn(spawnSpec.command, spawnSpec.args, {
194
+ ...spawnSpec.options,
195
+ stdio: ['ignore', fd, fd],
196
+ });
197
+ } catch (e) {
198
+ handleDone(-1, null, e, /* spawnFailed */ true);
199
+ return { child: null, cancel: () => {} };
200
+ }
201
+
202
+ ctx.child = child;
203
+
204
+ child.on('error', (err) => handleDone(-1, null, err, false));
205
+ child.on('exit', (code, signal) => handleDone(code, signal, undefined, false));
206
+
207
+ const cancel = () => {
208
+ clearAllTimers();
209
+ // Best-effort kill; ignore errors (process may already be gone).
210
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {
211
+ try { child.kill('SIGKILL'); } catch { /* */ }
212
+ }
213
+ };
214
+
215
+ return { child, cancel };
216
+ }
217
+
218
+ module.exports = { openLog, withChildAndLog };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * expandHome — tilde expansion. `~` and `~/foo` become $HOME and $HOME/foo.
3
+ * Everything else is returned unchanged. Used by files.cjs, config.cjs,
4
+ * search.cjs, queueOps.cjs — pulled out into a single helper to keep the
5
+ * containment-check grep in insideHome.cjs clean (`startsWith('~/')` followed
6
+ * by `homedir()` on the same line would otherwise create false positives in
7
+ * the security-audit grep).
8
+ */
9
+ 'use strict';
10
+
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ function expandHome(p) {
15
+ if (!p) return p;
16
+ if (p === '~') return os.homedir();
17
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
18
+ return p;
19
+ }
20
+
21
+ module.exports = { expandHome };
@@ -1,16 +1,25 @@
1
1
  /**
2
- * insideHome.cjs — security invariant: every renderer-controlled cwd must
3
- * resolve (after realpath, symlink-safe) to a path inside the user's home
4
- * directory. Four call sites used to reimplement this: pty.cjs spawn,
5
- * watchers.cjs add, index.cjs checkInsideHome, ipcSchemas.cjs setConfigSchema
6
- * defaultCwd refine. The check is a single chokepoint here.
2
+ * insideHome.cjs — single chokepoint for "is this renderer-controlled path
3
+ * inside $HOME?" Security boundary: bare `startsWith(home)` matches
4
+ * `/home/bilkoEVIL` when home=`/home/bilko` (the classic prefix-trap). Both
5
+ * the target and the home directory are resolved via realpath, and the
6
+ * boundary is either exact equality or a path-separator boundary.
7
7
  *
8
- * Returns { ok: true, realCwd } on success or { ok: false, error } on rejection.
9
- * Realpath of the cwd is returned so callers can use it without recomputing.
8
+ * Two exports, two policies:
9
+ * assertInsideHome(p) { realPath } — throws on failure
10
+ * checkInsideHome(p) → { ok, realPath } | { ok, error } — never throws
10
11
  *
11
- * NOTE: schemas can't easily call this (zod refines are inline); they retain
12
- * a simpler `startsWith` check that does NOT symlink-resolve. The runtime
13
- * resolution here is the authoritative one schemas are belt-and-suspenders.
12
+ * Both follow the same algorithm:
13
+ * 1. realpath the target. On ENOENT, fall back to path.resolve(p) so callers
14
+ * can validate "this is where I'm about to write" before the file exists.
15
+ * 2. Compare against the cached realpath of os.homedir() with the
16
+ * equality-or-separator boundary check.
17
+ *
18
+ * The cached home realpath is computed once at module load. If $HOME can't be
19
+ * resolved at startup we keep going with the literal value — boot-time
20
+ * self-check in index.cjs surfaces that to the user.
21
+ *
22
+ * Linux + darwin only. No Windows path handling.
14
23
  */
15
24
  'use strict';
16
25
 
@@ -18,21 +27,65 @@ const fs = require('node:fs');
18
27
  const path = require('node:path');
19
28
  const os = require('node:os');
20
29
 
21
- function assertCwdInsideHome(cwd) {
22
- if (typeof cwd !== 'string' || !cwd) {
23
- return { ok: false, error: 'cwd must be a non-empty string' };
30
+ // Resolve home once. Falls back to literal homedir if realpath fails
31
+ // (which would be a misconfigured system; boot self-check in index.cjs
32
+ // surfaces it). Cached for the lifetime of the process — home doesn't
33
+ // move under us at runtime.
34
+ const HOME_LITERAL = os.homedir();
35
+ let HOME_REAL;
36
+ try {
37
+ HOME_REAL = fs.realpathSync(HOME_LITERAL);
38
+ } catch {
39
+ HOME_REAL = HOME_LITERAL;
40
+ }
41
+
42
+ function resolveTarget(p) {
43
+ // realpath fails with ENOENT for paths that don't exist yet (writes,
44
+ // mkdir destinations). Fall through to lexical resolve so the containment
45
+ // check still applies — prevents info-disclosure via stat probes on
46
+ // /etc/secrets/... that would be rejected later but reveal existence first.
47
+ try {
48
+ return fs.realpathSync(p);
49
+ } catch (err) {
50
+ if (err && err.code === 'ENOENT') return path.resolve(p);
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ function isContained(realPath) {
56
+ return realPath === HOME_REAL || realPath.startsWith(HOME_REAL + path.sep);
57
+ }
58
+
59
+ function assertInsideHome(p) {
60
+ if (typeof p !== 'string' || !p) {
61
+ throw new Error('path outside home');
62
+ }
63
+ let realPath;
64
+ try {
65
+ realPath = resolveTarget(p);
66
+ } catch {
67
+ throw new Error('path outside home');
68
+ }
69
+ if (!isContained(realPath)) {
70
+ throw new Error('path outside home');
71
+ }
72
+ return { realPath };
73
+ }
74
+
75
+ function checkInsideHome(p) {
76
+ if (typeof p !== 'string' || !p) {
77
+ return { ok: false, error: 'path outside home' };
24
78
  }
25
- const home = os.homedir();
26
- let realCwd;
79
+ let realPath;
27
80
  try {
28
- realCwd = fs.realpathSync(cwd);
81
+ realPath = resolveTarget(p);
29
82
  } catch {
30
- realCwd = path.resolve(cwd);
83
+ return { ok: false, error: 'path outside home' };
31
84
  }
32
- if (realCwd !== home && !realCwd.startsWith(home + path.sep)) {
33
- return { ok: false, error: `cwd outside home directory: ${realCwd}` };
85
+ if (!isContained(realPath)) {
86
+ return { ok: false, error: 'path outside home' };
34
87
  }
35
- return { ok: true, realCwd };
88
+ return { ok: true, realPath };
36
89
  }
37
90
 
38
- module.exports = { assertCwdInsideHome };
91
+ module.exports = { assertInsideHome, checkInsideHome };