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.
- package/dist/assets/{TiptapBody-Btu-_mZq.js → TiptapBody-D0tfDVZb.js} +1 -1
- package/dist/assets/{cssMode-EkBJI3CN.js → cssMode-C3tZkaJ9.js} +1 -1
- package/dist/assets/{freemarker2-DFbgmGC_.js → freemarker2-DEh8tC5X.js} +1 -1
- package/dist/assets/{handlebars-CUy9oTnL.js → handlebars-D_6KmsOK.js} +1 -1
- package/dist/assets/{html-CUFKxIqK.js → html-DF1KVjzv.js} +1 -1
- package/dist/assets/{htmlMode-hBSk7Fab.js → htmlMode-DEakPokt.js} +1 -1
- package/dist/assets/{index-D4dlTF2R.css → index-D-kX3T0V.css} +1 -1
- package/dist/assets/{index-BGIAQ9_i.js → index-Zg61GP50.js} +1416 -1115
- package/dist/assets/{javascript-CAH2ooMO.js → javascript-Du7a359D.js} +1 -1
- package/dist/assets/{jsonMode-BSMCRMaw.js → jsonMode-W3BJwbUD.js} +1 -1
- package/dist/assets/{liquid-CSMs05cs.js → liquid-DDj1fqca.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-DEyYbu0m.js → lspLanguageFeatures-uyEbiR-d.js} +1 -1
- package/dist/assets/{mdx-BocaqTLI.js → mdx-DUqSETvC.js} +1 -1
- package/dist/assets/{python-Dj6u2CWq.js → python-D7S2lUAn.js} +1 -1
- package/dist/assets/{razor-D5OWmIwh.js → razor-19nfZNaZ.js} +1 -1
- package/dist/assets/{tsMode-Cy6xJd5e.js → tsMode-CQke5zpL.js} +1 -1
- package/dist/assets/{typescript-CsWWOqVn.js → typescript-D4ge0PUF.js} +1 -1
- package/dist/assets/{xml-DiTZK7ii.js → xml-D42QDS7q.js} +1 -1
- package/dist/assets/{yaml-pdLhZQr9.js → yaml-CEiz9NyU.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/runVerify.test.cjs +388 -0
- package/src/main/config.cjs +1 -7
- package/src/main/files.cjs +4 -37
- package/src/main/historyAggregator.cjs +37 -14
- package/src/main/index.cjs +57 -134
- package/src/main/ipcSchemas.cjs +4 -0
- package/src/main/lib/childWithLog.cjs +218 -0
- package/src/main/lib/expandHome.cjs +21 -0
- package/src/main/lib/insideHome.cjs +74 -21
- package/src/main/lib/openExternalApp.cjs +141 -0
- package/src/main/pluginInstall.cjs +39 -1
- package/src/main/pty.cjs +11 -4
- package/src/main/queueOps.cjs +2 -2
- package/src/main/runVerify.cjs +527 -0
- package/src/main/scheduler.cjs +335 -286
- package/src/main/search.cjs +1 -6
- package/src/main/superagent.cjs +10 -0
- package/src/main/supervisor.cjs +16 -8
- package/src/main/transcripts.cjs +18 -0
- package/src/main/usageMatrix.cjs +336 -0
- package/src/main/watchers.cjs +2 -2
- package/src/preload/api.d.ts +68 -7
- package/src/preload/index.cjs +9 -0
package/src/main/index.cjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 (
|
|
468
|
-
//
|
|
469
|
-
//
|
|
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
|
|
483
|
-
//
|
|
484
|
-
//
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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-
|
|
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 (
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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 {
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
ipcMain.handle('app:
|
|
618
|
-
const
|
|
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 =
|
|
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
|
});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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 —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Two exports, two policies:
|
|
9
|
+
* assertInsideHome(p) → { realPath } — throws on failure
|
|
10
|
+
* checkInsideHome(p) → { ok, realPath } | { ok, error } — never throws
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
let realCwd;
|
|
79
|
+
let realPath;
|
|
27
80
|
try {
|
|
28
|
-
|
|
81
|
+
realPath = resolveTarget(p);
|
|
29
82
|
} catch {
|
|
30
|
-
|
|
83
|
+
return { ok: false, error: 'path outside home' };
|
|
31
84
|
}
|
|
32
|
-
if (
|
|
33
|
-
return { ok: false, error:
|
|
85
|
+
if (!isContained(realPath)) {
|
|
86
|
+
return { ok: false, error: 'path outside home' };
|
|
34
87
|
}
|
|
35
|
-
return { ok: true,
|
|
88
|
+
return { ok: true, realPath };
|
|
36
89
|
}
|
|
37
90
|
|
|
38
|
-
module.exports = {
|
|
91
|
+
module.exports = { assertInsideHome, checkInsideHome };
|