@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/client.js +1766 -1278
- package/lib/index.js +61 -5
- package/lib/mirror-sync-service.js +54 -13
- package/lib/remote-fs-service.js +58 -7
- package/lib/routes.js +215 -46
- package/lib/ssh-service.js +158 -10
- package/lib/tools.js +25 -11
- package/lib/tunnel-service.js +79 -36
- package/lib/vault-service.js +184 -0
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -1,41 +1,45 @@
|
|
|
1
|
-
export function
|
|
2
|
-
|
|
3
|
-
res.
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
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
|
-
|
|
30
|
-
});
|
|
31
|
-
}
|
|
27
|
+
}
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
}
|
package/lib/ssh-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
57
|
+
* Connect to remote machine with pooling, keep-alive, and auto-reconnect
|
|
38
58
|
*/
|
|
39
|
-
async getConnection(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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' },
|