claude-code-session-manager 0.8.1 → 0.8.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.
- package/README.md +66 -11
- package/dist/assets/{cssMode-DKTELvb6.js → cssMode-DyaNC2Cs.js} +1 -1
- package/dist/assets/{editor.main-Dx55Am4z.js → editor.main-BhSGi_Jw.js} +3 -3
- package/dist/assets/{freemarker2-CBdvn_u-.js → freemarker2-DZH3si5v.js} +1 -1
- package/dist/assets/{handlebars-B67ay2ue.js → handlebars-DvzTd6uL.js} +1 -1
- package/dist/assets/{html-002uK0_M.js → html-C5GmopAN.js} +1 -1
- package/dist/assets/{htmlMode-DsT8oVY_.js → htmlMode-DwnrHwx1.js} +1 -1
- package/dist/assets/index-BGshD4Pw.js +2976 -0
- package/dist/assets/index-DCK87t79.css +32 -0
- package/dist/assets/{javascript-Cfg-gFlu.js → javascript-JqHrxiCa.js} +1 -1
- package/dist/assets/{jsonMode-CCIKxANa.js → jsonMode-8rZcy09i.js} +1 -1
- package/dist/assets/{liquid-DewgYvox.js → liquid-ClpD_v7G.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-BcMPMUo0.js → lspLanguageFeatures-u0WgQBQz.js} +1 -1
- package/dist/assets/{mdx-BGrrIvjV.js → mdx-DtViUgdm.js} +1 -1
- package/dist/assets/{python-CVhAv32T.js → python-CaAvhRGm.js} +1 -1
- package/dist/assets/{razor-DteXtrPO.js → razor-saGNVU7l.js} +1 -1
- package/dist/assets/{tsMode-DKeWRYvl.js → tsMode-HZwWTCj8.js} +1 -1
- package/dist/assets/{typescript-Dl1KPrAp.js → typescript-BInV4PNE.js} +1 -1
- package/dist/assets/{xml-DdyOGE0N.js → xml-tgO806YR.js} +1 -1
- package/dist/assets/{yaml-BwFXDW6t.js → yaml-CHApZArv.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/config.cjs +93 -19
- package/src/main/index.cjs +163 -31
- package/src/main/ipcSchemas.cjs +59 -2
- package/src/main/lib/cleanEnv.cjs +20 -0
- package/src/main/lib/credentials.cjs +184 -0
- package/src/main/lib/schedulerConfig.cjs +10 -0
- package/src/main/logs.cjs +1 -1
- package/src/main/otelSettings.cjs +1 -1
- package/src/main/pty.cjs +53 -6
- package/src/main/scheduler.cjs +518 -147
- package/src/main/transcripts.cjs +26 -21
- package/src/main/usage.cjs +76 -25
- package/src/main/voiceSettings.cjs +1 -1
- package/src/main/watchers.cjs +69 -11
- package/src/preload/api.d.ts +51 -11
- package/src/preload/index.cjs +13 -0
- package/dist/assets/index-DsC4vT8M.css +0 -32
- package/dist/assets/index-E14-spyd.js +0 -2972
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Claude Session Manager</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-BGshD4Pw.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-DCK87t79.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body class="bg-bg text-fg font-mono antialiased">
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/main/config.cjs
CHANGED
|
@@ -36,10 +36,31 @@ function expandHome(p) {
|
|
|
36
36
|
return p;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a path to its realpath, handling non-existent files by resolving
|
|
41
|
+
* the parent directory instead. Prevents symlink traversal attacks.
|
|
42
|
+
*/
|
|
43
|
+
function realResolve(abs) {
|
|
44
|
+
const lex = path.resolve(expandHome(abs));
|
|
45
|
+
try {
|
|
46
|
+
return fs.realpathSync(lex);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (e.code === 'ENOENT') {
|
|
49
|
+
const parent = path.dirname(lex);
|
|
50
|
+
try {
|
|
51
|
+
return path.join(fs.realpathSync(parent), path.basename(lex));
|
|
52
|
+
} catch {
|
|
53
|
+
return lex;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
/**
|
|
40
61
|
* Validate that a resolved path stays within an allowed boundary. Prevents
|
|
41
62
|
* path traversal attacks where a renderer-controlled path uses `..` segments
|
|
42
|
-
* to escape into arbitrary filesystem locations.
|
|
63
|
+
* or symlinks to escape into arbitrary filesystem locations.
|
|
43
64
|
*
|
|
44
65
|
* Allowed roots: home directory and any cwd the app has seen (project scopes).
|
|
45
66
|
*/
|
|
@@ -50,11 +71,53 @@ function addAllowedRoot(dir) {
|
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
function validatePath(abs) {
|
|
53
|
-
const
|
|
74
|
+
const real = realResolve(abs);
|
|
54
75
|
for (const root of allowedRoots) {
|
|
55
|
-
|
|
76
|
+
let realRoot;
|
|
77
|
+
try {
|
|
78
|
+
realRoot = fs.realpathSync(root);
|
|
79
|
+
} catch {
|
|
80
|
+
realRoot = root;
|
|
81
|
+
}
|
|
82
|
+
if (real === realRoot || real.startsWith(realRoot + path.sep)) return real;
|
|
56
83
|
}
|
|
57
|
-
throw new Error(`Path outside allowed boundaries: ${
|
|
84
|
+
throw new Error(`Path outside allowed boundaries: ${real}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Paths that are allowed as write destinations.
|
|
88
|
+
const WRITE_PREFIXES = [
|
|
89
|
+
path.join(os.homedir(), '.claude'),
|
|
90
|
+
path.join(os.homedir(), '.claude.json'), // global MCP servers config
|
|
91
|
+
path.join(os.homedir(), '.config', 'claude-code'),
|
|
92
|
+
path.join(os.homedir(), '.config', 'session-manager'),
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Restricts write operations to a tighter set of destinations than reads.
|
|
97
|
+
* Never allows writing to .credentials.json.
|
|
98
|
+
* Call after validatePath (which provides the realpath).
|
|
99
|
+
*/
|
|
100
|
+
function validateWrite(realAbs) {
|
|
101
|
+
if (path.basename(realAbs) === '.credentials.json') {
|
|
102
|
+
throw new Error(`Write to .credentials.json denied`);
|
|
103
|
+
}
|
|
104
|
+
const inWritePrefix = WRITE_PREFIXES.some(
|
|
105
|
+
(p) => realAbs === p || realAbs.startsWith(p + path.sep)
|
|
106
|
+
);
|
|
107
|
+
if (inWritePrefix) return;
|
|
108
|
+
// Also allowed inside a registered project root's .claude/ subtree.
|
|
109
|
+
for (const root of allowedRoots) {
|
|
110
|
+
if (root === os.homedir()) continue;
|
|
111
|
+
let realRoot;
|
|
112
|
+
try { realRoot = fs.realpathSync(root); } catch { realRoot = root; }
|
|
113
|
+
if (realAbs === realRoot || realAbs.startsWith(realRoot + path.sep)) {
|
|
114
|
+
const claudeSub = path.join(realRoot, '.claude');
|
|
115
|
+
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
|
|
58
121
|
}
|
|
59
122
|
|
|
60
123
|
async function readJson(abs) {
|
|
@@ -97,13 +160,14 @@ async function readText(abs) {
|
|
|
97
160
|
* directories if missing.
|
|
98
161
|
*/
|
|
99
162
|
async function writeTextAtomic(abs, text) {
|
|
100
|
-
|
|
101
|
-
|
|
163
|
+
const real = validatePath(expandHome(abs));
|
|
164
|
+
validateWrite(real);
|
|
165
|
+
const dir = path.dirname(real);
|
|
102
166
|
await fsp.mkdir(dir, { recursive: true });
|
|
103
|
-
const tmp = `${
|
|
167
|
+
const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
104
168
|
await fsp.writeFile(tmp, text, 'utf8');
|
|
105
|
-
await fsp.rename(tmp,
|
|
106
|
-
const stat = await fsp.stat(
|
|
169
|
+
await fsp.rename(tmp, real);
|
|
170
|
+
const stat = await fsp.stat(real);
|
|
107
171
|
return { ok: true, mtimeMs: stat.mtimeMs };
|
|
108
172
|
}
|
|
109
173
|
|
|
@@ -157,6 +221,15 @@ async function exists(abs) {
|
|
|
157
221
|
}
|
|
158
222
|
}
|
|
159
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Normalize a path to a consistent map key: expand ~ then resolve to an
|
|
226
|
+
* absolute lexical path. Used as the refcount Map key in both watch() and
|
|
227
|
+
* unwatch() so callers can use either spelling interchangeably.
|
|
228
|
+
*/
|
|
229
|
+
function normalizePathKey(p) {
|
|
230
|
+
return path.resolve(expandHome(p));
|
|
231
|
+
}
|
|
232
|
+
|
|
160
233
|
/**
|
|
161
234
|
* Watch one or more absolute paths. Each call increments a refcount per path.
|
|
162
235
|
* Unwatch decrements; when refcount hits zero the underlying watcher is closed.
|
|
@@ -164,13 +237,14 @@ async function exists(abs) {
|
|
|
164
237
|
*/
|
|
165
238
|
function watch(paths) {
|
|
166
239
|
for (const rawPath of paths) {
|
|
167
|
-
|
|
168
|
-
const
|
|
240
|
+
validatePath(expandHome(rawPath)); // security check — throws if out of bounds
|
|
241
|
+
const key = normalizePathKey(rawPath);
|
|
242
|
+
const existing = watchers.get(key);
|
|
169
243
|
if (existing) {
|
|
170
244
|
existing.refCount++;
|
|
171
245
|
continue;
|
|
172
246
|
}
|
|
173
|
-
const w = chokidar.watch(
|
|
247
|
+
const w = chokidar.watch(key, {
|
|
174
248
|
ignoreInitial: true,
|
|
175
249
|
persistent: true,
|
|
176
250
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
|
@@ -178,34 +252,34 @@ function watch(paths) {
|
|
|
178
252
|
const emit = (kind) => async () => {
|
|
179
253
|
let mtimeMs = 0;
|
|
180
254
|
try {
|
|
181
|
-
const st = await fsp.stat(
|
|
255
|
+
const st = await fsp.stat(key);
|
|
182
256
|
mtimeMs = st.mtimeMs;
|
|
183
257
|
} catch {
|
|
184
258
|
/* file may have been deleted */
|
|
185
259
|
}
|
|
186
260
|
if (window && !window.isDestroyed()) {
|
|
187
|
-
window.webContents.send('config:changed', { path:
|
|
261
|
+
window.webContents.send('config:changed', { path: key, mtimeMs, kind });
|
|
188
262
|
}
|
|
189
263
|
};
|
|
190
264
|
w.on('add', emit('add'));
|
|
191
265
|
w.on('change', emit('change'));
|
|
192
266
|
w.on('unlink', emit('unlink'));
|
|
193
267
|
w.on('error', (err) => {
|
|
194
|
-
console.warn('[config] watcher error for',
|
|
268
|
+
console.warn('[config] watcher error for', key, err.message);
|
|
195
269
|
});
|
|
196
|
-
watchers.set(
|
|
270
|
+
watchers.set(key, { watcher: w, refCount: 1 });
|
|
197
271
|
}
|
|
198
272
|
}
|
|
199
273
|
|
|
200
274
|
function unwatch(paths) {
|
|
201
275
|
for (const rawPath of paths) {
|
|
202
|
-
const
|
|
203
|
-
const entry = watchers.get(
|
|
276
|
+
const key = normalizePathKey(rawPath);
|
|
277
|
+
const entry = watchers.get(key);
|
|
204
278
|
if (!entry) continue;
|
|
205
279
|
entry.refCount--;
|
|
206
280
|
if (entry.refCount <= 0) {
|
|
207
281
|
entry.watcher.close().catch(() => {});
|
|
208
|
-
watchers.delete(
|
|
282
|
+
watchers.delete(key);
|
|
209
283
|
}
|
|
210
284
|
}
|
|
211
285
|
}
|
package/src/main/index.cjs
CHANGED
|
@@ -2,7 +2,10 @@ const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, g
|
|
|
2
2
|
const { spawn, execFile, execFileSync } = require('node:child_process');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
|
+
const fsp = require('node:fs/promises');
|
|
5
6
|
const os = require('node:os');
|
|
7
|
+
const { schemas } = require('./ipcSchemas.cjs');
|
|
8
|
+
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
6
9
|
const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
|
|
7
10
|
const configMgr = require('./config.cjs');
|
|
8
11
|
const transcripts = require('./transcripts.cjs');
|
|
@@ -68,7 +71,7 @@ function relaunchViaNpx() {
|
|
|
68
71
|
const child = spawn(npxPath, ['--yes', 'claude-code-session-manager@latest'], {
|
|
69
72
|
detached: true,
|
|
70
73
|
stdio: ['ignore', logFd, logFd],
|
|
71
|
-
env:
|
|
74
|
+
env: cleanChildEnv(),
|
|
72
75
|
});
|
|
73
76
|
// fd was dup'd into the child at spawn time; closing the parent copy frees
|
|
74
77
|
// our file table slot and ensures the child owns the lifetime of the log.
|
|
@@ -147,6 +150,7 @@ function createWindow() {
|
|
|
147
150
|
preload: path.join(__dirname, '..', 'preload', 'index.cjs'),
|
|
148
151
|
contextIsolation: true,
|
|
149
152
|
nodeIntegration: false,
|
|
153
|
+
sandbox: true,
|
|
150
154
|
},
|
|
151
155
|
});
|
|
152
156
|
|
|
@@ -171,27 +175,6 @@ function createWindow() {
|
|
|
171
175
|
return;
|
|
172
176
|
}
|
|
173
177
|
|
|
174
|
-
// Lock down navigation: deny window.open + reject any navigation away from
|
|
175
|
-
// the loaded UI. External links are routed to the OS browser via shell
|
|
176
|
-
// instead of opening inside the Electron window. Defense against XSS or
|
|
177
|
-
// a compromised dependency that tries to navigate to a phishing page.
|
|
178
|
-
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
179
|
-
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
180
|
-
shell.openExternal(url).catch(() => {});
|
|
181
|
-
}
|
|
182
|
-
return { action: 'deny' };
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
mainWindow.webContents.on('will-navigate', (event, url) => {
|
|
186
|
-
const allowed = useDevServer
|
|
187
|
-
? ['http://localhost:5173', 'http://127.0.0.1:5173']
|
|
188
|
-
: []; // file:// loads happen via loadFile, not navigation
|
|
189
|
-
if (!allowed.some((a) => url.startsWith(a))) {
|
|
190
|
-
event.preventDefault();
|
|
191
|
-
console.warn('[main] blocked will-navigate to', url);
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
|
|
195
178
|
mainWindow.on('closed', () => {
|
|
196
179
|
mainWindow = null;
|
|
197
180
|
});
|
|
@@ -238,13 +221,32 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
|
|
|
238
221
|
return { exitCode: -1, stdout: '', stderr: 'empty command', durationMs: 0 };
|
|
239
222
|
}
|
|
240
223
|
|
|
224
|
+
// Require main window focused and explicit user confirmation before exec.
|
|
225
|
+
if (!mainWindow || !mainWindow.isFocused()) {
|
|
226
|
+
return { exitCode: -1, stdout: '', stderr: 'window not focused', durationMs: 0 };
|
|
227
|
+
}
|
|
228
|
+
const detail = command.length > 500 ? command.slice(0, 500) + '…' : command;
|
|
229
|
+
const { response } = await dialog.showMessageBox(mainWindow, {
|
|
230
|
+
type: 'warning',
|
|
231
|
+
buttons: ['Cancel', 'Run'],
|
|
232
|
+
defaultId: 0,
|
|
233
|
+
cancelId: 0,
|
|
234
|
+
message: 'Run hook command?',
|
|
235
|
+
detail,
|
|
236
|
+
});
|
|
237
|
+
if (response !== 1) {
|
|
238
|
+
return { exitCode: -1, stdout: '', stderr: 'user cancelled', durationMs: 0 };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const MAX_BUF = 1024 * 1024; // 1 MiB per stream
|
|
242
|
+
|
|
241
243
|
return await new Promise((resolve) => {
|
|
242
244
|
const startedAt = Date.now();
|
|
243
245
|
let child;
|
|
244
246
|
try {
|
|
245
247
|
child = spawn(command, {
|
|
246
248
|
shell: true,
|
|
247
|
-
env:
|
|
249
|
+
env: cleanChildEnv(env ?? {}),
|
|
248
250
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
249
251
|
});
|
|
250
252
|
} catch (err) {
|
|
@@ -259,6 +261,8 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
|
|
|
259
261
|
|
|
260
262
|
const stdoutChunks = [];
|
|
261
263
|
const stderrChunks = [];
|
|
264
|
+
let stdoutLen = 0;
|
|
265
|
+
let stderrLen = 0;
|
|
262
266
|
let timedOut = false;
|
|
263
267
|
|
|
264
268
|
const killTimer = setTimeout(() => {
|
|
@@ -266,8 +270,18 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
|
|
|
266
270
|
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
267
271
|
}, timeoutMs);
|
|
268
272
|
|
|
269
|
-
child.stdout.on('data', (b) =>
|
|
270
|
-
|
|
273
|
+
child.stdout.on('data', (b) => {
|
|
274
|
+
if (stdoutLen < MAX_BUF) {
|
|
275
|
+
stdoutChunks.push(b);
|
|
276
|
+
stdoutLen += b.length;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
child.stderr.on('data', (b) => {
|
|
280
|
+
if (stderrLen < MAX_BUF) {
|
|
281
|
+
stderrChunks.push(b);
|
|
282
|
+
stderrLen += b.length;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
271
285
|
|
|
272
286
|
child.on('error', (err) => {
|
|
273
287
|
clearTimeout(killTimer);
|
|
@@ -317,6 +331,75 @@ ipcMain.handle('app:git-branch', async (_e, payload) => {
|
|
|
317
331
|
});
|
|
318
332
|
});
|
|
319
333
|
|
|
334
|
+
// Returns the resolved path of a command, or null if not found on PATH.
|
|
335
|
+
function findCommand(name) {
|
|
336
|
+
try {
|
|
337
|
+
const out = execFileSync(
|
|
338
|
+
process.platform === 'win32' ? 'where' : 'which',
|
|
339
|
+
[name],
|
|
340
|
+
{ encoding: 'utf8', env: process.env, timeout: 500 },
|
|
341
|
+
).trim().split(/\r?\n/)[0];
|
|
342
|
+
if (out) return out;
|
|
343
|
+
} catch { /* not found */ }
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
ipcMain.handle('app:open-in-editor', async (_e, payload) => {
|
|
348
|
+
const { cwd, editor } = schemas.openInEditor.parse(payload);
|
|
349
|
+
const home = os.homedir();
|
|
350
|
+
if (!path.resolve(cwd).startsWith(home)) throw new Error('cwd outside home');
|
|
351
|
+
const candidates = (editor && editor !== 'auto')
|
|
352
|
+
? [editor]
|
|
353
|
+
: [process.env.VISUAL, process.env.EDITOR, 'code', 'cursor', 'subl', 'nano'].filter(Boolean);
|
|
354
|
+
for (const cmd of candidates) {
|
|
355
|
+
if (!findCommand(cmd)) continue;
|
|
356
|
+
const child = spawn(cmd, [cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() });
|
|
357
|
+
child.unref();
|
|
358
|
+
return { ok: true, editor: cmd };
|
|
359
|
+
}
|
|
360
|
+
return { ok: false, error: 'no editor found' };
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
ipcMain.handle('app:open-in-finder', async (_e, payload) => {
|
|
364
|
+
const { cwd } = schemas.openInFinder.parse(payload);
|
|
365
|
+
const home = os.homedir();
|
|
366
|
+
if (!path.resolve(cwd).startsWith(home)) throw new Error('cwd outside home');
|
|
367
|
+
await shell.openPath(cwd);
|
|
368
|
+
return { ok: true };
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
ipcMain.handle('app:open-in-terminal', async (_e, payload) => {
|
|
372
|
+
const { cwd } = schemas.openInTerminal.parse(payload);
|
|
373
|
+
const home = os.homedir();
|
|
374
|
+
if (!path.resolve(cwd).startsWith(home)) throw new Error('cwd outside home');
|
|
375
|
+
if (process.platform === 'linux') {
|
|
376
|
+
const terms = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
|
|
377
|
+
for (const t of terms) {
|
|
378
|
+
if (!findCommand(t)) continue;
|
|
379
|
+
const args = t === 'gnome-terminal'
|
|
380
|
+
? ['--working-directory=' + cwd]
|
|
381
|
+
: ['-e', `bash -c "cd '${cwd.replace(/'/g, "'\\''")}' && exec bash"`];
|
|
382
|
+
const child = spawn(t, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
|
|
383
|
+
child.unref();
|
|
384
|
+
return { ok: true, terminal: t };
|
|
385
|
+
}
|
|
386
|
+
} else if (process.platform === 'darwin') {
|
|
387
|
+
spawn('open', ['-a', 'Terminal', cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
|
|
388
|
+
return { ok: true, terminal: 'Terminal.app' };
|
|
389
|
+
}
|
|
390
|
+
return { ok: false, error: 'no terminal found' };
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
ipcMain.handle('app:archive-project', async (_e, payload) => {
|
|
394
|
+
const { encoded } = schemas.archiveProject.parse(payload);
|
|
395
|
+
const home = os.homedir();
|
|
396
|
+
const src = path.join(home, '.claude', 'projects', encoded);
|
|
397
|
+
const dst = path.join(home, '.claude', 'projects-archive', encoded);
|
|
398
|
+
await fsp.mkdir(path.dirname(dst), { recursive: true });
|
|
399
|
+
await fsp.rename(src, dst);
|
|
400
|
+
return { ok: true };
|
|
401
|
+
});
|
|
402
|
+
|
|
320
403
|
registerPtyHandlers();
|
|
321
404
|
configMgr.registerConfigHandlers();
|
|
322
405
|
transcripts.registerTranscriptHandlers();
|
|
@@ -371,6 +454,33 @@ if (!isDev) {
|
|
|
371
454
|
}
|
|
372
455
|
}
|
|
373
456
|
|
|
457
|
+
// Global webContents hardening — applies to DevTools, future webContents, etc.
|
|
458
|
+
// Must be registered before app.whenReady so it captures the first window too.
|
|
459
|
+
app.on('web-contents-created', (_e, wc) => {
|
|
460
|
+
const useDevServer = process.env.SM_DEV === '1';
|
|
461
|
+
|
|
462
|
+
wc.setWindowOpenHandler(({ url }) => {
|
|
463
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
464
|
+
shell.openExternal(url).catch(() => {});
|
|
465
|
+
}
|
|
466
|
+
return { action: 'deny' };
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
wc.on('will-navigate', (event, url) => {
|
|
470
|
+
const allowed = useDevServer
|
|
471
|
+
? ['http://localhost:5173', 'http://127.0.0.1:5173']
|
|
472
|
+
: [];
|
|
473
|
+
if (!allowed.some((a) => url.startsWith(a))) {
|
|
474
|
+
event.preventDefault();
|
|
475
|
+
console.warn('[main] blocked will-navigate to', url);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
wc.on('will-attach-webview', (event) => {
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
374
484
|
app.whenReady().then(async () => {
|
|
375
485
|
logs.pruneOld();
|
|
376
486
|
logs.writeLine({ scope: 'main', level: 'info', message: 'app start', meta: { version: app.getVersion(), platform: process.platform } });
|
|
@@ -383,14 +493,36 @@ app.whenReady().then(async () => {
|
|
|
383
493
|
logs.writeLine({ scope: 'main', level: 'error', message: 'unhandledRejection', meta: r });
|
|
384
494
|
});
|
|
385
495
|
|
|
386
|
-
//
|
|
387
|
-
|
|
496
|
+
// Inject Content-Security-Policy for all renderer responses.
|
|
497
|
+
const CSP = [
|
|
498
|
+
"default-src 'self'",
|
|
499
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
500
|
+
"style-src 'self' 'unsafe-inline'",
|
|
501
|
+
"img-src 'self' data: blob:",
|
|
502
|
+
"font-src 'self' data:",
|
|
503
|
+
"connect-src 'self' https://api.anthropic.com",
|
|
504
|
+
"media-src 'self' blob:",
|
|
505
|
+
"worker-src 'self' blob:",
|
|
506
|
+
].join('; ') + ';';
|
|
507
|
+
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
|
|
508
|
+
cb({
|
|
509
|
+
responseHeaders: {
|
|
510
|
+
...details.responseHeaders,
|
|
511
|
+
'Content-Security-Policy': [CSP],
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// Grant microphone / media permissions only for trusted origins.
|
|
388
517
|
const MEDIA_PERMS = new Set(['media', 'audioCapture', 'microphone']);
|
|
389
|
-
|
|
390
|
-
|
|
518
|
+
const isTrustedOrigin = (url) =>
|
|
519
|
+
(typeof url === 'string') &&
|
|
520
|
+
(url.startsWith('file://') || url.startsWith('http://localhost:5173'));
|
|
521
|
+
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb, details) => {
|
|
522
|
+
cb(MEDIA_PERMS.has(permission) && isTrustedOrigin(details?.requestingUrl));
|
|
391
523
|
});
|
|
392
|
-
session.defaultSession.setPermissionCheckHandler((_wc, permission) => {
|
|
393
|
-
return MEDIA_PERMS.has(permission);
|
|
524
|
+
session.defaultSession.setPermissionCheckHandler((_wc, permission, requestingOrigin) => {
|
|
525
|
+
return MEDIA_PERMS.has(permission) && isTrustedOrigin(requestingOrigin);
|
|
394
526
|
});
|
|
395
527
|
// macOS: trigger the OS-level mic consent prompt up front. Safe no-op on
|
|
396
528
|
// other platforms (systemPreferences.askForMediaAccess is darwin-only).
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const { z } = require('zod');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
9
11
|
|
|
10
12
|
// ──────────────────────────────────────────── PTY
|
|
11
13
|
const ptySpawn = z.object({
|
|
@@ -30,17 +32,19 @@ const ptyResize = z.object({
|
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
// ──────────────────────────────────────────── Transcripts
|
|
35
|
+
const SESSION_UUID_RE = /^[a-zA-Z0-9-]{1,64}$/;
|
|
36
|
+
|
|
33
37
|
const transcriptSubscribe = z.object({
|
|
34
38
|
tabId: z.string().min(1).max(128),
|
|
35
39
|
cwd: z.string().min(1).max(4096),
|
|
36
|
-
sessionUuid: z.string().
|
|
40
|
+
sessionUuid: z.string().regex(SESSION_UUID_RE),
|
|
37
41
|
});
|
|
38
42
|
|
|
39
43
|
const transcriptTabId = z.object({ tabId: z.string().min(1).max(128) });
|
|
40
44
|
|
|
41
45
|
const transcriptPath = z.object({
|
|
42
46
|
cwd: z.string().min(1).max(4096),
|
|
43
|
-
sessionUuid: z.string().
|
|
47
|
+
sessionUuid: z.string().regex(SESSION_UUID_RE),
|
|
44
48
|
});
|
|
45
49
|
|
|
46
50
|
// ──────────────────────────────────────────── Config
|
|
@@ -79,6 +83,52 @@ const sessionsPayload = z.object({
|
|
|
79
83
|
activeTabId: z.string().max(128).nullable(),
|
|
80
84
|
});
|
|
81
85
|
|
|
86
|
+
// ──────────────────────────────────────────── Schedule
|
|
87
|
+
const SCHEDULE_SLUG_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
88
|
+
const SCHEDULE_RUN_ID_RE = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
89
|
+
|
|
90
|
+
const scheduleSlug = z.object({
|
|
91
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const scheduleReadLog = z.object({
|
|
95
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
96
|
+
runId: z.string().regex(SCHEDULE_RUN_ID_RE),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ──────────────────────────────────────────── Projects
|
|
100
|
+
const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
|
|
101
|
+
|
|
102
|
+
const openInEditor = z.object({
|
|
103
|
+
cwd: z.string().min(1).max(4096),
|
|
104
|
+
editor: z.string().max(256).nullable().optional(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const openInFinder = z.object({
|
|
108
|
+
cwd: z.string().min(1).max(4096),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const openInTerminal = z.object({
|
|
112
|
+
cwd: z.string().min(1).max(4096),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const archiveProject = z.object({
|
|
116
|
+
encoded: z.string().regex(ENCODED_SLUG_RE).min(1).max(4096),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const home = os.homedir();
|
|
120
|
+
const setConfigSchema = z.object({
|
|
121
|
+
enabled: z.boolean().optional(),
|
|
122
|
+
offsetMinutes: z.number().int().min(0).max(180).optional(),
|
|
123
|
+
concurrencyCap: z.number().int().min(1).max(20).optional(),
|
|
124
|
+
defaultCwd: z.string().max(4096).refine(
|
|
125
|
+
(s) => s === home || s.startsWith(home + path.sep),
|
|
126
|
+
'defaultCwd must be inside home directory'
|
|
127
|
+
).optional(),
|
|
128
|
+
firePolicy: z.enum(['when-available', 'on-reset', 'manual']).optional(),
|
|
129
|
+
utilizationThreshold: z.number().min(0).max(200).optional(),
|
|
130
|
+
}).strict();
|
|
131
|
+
|
|
82
132
|
/**
|
|
83
133
|
* Wrap an IPC handler with schema validation. Returns a new handler that
|
|
84
134
|
* parses the payload before calling the original.
|
|
@@ -105,6 +155,13 @@ module.exports = {
|
|
|
105
155
|
configListDir,
|
|
106
156
|
configWatch,
|
|
107
157
|
sessionsPayload,
|
|
158
|
+
scheduleSlug,
|
|
159
|
+
scheduleReadLog,
|
|
160
|
+
setConfigSchema,
|
|
161
|
+
openInEditor,
|
|
162
|
+
openInFinder,
|
|
163
|
+
openInTerminal,
|
|
164
|
+
archiveProject,
|
|
108
165
|
},
|
|
109
166
|
validated,
|
|
110
167
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const SECRET_KEY_RE = /^(?:.*_)?(TOKEN|API_?KEY|SECRET|PASSWORD|AUTHORIZATION|COOKIE|REFRESH[_-]?TOKEN|ACCESS[_-]?TOKEN)$/i;
|
|
2
|
+
|
|
3
|
+
function cleanChildEnv(extra = {}) {
|
|
4
|
+
const env = { ...process.env, ...extra };
|
|
5
|
+
for (const k of Object.keys(env)) {
|
|
6
|
+
if (
|
|
7
|
+
k === 'CLAUDE_EFFORT' ||
|
|
8
|
+
k === 'CLAUDECODE' ||
|
|
9
|
+
k === 'NODE_OPTIONS' ||
|
|
10
|
+
k.startsWith('CLAUDE_CODE_') ||
|
|
11
|
+
k.startsWith('npm_config_') ||
|
|
12
|
+
SECRET_KEY_RE.test(k)
|
|
13
|
+
) {
|
|
14
|
+
delete env[k];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return env;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { cleanChildEnv };
|