claude-remote-cli 3.7.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.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-capable" content="yes" />
12
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
13
13
  <meta name="theme-color" content="#1a1a1a" />
14
- <script type="module" crossorigin src="/assets/index-BYXQcBQc.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-cJ7MQBLi.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-CiwYPknn.css">
16
16
  </head>
17
17
  <body>
@@ -1,14 +1,34 @@
1
- import bcrypt from 'bcrypt';
2
1
  import crypto from 'node:crypto';
3
- const SALT_ROUNDS = 10;
2
+ import { promisify } from 'node:util';
3
+ const scrypt = promisify(crypto.scrypt);
4
+ const SCRYPT_KEYLEN = 64;
4
5
  const MAX_ATTEMPTS = 5;
5
6
  const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
6
7
  const attemptMap = new Map();
7
8
  export async function hashPin(pin) {
8
- return bcrypt.hash(pin, SALT_ROUNDS);
9
+ const salt = crypto.randomBytes(16).toString('hex');
10
+ const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
11
+ return `scrypt:${salt}:${derived.toString('hex')}`;
9
12
  }
10
13
  export async function verifyPin(pin, hash) {
11
- return bcrypt.compare(pin, hash);
14
+ if (hash.startsWith('scrypt:')) {
15
+ const [, salt, storedHashHex] = hash.split(':');
16
+ if (!salt || !storedHashHex)
17
+ return false;
18
+ try {
19
+ const storedBuf = Buffer.from(storedHashHex, 'hex');
20
+ if (storedBuf.length !== SCRYPT_KEYLEN)
21
+ return false;
22
+ const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
23
+ return crypto.timingSafeEqual(storedBuf, derived);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ // Legacy bcrypt hashes: require PIN reset
30
+ console.warn('[auth] Legacy bcrypt PIN hash detected. Delete pinHash from config and restart to set a new PIN.');
31
+ return false;
12
32
  }
13
33
  export function isRateLimited(ip) {
14
34
  const entry = attemptMap.get(ip);
@@ -26,21 +26,9 @@ import { semverLessThan } from './utils.js';
26
26
  const __filename = fileURLToPath(import.meta.url);
27
27
  const __dirname = path.dirname(__filename);
28
28
  const execFileAsync = promisify(execFile);
29
- // ── Signal protection ────────────────────────────────────────────────────
30
- // Ignore SIGPIPE: piped bash commands (e.g. `cmd | grep | tail`) generate
31
- // SIGPIPE when the reading end of the pipe closes before the writer finishes.
32
- // node-pty's native module can propagate these to PTY sessions, causing
33
- // unexpected "session exited" in the browser. Ignoring SIGPIPE at the server
34
- // level prevents this cascade.
35
- process.on('SIGPIPE', () => { });
36
- // Ignore SIGHUP: if the controlling terminal disconnects (e.g. SSH drops),
37
- // keep the server and all PTY sessions alive.
38
- process.on('SIGHUP', () => { });
39
29
  // When run via CLI bin, config lives in ~/.config/claude-remote-cli/
40
30
  // When run directly (development), fall back to local config.json
41
31
  const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
42
- // Ensure worktree metadata directory exists alongside config
43
- ensureMetaDir(CONFIG_PATH);
44
32
  const VERSION_CACHE_TTL = 5 * 60 * 1000;
45
33
  let versionCache = null;
46
34
  function getCurrentVersion() {
@@ -146,6 +134,11 @@ function ensureGitignore(repoPath, entry) {
146
134
  }
147
135
  }
148
136
  async function main() {
137
+ // Ignore SIGPIPE: node-pty can propagate pipe breaks causing unexpected session exits
138
+ process.on('SIGPIPE', () => { });
139
+ // Ignore SIGHUP: keep server alive if controlling terminal disconnects
140
+ process.on('SIGHUP', () => { });
141
+ ensureMetaDir(CONFIG_PATH);
149
142
  let config;
150
143
  try {
151
144
  config = loadConfig(CONFIG_PATH);
@@ -240,7 +233,7 @@ async function main() {
240
233
  getSession: sessions.get,
241
234
  broadcastEvent,
242
235
  fireStateChange: sessions.fireStateChange,
243
- notifySessionAttention: push.notifySessionIdle,
236
+ notifySessionAttention: push.notifySessionAttention,
244
237
  configPath: CONFIG_PATH,
245
238
  });
246
239
  app.use('/hooks', hooksRouter);
@@ -265,7 +258,7 @@ async function main() {
265
258
  if (session.hooksActive && session.lastAttentionNotifiedAt && Date.now() - session.lastAttentionNotifiedAt < 10000) {
266
259
  return;
267
260
  }
268
- push.notifySessionIdle(sessionId, session);
261
+ push.notifySessionAttention(sessionId, session);
269
262
  }
270
263
  }
271
264
  });
@@ -9,7 +9,5 @@ export class CodexOutputParser {
9
9
  onData(_chunk, _recentScrollback) {
10
10
  return null;
11
11
  }
12
- reset() {
13
- // No state to reset
14
- }
12
+ reset() { }
15
13
  }
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { AGENT_COMMANDS, AGENT_CONTINUE_ARGS } from './types.js';
7
7
  import { readMeta, writeMeta } from './config.js';
8
- import { fireSessionEnd } from './sessions.js';
8
+ import { cleanEnv } from './utils.js';
9
9
  import { outputParsers } from './output-parsers/index.js';
10
10
  const IDLE_TIMEOUT_MS = 5000;
11
11
  const MAX_SCROLLBACK = 256 * 1024; // 256KB max
@@ -24,7 +24,7 @@ export function resolveTmuxSpawn(command, args, tmuxSessionName) {
24
24
  ],
25
25
  };
26
26
  }
27
- export function generateHooksSettings(sessionId, port, token) {
27
+ function writeHooksSettingsFile(sessionId, port, token) {
28
28
  const dir = path.join(os.tmpdir(), 'claude-remote-cli', sessionId);
29
29
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
30
30
  const filePath = path.join(dir, 'hooks-settings.json');
@@ -47,14 +47,12 @@ export function generateHooksSettings(sessionId, port, token) {
47
47
  fs.chmodSync(filePath, 0o600);
48
48
  return filePath;
49
49
  }
50
- export function createPtySession(params, sessionsMap, idleChangeCallbacks, stateChangeCallbacks = []) {
50
+ export function createPtySession(params, sessionsMap, idleChangeCallbacks, stateChangeCallbacks = [], sessionEndCallbacks = []) {
51
51
  const { id, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args: rawArgs = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored, port, forceOutputParser, } = params;
52
52
  let args = rawArgs;
53
53
  const createdAt = new Date().toISOString();
54
54
  const resolvedCommand = command || AGENT_COMMANDS[agent];
55
- // Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
56
- const env = Object.assign({}, process.env);
57
- delete env.CLAUDECODE;
55
+ const env = cleanEnv();
58
56
  // Inject hooks settings when spawning a real claude agent (not custom command, not forceOutputParser)
59
57
  let hookToken = '';
60
58
  let hooksActive = false;
@@ -63,7 +61,7 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
63
61
  if (shouldInjectHooks) {
64
62
  hookToken = crypto.randomBytes(32).toString('hex');
65
63
  try {
66
- settingsPath = generateHooksSettings(id, port, hookToken);
64
+ settingsPath = writeHooksSettingsFile(id, port, hookToken);
67
65
  args = ['--settings', settingsPath, ...args];
68
66
  hooksActive = true;
69
67
  }
@@ -271,7 +269,14 @@ export function createPtySession(params, sessionsMap, idleChangeCallbacks, state
271
269
  if (configPath && worktreeName) {
272
270
  writeMeta(configPath, { worktreePath: repoPath, displayName: session.displayName, lastActivity: session.lastActivity });
273
271
  }
274
- fireSessionEnd(id, repoPath, session.branchName);
272
+ for (const cb of sessionEndCallbacks) {
273
+ try {
274
+ cb(id, repoPath, session.branchName);
275
+ }
276
+ catch (err) {
277
+ console.error('[pty-handler] sessionEnd callback error:', err);
278
+ }
279
+ }
275
280
  sessionsMap.delete(id);
276
281
  const tmpDir = path.join(os.tmpdir(), 'claude-remote-cli', id);
277
282
  fs.rm(tmpDir, { recursive: true, force: true }, () => { });
@@ -58,7 +58,7 @@ function truncatePayload(payload) {
58
58
  }
59
59
  return payload.slice(0, MAX_PAYLOAD_SIZE);
60
60
  }
61
- export function notifySessionIdle(sessionId, session) {
61
+ export function notifySessionAttention(sessionId, session) {
62
62
  if (!vapidPublicKey)
63
63
  return;
64
64
  const payloadObj = {
@@ -34,57 +34,49 @@ function onSessionEnd(cb) {
34
34
  sessionEndCallbacks.push(cb);
35
35
  }
36
36
  function fireSessionEnd(sessionId, repoPath, branchName) {
37
- for (const cb of sessionEndCallbacks)
38
- cb(sessionId, repoPath, branchName);
37
+ for (const cb of sessionEndCallbacks) {
38
+ try {
39
+ cb(sessionId, repoPath, branchName);
40
+ }
41
+ catch (err) {
42
+ console.error('[sessions] sessionEnd callback error:', err);
43
+ }
44
+ }
39
45
  }
40
46
  export function fireStateChange(sessionId, state) {
41
47
  for (const cb of stateChangeCallbacks)
42
48
  cb(sessionId, state);
43
49
  }
44
- function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, tmuxSessionName: paramTmuxSessionName, initialScrollback, restored: paramRestored, needsBranchRename: paramNeedsBranchRename, branchRenamePrompt: paramBranchRenamePrompt, port, forceOutputParser }) {
50
+ function create({ id: providedId, needsBranchRename, branchRenamePrompt, agent = 'claude', cols = 80, rows = 24, args = [], port, forceOutputParser, ...rest }) {
45
51
  const id = providedId || crypto.randomBytes(8).toString('hex');
46
- // PTY path
47
52
  const ptyParams = {
53
+ ...rest,
48
54
  id,
49
- type,
50
55
  agent,
51
- repoName,
52
- repoPath,
53
- cwd,
54
- root,
55
- worktreeName,
56
- branchName,
57
- displayName,
58
- command,
59
- args,
60
56
  cols,
61
57
  rows,
62
- configPath,
63
- useTmux: paramUseTmux,
64
- tmuxSessionName: paramTmuxSessionName,
65
- initialScrollback,
66
- restored: paramRestored,
58
+ args,
67
59
  port: port ?? defaultPort,
68
60
  forceOutputParser: forceOutputParser ?? defaultForceOutputParser,
69
61
  };
70
- const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks);
62
+ const { session: ptySession, result } = createPtySession(ptyParams, sessions, idleChangeCallbacks, stateChangeCallbacks, sessionEndCallbacks);
71
63
  trackEvent({
72
64
  category: 'session',
73
65
  action: 'created',
74
66
  target: id,
75
67
  properties: {
76
68
  agent,
77
- type: type ?? 'worktree',
78
- workspace: root ?? repoPath,
79
- mode: command ? 'terminal' : 'agent',
69
+ type: rest.type ?? 'worktree',
70
+ workspace: rest.root ?? rest.repoPath,
71
+ mode: rest.command ? 'terminal' : 'agent',
80
72
  },
81
73
  session_id: id,
82
74
  });
83
- if (paramNeedsBranchRename) {
75
+ if (needsBranchRename) {
84
76
  ptySession.needsBranchRename = true;
85
77
  }
86
- if (paramBranchRenamePrompt) {
87
- ptySession.branchRenamePrompt = paramBranchRenamePrompt;
78
+ if (branchRenamePrompt) {
79
+ ptySession.branchRenamePrompt = branchRenamePrompt;
88
80
  }
89
81
  return { ...result, needsBranchRename: !!ptySession.needsBranchRename };
90
82
  }
@@ -386,6 +378,4 @@ async function populateMetaCache() {
386
378
  }
387
379
  }));
388
380
  }
389
- // Re-export pty-handler utilities for backward compatibility
390
- export { generateTmuxSessionName, resolveTmuxSpawn } from './pty-handler.js';
391
- export { configure, create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, fireSessionEnd, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
381
+ export { configure, create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
@@ -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
- // GET /workspaces/settings — per-workspace settings
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
- if (merged) {
355
- const wsOverrides = config.workspaceSettings?.[resolved] ?? {};
356
- const effective = getWorkspaceSettings(config, resolved);
357
- const overridden = [];
358
- for (const key of ['defaultAgent', 'defaultContinue', 'defaultYolo', 'launchInTmux']) {
359
- if (wsOverrides[key] !== undefined)
360
- overridden.push(key);
361
- }
362
- res.json({ settings: effective, overridden });
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
@@ -72,15 +72,14 @@ function setupWebSocket(server, authenticatedTokens, watcher, _configPath) {
72
72
  const session = sessionMap.get(ws);
73
73
  if (!session)
74
74
  return;
75
- const ptySession = session;
76
75
  let dataDisposable = null;
77
76
  let exitDisposable = null;
78
- function attachToPty(ptyProcess) {
77
+ const attachToPty = (ptyProcess) => {
79
78
  // Dispose previous handlers
80
79
  dataDisposable?.dispose();
81
80
  exitDisposable?.dispose();
82
81
  // Replay scrollback
83
- for (const chunk of ptySession.scrollback) {
82
+ for (const chunk of session.scrollback) {
84
83
  if (ws.readyState === ws.OPEN)
85
84
  ws.send(chunk);
86
85
  }
@@ -92,29 +91,29 @@ function setupWebSocket(server, authenticatedTokens, watcher, _configPath) {
92
91
  if (ws.readyState === ws.OPEN)
93
92
  ws.close(1000);
94
93
  });
95
- }
96
- attachToPty(ptySession.pty);
94
+ };
95
+ attachToPty(session.pty);
97
96
  const ptyReplacedHandler = (newPty) => attachToPty(newPty);
98
- ptySession.onPtyReplacedCallbacks.push(ptyReplacedHandler);
97
+ session.onPtyReplacedCallbacks.push(ptyReplacedHandler);
99
98
  ws.on('message', (msg) => {
100
99
  const str = msg.toString();
101
100
  try {
102
101
  const parsed = JSON.parse(str);
103
102
  if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
104
- sessions.resize(ptySession.id, parsed.cols, parsed.rows);
103
+ sessions.resize(session.id, parsed.cols, parsed.rows);
105
104
  return;
106
105
  }
107
106
  }
108
107
  catch (_) { }
109
- // Use ptySession.pty dynamically so writes go to current PTY
110
- ptySession.pty.write(str);
108
+ // Use session.pty dynamically so writes go to current PTY
109
+ session.pty.write(str);
111
110
  });
112
111
  ws.on('close', () => {
113
112
  dataDisposable?.dispose();
114
113
  exitDisposable?.dispose();
115
- const idx = ptySession.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
114
+ const idx = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
116
115
  if (idx !== -1)
117
- ptySession.onPtyReplacedCallbacks.splice(idx, 1);
116
+ session.onPtyReplacedCallbacks.splice(idx, 1);
118
117
  });
119
118
  });
120
119
  sessions.onIdleChange((sessionId, idle) => {
@@ -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 bcrypt hash starting with $2b$', async () => {
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('$2b$'), `Expected hash to start with $2b$, got: ${hash}`);
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();
@@ -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, serializeAll, restoreFromDisk } from '../server/sessions.js';
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(() => {