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.
- 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/index.js +7 -14
- package/dist/server/output-parsers/codex-parser.js +1 -3
- package/dist/server/pty-handler.js +13 -8
- package/dist/server/push.js +1 -1
- package/dist/server/sessions.js +19 -29
- package/dist/server/workspaces.js +27 -54
- package/dist/server/ws.js +10 -11
- package/dist/test/auth.test.js +45 -2
- package/dist/test/sessions.test.js +2 -1
- package/package.json +1 -3
package/dist/frontend/index.html
CHANGED
|
@@ -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-
|
|
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>
|
package/dist/server/auth.js
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
|
-
import bcrypt from 'bcrypt';
|
|
2
1
|
import crypto from 'node:crypto';
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
261
|
+
push.notifySessionAttention(sessionId, session);
|
|
269
262
|
}
|
|
270
263
|
}
|
|
271
264
|
});
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 }, () => { });
|
package/dist/server/push.js
CHANGED
|
@@ -58,7 +58,7 @@ function truncatePayload(payload) {
|
|
|
58
58
|
}
|
|
59
59
|
return payload.slice(0, MAX_PAYLOAD_SIZE);
|
|
60
60
|
}
|
|
61
|
-
export function
|
|
61
|
+
export function notifySessionAttention(sessionId, session) {
|
|
62
62
|
if (!vapidPublicKey)
|
|
63
63
|
return;
|
|
64
64
|
const payloadObj = {
|
package/dist/server/sessions.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
75
|
+
if (needsBranchRename) {
|
|
84
76
|
ptySession.needsBranchRename = true;
|
|
85
77
|
}
|
|
86
|
-
if (
|
|
87
|
-
ptySession.branchRenamePrompt =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
94
|
+
};
|
|
95
|
+
attachToPty(session.pty);
|
|
97
96
|
const ptyReplacedHandler = (newPty) => attachToPty(newPty);
|
|
98
|
-
|
|
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(
|
|
103
|
+
sessions.resize(session.id, parsed.cols, parsed.rows);
|
|
105
104
|
return;
|
|
106
105
|
}
|
|
107
106
|
}
|
|
108
107
|
catch (_) { }
|
|
109
|
-
// Use
|
|
110
|
-
|
|
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 =
|
|
114
|
+
const idx = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
|
|
116
115
|
if (idx !== -1)
|
|
117
|
-
|
|
116
|
+
session.onPtyReplacedCallbacks.splice(idx, 1);
|
|
118
117
|
});
|
|
119
118
|
});
|
|
120
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();
|
|
@@ -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(() => {
|