lit-shell.js 0.1.4 → 1.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +189 -62
  3. package/dist/client/browser-bundle.js +301 -5
  4. package/dist/client/browser-bundle.js.map +3 -3
  5. package/dist/client/index.d.ts +2 -1
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +1 -0
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/terminal-client.d.ts +97 -3
  10. package/dist/client/terminal-client.d.ts.map +1 -1
  11. package/dist/client/terminal-client.js +298 -4
  12. package/dist/client/terminal-client.js.map +1 -1
  13. package/dist/server/circular-buffer.d.ts +55 -0
  14. package/dist/server/circular-buffer.d.ts.map +1 -0
  15. package/dist/server/circular-buffer.js +91 -0
  16. package/dist/server/circular-buffer.js.map +1 -0
  17. package/dist/server/index.d.ts +5 -1
  18. package/dist/server/index.d.ts.map +1 -1
  19. package/dist/server/index.js +4 -0
  20. package/dist/server/index.js.map +1 -1
  21. package/dist/server/session-manager.d.ts +201 -0
  22. package/dist/server/session-manager.d.ts.map +1 -0
  23. package/dist/server/session-manager.js +458 -0
  24. package/dist/server/session-manager.js.map +1 -0
  25. package/dist/server/terminal-server.d.ts +75 -5
  26. package/dist/server/terminal-server.d.ts.map +1 -1
  27. package/dist/server/terminal-server.js +515 -79
  28. package/dist/server/terminal-server.js.map +1 -1
  29. package/dist/shared/types.d.ts +185 -2
  30. package/dist/shared/types.d.ts.map +1 -1
  31. package/dist/ui/browser-bundle.js +1853 -88
  32. package/dist/ui/browser-bundle.js.map +4 -4
  33. package/dist/ui/index.d.ts +1 -0
  34. package/dist/ui/index.d.ts.map +1 -1
  35. package/dist/ui/index.js +1 -0
  36. package/dist/ui/index.js.map +1 -1
  37. package/dist/ui/lit-shell-terminal.d.ts +225 -6
  38. package/dist/ui/lit-shell-terminal.d.ts.map +1 -1
  39. package/dist/ui/lit-shell-terminal.js +1605 -60
  40. package/dist/ui/lit-shell-terminal.js.map +1 -1
  41. package/dist/ui/styles.d.ts.map +1 -1
  42. package/dist/ui/styles.js +22 -0
  43. package/dist/ui/styles.js.map +1 -1
  44. package/dist/version.d.ts +6 -0
  45. package/dist/version.d.ts.map +1 -0
  46. package/dist/version.js +6 -0
  47. package/dist/version.js.map +1 -0
  48. package/package.json +9 -4
@@ -2,10 +2,13 @@
2
2
  * Server-side terminal handler using node-pty
3
3
  *
4
4
  * Manages PTY sessions and WebSocket connections for web-based terminals.
5
+ * Supports session multiplexing - multiple clients can connect to the same session.
5
6
  */
6
7
  import { WebSocketServer } from 'ws';
7
8
  import * as path from 'path';
8
9
  import * as os from 'os';
10
+ import { exec } from 'child_process';
11
+ import { SessionManager } from './session-manager.js';
9
12
  /**
10
13
  * Get platform default shell
11
14
  */
@@ -16,14 +19,14 @@ function getDefaultShell() {
16
19
  return process.env.SHELL || '/bin/bash';
17
20
  }
18
21
  /**
19
- * Terminal server class
22
+ * Terminal server class with session multiplexing support
20
23
  */
21
24
  export class TerminalServer {
22
25
  constructor(options = {}) {
23
- this.sessions = new Map();
24
26
  this.wss = null;
25
27
  this.pty = null;
26
28
  this.cleanupInterval = null;
29
+ this.clientIds = new WeakMap();
27
30
  this.config = {
28
31
  allowedShells: options.allowedShells || [getDefaultShell()],
29
32
  allowedPaths: options.allowedPaths || [os.homedir()],
@@ -33,10 +36,51 @@ export class TerminalServer {
33
36
  idleTimeout: options.idleTimeout || 30 * 60 * 1000, // 30 minutes
34
37
  path: options.path || '/terminal',
35
38
  verbose: options.verbose || false,
39
+ // Docker options
40
+ allowDockerExec: options.allowDockerExec || false,
41
+ allowedContainerPatterns: options.allowedContainerPatterns || [],
42
+ defaultContainerShell: options.defaultContainerShell || '/bin/bash',
43
+ dockerPath: options.dockerPath || 'docker',
44
+ // Multiplexing options
45
+ maxClientsPerSession: options.maxClientsPerSession || 10,
46
+ orphanTimeout: options.orphanTimeout || 60000,
47
+ historySize: options.historySize || 50000,
48
+ historyEnabled: options.historyEnabled ?? true,
49
+ maxSessionsTotal: options.maxSessionsTotal || 100,
36
50
  };
37
- // Start cleanup interval
51
+ // Initialize session manager
52
+ this.sessionManager = new SessionManager({
53
+ maxClientsPerSession: this.config.maxClientsPerSession,
54
+ orphanTimeout: this.config.orphanTimeout,
55
+ historySize: this.config.historySize,
56
+ historyEnabled: this.config.historyEnabled,
57
+ maxSessionsTotal: this.config.maxSessionsTotal,
58
+ verbose: this.config.verbose,
59
+ });
60
+ // Handle session manager events
61
+ this.sessionManager.on('sessionClosed', (sessionId, reason) => {
62
+ this.log(`Session ${sessionId} closed: ${reason}`);
63
+ });
64
+ // Start cleanup interval for idle sessions
38
65
  this.cleanupInterval = setInterval(() => this.cleanupSessions(), 60000);
39
66
  }
67
+ /**
68
+ * Generate a unique client ID
69
+ */
70
+ generateClientId() {
71
+ return `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
72
+ }
73
+ /**
74
+ * Get or create client ID for a WebSocket
75
+ */
76
+ getClientId(ws) {
77
+ let clientId = this.clientIds.get(ws);
78
+ if (!clientId) {
79
+ clientId = this.generateClientId();
80
+ this.clientIds.set(ws, clientId);
81
+ }
82
+ return clientId;
83
+ }
40
84
  /**
41
85
  * Initialize node-pty (lazy load)
42
86
  */
@@ -86,6 +130,9 @@ export class TerminalServer {
86
130
  * Can be called directly for manual WebSocket upgrade handling
87
131
  */
88
132
  async handleConnection(ws, req) {
133
+ // Generate client ID
134
+ const clientId = this.getClientId(ws);
135
+ this.log(`Assigned client ID: ${clientId}`);
89
136
  // Load node-pty
90
137
  try {
91
138
  await this.initPty();
@@ -95,40 +142,41 @@ export class TerminalServer {
95
142
  ws.close();
96
143
  return;
97
144
  }
145
+ // Send server info to client
146
+ this.sendServerInfo(ws);
98
147
  // Handle messages
99
148
  ws.on('message', (data) => {
100
149
  try {
101
150
  const message = JSON.parse(data.toString());
102
- this.handleMessage(ws, message);
151
+ this.handleMessage(ws, clientId, message);
103
152
  }
104
153
  catch (error) {
105
154
  this.log(`Invalid message: ${error}`, 'error');
106
155
  }
107
156
  });
108
157
  ws.on('close', () => {
109
- this.log('Client disconnected');
110
- // Clean up sessions for this WebSocket
111
- for (const [sessionId, session] of this.sessions.entries()) {
112
- if (session.ws === ws) {
113
- this.closeSession(sessionId);
114
- }
158
+ this.log(`Client ${clientId} disconnected`);
159
+ // Remove client from all sessions (sessions may survive if other clients connected)
160
+ const affectedSessions = this.sessionManager.removeClientFromAllSessions(clientId);
161
+ if (affectedSessions.length > 0) {
162
+ this.log(`Removed client from sessions: ${affectedSessions.join(', ')}`);
115
163
  }
116
164
  });
117
165
  ws.on('error', (error) => {
118
- this.log(`WebSocket error: ${error.message}`, 'error');
166
+ this.log(`WebSocket error for client ${clientId}: ${error.message}`, 'error');
119
167
  });
120
168
  }
121
169
  /**
122
170
  * Handle message from client
123
171
  */
124
- handleMessage(ws, message) {
172
+ handleMessage(ws, clientId, message) {
125
173
  switch (message.type) {
126
174
  case 'spawn':
127
- this.spawnSession(ws, message.options || {});
175
+ this.spawnSession(ws, clientId, message.options || {});
128
176
  break;
129
177
  case 'data':
130
178
  if (message.sessionId) {
131
- this.writeToSession(message.sessionId, message.data);
179
+ this.writeToSession(message.sessionId, clientId, message.data);
132
180
  }
133
181
  break;
134
182
  case 'resize':
@@ -138,13 +186,118 @@ export class TerminalServer {
138
186
  break;
139
187
  case 'close':
140
188
  if (message.sessionId) {
141
- this.closeSession(message.sessionId);
189
+ this.closeSession(message.sessionId, clientId);
142
190
  }
143
191
  break;
192
+ case 'listContainers':
193
+ this.listContainers(ws);
194
+ break;
195
+ // Session multiplexing messages
196
+ case 'listSessions':
197
+ this.handleListSessions(ws, message.filter);
198
+ break;
199
+ case 'join':
200
+ this.handleJoinSession(ws, clientId, message.options);
201
+ break;
202
+ case 'leave':
203
+ this.handleLeaveSession(ws, clientId, message.sessionId);
204
+ break;
144
205
  default:
145
206
  this.log(`Unknown message type: ${message.type}`, 'warn');
146
207
  }
147
208
  }
209
+ // ===========================================================================
210
+ // Session Multiplexing Handlers
211
+ // ===========================================================================
212
+ /**
213
+ * Handle list sessions request
214
+ */
215
+ handleListSessions(ws, filter) {
216
+ const sessions = this.sessionManager.getSessions(filter);
217
+ const sessionInfos = sessions.map((s) => this.sessionManager.toSharedSessionInfo(s));
218
+ this.log(`Listed ${sessions.length} sessions: ${sessions.map(s => s.id).join(', ')}`);
219
+ ws.send(JSON.stringify({
220
+ type: 'sessionList',
221
+ sessions: sessionInfos,
222
+ }));
223
+ }
224
+ /**
225
+ * Handle join session request
226
+ */
227
+ handleJoinSession(ws, clientId, options) {
228
+ this.log(`Client ${clientId} attempting to join session: ${options.sessionId}`);
229
+ const session = this.sessionManager.getSession(options.sessionId);
230
+ if (!session) {
231
+ const allSessions = this.sessionManager.getSessions();
232
+ this.log(`Session not found: ${options.sessionId}. Available sessions: ${allSessions.map(s => s.id).join(', ') || 'none'}`, 'warn');
233
+ this.sendError(ws, `Session not found: ${options.sessionId}`);
234
+ return;
235
+ }
236
+ if (!session.accepting) {
237
+ this.sendError(ws, `Session is not accepting new clients`);
238
+ return;
239
+ }
240
+ // Add client to session
241
+ const success = this.sessionManager.addClient(options.sessionId, clientId, ws);
242
+ if (!success) {
243
+ this.sendError(ws, `Failed to join session: ${options.sessionId}`);
244
+ return;
245
+ }
246
+ // Get history if requested
247
+ let history;
248
+ if (options.requestHistory && session.historyEnabled) {
249
+ history = this.sessionManager.getHistory(options.sessionId, options.historyLimit);
250
+ }
251
+ // Send joined response
252
+ ws.send(JSON.stringify({
253
+ type: 'joined',
254
+ sessionId: options.sessionId,
255
+ session: this.sessionManager.toSharedSessionInfo(session),
256
+ history,
257
+ }));
258
+ // Broadcast to other clients
259
+ this.sessionManager.broadcastToSession(options.sessionId, {
260
+ type: 'clientJoined',
261
+ sessionId: options.sessionId,
262
+ clientCount: session.clients.size,
263
+ }, clientId);
264
+ // Send a newline to trigger a fresh prompt for the joining client
265
+ // This ensures the prompt is visible immediately after joining
266
+ if (session.pty) {
267
+ session.pty.write('\n');
268
+ }
269
+ this.log(`Client ${clientId} joined session ${options.sessionId}`);
270
+ }
271
+ /**
272
+ * Handle leave session request
273
+ */
274
+ handleLeaveSession(ws, clientId, sessionId) {
275
+ const session = this.sessionManager.getSession(sessionId);
276
+ if (!session) {
277
+ this.sendError(ws, `Session not found: ${sessionId}`);
278
+ return;
279
+ }
280
+ // Remove client from session
281
+ this.sessionManager.removeClient(sessionId, clientId);
282
+ // Send left response
283
+ ws.send(JSON.stringify({
284
+ type: 'left',
285
+ sessionId,
286
+ }));
287
+ // Broadcast to remaining clients
288
+ if (this.sessionManager.hasSession(sessionId)) {
289
+ const updatedSession = this.sessionManager.getSession(sessionId);
290
+ this.sessionManager.broadcastToSession(sessionId, {
291
+ type: 'clientLeft',
292
+ sessionId,
293
+ clientCount: updatedSession.clients.size,
294
+ });
295
+ }
296
+ this.log(`Client ${clientId} left session ${sessionId}`);
297
+ }
298
+ // ===========================================================================
299
+ // Validation Methods
300
+ // ===========================================================================
148
301
  /**
149
302
  * Validate shell path
150
303
  */
@@ -170,24 +323,200 @@ export class TerminalServer {
170
323
  return this.config.allowedPaths.some((allowedPath) => normalizedCwd.startsWith(path.normalize(path.resolve(allowedPath))));
171
324
  }
172
325
  /**
173
- * Spawn a new terminal session
326
+ * Validate container name/ID against allowed patterns
174
327
  */
175
- spawnSession(ws, options) {
176
- const shell = options.shell || this.config.defaultShell;
177
- const cwd = options.cwd || this.config.defaultCwd;
328
+ isContainerAllowed(container) {
329
+ // Docker exec must be enabled
330
+ if (!this.config.allowDockerExec)
331
+ return false;
332
+ // If no patterns specified, all containers allowed (when Docker exec is enabled)
333
+ if (this.config.allowedContainerPatterns.length === 0)
334
+ return true;
335
+ // Check against allowed patterns
336
+ return this.config.allowedContainerPatterns.some((pattern) => {
337
+ try {
338
+ const regex = new RegExp(pattern);
339
+ return regex.test(container);
340
+ }
341
+ catch {
342
+ // If pattern is invalid regex, treat as literal string match
343
+ return container === pattern || container.startsWith(pattern);
344
+ }
345
+ });
346
+ }
347
+ // ===========================================================================
348
+ // Session Spawning
349
+ // ===========================================================================
350
+ /**
351
+ * Spawn a Docker exec session
352
+ */
353
+ spawnDockerExecSession(ws, clientId, options, sessionId) {
354
+ const container = options.container;
355
+ const shell = options.containerShell || this.config.defaultContainerShell;
178
356
  const cols = options.cols || 80;
179
357
  const rows = options.rows || 24;
180
- const env = options.env || {};
181
- // Count sessions for this client
182
- let clientSessions = 0;
183
- for (const session of this.sessions.values()) {
184
- if (session.ws === ws)
185
- clientSessions++;
358
+ // Build docker exec args
359
+ const args = ['exec', '-it'];
360
+ // Add user if specified
361
+ if (options.containerUser) {
362
+ args.push('-u', options.containerUser);
363
+ }
364
+ // Add working directory if specified
365
+ if (options.containerCwd) {
366
+ args.push('-w', options.containerCwd);
367
+ }
368
+ // Add environment variables
369
+ if (options.env) {
370
+ for (const [key, value] of Object.entries(options.env)) {
371
+ args.push('-e', `${key}=${value}`);
372
+ }
373
+ }
374
+ // Add container
375
+ args.push(container);
376
+ // Check if tmux mode is enabled
377
+ if (options.useTmux) {
378
+ // Use tmux for persistent session
379
+ // tmux new-session -A -s <name> means "attach if exists, create if not"
380
+ const tmuxSessionName = options.tmuxSession || `ls-${sessionId.substring(0, 8)}`;
381
+ args.push('tmux', 'new-session', '-A', '-s', tmuxSessionName);
382
+ this.log(`Spawning Docker exec with tmux: ${this.config.dockerPath} ${args.join(' ')}`);
383
+ }
384
+ else {
385
+ // Regular shell
386
+ args.push(shell);
387
+ this.log(`Spawning Docker exec: ${this.config.dockerPath} ${args.join(' ')}`);
388
+ }
389
+ try {
390
+ // Spawn PTY with docker exec
391
+ const ptyProcess = this.pty.spawn(this.config.dockerPath, args, {
392
+ name: 'xterm-256color',
393
+ cols,
394
+ rows,
395
+ env: process.env,
396
+ });
397
+ // Create session via SessionManager
398
+ const session = this.sessionManager.createSession({
399
+ id: sessionId,
400
+ type: 'docker-exec',
401
+ pty: ptyProcess,
402
+ shell: options.useTmux ? 'tmux' : shell,
403
+ cwd: options.containerCwd || '/',
404
+ cols,
405
+ rows,
406
+ ownerId: clientId,
407
+ ownerWs: ws,
408
+ container,
409
+ label: options.label,
410
+ allowJoin: options.allowJoin,
411
+ enableHistory: options.enableHistory,
412
+ orphanTimeout: options.orphanTimeout,
413
+ useTmux: options.useTmux,
414
+ });
415
+ return session;
416
+ }
417
+ catch (error) {
418
+ this.log(`Failed to spawn Docker exec: ${error}`, 'error');
419
+ this.sendError(ws, `Failed to exec into container: ${error.message}`, sessionId);
420
+ return null;
421
+ }
422
+ }
423
+ /**
424
+ * Spawn a Docker attach session (connects to container's main process)
425
+ */
426
+ spawnDockerAttachSession(ws, clientId, options, sessionId) {
427
+ const container = options.container;
428
+ const cols = options.cols || 80;
429
+ const rows = options.rows || 24;
430
+ // Build docker attach args
431
+ // --sig-proxy=false prevents signals from being proxied to the container
432
+ // --detach-keys allows detaching without killing the session
433
+ const args = [
434
+ 'attach',
435
+ '--sig-proxy=false',
436
+ '--detach-keys=ctrl-p,ctrl-q',
437
+ container,
438
+ ];
439
+ this.log(`Spawning Docker attach: ${this.config.dockerPath} ${args.join(' ')}`);
440
+ try {
441
+ // Spawn PTY with docker attach
442
+ const ptyProcess = this.pty.spawn(this.config.dockerPath, args, {
443
+ name: 'xterm-256color',
444
+ cols,
445
+ rows,
446
+ env: process.env,
447
+ });
448
+ // Create session via SessionManager
449
+ const session = this.sessionManager.createSession({
450
+ id: sessionId,
451
+ type: 'docker-attach',
452
+ pty: ptyProcess,
453
+ shell: 'attach',
454
+ cwd: '/',
455
+ cols,
456
+ rows,
457
+ ownerId: clientId,
458
+ ownerWs: ws,
459
+ container,
460
+ label: options.label,
461
+ allowJoin: options.allowJoin,
462
+ enableHistory: options.enableHistory,
463
+ });
464
+ return session;
186
465
  }
187
- if (clientSessions >= this.config.maxSessionsPerClient) {
466
+ catch (error) {
467
+ this.log(`Failed to spawn Docker attach: ${error}`, 'error');
468
+ this.sendError(ws, `Failed to attach to container: ${error.message}`, sessionId);
469
+ return null;
470
+ }
471
+ }
472
+ /**
473
+ * Spawn a new terminal session
474
+ */
475
+ spawnSession(ws, clientId, options) {
476
+ // Check client session limit
477
+ const clientSessions = this.sessionManager.getClientSessions(clientId);
478
+ if (clientSessions.length >= this.config.maxSessionsPerClient) {
188
479
  this.sendError(ws, `Maximum sessions (${this.config.maxSessionsPerClient}) reached`);
189
480
  return;
190
481
  }
482
+ const sessionId = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
483
+ // Check if this is a Docker request
484
+ if (options.container) {
485
+ // Validate container access
486
+ if (!this.isContainerAllowed(options.container)) {
487
+ this.sendError(ws, `Container access not allowed: ${options.container}. Docker exec ${this.config.allowDockerExec ? 'is enabled but container pattern not matched' : 'is disabled'}.`);
488
+ return;
489
+ }
490
+ let session;
491
+ // Check if attach mode requested
492
+ if (options.attachMode) {
493
+ session = this.spawnDockerAttachSession(ws, clientId, options, sessionId);
494
+ }
495
+ else {
496
+ session = this.spawnDockerExecSession(ws, clientId, options, sessionId);
497
+ }
498
+ if (!session)
499
+ return;
500
+ this.setupSessionHandlers(session);
501
+ // Notify client
502
+ ws.send(JSON.stringify({
503
+ type: 'spawned',
504
+ sessionId,
505
+ shell: session.shell,
506
+ cwd: session.cwd,
507
+ cols: session.cols,
508
+ rows: session.rows,
509
+ container: session.container,
510
+ }));
511
+ this.log(`Docker ${options.attachMode ? 'attach' : 'exec'} session spawned: ${sessionId} (container: ${session.container})`);
512
+ return;
513
+ }
514
+ // Regular local shell session
515
+ const shell = options.shell || this.config.defaultShell;
516
+ const cwd = options.cwd || this.config.defaultCwd;
517
+ const cols = options.cols || 80;
518
+ const rows = options.rows || 24;
519
+ const env = options.env || {};
191
520
  // Validate shell
192
521
  if (!this.isShellAllowed(shell)) {
193
522
  this.sendError(ws, `Shell not allowed: ${shell}. Allowed: ${this.config.allowedShells.join(', ')}`);
@@ -207,40 +536,23 @@ export class TerminalServer {
207
536
  cwd,
208
537
  env: { ...process.env, ...env },
209
538
  });
210
- const sessionId = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
211
- const now = new Date();
212
- // Store session
213
- const session = {
539
+ // Create session via SessionManager
540
+ const session = this.sessionManager.createSession({
214
541
  id: sessionId,
542
+ type: 'local',
215
543
  pty: ptyProcess,
216
- ws,
217
544
  shell,
218
545
  cwd,
219
546
  cols,
220
547
  rows,
221
- createdAt: now,
222
- lastActivity: now,
223
- };
224
- this.sessions.set(sessionId, session);
225
- // Handle PTY output
226
- ptyProcess.onData((data) => {
227
- session.lastActivity = new Date();
228
- ws.send(JSON.stringify({
229
- type: 'data',
230
- sessionId,
231
- data,
232
- }));
233
- });
234
- // Handle PTY exit
235
- ptyProcess.onExit(({ exitCode }) => {
236
- ws.send(JSON.stringify({
237
- type: 'exit',
238
- sessionId,
239
- exitCode,
240
- }));
241
- this.sessions.delete(sessionId);
242
- this.log(`Session exited: ${sessionId} (code: ${exitCode})`);
548
+ ownerId: clientId,
549
+ ownerWs: ws,
550
+ label: options.label,
551
+ allowJoin: options.allowJoin,
552
+ enableHistory: options.enableHistory,
553
+ orphanTimeout: options.orphanTimeout,
243
554
  });
555
+ this.setupSessionHandlers(session);
244
556
  // Notify client
245
557
  ws.send(JSON.stringify({
246
558
  type: 'spawned',
@@ -257,23 +569,65 @@ export class TerminalServer {
257
569
  this.sendError(ws, error.message);
258
570
  }
259
571
  }
572
+ /**
573
+ * Setup PTY event handlers for a session
574
+ */
575
+ setupSessionHandlers(session) {
576
+ const sessionId = session.id;
577
+ // Handle PTY output
578
+ session.pty.onData((data) => {
579
+ // Update activity
580
+ this.sessionManager.updateSessionActivity(sessionId);
581
+ // Store in history buffer
582
+ this.sessionManager.appendHistory(sessionId, data);
583
+ // Broadcast to all connected clients
584
+ this.sessionManager.broadcastToSession(sessionId, {
585
+ type: 'data',
586
+ sessionId,
587
+ data,
588
+ });
589
+ });
590
+ // Handle PTY exit
591
+ session.pty.onExit(({ exitCode }) => {
592
+ // Broadcast exit to all clients
593
+ this.sessionManager.broadcastToSession(sessionId, {
594
+ type: 'exit',
595
+ sessionId,
596
+ exitCode,
597
+ });
598
+ // Also broadcast session closed
599
+ this.sessionManager.broadcastToSession(sessionId, {
600
+ type: 'sessionClosed',
601
+ sessionId,
602
+ reason: 'process_exit',
603
+ });
604
+ // Close the session
605
+ this.sessionManager.closeSession(sessionId, 'process_exit');
606
+ this.log(`Session exited: ${sessionId} (code: ${exitCode})`);
607
+ });
608
+ }
260
609
  /**
261
610
  * Write data to session
262
611
  */
263
- writeToSession(sessionId, data) {
264
- const session = this.sessions.get(sessionId);
612
+ writeToSession(sessionId, clientId, data) {
613
+ const session = this.sessionManager.getSession(sessionId);
265
614
  if (!session) {
266
615
  this.log(`Session not found: ${sessionId}`, 'warn');
267
616
  return;
268
617
  }
269
- session.lastActivity = new Date();
618
+ // Verify client is in session
619
+ if (!this.sessionManager.isClientInSession(sessionId, clientId)) {
620
+ this.log(`Client ${clientId} not in session ${sessionId}`, 'warn');
621
+ return;
622
+ }
623
+ this.sessionManager.updateClientActivity(sessionId, clientId);
270
624
  session.pty.write(data);
271
625
  }
272
626
  /**
273
627
  * Resize session
274
628
  */
275
629
  resizeSession(sessionId, cols, rows) {
276
- const session = this.sessions.get(sessionId);
630
+ const session = this.sessionManager.getSession(sessionId);
277
631
  if (!session) {
278
632
  this.log(`Session not found: ${sessionId}`, 'warn');
279
633
  return;
@@ -283,20 +637,28 @@ export class TerminalServer {
283
637
  session.pty.resize(cols, rows);
284
638
  }
285
639
  /**
286
- * Close session
640
+ * Close session (only owner can close, or force close)
287
641
  */
288
- closeSession(sessionId) {
289
- const session = this.sessions.get(sessionId);
642
+ closeSession(sessionId, clientId) {
643
+ const session = this.sessionManager.getSession(sessionId);
290
644
  if (!session)
291
645
  return;
292
- try {
293
- session.pty.kill();
294
- }
295
- catch (error) {
296
- this.log(`Error killing PTY: ${error}`, 'error');
646
+ // Check if client is the owner
647
+ if (session.owner !== clientId) {
648
+ this.log(`Client ${clientId} attempted to close session owned by ${session.owner}`, 'warn');
649
+ // Just remove the client from the session instead
650
+ this.sessionManager.removeClient(sessionId, clientId);
651
+ return;
297
652
  }
298
- this.sessions.delete(sessionId);
299
- this.log(`Session closed: ${sessionId}`);
653
+ // Broadcast session closed to all clients
654
+ this.sessionManager.broadcastToSession(sessionId, {
655
+ type: 'sessionClosed',
656
+ sessionId,
657
+ reason: 'owner_closed',
658
+ });
659
+ // Close the session
660
+ this.sessionManager.closeSession(sessionId, 'owner_closed');
661
+ this.log(`Session closed by owner: ${sessionId}`);
300
662
  }
301
663
  /**
302
664
  * Clean up inactive sessions
@@ -305,16 +667,17 @@ export class TerminalServer {
305
667
  if (this.config.idleTimeout === 0)
306
668
  return;
307
669
  const now = Date.now();
308
- for (const [sessionId, session] of this.sessions.entries()) {
670
+ for (const session of this.sessionManager.getSessions()) {
309
671
  const idleTime = now - session.lastActivity.getTime();
310
672
  if (idleTime > this.config.idleTimeout) {
311
- this.log(`Closing inactive session: ${sessionId}`);
312
- session.ws.send(JSON.stringify({
673
+ this.log(`Closing inactive session: ${session.id}`);
674
+ // Broadcast to all clients
675
+ this.sessionManager.broadcastToSession(session.id, {
313
676
  type: 'exit',
314
- sessionId,
677
+ sessionId: session.id,
315
678
  exitCode: -1,
316
- }));
317
- this.closeSession(sessionId);
679
+ });
680
+ this.sessionManager.closeSession(session.id, 'idle_timeout');
318
681
  }
319
682
  }
320
683
  }
@@ -347,18 +710,93 @@ export class TerminalServer {
347
710
  }
348
711
  }
349
712
  /**
350
- * Get all active sessions
713
+ * Send server info to client
714
+ */
715
+ sendServerInfo(ws) {
716
+ const info = {
717
+ dockerEnabled: this.config.allowDockerExec,
718
+ allowedShells: this.config.allowedShells,
719
+ defaultShell: this.config.defaultShell,
720
+ defaultContainerShell: this.config.defaultContainerShell,
721
+ };
722
+ ws.send(JSON.stringify({
723
+ type: 'serverInfo',
724
+ info,
725
+ }));
726
+ }
727
+ /**
728
+ * List available Docker containers
729
+ */
730
+ listContainers(ws) {
731
+ if (!this.config.allowDockerExec) {
732
+ ws.send(JSON.stringify({
733
+ type: 'containerList',
734
+ containers: [],
735
+ }));
736
+ return;
737
+ }
738
+ // Run docker ps to get running containers
739
+ exec(`${this.config.dockerPath} ps --format "{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.State}}"`, (error, stdout, stderr) => {
740
+ if (error) {
741
+ this.log(`Failed to list containers: ${error.message}`, 'error');
742
+ ws.send(JSON.stringify({
743
+ type: 'containerList',
744
+ containers: [],
745
+ }));
746
+ return;
747
+ }
748
+ const containers = [];
749
+ const lines = stdout.trim().split('\n').filter((line) => line.trim());
750
+ for (const line of lines) {
751
+ const [id, name, image, status, state] = line.split('\t');
752
+ if (!id)
753
+ continue;
754
+ // Check if container matches allowed patterns
755
+ if (this.isContainerAllowed(name) || this.isContainerAllowed(id)) {
756
+ containers.push({
757
+ id,
758
+ name,
759
+ image,
760
+ status,
761
+ state: state || 'unknown',
762
+ });
763
+ }
764
+ }
765
+ ws.send(JSON.stringify({
766
+ type: 'containerList',
767
+ containers,
768
+ }));
769
+ this.log(`Listed ${containers.length} containers`);
770
+ });
771
+ }
772
+ /**
773
+ * Get all active sessions (for external access)
351
774
  */
352
775
  getSessions() {
353
- return Array.from(this.sessions.values()).map((session) => ({
776
+ return this.sessionManager.getSessions().map((session) => ({
354
777
  sessionId: session.id,
355
778
  shell: session.shell,
356
779
  cwd: session.cwd,
357
780
  cols: session.cols,
358
781
  rows: session.rows,
359
782
  createdAt: session.createdAt,
783
+ container: session.container,
360
784
  }));
361
785
  }
786
+ /**
787
+ * Get all active sessions with multiplexing info
788
+ */
789
+ getSharedSessions(filter) {
790
+ return this.sessionManager
791
+ .getSessions(filter)
792
+ .map((s) => this.sessionManager.toSharedSessionInfo(s));
793
+ }
794
+ /**
795
+ * Get session manager statistics
796
+ */
797
+ getStats() {
798
+ return this.sessionManager.getStats();
799
+ }
362
800
  /**
363
801
  * Close all sessions and stop server
364
802
  */
@@ -368,10 +806,8 @@ export class TerminalServer {
368
806
  clearInterval(this.cleanupInterval);
369
807
  this.cleanupInterval = null;
370
808
  }
371
- // Close all sessions
372
- for (const sessionId of this.sessions.keys()) {
373
- this.closeSession(sessionId);
374
- }
809
+ // Cleanup session manager
810
+ this.sessionManager.cleanup();
375
811
  // Close WebSocket server
376
812
  if (this.wss) {
377
813
  this.wss.close();