claude-remote-cli 3.6.0 → 3.8.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/frontend/assets/{index-BYXQcBQc.js → index-cJ7MQBLi.js} +21 -21
- package/dist/frontend/index.html +1 -1
- package/dist/server/auth.js +24 -4
- package/dist/server/hooks.js +196 -0
- package/dist/server/index.js +24 -24
- package/dist/server/output-parsers/claude-parser.js +1 -1
- package/dist/server/output-parsers/codex-parser.js +1 -3
- package/dist/server/pty-handler.js +90 -11
- package/dist/server/push.js +1 -1
- package/dist/server/sessions.js +33 -29
- package/dist/server/utils.js +22 -0
- package/dist/server/workspaces.js +27 -54
- package/dist/server/ws.js +11 -115
- package/dist/test/auth.test.js +45 -2
- package/dist/test/hooks.test.js +139 -0
- package/dist/test/sessions.test.js +2 -1
- package/package.json +1 -3
|
@@ -15,9 +15,7 @@ const BROWSE_DENYLIST = new Set([
|
|
|
15
15
|
]);
|
|
16
16
|
const BROWSE_MAX_ENTRIES = 100;
|
|
17
17
|
const BULK_MAX_PATHS = 50;
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
18
|
// Exported helpers
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
19
|
/**
|
|
22
20
|
* Resolves and validates a raw workspace path string.
|
|
23
21
|
* Throws with a human-readable message if the path is invalid.
|
|
@@ -70,9 +68,7 @@ export async function detectGitRepo(dirPath, execAsync = execFileAsync) {
|
|
|
70
68
|
}
|
|
71
69
|
return { isGitRepo: true, defaultBranch };
|
|
72
70
|
}
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
71
|
// Router factory
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
72
|
/**
|
|
77
73
|
* Creates and returns an Express Router that handles all /workspaces routes.
|
|
78
74
|
*
|
|
@@ -87,9 +83,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
87
83
|
function getConfig() {
|
|
88
84
|
return loadConfig(configPath);
|
|
89
85
|
}
|
|
90
|
-
// -------------------------------------------------------------------------
|
|
91
86
|
// GET /workspaces — list all workspaces with git info
|
|
92
|
-
// -------------------------------------------------------------------------
|
|
93
87
|
router.get('/', async (_req, res) => {
|
|
94
88
|
const config = getConfig();
|
|
95
89
|
const workspacePaths = config.workspaces ?? [];
|
|
@@ -100,9 +94,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
100
94
|
}));
|
|
101
95
|
res.json({ workspaces: results });
|
|
102
96
|
});
|
|
103
|
-
// -------------------------------------------------------------------------
|
|
104
97
|
// POST /workspaces — add a workspace
|
|
105
|
-
// -------------------------------------------------------------------------
|
|
106
98
|
router.post('/', async (req, res) => {
|
|
107
99
|
const body = req.body;
|
|
108
100
|
const rawPath = body.path;
|
|
@@ -145,9 +137,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
145
137
|
};
|
|
146
138
|
res.status(201).json(workspace);
|
|
147
139
|
});
|
|
148
|
-
// -------------------------------------------------------------------------
|
|
149
140
|
// DELETE /workspaces — remove a workspace
|
|
150
|
-
// -------------------------------------------------------------------------
|
|
151
141
|
router.delete('/', async (req, res) => {
|
|
152
142
|
const body = req.body;
|
|
153
143
|
const rawPath = body.path;
|
|
@@ -168,9 +158,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
168
158
|
trackEvent({ category: 'workspace', action: 'removed', target: resolved });
|
|
169
159
|
res.json({ removed: resolved });
|
|
170
160
|
});
|
|
171
|
-
// -------------------------------------------------------------------------
|
|
172
161
|
// PUT /workspaces/reorder — reorder workspaces
|
|
173
|
-
// -------------------------------------------------------------------------
|
|
174
162
|
router.put('/reorder', async (req, res) => {
|
|
175
163
|
const body = req.body;
|
|
176
164
|
const rawPaths = body.paths;
|
|
@@ -201,9 +189,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
201
189
|
}));
|
|
202
190
|
res.json({ workspaces: results });
|
|
203
191
|
});
|
|
204
|
-
// -------------------------------------------------------------------------
|
|
205
192
|
// POST /workspaces/bulk — add multiple workspaces at once
|
|
206
|
-
// -------------------------------------------------------------------------
|
|
207
193
|
router.post('/bulk', async (req, res) => {
|
|
208
194
|
const body = req.body;
|
|
209
195
|
const rawPaths = body.paths;
|
|
@@ -255,9 +241,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
255
241
|
}
|
|
256
242
|
res.status(201).json({ added, errors });
|
|
257
243
|
});
|
|
258
|
-
// -------------------------------------------------------------------------
|
|
259
244
|
// GET /workspaces/dashboard — aggregated PR + activity data for a workspace
|
|
260
|
-
// -------------------------------------------------------------------------
|
|
261
245
|
router.get('/dashboard', async (req, res) => {
|
|
262
246
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
263
247
|
if (!workspacePath) {
|
|
@@ -339,36 +323,44 @@ export function createWorkspaceRouter(deps) {
|
|
|
339
323
|
activity,
|
|
340
324
|
});
|
|
341
325
|
});
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
326
|
+
function buildMergedSettings(config, workspacePath) {
|
|
327
|
+
const resolved = path.resolve(workspacePath);
|
|
328
|
+
const wsOverrides = config.workspaceSettings?.[resolved] ?? {};
|
|
329
|
+
const effective = getWorkspaceSettings(config, resolved);
|
|
330
|
+
const overridden = [];
|
|
331
|
+
for (const key of ['defaultAgent', 'defaultContinue', 'defaultYolo', 'launchInTmux']) {
|
|
332
|
+
if (wsOverrides[key] !== undefined)
|
|
333
|
+
overridden.push(key);
|
|
334
|
+
}
|
|
335
|
+
return { settings: effective, overridden };
|
|
336
|
+
}
|
|
337
|
+
// GET /workspaces/settings — per-workspace overrides only
|
|
345
338
|
router.get('/settings', async (req, res) => {
|
|
346
339
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
347
|
-
const merged = req.query.merged === 'true';
|
|
348
340
|
if (!workspacePath) {
|
|
349
341
|
res.status(400).json({ error: 'path query parameter is required' });
|
|
350
342
|
return;
|
|
351
343
|
}
|
|
344
|
+
// Backward compat: handle merged=true inline (same logic as /settings/merged)
|
|
345
|
+
if (req.query.merged === 'true') {
|
|
346
|
+
res.json(buildMergedSettings(getConfig(), workspacePath));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
352
349
|
const config = getConfig();
|
|
353
350
|
const resolved = path.resolve(workspacePath);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
else {
|
|
365
|
-
const settings = config.workspaceSettings?.[resolved] ?? {};
|
|
366
|
-
res.json(settings);
|
|
351
|
+
const settings = config.workspaceSettings?.[resolved] ?? {};
|
|
352
|
+
res.json(settings);
|
|
353
|
+
});
|
|
354
|
+
// GET /workspaces/settings/merged — effective settings with override tracking
|
|
355
|
+
router.get('/settings/merged', async (req, res) => {
|
|
356
|
+
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
357
|
+
if (!workspacePath) {
|
|
358
|
+
res.status(400).json({ error: 'path query parameter is required' });
|
|
359
|
+
return;
|
|
367
360
|
}
|
|
361
|
+
res.json(buildMergedSettings(getConfig(), workspacePath));
|
|
368
362
|
});
|
|
369
|
-
// -------------------------------------------------------------------------
|
|
370
363
|
// PATCH /workspaces/settings — update per-workspace settings
|
|
371
|
-
// -------------------------------------------------------------------------
|
|
372
364
|
router.patch('/settings', async (req, res) => {
|
|
373
365
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
374
366
|
if (!workspacePath) {
|
|
@@ -401,9 +393,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
401
393
|
const final = config.workspaceSettings?.[resolved] ?? {};
|
|
402
394
|
res.json(final);
|
|
403
395
|
});
|
|
404
|
-
// -------------------------------------------------------------------------
|
|
405
396
|
// GET /workspaces/pr — PR info for a specific branch
|
|
406
|
-
// -------------------------------------------------------------------------
|
|
407
397
|
router.get('/pr', async (req, res) => {
|
|
408
398
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
409
399
|
const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
|
|
@@ -430,9 +420,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
430
420
|
res.status(404).json({ error: 'No PR found for branch' });
|
|
431
421
|
}
|
|
432
422
|
});
|
|
433
|
-
// -------------------------------------------------------------------------
|
|
434
423
|
// GET /workspaces/ci-status — CI check results for a workspace + branch
|
|
435
|
-
// -------------------------------------------------------------------------
|
|
436
424
|
router.get('/ci-status', async (req, res) => {
|
|
437
425
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
438
426
|
const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
|
|
@@ -448,9 +436,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
448
436
|
res.json({ total: 0, passing: 0, failing: 0, pending: 0 });
|
|
449
437
|
}
|
|
450
438
|
});
|
|
451
|
-
// -------------------------------------------------------------------------
|
|
452
439
|
// POST /workspaces/branch — switch branch for a workspace
|
|
453
|
-
// -------------------------------------------------------------------------
|
|
454
440
|
router.post('/branch', async (req, res) => {
|
|
455
441
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
456
442
|
if (!workspacePath) {
|
|
@@ -471,9 +457,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
471
457
|
res.status(400).json({ error: result.error ?? `Failed to switch to branch: ${branch}` });
|
|
472
458
|
}
|
|
473
459
|
});
|
|
474
|
-
// -------------------------------------------------------------------------
|
|
475
460
|
// POST /workspaces/worktree — create a new worktree with the next mountain name
|
|
476
|
-
// -------------------------------------------------------------------------
|
|
477
461
|
router.post('/worktree', async (req, res) => {
|
|
478
462
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
479
463
|
if (!workspacePath) {
|
|
@@ -534,9 +518,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
534
518
|
}
|
|
535
519
|
res.json({ branchName, mountainName, worktreePath });
|
|
536
520
|
});
|
|
537
|
-
// -------------------------------------------------------------------------
|
|
538
521
|
// GET /workspaces/current-branch — current checked-out branch for a path
|
|
539
|
-
// -------------------------------------------------------------------------
|
|
540
522
|
router.get('/current-branch', async (req, res) => {
|
|
541
523
|
const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
542
524
|
if (!workspacePath) {
|
|
@@ -546,9 +528,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
546
528
|
const branch = await getCurrentBranch(path.resolve(workspacePath));
|
|
547
529
|
res.json({ branch });
|
|
548
530
|
});
|
|
549
|
-
// -------------------------------------------------------------------------
|
|
550
531
|
// GET /workspaces/browse — browse filesystem directories for tree UI
|
|
551
|
-
// -------------------------------------------------------------------------
|
|
552
532
|
router.get('/browse', async (req, res) => {
|
|
553
533
|
const rawPath = typeof req.query.path === 'string' ? req.query.path : '~';
|
|
554
534
|
const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : '';
|
|
@@ -558,7 +538,6 @@ export function createWorkspaceRouter(deps) {
|
|
|
558
538
|
? path.join(os.homedir(), rawPath.slice(1))
|
|
559
539
|
: rawPath;
|
|
560
540
|
const resolved = path.resolve(expanded);
|
|
561
|
-
// Validate path
|
|
562
541
|
let stat;
|
|
563
542
|
try {
|
|
564
543
|
stat = await fs.promises.stat(resolved);
|
|
@@ -577,7 +556,6 @@ export function createWorkspaceRouter(deps) {
|
|
|
577
556
|
res.status(400).json({ error: `Not a directory: ${resolved}` });
|
|
578
557
|
return;
|
|
579
558
|
}
|
|
580
|
-
// Read directory entries
|
|
581
559
|
let dirents;
|
|
582
560
|
try {
|
|
583
561
|
dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
|
|
@@ -600,7 +578,6 @@ export function createWorkspaceRouter(deps) {
|
|
|
600
578
|
return false;
|
|
601
579
|
return true;
|
|
602
580
|
});
|
|
603
|
-
// Sort alphabetically case-insensitive
|
|
604
581
|
dirs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
605
582
|
const total = dirs.length;
|
|
606
583
|
const truncated = dirs.length > BROWSE_MAX_ENTRIES;
|
|
@@ -609,7 +586,6 @@ export function createWorkspaceRouter(deps) {
|
|
|
609
586
|
// Enrich each entry with isGitRepo and hasChildren (parallelized)
|
|
610
587
|
const entries = await Promise.all(dirs.map(async (d) => {
|
|
611
588
|
const entryPath = path.join(resolved, d.name);
|
|
612
|
-
// Check for .git directory (isGitRepo)
|
|
613
589
|
let isGitRepo = false;
|
|
614
590
|
try {
|
|
615
591
|
const gitStat = await fs.promises.stat(path.join(entryPath, '.git'));
|
|
@@ -618,7 +594,6 @@ export function createWorkspaceRouter(deps) {
|
|
|
618
594
|
catch {
|
|
619
595
|
// not a git repo
|
|
620
596
|
}
|
|
621
|
-
// Check if has at least one subdirectory child (hasChildren)
|
|
622
597
|
let hasChildren = false;
|
|
623
598
|
try {
|
|
624
599
|
const children = await fs.promises.readdir(entryPath, { withFileTypes: true });
|
|
@@ -636,9 +611,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
636
611
|
}));
|
|
637
612
|
res.json({ resolved, entries, truncated, total });
|
|
638
613
|
});
|
|
639
|
-
// -------------------------------------------------------------------------
|
|
640
614
|
// GET /workspaces/autocomplete — path prefix autocomplete
|
|
641
|
-
// -------------------------------------------------------------------------
|
|
642
615
|
router.get('/autocomplete', async (req, res) => {
|
|
643
616
|
const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : '';
|
|
644
617
|
if (!prefix) {
|
package/dist/server/ws.js
CHANGED
|
@@ -1,85 +1,6 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
3
|
-
import { promisify } from 'node:util';
|
|
4
2
|
import * as sessions from './sessions.js';
|
|
5
|
-
import { writeMeta } from './config.js';
|
|
6
|
-
import { branchToDisplayName } from './git.js';
|
|
7
3
|
import { trackEvent } from './analytics.js';
|
|
8
|
-
const execFileAsync = promisify(execFile);
|
|
9
|
-
const BRANCH_POLL_INTERVAL_MS = 3000;
|
|
10
|
-
const BRANCH_POLL_MAX_ATTEMPTS = 10;
|
|
11
|
-
function startBranchWatcher(session, broadcastEvent, cfgPath) {
|
|
12
|
-
const originalBranch = session.branchName;
|
|
13
|
-
let attempts = 0;
|
|
14
|
-
const timer = setInterval(async () => {
|
|
15
|
-
attempts++;
|
|
16
|
-
if (attempts > BRANCH_POLL_MAX_ATTEMPTS) {
|
|
17
|
-
clearInterval(timer);
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
|
-
try {
|
|
21
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: session.cwd });
|
|
22
|
-
const currentBranch = stdout.trim();
|
|
23
|
-
if (currentBranch && currentBranch !== originalBranch) {
|
|
24
|
-
clearInterval(timer);
|
|
25
|
-
const displayName = branchToDisplayName(currentBranch);
|
|
26
|
-
session.branchName = currentBranch;
|
|
27
|
-
session.displayName = displayName;
|
|
28
|
-
broadcastEvent('session-renamed', { sessionId: session.id, branchName: currentBranch, displayName });
|
|
29
|
-
writeMeta(cfgPath, {
|
|
30
|
-
worktreePath: session.repoPath,
|
|
31
|
-
displayName,
|
|
32
|
-
lastActivity: new Date().toISOString(),
|
|
33
|
-
branchName: currentBranch,
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// git command failed — session cwd may not exist yet, retry
|
|
39
|
-
}
|
|
40
|
-
}, BRANCH_POLL_INTERVAL_MS);
|
|
41
|
-
}
|
|
42
|
-
/** Sideband branch rename: uses headless claude to generate a branch name from the first message */
|
|
43
|
-
async function spawnBranchRename(session, firstMessage, cfgPath, broadcastEvent) {
|
|
44
|
-
try {
|
|
45
|
-
const cleanMessage = firstMessage.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/[\x00-\x1f]/g, ' ').trim();
|
|
46
|
-
if (!cleanMessage)
|
|
47
|
-
return;
|
|
48
|
-
const basePrompt = session.branchRenamePrompt
|
|
49
|
-
?? `Output ONLY a short kebab-case git branch name (no explanation, no backticks, no prefix, just the name) that describes this task:`;
|
|
50
|
-
const prompt = `${basePrompt}\n\n${cleanMessage.slice(0, 500)}`;
|
|
51
|
-
const { stdout } = await execFileAsync('claude', ['-p', '--model', 'haiku', prompt], {
|
|
52
|
-
cwd: session.cwd,
|
|
53
|
-
timeout: 30000,
|
|
54
|
-
});
|
|
55
|
-
const branchName = stdout.trim().replace(/`/g, '').replace(/[^a-z0-9-]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 60);
|
|
56
|
-
if (!branchName)
|
|
57
|
-
return;
|
|
58
|
-
await execFileAsync('git', ['branch', '-m', branchName], { cwd: session.cwd });
|
|
59
|
-
// Update session state
|
|
60
|
-
const displayName = branchToDisplayName(branchName);
|
|
61
|
-
session.branchName = branchName;
|
|
62
|
-
session.displayName = displayName;
|
|
63
|
-
broadcastEvent('session-renamed', {
|
|
64
|
-
sessionId: session.id,
|
|
65
|
-
branchName,
|
|
66
|
-
displayName,
|
|
67
|
-
});
|
|
68
|
-
if (cfgPath) {
|
|
69
|
-
writeMeta(cfgPath, {
|
|
70
|
-
worktreePath: session.repoPath,
|
|
71
|
-
displayName,
|
|
72
|
-
lastActivity: new Date().toISOString(),
|
|
73
|
-
branchName,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
// Sideband rename is best-effort — fall back to branch watcher if claude CLI isn't available
|
|
79
|
-
if (cfgPath)
|
|
80
|
-
startBranchWatcher(session, broadcastEvent, cfgPath);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
4
|
function parseCookies(cookieHeader) {
|
|
84
5
|
const cookies = {};
|
|
85
6
|
if (!cookieHeader)
|
|
@@ -94,7 +15,7 @@ function parseCookies(cookieHeader) {
|
|
|
94
15
|
});
|
|
95
16
|
return cookies;
|
|
96
17
|
}
|
|
97
|
-
function setupWebSocket(server, authenticatedTokens, watcher,
|
|
18
|
+
function setupWebSocket(server, authenticatedTokens, watcher, _configPath) {
|
|
98
19
|
const wss = new WebSocketServer({ noServer: true });
|
|
99
20
|
const eventClients = new Set();
|
|
100
21
|
function broadcastEvent(type, data) {
|
|
@@ -151,15 +72,14 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
|
151
72
|
const session = sessionMap.get(ws);
|
|
152
73
|
if (!session)
|
|
153
74
|
return;
|
|
154
|
-
const ptySession = session;
|
|
155
75
|
let dataDisposable = null;
|
|
156
76
|
let exitDisposable = null;
|
|
157
|
-
|
|
77
|
+
const attachToPty = (ptyProcess) => {
|
|
158
78
|
// Dispose previous handlers
|
|
159
79
|
dataDisposable?.dispose();
|
|
160
80
|
exitDisposable?.dispose();
|
|
161
81
|
// Replay scrollback
|
|
162
|
-
for (const chunk of
|
|
82
|
+
for (const chunk of session.scrollback) {
|
|
163
83
|
if (ws.readyState === ws.OPEN)
|
|
164
84
|
ws.send(chunk);
|
|
165
85
|
}
|
|
@@ -171,53 +91,29 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
|
171
91
|
if (ws.readyState === ws.OPEN)
|
|
172
92
|
ws.close(1000);
|
|
173
93
|
});
|
|
174
|
-
}
|
|
175
|
-
attachToPty(
|
|
94
|
+
};
|
|
95
|
+
attachToPty(session.pty);
|
|
176
96
|
const ptyReplacedHandler = (newPty) => attachToPty(newPty);
|
|
177
|
-
|
|
97
|
+
session.onPtyReplacedCallbacks.push(ptyReplacedHandler);
|
|
178
98
|
ws.on('message', (msg) => {
|
|
179
99
|
const str = msg.toString();
|
|
180
100
|
try {
|
|
181
101
|
const parsed = JSON.parse(str);
|
|
182
102
|
if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
|
|
183
|
-
sessions.resize(
|
|
103
|
+
sessions.resize(session.id, parsed.cols, parsed.rows);
|
|
184
104
|
return;
|
|
185
105
|
}
|
|
186
106
|
}
|
|
187
107
|
catch (_) { }
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
ptySession.pty.write(str);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
if (ptySession.needsBranchRename) {
|
|
194
|
-
if (!ptySession._renameBuffer)
|
|
195
|
-
ptySession._renameBuffer = '';
|
|
196
|
-
const enterIndex = str.indexOf('\r');
|
|
197
|
-
if (enterIndex === -1) {
|
|
198
|
-
ptySession._renameBuffer += str;
|
|
199
|
-
ptySession.pty.write(str); // pass through to PTY normally — user sees their typing
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
// Enter detected — pass everything through unmodified
|
|
203
|
-
const buffered = ptySession._renameBuffer;
|
|
204
|
-
const firstMessage = buffered + str.slice(0, enterIndex);
|
|
205
|
-
ptySession.pty.write(str); // pass through the Enter key
|
|
206
|
-
ptySession.needsBranchRename = false;
|
|
207
|
-
delete ptySession._renameBuffer;
|
|
208
|
-
// Sideband: spawn headless claude to generate branch name (async, non-blocking)
|
|
209
|
-
spawnBranchRename(ptySession, firstMessage, configPath, broadcastEvent);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
// Use ptySession.pty dynamically so writes go to current PTY
|
|
213
|
-
ptySession.pty.write(str);
|
|
108
|
+
// Use session.pty dynamically so writes go to current PTY
|
|
109
|
+
session.pty.write(str);
|
|
214
110
|
});
|
|
215
111
|
ws.on('close', () => {
|
|
216
112
|
dataDisposable?.dispose();
|
|
217
113
|
exitDisposable?.dispose();
|
|
218
|
-
const idx =
|
|
114
|
+
const idx = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
|
|
219
115
|
if (idx !== -1)
|
|
220
|
-
|
|
116
|
+
session.onPtyReplacedCallbacks.splice(idx, 1);
|
|
221
117
|
});
|
|
222
118
|
});
|
|
223
119
|
sessions.onIdleChange((sessionId, idle) => {
|
package/dist/test/auth.test.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import { hashPin, verifyPin, isRateLimited, recordFailedAttempt, generateCookieToken, _resetForTesting, } from '../server/auth.js';
|
|
4
|
-
test('hashPin returns
|
|
4
|
+
test('hashPin returns scrypt hash with expected format', async () => {
|
|
5
5
|
_resetForTesting();
|
|
6
6
|
const hash = await hashPin('1234');
|
|
7
|
-
assert.ok(hash.startsWith('
|
|
7
|
+
assert.ok(hash.startsWith('scrypt:'), `Expected hash to start with scrypt:, got: ${hash}`);
|
|
8
|
+
const parts = hash.split(':');
|
|
9
|
+
assert.strictEqual(parts.length, 3, 'Hash should have 3 colon-separated parts');
|
|
8
10
|
});
|
|
9
11
|
test('verifyPin returns true for correct PIN', async () => {
|
|
10
12
|
_resetForTesting();
|
|
@@ -34,6 +36,47 @@ test('rate limiter allows under threshold', () => {
|
|
|
34
36
|
}
|
|
35
37
|
assert.strictEqual(isRateLimited(ip), false);
|
|
36
38
|
});
|
|
39
|
+
test('verifyPin returns false for legacy bcrypt hash (requires PIN reset)', async () => {
|
|
40
|
+
_resetForTesting();
|
|
41
|
+
const legacyHash = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012';
|
|
42
|
+
const result = await verifyPin('1234', legacyHash);
|
|
43
|
+
assert.strictEqual(result, false);
|
|
44
|
+
});
|
|
45
|
+
test('verifyPin returns false for malformed scrypt hash (missing parts)', async () => {
|
|
46
|
+
_resetForTesting();
|
|
47
|
+
const result = await verifyPin('1234', 'scrypt:saltonly');
|
|
48
|
+
assert.strictEqual(result, false);
|
|
49
|
+
});
|
|
50
|
+
test('verifyPin returns false for scrypt hash with empty salt', async () => {
|
|
51
|
+
_resetForTesting();
|
|
52
|
+
const result = await verifyPin('1234', 'scrypt::deadbeef');
|
|
53
|
+
assert.strictEqual(result, false);
|
|
54
|
+
});
|
|
55
|
+
test('verifyPin returns false for scrypt hash with wrong key length', async () => {
|
|
56
|
+
_resetForTesting();
|
|
57
|
+
// Valid hex but wrong length (should be 64 bytes = 128 hex chars)
|
|
58
|
+
const result = await verifyPin('1234', 'scrypt:abcd1234:deadbeef');
|
|
59
|
+
assert.strictEqual(result, false);
|
|
60
|
+
});
|
|
61
|
+
test('verifyPin returns false for completely empty hash', async () => {
|
|
62
|
+
_resetForTesting();
|
|
63
|
+
const result = await verifyPin('1234', '');
|
|
64
|
+
assert.strictEqual(result, false);
|
|
65
|
+
});
|
|
66
|
+
test('verifyPin returns false for garbage input', async () => {
|
|
67
|
+
_resetForTesting();
|
|
68
|
+
const result = await verifyPin('1234', 'not-a-valid-hash-at-all');
|
|
69
|
+
assert.strictEqual(result, false);
|
|
70
|
+
});
|
|
71
|
+
test('hashPin produces unique salts', async () => {
|
|
72
|
+
_resetForTesting();
|
|
73
|
+
const hash1 = await hashPin('1234');
|
|
74
|
+
const hash2 = await hashPin('1234');
|
|
75
|
+
assert.notStrictEqual(hash1, hash2, 'Two hashes of the same PIN should have different salts');
|
|
76
|
+
// But both should verify correctly
|
|
77
|
+
assert.strictEqual(await verifyPin('1234', hash1), true);
|
|
78
|
+
assert.strictEqual(await verifyPin('1234', hash2), true);
|
|
79
|
+
});
|
|
37
80
|
test('generateCookieToken returns non-empty string', () => {
|
|
38
81
|
_resetForTesting();
|
|
39
82
|
const token = generateCookieToken();
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { stripAnsi, semverLessThan, cleanEnv } from '../server/utils.js';
|
|
4
|
+
import { onStateChange, fireStateChange } from '../server/sessions.js';
|
|
5
|
+
describe('stripAnsi', () => {
|
|
6
|
+
it('strips CSI color sequences', () => {
|
|
7
|
+
assert.equal(stripAnsi('\x1b[32mhello\x1b[0m'), 'hello');
|
|
8
|
+
});
|
|
9
|
+
it('strips CSI bold/reset sequences', () => {
|
|
10
|
+
assert.equal(stripAnsi('\x1b[1mbold\x1b[0m'), 'bold');
|
|
11
|
+
});
|
|
12
|
+
it('strips OSC sequences', () => {
|
|
13
|
+
assert.equal(stripAnsi('\x1b]0;window title\x07plain'), 'plain');
|
|
14
|
+
});
|
|
15
|
+
it('strips cursor movement sequences', () => {
|
|
16
|
+
assert.equal(stripAnsi('\x1b[2Jhello'), 'hello');
|
|
17
|
+
});
|
|
18
|
+
it('preserves plain text', () => {
|
|
19
|
+
assert.equal(stripAnsi('hello world'), 'hello world');
|
|
20
|
+
});
|
|
21
|
+
it('handles empty string', () => {
|
|
22
|
+
assert.equal(stripAnsi(''), '');
|
|
23
|
+
});
|
|
24
|
+
it('strips multiple sequences in one string', () => {
|
|
25
|
+
assert.equal(stripAnsi('\x1b[32mfoo\x1b[0m and \x1b[1mbar\x1b[0m'), 'foo and bar');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('semverLessThan', () => {
|
|
29
|
+
it('returns true when major is lower', () => {
|
|
30
|
+
assert.equal(semverLessThan('1.0.0', '2.0.0'), true);
|
|
31
|
+
});
|
|
32
|
+
it('returns false when major is higher', () => {
|
|
33
|
+
assert.equal(semverLessThan('2.0.0', '1.0.0'), false);
|
|
34
|
+
});
|
|
35
|
+
it('returns true when patch is lower', () => {
|
|
36
|
+
assert.equal(semverLessThan('1.2.3', '1.2.4'), true);
|
|
37
|
+
});
|
|
38
|
+
it('returns false when patch is higher', () => {
|
|
39
|
+
assert.equal(semverLessThan('1.2.4', '1.2.3'), false);
|
|
40
|
+
});
|
|
41
|
+
it('returns false for equal versions', () => {
|
|
42
|
+
assert.equal(semverLessThan('1.0.0', '1.0.0'), false);
|
|
43
|
+
});
|
|
44
|
+
it('strips pre-release tag before comparing — 1.2.3-beta.1 vs 1.2.3 treated as equal', () => {
|
|
45
|
+
assert.equal(semverLessThan('1.2.3-beta.1', '1.2.3'), false);
|
|
46
|
+
});
|
|
47
|
+
it('strips pre-release tag before comparing — 1.2.3-beta.1 vs 1.3.0 treated as less than', () => {
|
|
48
|
+
assert.equal(semverLessThan('1.2.3-beta.1', '1.3.0'), true);
|
|
49
|
+
});
|
|
50
|
+
it('returns true when minor is lower', () => {
|
|
51
|
+
assert.equal(semverLessThan('1.1.0', '1.2.0'), true);
|
|
52
|
+
});
|
|
53
|
+
it('handles major version jumps', () => {
|
|
54
|
+
assert.equal(semverLessThan('1.9.9', '2.0.0'), true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('cleanEnv', () => {
|
|
58
|
+
it('returns an object that does not contain CLAUDECODE', () => {
|
|
59
|
+
const originalValue = process.env.CLAUDECODE;
|
|
60
|
+
process.env.CLAUDECODE = 'some-value';
|
|
61
|
+
try {
|
|
62
|
+
const env = cleanEnv();
|
|
63
|
+
assert.equal(Object.prototype.hasOwnProperty.call(env, 'CLAUDECODE'), false);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
if (originalValue === undefined) {
|
|
67
|
+
delete process.env.CLAUDECODE;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
process.env.CLAUDECODE = originalValue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
it('does not modify original process.env', () => {
|
|
75
|
+
const originalValue = process.env.CLAUDECODE;
|
|
76
|
+
process.env.CLAUDECODE = 'test-token';
|
|
77
|
+
try {
|
|
78
|
+
cleanEnv();
|
|
79
|
+
assert.equal(process.env.CLAUDECODE, 'test-token');
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
if (originalValue === undefined) {
|
|
83
|
+
delete process.env.CLAUDECODE;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
process.env.CLAUDECODE = originalValue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
it('returns a copy — mutations do not affect process.env', () => {
|
|
91
|
+
const env = cleanEnv();
|
|
92
|
+
const testKey = '__CRC_TEST_KEY__';
|
|
93
|
+
env[testKey] = 'injected';
|
|
94
|
+
assert.equal(process.env[testKey], undefined);
|
|
95
|
+
});
|
|
96
|
+
it('preserves other environment variables', () => {
|
|
97
|
+
const env = cleanEnv();
|
|
98
|
+
// PATH is virtually always set; verify it round-trips
|
|
99
|
+
if (process.env.PATH !== undefined) {
|
|
100
|
+
assert.equal(env.PATH, process.env.PATH);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('fireStateChange callbacks', () => {
|
|
105
|
+
it('calls a registered onStateChange callback with correct args', () => {
|
|
106
|
+
const received = [];
|
|
107
|
+
onStateChange((sessionId, state) => {
|
|
108
|
+
received.push({ sessionId, state });
|
|
109
|
+
});
|
|
110
|
+
fireStateChange('test-session-id', 'processing');
|
|
111
|
+
const match = received.find(e => e.sessionId === 'test-session-id' && e.state === 'processing');
|
|
112
|
+
assert.ok(match, 'callback should have been called with the expected sessionId and state');
|
|
113
|
+
});
|
|
114
|
+
it('fires multiple registered callbacks', () => {
|
|
115
|
+
let count = 0;
|
|
116
|
+
onStateChange(() => { count++; });
|
|
117
|
+
onStateChange(() => { count++; });
|
|
118
|
+
fireStateChange('multi-cb-session', 'idle');
|
|
119
|
+
assert.ok(count >= 2, 'both callbacks should have been invoked');
|
|
120
|
+
});
|
|
121
|
+
it('passes idle state to callback', () => {
|
|
122
|
+
let received;
|
|
123
|
+
onStateChange((_, state) => { received = state; });
|
|
124
|
+
fireStateChange('some-session', 'idle');
|
|
125
|
+
assert.equal(received, 'idle');
|
|
126
|
+
});
|
|
127
|
+
it('passes permission-prompt state to callback', () => {
|
|
128
|
+
let received;
|
|
129
|
+
onStateChange((_, state) => { received = state; });
|
|
130
|
+
fireStateChange('some-session', 'permission-prompt');
|
|
131
|
+
assert.equal(received, 'permission-prompt');
|
|
132
|
+
});
|
|
133
|
+
it('passes waiting-for-input state to callback', () => {
|
|
134
|
+
let received;
|
|
135
|
+
onStateChange((_, state) => { received = state; });
|
|
136
|
+
fireStateChange('some-session', 'waiting-for-input');
|
|
137
|
+
assert.equal(received, 'waiting-for-input');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -4,7 +4,8 @@ import fs from 'node:fs';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import * as sessions from '../server/sessions.js';
|
|
7
|
-
import { resolveTmuxSpawn, generateTmuxSessionName
|
|
7
|
+
import { resolveTmuxSpawn, generateTmuxSessionName } from '../server/pty-handler.js';
|
|
8
|
+
import { serializeAll, restoreFromDisk } from '../server/sessions.js';
|
|
8
9
|
// Track created session IDs so we can clean up after each test
|
|
9
10
|
const createdIds = [];
|
|
10
11
|
afterEach(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-remote-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Remote web interface for Claude Code CLI sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server/index.js",
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
"@tanstack/svelte-query": "^6.0.18",
|
|
46
46
|
"@xterm/addon-fit": "^0.11.0",
|
|
47
47
|
"@xterm/xterm": "^6.0.0",
|
|
48
|
-
"bcrypt": "^5.1.1",
|
|
49
48
|
"better-sqlite3": "^12.8.0",
|
|
50
49
|
"cookie-parser": "^1.4.7",
|
|
51
50
|
"express": "^4.21.0",
|
|
@@ -58,7 +57,6 @@
|
|
|
58
57
|
"devDependencies": {
|
|
59
58
|
"@playwright/test": "^1.58.2",
|
|
60
59
|
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
61
|
-
"@types/bcrypt": "^5.0.2",
|
|
62
60
|
"@types/better-sqlite3": "^7.6.13",
|
|
63
61
|
"@types/cookie-parser": "^1.4.7",
|
|
64
62
|
"@types/express": "^4.17.21",
|