@goodandready/dsh-remote-workspace 0.1.0 → 0.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.
@@ -3,13 +3,33 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
 
6
+ /**
7
+ * Utility to strip ANSI escape sequences from terminal output for clean LLM ingestion
8
+ */
9
+ export function stripAnsi(str) {
10
+ if (!str) return '';
11
+ // Match standard ANSI escape codes, OSC sequences, and CSI codes
12
+ return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
13
+ }
14
+
6
15
  export class SshService {
7
- constructor(ctx) {
16
+ /**
17
+ * @param {any} ctx
18
+ * @param {import('./vault-service.js').VaultService} [vaultService]
19
+ */
20
+ constructor(ctx, vaultService = null) {
8
21
  this.ctx = ctx;
22
+ this.vaultService = vaultService;
9
23
  /** @type {Map<string, Client>} */
10
24
  this.connections = new Map();
11
25
  /** @type {Map<string, any>} */
12
26
  this.sftpSessions = new Map();
27
+ /** @type {Map<string, any>} */
28
+ this.terminalSessions = new Map();
29
+ }
30
+
31
+ setVaultService(vault) {
32
+ this.vaultService = vault;
13
33
  }
14
34
 
15
35
  /**
@@ -20,27 +40,35 @@ export class SshService {
20
40
  return profile.privateKey;
21
41
  }
22
42
  if (profile.privateKeyPath) {
23
- let resolvedPath = profile.privateKeyPath;
43
+ let resolvedPath = profile.privateKeyPath.trim();
24
44
  if (resolvedPath.startsWith('~')) {
25
45
  resolvedPath = path.join(os.homedir(), resolvedPath.slice(1));
26
46
  }
27
- if (fs.existsSync(resolvedPath)) {
28
- return fs.readFileSync(resolvedPath, 'utf8');
29
- }
47
+ try {
48
+ if (fs.existsSync(resolvedPath)) {
49
+ return fs.readFileSync(resolvedPath, 'utf8');
50
+ }
51
+ } catch (_) {}
30
52
  }
31
53
  return undefined;
32
54
  }
33
55
 
34
56
  /**
35
- * Connect to remote machine with pooling and keep-alive
57
+ * Connect to remote machine with pooling, keep-alive, and auto-reconnect
36
58
  */
37
- async getConnection(profile) {
59
+ async getConnection(rawProfile) {
60
+ const profile = this.vaultService ? this.vaultService.hydrateProfile(rawProfile) : rawProfile;
38
61
  const connId = profile.id;
62
+
39
63
  const existing = this.connections.get(connId);
40
64
  if (existing && existing._sock && !existing._sock.destroyed) {
41
65
  return existing;
42
66
  }
43
67
 
68
+ // Clean any broken reference
69
+ this.connections.delete(connId);
70
+ this.sftpSessions.delete(connId);
71
+
44
72
  return new Promise((resolve, reject) => {
45
73
  const conn = new Client();
46
74
  const connConfig = {
@@ -53,7 +81,7 @@ export class SshService {
53
81
  };
54
82
 
55
83
  if (profile.authType === 'password') {
56
- connConfig.password = profile.password;
84
+ connConfig.password = profile.password || '';
57
85
  } else if (profile.authType === 'agent') {
58
86
  connConfig.agent = process.env.SSH_AUTH_SOCK;
59
87
  } else {
@@ -92,6 +120,9 @@ export class SshService {
92
120
  * Test connection and measure ping / detect OS
93
121
  */
94
122
  async testConnection(profile) {
123
+ if (profile.id) {
124
+ this.disconnect(profile.id);
125
+ }
95
126
  const start = Date.now();
96
127
  const conn = await this.getConnection(profile);
97
128
  const latency = Date.now() - start;
@@ -99,19 +130,32 @@ export class SshService {
99
130
  return new Promise((resolve) => {
100
131
  conn.exec('uname -s -r -m || ver', (err, stream) => {
101
132
  if (err) {
102
- return resolve({ success: true, latency, os: 'Unknown' });
133
+ return resolve({
134
+ success: true,
135
+ latency,
136
+ latencyMs: latency,
137
+ os: 'Unknown',
138
+ remoteOs: 'Unknown'
139
+ });
103
140
  }
104
141
  let output = '';
105
142
  stream.on('data', (d) => { output += d.toString(); });
106
143
  stream.on('close', () => {
107
- resolve({ success: true, latency, os: output.trim() });
144
+ const osStr = output.trim() || 'Unknown';
145
+ resolve({
146
+ success: true,
147
+ latency,
148
+ latencyMs: latency,
149
+ os: osStr,
150
+ remoteOs: osStr
151
+ });
108
152
  });
109
153
  });
110
154
  });
111
155
  }
112
156
 
113
157
  /**
114
- * Obtain active SFTP wrapper
158
+ * Obtain active SFTP wrapper with connection reuse
115
159
  */
116
160
  async getSftp(profile) {
117
161
  const connId = profile.id;
@@ -129,38 +173,161 @@ export class SshService {
129
173
  }
130
174
 
131
175
  /**
132
- * Execute command on remote host
176
+ * Execute command on remote host with PTY, streaming, timeout, and ANSI sanitization
177
+ * @param {any} profile
178
+ * @param {string} command
179
+ * @param {string|object} [optionsOrCwd] - cwd string or options object
180
+ * @param {function} [legacyOnData]
133
181
  */
134
- async exec(profile, command, cwd, onData) {
182
+ async exec(profile, command, optionsOrCwd = null, legacyOnData = null) {
183
+ let cwd = null;
184
+ let onData = legacyOnData;
185
+ let pty = false;
186
+ let timeoutMs = 0;
187
+ let cleanAnsi = false;
188
+
189
+ if (typeof optionsOrCwd === 'string') {
190
+ cwd = optionsOrCwd;
191
+ } else if (optionsOrCwd && typeof optionsOrCwd === 'object') {
192
+ cwd = optionsOrCwd.cwd || null;
193
+ onData = optionsOrCwd.onData || legacyOnData;
194
+ pty = Boolean(optionsOrCwd.pty);
195
+ timeoutMs = optionsOrCwd.timeout ? optionsOrCwd.timeout * 1000 : 0;
196
+ cleanAnsi = Boolean(optionsOrCwd.cleanAnsi);
197
+ }
198
+
135
199
  const conn = await this.getConnection(profile);
136
200
  const shellCmd = cwd ? `cd ${JSON.stringify(cwd)} && ${command}` : command;
137
201
 
202
+ const execOptions = {};
203
+ if (pty) {
204
+ execOptions.pty = {
205
+ rows: typeof pty === 'object' ? (pty.rows || 24) : 24,
206
+ cols: typeof pty === 'object' ? (pty.cols || 80) : 80,
207
+ term: typeof pty === 'object' ? (pty.term || 'xterm-256color') : 'xterm-256color'
208
+ };
209
+ }
210
+
138
211
  return new Promise((resolve, reject) => {
139
- conn.exec(shellCmd, (err, stream) => {
212
+ let timer = null;
213
+
214
+ conn.exec(shellCmd, execOptions, (err, stream) => {
140
215
  if (err) return reject(err);
141
216
  let stdout = '';
142
217
  let stderr = '';
218
+
219
+ if (timeoutMs > 0) {
220
+ timer = setTimeout(() => {
221
+ try {
222
+ stream.signal('SIGTERM');
223
+ stream.close();
224
+ } catch (_) {}
225
+ const errTimeout = new Error(`Command timed out after ${timeoutMs / 1000}s: ${command}`);
226
+ errTimeout.code = 'ETIMEDOUT';
227
+ errTimeout.stdout = cleanAnsi ? stripAnsi(stdout) : stdout;
228
+ errTimeout.stderr = cleanAnsi ? stripAnsi(stderr) : stderr;
229
+ reject(errTimeout);
230
+ }, timeoutMs);
231
+ }
232
+
143
233
  stream.on('data', (data) => {
144
234
  const str = data.toString();
145
235
  stdout += str;
146
236
  if (onData) onData({ type: 'stdout', data: str });
147
237
  });
148
- stream.stderr.on('data', (data) => {
238
+
239
+ stream.stderr?.on('data', (data) => {
149
240
  const str = data.toString();
150
241
  stderr += str;
151
242
  if (onData) onData({ type: 'stderr', data: str });
152
243
  });
244
+
153
245
  stream.on('close', (code, signal) => {
154
- resolve({ code: code ?? 0, signal, stdout, stderr });
246
+ if (timer) clearTimeout(timer);
247
+ resolve({
248
+ code: code ?? 0,
249
+ signal,
250
+ stdout: cleanAnsi ? stripAnsi(stdout) : stdout,
251
+ stderr: cleanAnsi ? stripAnsi(stderr) : stderr
252
+ });
253
+ });
254
+ });
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Create an interactive PTY terminal session for Web UI
260
+ */
261
+ async createTerminalSession(profile, options = {}) {
262
+ const conn = await this.getConnection(profile);
263
+ const cols = options.cols || 80;
264
+ const rows = options.rows || 24;
265
+ const term = options.term || 'xterm-256color';
266
+ const sessionId = `term_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
267
+
268
+ return new Promise((resolve, reject) => {
269
+ conn.shell({ term, cols, rows }, (err, stream) => {
270
+ if (err) return reject(err);
271
+
272
+ const session = {
273
+ id: sessionId,
274
+ profileId: profile.id,
275
+ stream,
276
+ createdAt: Date.now(),
277
+ buffer: [],
278
+ subscribers: new Set(),
279
+ write(data) {
280
+ try { stream.write(data); } catch (_) {}
281
+ },
282
+ resize(c, r) {
283
+ try { stream.setWindow(r, c, 0, 0); } catch (_) {}
284
+ },
285
+ close() {
286
+ try { stream.end(); } catch (_) {}
287
+ }
288
+ };
289
+
290
+ stream.on('data', (d) => {
291
+ const chunk = d.toString('utf8');
292
+ session.buffer.push(chunk);
293
+ if (session.buffer.length > 500) session.buffer.shift();
294
+ for (const sub of session.subscribers) {
295
+ try { sub(chunk); } catch (_) {}
296
+ }
155
297
  });
298
+
299
+ stream.on('close', () => {
300
+ this.terminalSessions.delete(sessionId);
301
+ });
302
+
303
+ this.terminalSessions.set(sessionId, session);
304
+ resolve(session);
156
305
  });
157
306
  });
158
307
  }
159
308
 
309
+ getTerminalSession(sessionId) {
310
+ return this.terminalSessions.get(sessionId);
311
+ }
312
+
313
+ closeTerminalSession(sessionId) {
314
+ const session = this.terminalSessions.get(sessionId);
315
+ if (session) {
316
+ session.close();
317
+ this.terminalSessions.delete(sessionId);
318
+ return true;
319
+ }
320
+ return false;
321
+ }
322
+
323
+ invalidate(profileId) {
324
+ this.disconnect(profileId);
325
+ }
326
+
160
327
  disconnect(profileId) {
161
328
  const conn = this.connections.get(profileId);
162
329
  if (conn) {
163
- conn.end();
330
+ try { conn.end(); } catch (_) {}
164
331
  this.connections.delete(profileId);
165
332
  this.sftpSessions.delete(profileId);
166
333
  }
@@ -172,5 +339,9 @@ export class SshService {
172
339
  }
173
340
  this.connections.clear();
174
341
  this.sftpSessions.clear();
342
+ for (const [id, term] of this.terminalSessions.entries()) {
343
+ try { term.close(); } catch (_) {}
344
+ }
345
+ this.terminalSessions.clear();
175
346
  }
176
347
  }
package/lib/tools.js CHANGED
@@ -11,21 +11,34 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
11
11
  // 1. remote_exec
12
12
  ctx.tools.register({
13
13
  name: 'remote_exec',
14
- description: 'Execute bash/shell command on the active remote workspace host',
14
+ description: 'Execute bash/shell command on the active remote workspace host with timeout and ANSI cleaning',
15
15
  parameters: {
16
16
  command: { type: 'string', required: true, description: 'Command line to execute on remote server' },
17
- cwd: { type: 'string', description: 'Working directory on remote server (defaults to remote workspace)' }
17
+ cwd: { type: 'string', description: 'Working directory on remote server (defaults to remote workspace)' },
18
+ timeout: { type: 'number', description: 'Execution timeout in seconds (default 60s, 0 to disable)' },
19
+ pty: { type: 'boolean', description: 'Allocate pseudo-terminal for colored or interactive outputs' },
20
+ cleanAnsi: { type: 'boolean', description: 'Strip ANSI escape codes from output for clean LLM context (default true)' }
18
21
  },
19
22
  output: genericOutput,
20
23
  async execute(args) {
21
24
  const profile = getActiveProfile();
22
25
  if (!profile) throw new Error('No active remote workspace profile selected');
23
26
  const dir = args.cwd || profile.remoteWorkspace;
24
- const res = await sshService.exec(profile, args.command, dir);
27
+ const timeout = args.timeout !== undefined ? args.timeout : 60;
28
+ const cleanAnsi = args.cleanAnsi !== undefined ? args.cleanAnsi : true;
29
+
30
+ const res = await sshService.exec(profile, args.command, {
31
+ cwd: dir,
32
+ timeout,
33
+ pty: Boolean(args.pty),
34
+ cleanAnsi
35
+ });
36
+
25
37
  return {
26
38
  exitCode: res.code,
27
39
  stdout: res.stdout,
28
- stderr: res.stderr
40
+ stderr: res.stderr,
41
+ signal: res.signal
29
42
  };
30
43
  }
31
44
  });
@@ -33,7 +46,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
33
46
  // 2. remote_fs
34
47
  ctx.tools.register({
35
48
  name: 'remote_fs',
36
- description: 'Perform file operations (read, write, stat, list, mkdir, remove) on remote workspace',
49
+ description: 'Perform file operations (read, write, stat, list, mkdir, remove) on remote workspace with caching',
37
50
  parameters: {
38
51
  action: {
39
52
  type: 'string',
@@ -42,13 +55,14 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
42
55
  },
43
56
  path: { type: 'string', required: true, description: 'Absolute or workspace-relative remote path' },
44
57
  content: { type: 'string', description: 'Content for write action' },
45
- recursive: { type: 'boolean', description: 'Recursive flag for remove action' }
58
+ recursive: { type: 'boolean', description: 'Recursive flag for remove action' },
59
+ bypassCache: { type: 'boolean', description: 'Bypass in-memory cache for fresh results' }
46
60
  },
47
61
  output: genericOutput,
48
62
  async execute(args) {
49
63
  const profile = getActiveProfile();
50
64
  if (!profile) throw new Error('No active remote workspace profile selected');
51
- const { action, path: targetPath, content, recursive } = args;
65
+ const { action, path: targetPath, content, recursive, bypassCache } = args;
52
66
 
53
67
  switch (action) {
54
68
  case 'read': {
@@ -61,10 +75,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
61
75
  return res;
62
76
  }
63
77
  case 'stat': {
64
- return await remoteFs.stat(profile, targetPath);
78
+ return await remoteFs.stat(profile, targetPath, Boolean(bypassCache));
65
79
  }
66
80
  case 'list': {
67
- const entries = await remoteFs.listDir(profile, targetPath);
81
+ const entries = await remoteFs.listDir(profile, targetPath, Boolean(bypassCache));
68
82
  return { entries };
69
83
  }
70
84
  case 'mkdir': {
@@ -82,7 +96,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
82
96
  // 3. remote_sync
83
97
  ctx.tools.register({
84
98
  name: 'remote_sync',
85
- description: 'Perform 3-way conflict-aware synchronization between local mirror and remote server',
99
+ description: 'Perform fast 3-way delta synchronization between local mirror and remote server',
86
100
  parameters: {
87
101
  direction: { type: 'string', required: true, description: 'pull (remote -> local) or push (local -> remote)' },
88
102
  force: { type: 'boolean', description: 'Overwrite conflicts if true (use with caution)' },
@@ -107,7 +121,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
107
121
  // 4. remote_tunnel
108
122
  ctx.tools.register({
109
123
  name: 'remote_tunnel',
110
- description: 'Manage SSH port forwarding tunnels (start, stop, list)',
124
+ description: 'Manage SSH port forwarding tunnels (start, stop, list) with telemetry metrics',
111
125
  parameters: {
112
126
  action: { type: 'string', required: true, description: 'start, stop, or list' },
113
127
  localPort: { type: 'number', description: 'Local listening port' },
@@ -6,66 +6,112 @@ export class TunnelService {
6
6
  */
7
7
  constructor(sshService) {
8
8
  this.sshService = sshService;
9
- /** @type {Map<string, { server?: net.Server, profileId: string, localPort: number, remotePort: number }>} */
9
+ /** @type {Map<string, {
10
+ * server: net.Server,
11
+ * profileId: string,
12
+ * localPort: number,
13
+ * targetHost: string,
14
+ * remotePort: number,
15
+ * createdAt: number,
16
+ * bytesRx: number,
17
+ * bytesTx: number,
18
+ * totalConnections: number,
19
+ * activeConnections: number
20
+ * }>} */
10
21
  this.activeTunnels = new Map();
11
22
  }
12
23
 
13
24
  /**
14
- * Start Local Port Forwarding: 127.0.0.1:localPort -> Remote:targetHost:targetPort
25
+ * Start local port forwarding tunnel: LocalPort -> RemoteHost:RemotePort with live telemetry
15
26
  */
16
- async startLocalTunnel(profile, localPort, targetHost = '127.0.0.1', targetPort) {
17
- const tunnelId = `${profile.id}:${localPort}->${targetPort}`;
27
+ async startLocalTunnel(profile, localPort, targetHost, remotePort) {
28
+ const tunnelId = `${profile.id}:${localPort}->${targetHost}:${remotePort}`;
18
29
  if (this.activeTunnels.has(tunnelId)) {
19
- return { success: true, message: 'Tunnel already active', tunnelId };
30
+ return { success: true, tunnelId, alreadyRunning: true };
20
31
  }
21
32
 
22
33
  const conn = await this.sshService.getConnection(profile);
23
34
 
24
35
  return new Promise((resolve, reject) => {
25
- const server = net.createServer((socket) => {
36
+ const tunnelMeta = {
37
+ server: null,
38
+ profileId: profile.id,
39
+ localPort,
40
+ targetHost,
41
+ remotePort,
42
+ createdAt: Date.now(),
43
+ bytesRx: 0,
44
+ bytesTx: 0,
45
+ totalConnections: 0,
46
+ activeConnections: 0
47
+ };
48
+
49
+ const server = net.createServer((clientSocket) => {
50
+ tunnelMeta.totalConnections++;
51
+ tunnelMeta.activeConnections++;
52
+
26
53
  conn.forwardOut(
27
54
  '127.0.0.1',
28
- socket.remotePort || 0,
55
+ clientSocket.remotePort || 0,
29
56
  targetHost,
30
- targetPort,
57
+ remotePort,
31
58
  (err, stream) => {
32
59
  if (err) {
33
- socket.destroy();
60
+ tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
61
+ clientSocket.destroy();
34
62
  return;
35
63
  }
36
- socket.pipe(stream).pipe(socket);
64
+
65
+ // Telemetry: measure bytes flowing through the tunnel
66
+ clientSocket.on('data', (d) => {
67
+ tunnelMeta.bytesRx += d.length;
68
+ });
69
+ stream.on('data', (d) => {
70
+ tunnelMeta.bytesTx += d.length;
71
+ });
72
+
73
+ clientSocket.pipe(stream);
74
+ stream.pipe(clientSocket);
75
+
76
+ const cleanup = () => {
77
+ tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
78
+ };
79
+ clientSocket.on('close', cleanup);
80
+ clientSocket.on('error', cleanup);
81
+ stream.on('close', cleanup);
82
+ stream.on('error', cleanup);
37
83
  }
38
84
  );
39
85
  });
40
86
 
41
- server.listen(localPort, '127.0.0.1', () => {
42
- this.activeTunnels.set(tunnelId, {
43
- server,
44
- profileId: profile.id,
45
- localPort,
46
- remotePort: targetPort
47
- });
48
- resolve({ success: true, tunnelId, localPort, targetPort });
49
- });
87
+ tunnelMeta.server = server;
50
88
 
51
89
  server.on('error', (err) => {
52
90
  this.activeTunnels.delete(tunnelId);
53
91
  reject(err);
54
92
  });
93
+
94
+ server.listen(localPort, '127.0.0.1', () => {
95
+ this.activeTunnels.set(tunnelId, tunnelMeta);
96
+ resolve({
97
+ success: true,
98
+ tunnelId,
99
+ localPort,
100
+ targetHost,
101
+ remotePort
102
+ });
103
+ });
55
104
  });
56
105
  }
57
106
 
58
- /**
59
- * Stop active tunnel
60
- */
61
107
  stopTunnel(tunnelId) {
62
108
  const tunnel = this.activeTunnels.get(tunnelId);
63
- if (tunnel && tunnel.server) {
109
+ if (!tunnel) return false;
110
+ try {
64
111
  tunnel.server.close();
65
- this.activeTunnels.delete(tunnelId);
66
- return true;
67
- }
68
- return false;
112
+ } catch (_) {}
113
+ this.activeTunnels.delete(tunnelId);
114
+ return true;
69
115
  }
70
116
 
71
117
  listActiveTunnels() {
@@ -73,15 +119,22 @@ export class TunnelService {
73
119
  id,
74
120
  profileId: t.profileId,
75
121
  localPort: t.localPort,
76
- remotePort: t.remotePort
122
+ targetHost: t.targetHost,
123
+ remotePort: t.remotePort,
124
+ createdAt: t.createdAt,
125
+ uptimeSeconds: Math.floor((Date.now() - t.createdAt) / 1000),
126
+ bytesRx: t.bytesRx,
127
+ bytesTx: t.bytesTx,
128
+ totalConnections: t.totalConnections,
129
+ activeConnections: t.activeConnections
77
130
  }));
78
131
  }
79
132
 
80
133
  stopAll() {
81
- for (const [id, t] of this.activeTunnels.entries()) {
82
- if (t.server) {
83
- try { t.server.close(); } catch (_) {}
84
- }
134
+ for (const [id, tunnel] of this.activeTunnels.entries()) {
135
+ try {
136
+ tunnel.server.close();
137
+ } catch (_) {}
85
138
  }
86
139
  this.activeTunnels.clear();
87
140
  }