@goodandready/dsh-remote-workspace 0.1.1 → 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.
package/lib/routes.js CHANGED
@@ -1,41 +1,45 @@
1
- export function writeJson(res, code, data) {
2
- try {
3
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
4
- res.end(JSON.stringify(data));
5
- } catch {}
6
- }
1
+ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
2
+ function writeJson(res, statusCode, body) {
3
+ if (typeof res.setHeader === 'function') {
4
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
5
+ res.statusCode = statusCode;
6
+ } else if (typeof res.writeHead === 'function') {
7
+ res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
8
+ } else {
9
+ res.statusCode = statusCode;
10
+ }
11
+ res.end(JSON.stringify(body));
12
+ }
7
13
 
8
- export function readBody(req, maxBytes = 256 * 1024) {
9
- return new Promise((resolve, reject) => {
10
- const chunks = [];
11
- let size = 0;
12
- req.on('data', (c) => {
13
- size += c.length;
14
- if (size > maxBytes) {
15
- reject(new Error('Payload too large'));
16
- req.destroy();
17
- return;
18
- }
19
- chunks.push(c);
20
- });
21
- req.on('end', () => {
22
- try {
23
- const raw = Buffer.concat(chunks).toString('utf8');
24
- resolve(raw ? JSON.parse(raw) : {});
25
- } catch (err) {
26
- reject(new Error('Invalid JSON'));
27
- }
14
+ function readBody(req) {
15
+ return new Promise((resolve, reject) => {
16
+ let body = '';
17
+ req.on('data', (chunk) => { body += chunk.toString(); });
18
+ req.on('end', () => {
19
+ try {
20
+ resolve(body ? JSON.parse(body) : {});
21
+ } catch (err) {
22
+ reject(err);
23
+ }
24
+ });
25
+ req.on('error', reject);
28
26
  });
29
- req.on('error', reject);
30
- });
31
- }
27
+ }
32
28
 
33
- /** Reject cross-site writes. LAN / reverse-proxy UIs are allowed. */
34
- export function isTrustedSettingsRequest(request) {
35
- return request.headers['sec-fetch-site'] !== 'cross-site';
36
- }
29
+ function isTrustedSettingsRequest(req) {
30
+ const rawAuth = req.headers['authorization'];
31
+ if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) return true;
32
+ const cookieHeader = req.headers['cookie'];
33
+ if (typeof cookieHeader === 'string' && (cookieHeader.includes('token=') || cookieHeader.includes('dsh_token='))) {
34
+ return true;
35
+ }
36
+ const secFetch = req.headers['sec-fetch-site'];
37
+ if (secFetch === 'same-origin') return true;
38
+ const ip = req.socket?.remoteAddress || '';
39
+ if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
40
+ return false;
41
+ }
37
42
 
38
- export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
39
43
  ctx.inject(['webServer'], (sctx) => {
40
44
  const webServer = sctx.get ? (sctx.get('webServer') ?? sctx.webServer) : sctx.webServer;
41
45
  if (!webServer || typeof webServer.register !== 'function') return;
@@ -46,10 +50,20 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
46
50
  path: '/dsh-remote-workspace/state',
47
51
  handler: async (req, res) => {
48
52
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
49
- const profiles = store.getProfiles();
50
- const activeId = store.getActiveId();
51
- const tunnels = tunnelService.listActiveTunnels();
52
- writeJson(res, 200, { ok: true, profiles, activeId, tunnels });
53
+ try {
54
+ const profiles = store.getProfiles();
55
+ const activeId = store.getActiveId() || (profiles[0]?.id || '');
56
+ const tunnels = tunnelService.listActiveTunnels();
57
+ writeJson(res, 200, {
58
+ ok: true,
59
+ profiles,
60
+ activeId,
61
+ tunnels,
62
+ vaultProtected: true
63
+ });
64
+ } catch (err) {
65
+ writeJson(res, 500, { ok: false, error: err.message });
66
+ }
53
67
  }
54
68
  }), 'dsh-remote-workspace: /state');
55
69
 
@@ -67,7 +81,8 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
67
81
  }
68
82
  sshService.invalidate(profile.id);
69
83
  await store.saveProfile(profile);
70
- writeJson(res, 200, { ok: true, profile });
84
+ const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : profile;
85
+ writeJson(res, 200, { ok: true, profile: sanitized });
71
86
  } catch (err) {
72
87
  writeJson(res, 500, { ok: false, error: err.message });
73
88
  }
@@ -119,7 +134,10 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
119
134
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
120
135
  try {
121
136
  const body = await readBody(req);
122
- const profile = body.profile || (body.id ? store.getProfile(body.id) : body);
137
+ let profile = body.profile || (body.id ? store.getProfile(body.id) : body);
138
+ if (store.vault && profile) {
139
+ profile = store.vault.hydrateProfile(profile);
140
+ }
123
141
  if (!profile || !profile.host) {
124
142
  return writeJson(res, 400, { ok: false, error: 'Invalid profile configuration' });
125
143
  }
@@ -152,7 +170,7 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
152
170
  return writeJson(res, 400, { ok: false, error: 'No profile specified' });
153
171
  }
154
172
  const remotePath = body.remotePath || '/';
155
- const entries = await remoteFs.listDir(profile, remotePath);
173
+ const entries = await remoteFs.listDir(profile, remotePath, Boolean(body.refresh));
156
174
  writeJson(res, 200, { ok: true, currentPath: remotePath, entries });
157
175
  } catch (err) {
158
176
  writeJson(res, 500, { ok: false, error: err.message });
@@ -160,7 +178,144 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
160
178
  }
161
179
  }), 'dsh-remote-workspace: /browse');
162
180
 
163
- // 7. POST /dsh-remote-workspace/sync
181
+ // 7. POST /dsh-remote-workspace/file/view
182
+ sctx.effect(() => webServer.register({
183
+ kind: 'exact',
184
+ path: '/dsh-remote-workspace/file/view',
185
+ handler: async (req, res) => {
186
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
187
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
188
+ try {
189
+ const { profileId, filePath } = await readBody(req);
190
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
191
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
192
+ if (!filePath) return writeJson(res, 400, { ok: false, error: 'filePath required' });
193
+
194
+ const stat = await remoteFs.stat(profile, filePath);
195
+ if (stat.size > 1024 * 512) {
196
+ return writeJson(res, 400, { ok: false, error: `File too large for preview (${Math.round(stat.size / 1024)} KB). Max 512 KB.` });
197
+ }
198
+
199
+ const content = await remoteFs.readFile(profile, filePath, 'utf8');
200
+ writeJson(res, 200, { ok: true, filePath, content, size: stat.size, mtime: stat.mtime });
201
+ } catch (err) {
202
+ writeJson(res, 500, { ok: false, error: err.message });
203
+ }
204
+ }
205
+ }), 'dsh-remote-workspace: /file/view');
206
+
207
+ // 8. POST /dsh-remote-workspace/file/save
208
+ sctx.effect(() => webServer.register({
209
+ kind: 'exact',
210
+ path: '/dsh-remote-workspace/file/save',
211
+ handler: async (req, res) => {
212
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
213
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
214
+ try {
215
+ const { profileId, filePath, content } = await readBody(req);
216
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
217
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
218
+ if (!filePath || content === undefined) return writeJson(res, 400, { ok: false, error: 'filePath and content required' });
219
+
220
+ const result = await remoteFs.writeFile(profile, filePath, content, 'utf8');
221
+ writeJson(res, 200, { ok: true, ...result });
222
+ } catch (err) {
223
+ writeJson(res, 500, { ok: false, error: err.message });
224
+ }
225
+ }
226
+ }), 'dsh-remote-workspace: /file/save');
227
+
228
+ // 9. POST /dsh-remote-workspace/terminal/create
229
+ sctx.effect(() => webServer.register({
230
+ kind: 'exact',
231
+ path: '/dsh-remote-workspace/terminal/create',
232
+ handler: async (req, res) => {
233
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
234
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
235
+ try {
236
+ const { profileId, cols, rows } = await readBody(req);
237
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
238
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
239
+
240
+ const session = await sshService.createTerminalSession(profile, { cols, rows });
241
+ writeJson(res, 200, { ok: true, sessionId: session.id });
242
+ } catch (err) {
243
+ writeJson(res, 500, { ok: false, error: err.message });
244
+ }
245
+ }
246
+ }), 'dsh-remote-workspace: /terminal/create');
247
+
248
+ // 10. POST /dsh-remote-workspace/terminal/input
249
+ sctx.effect(() => webServer.register({
250
+ kind: 'exact',
251
+ path: '/dsh-remote-workspace/terminal/input',
252
+ handler: async (req, res) => {
253
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
254
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
255
+ try {
256
+ const { sessionId, data } = await readBody(req);
257
+ const session = sshService.getTerminalSession(sessionId);
258
+ if (!session) return writeJson(res, 404, { ok: false, error: 'Terminal session not found' });
259
+ session.write(data || '');
260
+ writeJson(res, 200, { ok: true });
261
+ } catch (err) {
262
+ writeJson(res, 500, { ok: false, error: err.message });
263
+ }
264
+ }
265
+ }), 'dsh-remote-workspace: /terminal/input');
266
+
267
+ // 11. GET /dsh-remote-workspace/terminal/stream (SSE)
268
+ sctx.effect(() => webServer.register({
269
+ kind: 'exact',
270
+ path: '/dsh-remote-workspace/terminal/stream',
271
+ handler: async (req, res) => {
272
+ const url = new URL(req.url, 'http://localhost');
273
+ const sessionId = url.searchParams.get('sessionId');
274
+ const session = sshService.getTerminalSession(sessionId);
275
+ if (!session) return writeJson(res, 404, { ok: false, error: 'Terminal session not found' });
276
+
277
+ if (typeof res.writeHead === 'function') {
278
+ res.writeHead(200, {
279
+ 'Content-Type': 'text/event-stream',
280
+ 'Cache-Control': 'no-cache, no-transform',
281
+ 'Connection': 'keep-alive'
282
+ });
283
+ }
284
+
285
+ // Send historical buffer
286
+ for (const chunk of session.buffer) {
287
+ res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
288
+ }
289
+
290
+ const listener = (chunk) => {
291
+ res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
292
+ };
293
+ session.subscribers.add(listener);
294
+
295
+ req.on('close', () => {
296
+ session.subscribers.delete(listener);
297
+ });
298
+ }
299
+ }), 'dsh-remote-workspace: /terminal/stream');
300
+
301
+ // 12. POST /dsh-remote-workspace/terminal/close
302
+ sctx.effect(() => webServer.register({
303
+ kind: 'exact',
304
+ path: '/dsh-remote-workspace/terminal/close',
305
+ handler: async (req, res) => {
306
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
307
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
308
+ try {
309
+ const { sessionId } = await readBody(req);
310
+ const ok = sshService.closeTerminalSession(sessionId);
311
+ writeJson(res, 200, { ok });
312
+ } catch (err) {
313
+ writeJson(res, 500, { ok: false, error: err.message });
314
+ }
315
+ }
316
+ }), 'dsh-remote-workspace: /terminal/close');
317
+
318
+ // 13. POST /dsh-remote-workspace/sync
164
319
  sctx.effect(() => webServer.register({
165
320
  kind: 'exact',
166
321
  path: '/dsh-remote-workspace/sync',
@@ -168,14 +323,14 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
168
323
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
169
324
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
170
325
  try {
171
- const { profileId, direction } = await readBody(req);
326
+ const { profileId, direction, force } = await readBody(req);
172
327
  const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
173
328
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
174
329
  if (!profile.remoteWorkspace || !profile.localMirrorPath) {
175
330
  return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be configured' });
176
331
  }
177
332
  const resSync = direction === 'push'
178
- ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace)
333
+ ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace, Boolean(force))
179
334
  : await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
180
335
  writeJson(res, 200, { ok: resSync.success, ...resSync });
181
336
  } catch (err) {
@@ -184,7 +339,7 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
184
339
  }
185
340
  }), 'dsh-remote-workspace: /sync');
186
341
 
187
- // 8. POST /dsh-remote-workspace/tunnels/start
342
+ // 14. POST /dsh-remote-workspace/tunnels/start
188
343
  sctx.effect(() => webServer.register({
189
344
  kind: 'exact',
190
345
  path: '/dsh-remote-workspace/tunnels/start',
@@ -208,7 +363,7 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
208
363
  }
209
364
  }), 'dsh-remote-workspace: /tunnels/start');
210
365
 
211
- // 9. POST /dsh-remote-workspace/tunnels/stop
366
+ // 15. POST /dsh-remote-workspace/tunnels/stop
212
367
  sctx.effect(() => webServer.register({
213
368
  kind: 'exact',
214
369
  path: '/dsh-remote-workspace/tunnels/stop',
@@ -225,5 +380,19 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
225
380
  }
226
381
  }
227
382
  }), 'dsh-remote-workspace: /tunnels/stop');
383
+
384
+ // 16. GET /dsh-remote-workspace/tunnels/telemetry
385
+ sctx.effect(() => webServer.register({
386
+ kind: 'exact',
387
+ path: '/dsh-remote-workspace/tunnels/telemetry',
388
+ handler: async (req, res) => {
389
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
390
+ try {
391
+ writeJson(res, 200, { ok: true, tunnels: tunnelService.listActiveTunnels() });
392
+ } catch (err) {
393
+ writeJson(res, 500, { ok: false, error: err.message });
394
+ }
395
+ }
396
+ }), 'dsh-remote-workspace: /tunnels/telemetry');
228
397
  });
229
398
  }
@@ -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
  /**
@@ -34,15 +54,21 @@ export class SshService {
34
54
  }
35
55
 
36
56
  /**
37
- * Connect to remote machine with pooling and keep-alive
57
+ * Connect to remote machine with pooling, keep-alive, and auto-reconnect
38
58
  */
39
- async getConnection(profile) {
59
+ async getConnection(rawProfile) {
60
+ const profile = this.vaultService ? this.vaultService.hydrateProfile(rawProfile) : rawProfile;
40
61
  const connId = profile.id;
62
+
41
63
  const existing = this.connections.get(connId);
42
64
  if (existing && existing._sock && !existing._sock.destroyed) {
43
65
  return existing;
44
66
  }
45
67
 
68
+ // Clean any broken reference
69
+ this.connections.delete(connId);
70
+ this.sftpSessions.delete(connId);
71
+
46
72
  return new Promise((resolve, reject) => {
47
73
  const conn = new Client();
48
74
  const connConfig = {
@@ -94,7 +120,6 @@ export class SshService {
94
120
  * Test connection and measure ping / detect OS
95
121
  */
96
122
  async testConnection(profile) {
97
- // Invalidate any existing connection for this profile id to ensure fresh credentials are used
98
123
  if (profile.id) {
99
124
  this.disconnect(profile.id);
100
125
  }
@@ -130,7 +155,7 @@ export class SshService {
130
155
  }
131
156
 
132
157
  /**
133
- * Obtain active SFTP wrapper
158
+ * Obtain active SFTP wrapper with connection reuse
134
159
  */
135
160
  async getSftp(profile) {
136
161
  const connId = profile.id;
@@ -148,34 +173,153 @@ export class SshService {
148
173
  }
149
174
 
150
175
  /**
151
- * 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]
152
181
  */
153
- 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
+
154
199
  const conn = await this.getConnection(profile);
155
200
  const shellCmd = cwd ? `cd ${JSON.stringify(cwd)} && ${command}` : command;
156
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
+
157
211
  return new Promise((resolve, reject) => {
158
- conn.exec(shellCmd, (err, stream) => {
212
+ let timer = null;
213
+
214
+ conn.exec(shellCmd, execOptions, (err, stream) => {
159
215
  if (err) return reject(err);
160
216
  let stdout = '';
161
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
+
162
233
  stream.on('data', (data) => {
163
234
  const str = data.toString();
164
235
  stdout += str;
165
236
  if (onData) onData({ type: 'stdout', data: str });
166
237
  });
167
- stream.stderr.on('data', (data) => {
238
+
239
+ stream.stderr?.on('data', (data) => {
168
240
  const str = data.toString();
169
241
  stderr += str;
170
242
  if (onData) onData({ type: 'stderr', data: str });
171
243
  });
244
+
172
245
  stream.on('close', (code, signal) => {
173
- 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
+ }
297
+ });
298
+
299
+ stream.on('close', () => {
300
+ this.terminalSessions.delete(sessionId);
174
301
  });
302
+
303
+ this.terminalSessions.set(sessionId, session);
304
+ resolve(session);
175
305
  });
176
306
  });
177
307
  }
178
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
+
179
323
  invalidate(profileId) {
180
324
  this.disconnect(profileId);
181
325
  }
@@ -195,5 +339,9 @@ export class SshService {
195
339
  }
196
340
  this.connections.clear();
197
341
  this.sftpSessions.clear();
342
+ for (const [id, term] of this.terminalSessions.entries()) {
343
+ try { term.close(); } catch (_) {}
344
+ }
345
+ this.terminalSessions.clear();
198
346
  }
199
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' },