@dinko_abdic/claude-code-remote 0.1.1

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/src/index.js ADDED
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env node
2
+ const http = require('http');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const fs = require('fs');
6
+ const { WebSocketServer } = require('ws');
7
+ const { v4: uuidv4 } = require('uuid');
8
+ const { loadOrCreate, saveConfig, CONFIG_PATH } = require('./config');
9
+ const { authenticate } = require('./auth');
10
+ const { MessageType, validate, makeSessionCreated, makeSessionEnded, makeError } = require('./protocol');
11
+ const tm = require('./terminal-manager');
12
+ const { getTailscaleStatus } = require('./tailscale');
13
+ const { generateDashboard } = require('./dashboard');
14
+ const logger = require('./logger');
15
+
16
+ const config = loadOrCreate();
17
+ const { port, shell, sandboxRoot } = config;
18
+
19
+ // --- Auth helpers ---
20
+ const tsInfo = getTailscaleStatus();
21
+ const localTailscaleIp = tsInfo.ip;
22
+
23
+ function isLocalRequest(req) {
24
+ const addr = req.socket.remoteAddress;
25
+ if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1') return true;
26
+ // Requests from the daemon's own Tailscale IP are local (same machine via Tailscale)
27
+ if (localTailscaleIp && (addr === localTailscaleIp || addr === `::ffff:${localTailscaleIp}`)) return true;
28
+ return false;
29
+ }
30
+
31
+ // --- HTTP request handler (shared by both servers) ---
32
+ async function requestHandler(req, res) {
33
+ const url = new URL(req.url, `http://${req.headers.host}`);
34
+ const pathname = url.pathname;
35
+
36
+ // Auth gate: localhost is allowed without token, remote requires auth
37
+ const local = isLocalRequest(req);
38
+ if (!local && !authenticate(req, config.token)) {
39
+ res.writeHead(401, { 'Content-Type': 'application/json' });
40
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
41
+ return;
42
+ }
43
+
44
+ if (pathname === '/' || pathname === '/dashboard') {
45
+ try {
46
+ const tsStatus = getTailscaleStatus();
47
+ const sessionList = tm.getSessionList();
48
+ const connectedDevices = sessionList
49
+ .filter(s => s.hasClient)
50
+ .map(s => ({ sessionId: s.id, name: s.name, deviceName: s.deviceName }));
51
+ const html = await generateDashboard(config, tsStatus, tm.getSessionCount(), connectedDevices, local);
52
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
53
+ res.end(html);
54
+ } catch (err) {
55
+ logger.error(`Dashboard error: ${err.message}`);
56
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
57
+ res.end('Internal server error');
58
+ }
59
+ return;
60
+ }
61
+
62
+ if (pathname === '/api/status') {
63
+ const tsStatus = getTailscaleStatus();
64
+ const sessionList = tm.getSessionList();
65
+ const connectedDevices = sessionList
66
+ .filter(s => s.hasClient)
67
+ .map(s => ({ sessionId: s.id, name: s.name, deviceName: s.deviceName }));
68
+ res.writeHead(200, { 'Content-Type': 'application/json' });
69
+ res.end(JSON.stringify({
70
+ tailscale: { installed: tsStatus.installed, running: tsStatus.running, ip: tsStatus.ip },
71
+ port: config.port,
72
+ sessions: tm.getSessionCount(),
73
+ connectedDevices,
74
+ defaultCwd: config.defaultCwd || null,
75
+ }));
76
+ return;
77
+ }
78
+
79
+ if (pathname === '/api/settings' && req.method === 'GET') {
80
+ res.writeHead(200, { 'Content-Type': 'application/json' });
81
+ res.end(JSON.stringify({ defaultCwd: config.defaultCwd || '' }));
82
+ return;
83
+ }
84
+
85
+ if (pathname === '/api/pick-directory' && req.method === 'POST') {
86
+ const { execFile } = require('child_process');
87
+ const scriptPath = path.join(__dirname, 'pick-folder.ps1');
88
+ execFile('powershell', ['-STA', '-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], { encoding: 'utf-8', timeout: 120000, windowsHide: true }, (err, stdout) => {
89
+ if (err) {
90
+ res.writeHead(500, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({ error: err.message }));
92
+ return;
93
+ }
94
+ res.writeHead(200, { 'Content-Type': 'application/json' });
95
+ res.end(JSON.stringify({ path: stdout.trim() }));
96
+ });
97
+ return;
98
+ }
99
+
100
+ if (pathname === '/api/browse' && req.method === 'GET') {
101
+ const { validatePath } = require('./sandbox');
102
+ const requestedPath = url.searchParams.get('path');
103
+
104
+ // No path provided on Windows → list drive letters
105
+ if (!requestedPath && process.platform === 'win32') {
106
+ const { execSync } = require('child_process');
107
+ try {
108
+ const raw = execSync('wmic logicaldisk get name,volumename', { encoding: 'utf-8' });
109
+ const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
110
+ // Skip header line, parse "C: Label" rows
111
+ const drives = [];
112
+ for (let i = 1; i < lines.length; i++) {
113
+ const match = lines[i].match(/^([A-Z]:)\s*(.*)/i);
114
+ if (match) {
115
+ drives.push({
116
+ name: match[1].toUpperCase() + '\\',
117
+ label: match[2].trim() || null,
118
+ isDirectory: true,
119
+ });
120
+ }
121
+ }
122
+ res.writeHead(200, { 'Content-Type': 'application/json' });
123
+ res.end(JSON.stringify({
124
+ path: null,
125
+ parent: null,
126
+ entries: drives,
127
+ }));
128
+ } catch (err) {
129
+ res.writeHead(500, { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify({ error: 'Failed to list drives: ' + err.message }));
131
+ }
132
+ return;
133
+ }
134
+
135
+ // No path and not Windows → use defaultCwd or home
136
+ const targetPath = requestedPath || config.defaultCwd || os.homedir();
137
+
138
+ // Sandbox check
139
+ if (config.sandboxRoot) {
140
+ const validated = validatePath(targetPath, config.sandboxRoot);
141
+ if (!validated) {
142
+ res.writeHead(403, { 'Content-Type': 'application/json' });
143
+ res.end(JSON.stringify({ error: 'Path outside sandbox' }));
144
+ return;
145
+ }
146
+ }
147
+
148
+ try {
149
+ const resolved = path.resolve(targetPath);
150
+ const entries = fs.readdirSync(resolved, { withFileTypes: true });
151
+ const dirs = entries
152
+ .filter(e => e.isDirectory())
153
+ .map(e => ({ name: e.name, isDirectory: true }))
154
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
155
+
156
+ const parsed = path.parse(resolved);
157
+ const parent = parsed.dir === resolved ? null : parsed.dir;
158
+
159
+ res.writeHead(200, { 'Content-Type': 'application/json' });
160
+ res.end(JSON.stringify({ path: resolved, parent, entries: dirs }));
161
+ } catch (err) {
162
+ const status = err.code === 'ENOENT' ? 404 : err.code === 'EACCES' ? 403 : 500;
163
+ res.writeHead(status, { 'Content-Type': 'application/json' });
164
+ res.end(JSON.stringify({ error: err.message }));
165
+ }
166
+ return;
167
+ }
168
+
169
+ if (pathname === '/api/mkdir' && req.method === 'POST') {
170
+ const { validatePath } = require('./sandbox');
171
+ let body = '';
172
+ req.on('data', (chunk) => { body += chunk; });
173
+ req.on('end', () => {
174
+ try {
175
+ const { path: dirPath } = JSON.parse(body);
176
+ if (!dirPath || typeof dirPath !== 'string') {
177
+ res.writeHead(400, { 'Content-Type': 'application/json' });
178
+ res.end(JSON.stringify({ error: 'Missing or invalid "path" field' }));
179
+ return;
180
+ }
181
+
182
+ const resolved = path.resolve(dirPath);
183
+
184
+ if (config.sandboxRoot) {
185
+ const validated = validatePath(resolved, config.sandboxRoot);
186
+ if (!validated) {
187
+ res.writeHead(403, { 'Content-Type': 'application/json' });
188
+ res.end(JSON.stringify({ error: 'Path outside sandbox' }));
189
+ return;
190
+ }
191
+ }
192
+
193
+ fs.mkdirSync(resolved, { recursive: true });
194
+ res.writeHead(200, { 'Content-Type': 'application/json' });
195
+ res.end(JSON.stringify({ ok: true, path: resolved }));
196
+ } catch (err) {
197
+ const status = err.code === 'EACCES' ? 403 : 500;
198
+ res.writeHead(status, { 'Content-Type': 'application/json' });
199
+ res.end(JSON.stringify({ error: err.message }));
200
+ }
201
+ });
202
+ return;
203
+ }
204
+
205
+ if (pathname === '/api/settings' && req.method === 'POST') {
206
+ let body = '';
207
+ req.on('data', (chunk) => { body += chunk; });
208
+ req.on('end', () => {
209
+ try {
210
+ const updates = JSON.parse(body);
211
+ if (typeof updates.defaultCwd === 'string') {
212
+ config.defaultCwd = updates.defaultCwd || null;
213
+ }
214
+ saveConfig(config);
215
+ res.writeHead(200, { 'Content-Type': 'application/json' });
216
+ res.end(JSON.stringify({ ok: true }));
217
+ } catch (err) {
218
+ res.writeHead(400, { 'Content-Type': 'application/json' });
219
+ res.end(JSON.stringify({ error: err.message }));
220
+ }
221
+ });
222
+ return;
223
+ }
224
+
225
+ if (pathname === '/api/sessions' && req.method === 'GET') {
226
+ res.writeHead(200, { 'Content-Type': 'application/json' });
227
+ res.end(JSON.stringify({ sessions: tm.getSessionList() }));
228
+ return;
229
+ }
230
+
231
+ if (pathname === '/api/external-sessions' && req.method === 'GET') {
232
+ const { scanExternalClaudeSessions } = require('./process-scanner');
233
+ const daemonPids = tm.getDaemonPtyPids();
234
+ const external = scanExternalClaudeSessions(daemonPids);
235
+ res.writeHead(200, { 'Content-Type': 'application/json' });
236
+ res.end(JSON.stringify({ sessions: external }));
237
+ return;
238
+ }
239
+
240
+ // DELETE /api/sessions/:id
241
+ const deleteMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
242
+ if (deleteMatch && req.method === 'DELETE') {
243
+ const sessionId = decodeURIComponent(deleteMatch[1]);
244
+ tm.destroySession(sessionId);
245
+ res.writeHead(200, { 'Content-Type': 'application/json' });
246
+ res.end(JSON.stringify({ ok: true }));
247
+ return;
248
+ }
249
+
250
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
251
+ res.end('claude-code-remote daemon is running\n');
252
+ }
253
+
254
+ // --- WebSocket upgrade handler (shared by both servers) ---
255
+ function upgradeHandler(req, socket, head) {
256
+ if (!authenticate(req, config.token)) {
257
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
258
+ socket.destroy();
259
+ logger.warn('Rejected unauthenticated upgrade request');
260
+ return;
261
+ }
262
+
263
+ wss.handleUpgrade(req, socket, head, (ws) => {
264
+ wss.emit('connection', ws, req);
265
+ });
266
+ }
267
+
268
+ // --- Create servers ---
269
+ const localServer = http.createServer(requestHandler);
270
+ localServer.on('upgrade', upgradeHandler);
271
+
272
+ let tsServer = null;
273
+
274
+ // --- WebSocket server (noServer mode) ---
275
+ const wss = new WebSocketServer({ noServer: true });
276
+
277
+ // --- Connection handling ---
278
+ wss.on('connection', (ws, req) => {
279
+ const clientAddr = req.socket.remoteAddress;
280
+ logger.info(`Client connected from ${clientAddr}`);
281
+
282
+ // Parse desired cols/rows from query params, default 80x24
283
+ let cols = 80;
284
+ let rows = 24;
285
+ let cwd = config.defaultCwd || process.cwd();
286
+ let sessionName;
287
+ let deviceName;
288
+ try {
289
+ const url = new URL(req.url, `http://${req.headers.host}`);
290
+ if (url.searchParams.has('cols')) cols = Math.max(1, parseInt(url.searchParams.get('cols'), 10) || 80);
291
+ if (url.searchParams.has('rows')) rows = Math.max(1, parseInt(url.searchParams.get('rows'), 10) || 24);
292
+ if (url.searchParams.has('cwd') && url.searchParams.get('cwd')) cwd = url.searchParams.get('cwd');
293
+ if (url.searchParams.has('name') && url.searchParams.get('name')) sessionName = url.searchParams.get('name');
294
+ if (url.searchParams.has('deviceName') && url.searchParams.get('deviceName')) deviceName = url.searchParams.get('deviceName');
295
+ } catch {
296
+ // use defaults
297
+ }
298
+
299
+ // Reconnect to existing session or create new one
300
+ let sessionId;
301
+ let requestedId;
302
+ try {
303
+ const url = new URL(req.url, `http://${req.headers.host}`);
304
+ requestedId = url.searchParams.get('sessionId');
305
+ } catch {}
306
+
307
+ try {
308
+ if (requestedId && tm.getSession(requestedId)) {
309
+ // Reattach to existing session
310
+ sessionId = requestedId;
311
+ if (deviceName) tm.setDeviceName(sessionId, deviceName);
312
+ tm.attachWebSocket(sessionId, ws);
313
+ const session = tm.getSession(sessionId);
314
+ ws.send(makeSessionCreated(sessionId, cols, rows, {
315
+ cwd: session.cwd,
316
+ name: session.name,
317
+ createdAt: session.createdAt,
318
+ }));
319
+ logger.info(`Session ${sessionId} reattached`);
320
+ } else {
321
+ // Create new session
322
+ sessionId = uuidv4();
323
+ tm.createSession(sessionId, cwd, cols, rows, shell, sandboxRoot, sessionName, deviceName);
324
+ tm.attachWebSocket(sessionId, ws);
325
+ const session = tm.getSession(sessionId);
326
+ ws.send(makeSessionCreated(sessionId, cols, rows, {
327
+ cwd: session.cwd,
328
+ name: session.name,
329
+ createdAt: session.createdAt,
330
+ }));
331
+ }
332
+ } catch (err) {
333
+ logger.error(`Failed to create/reattach session: ${err.message}`);
334
+ ws.send(makeError(err.message));
335
+ ws.close(1011, 'Session creation failed');
336
+ return;
337
+ }
338
+
339
+ // Store session ID on the ws object for cleanup
340
+ ws._sessionId = sessionId;
341
+
342
+ // --- Message routing ---
343
+ ws.on('message', (raw) => {
344
+ let msg;
345
+ try {
346
+ msg = JSON.parse(raw.toString());
347
+ } catch {
348
+ ws.send(makeError('Invalid JSON'));
349
+ return;
350
+ }
351
+
352
+ const validationError = validate(msg);
353
+ if (validationError) {
354
+ ws.send(makeError(validationError));
355
+ return;
356
+ }
357
+
358
+ switch (msg.type) {
359
+ case MessageType.TERMINAL_INPUT:
360
+ try {
361
+ tm.writeToSession(msg.sessionId, msg.data);
362
+ } catch (err) {
363
+ ws.send(makeError(err.message));
364
+ }
365
+ break;
366
+
367
+ case MessageType.TERMINAL_RESIZE:
368
+ try {
369
+ tm.resizeSession(msg.sessionId, msg.cols, msg.rows);
370
+ } catch (err) {
371
+ ws.send(makeError(err.message));
372
+ }
373
+ break;
374
+
375
+ default:
376
+ ws.send(makeError(`Unhandled message type: ${msg.type}`));
377
+ }
378
+ });
379
+
380
+ // --- Disconnect handling ---
381
+ ws.on('close', () => {
382
+ logger.info(`Client disconnected (session ${sessionId})`);
383
+ tm.detachWebSocket(sessionId);
384
+ // Keep pty alive for reconnection (configurable, default 30 min)
385
+ const keepAliveMs = (config.sessionKeepAliveMinutes || 30) * 60 * 1000;
386
+ tm.scheduleDestroy(sessionId, keepAliveMs);
387
+ });
388
+
389
+ ws.on('error', (err) => {
390
+ logger.error(`WebSocket error (session ${sessionId}): ${err.message}`);
391
+ });
392
+ });
393
+
394
+ // --- Start listening ---
395
+ localServer.listen(port, '127.0.0.1', () => {
396
+ logger.info(`Listening on 127.0.0.1:${port}`);
397
+ logger.info(`Config: ${CONFIG_PATH}`);
398
+ if (sandboxRoot) {
399
+ logger.info(`Sandbox root: ${sandboxRoot}`);
400
+ } else {
401
+ logger.info('Sandbox: disabled (no sandboxRoot configured)');
402
+ }
403
+ logger.info(`Local dashboard: http://localhost:${port}`);
404
+
405
+ // Start Tailscale server if IP is available
406
+ const tsStatus = getTailscaleStatus();
407
+ if (tsStatus.ip) {
408
+ tsServer = http.createServer(requestHandler);
409
+ tsServer.on('upgrade', upgradeHandler);
410
+ tsServer.listen(port, tsStatus.ip, () => {
411
+ logger.info(`Listening on ${tsStatus.ip}:${port}`);
412
+ logger.info(`Remote dashboard: http://${tsStatus.ip}:${port}`);
413
+ });
414
+ tsServer.on('error', (err) => {
415
+ logger.warn(`Failed to bind Tailscale server on ${tsStatus.ip}:${port}: ${err.message}`);
416
+ tsServer = null;
417
+ });
418
+ } else {
419
+ logger.warn(`Tailscale: ${tsStatus.error || 'not available'}`);
420
+ }
421
+ });
422
+
423
+ // --- Graceful shutdown ---
424
+ function shutdown(signal) {
425
+ logger.info(`${signal} received, shutting down...`);
426
+ tm.destroyAll();
427
+ wss.close(() => {
428
+ localServer.close(() => {
429
+ if (tsServer) {
430
+ tsServer.close(() => {
431
+ logger.info('Daemon stopped');
432
+ process.exit(0);
433
+ });
434
+ } else {
435
+ logger.info('Daemon stopped');
436
+ process.exit(0);
437
+ }
438
+ });
439
+ });
440
+ // Force exit after 5 seconds
441
+ setTimeout(() => process.exit(1), 5000);
442
+ }
443
+
444
+ process.on('SIGINT', () => shutdown('SIGINT'));
445
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
package/src/logger.js ADDED
@@ -0,0 +1,9 @@
1
+ const PREFIX = '[claude-remote]';
2
+
3
+ const logger = {
4
+ info: (...args) => console.log(PREFIX, new Date().toISOString(), ...args),
5
+ warn: (...args) => console.warn(PREFIX, new Date().toISOString(), ...args),
6
+ error: (...args) => console.error(PREFIX, new Date().toISOString(), ...args),
7
+ };
8
+
9
+ module.exports = logger;
@@ -0,0 +1,82 @@
1
+ Add-Type -AssemblyName System.Windows.Forms
2
+ Add-Type -TypeDefinition @"
3
+ using System;
4
+ using System.Runtime.InteropServices;
5
+
6
+ [ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
7
+ internal class FileOpenDialogRCW {}
8
+
9
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
10
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
11
+ public interface IShellItem {
12
+ void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
13
+ void GetParent(out IShellItem ppsi);
14
+ void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
15
+ void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
16
+ int Compare(IShellItem psi, uint hint);
17
+ }
18
+
19
+ [ComImport, Guid("42F85136-DB7E-439C-85F1-E4075D135FC8")]
20
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
21
+ internal interface IFileOpenDialog {
22
+ [PreserveSig] int Show(IntPtr hwndOwner);
23
+ void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
24
+ void SetFileTypeIndex(uint iFileType);
25
+ void GetFileTypeIndex(out uint piFileType);
26
+ void Advise(IntPtr pfde, out uint pdwCookie);
27
+ void Unadvise(uint dwCookie);
28
+ void SetOptions(uint fos);
29
+ void GetOptions(out uint pfos);
30
+ void SetDefaultFolder(IShellItem psi);
31
+ void SetFolder(IShellItem psi);
32
+ IShellItem GetFolder();
33
+ IShellItem GetCurrentSelection();
34
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
35
+ [return: MarshalAs(UnmanagedType.LPWStr)] string GetFileName();
36
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
37
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
38
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
39
+ IShellItem GetResult();
40
+ void AddPlace(IShellItem psi, int fdap);
41
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
42
+ void Close(int hr);
43
+ void SetClientGuid(ref Guid guid);
44
+ void ClearClientData();
45
+ void SetFilter(IntPtr pFilter);
46
+ void GetResults(out IntPtr ppenum);
47
+ void GetSelectedItems(out IntPtr ppsai);
48
+ }
49
+
50
+ public class ModernFolderPicker {
51
+ public static string ShowDialog(string title, IntPtr owner) {
52
+ IFileOpenDialog dlg = (IFileOpenDialog)new FileOpenDialogRCW();
53
+ try {
54
+ uint options;
55
+ dlg.GetOptions(out options);
56
+ dlg.SetOptions(options | 0x20u);
57
+ dlg.SetTitle(title);
58
+ if (dlg.Show(owner) == 0) {
59
+ IShellItem result = dlg.GetResult();
60
+ string path;
61
+ result.GetDisplayName(0x80058000u, out path);
62
+ return path ?? "";
63
+ }
64
+ } catch {}
65
+ finally { Marshal.FinalReleaseComObject(dlg); }
66
+ return "";
67
+ }
68
+ }
69
+ "@
70
+
71
+ # Create a hidden topmost form to own the dialog and bring it to front
72
+ $form = New-Object System.Windows.Forms.Form
73
+ $form.TopMost = $true
74
+ $form.ShowInTaskbar = $false
75
+ $form.WindowState = 'Minimized'
76
+ $form.Opacity = 0
77
+ $form.Show()
78
+
79
+ $result = [ModernFolderPicker]::ShowDialog("Select default directory", $form.Handle)
80
+ $form.Close()
81
+ $form.Dispose()
82
+ if ($result) { Write-Output $result }