@goodandready/dsh-remote-workspace 0.3.8 → 0.3.9

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.
@@ -1,6 +1,25 @@
1
1
  import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
+ import { normalizeTerminalFont } from '../terminal-font.js';
2
3
 
3
4
  export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
5
+
6
+ sctx.effect(() => webServer.register({
7
+ kind: 'exact',
8
+ path: '/dsh-remote-workspace/terminal/font',
9
+ handler: async (req, res) => {
10
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
11
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
12
+ try {
13
+ const body = await readBody(req);
14
+ const fontFamily = await store.setTerminalFont(body.fontFamily);
15
+ writeJson(res, 200, { ok: true, terminalFontFamily: fontFamily });
16
+ } catch (err) {
17
+ const status = err.code === 'INVALID_FONT' ? 400 : 500;
18
+ writeJson(res, status, { ok: false, error: err.message });
19
+ }
20
+ }
21
+ }), 'dsh-remote-workspace: /terminal/font');
22
+
4
23
  // 13. POST /dsh-remote-workspace/terminal/create
5
24
  sctx.effect(() => webServer.register({
6
25
  kind: 'exact',
@@ -2,45 +2,121 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
 
5
+ function expandHome(value) {
6
+ if (value.startsWith('~')) return path.join(os.homedir(), value.slice(1));
7
+ return value;
8
+ }
9
+
10
+ function defaultExpandInclude(pattern, baseDir) {
11
+ const expanded = expandHome(pattern);
12
+ const full = path.isAbsolute(expanded) ? expanded : path.join(baseDir, expanded);
13
+ if (full.includes('*') || full.includes('?')) {
14
+ return fs.globSync(full, { windowsPathsNoEscape: true })
15
+ .filter((file) => {
16
+ try { return fs.statSync(file).isFile(); } catch { return false; }
17
+ })
18
+ .sort();
19
+ }
20
+ return fs.existsSync(full) ? [full] : [];
21
+ }
22
+
5
23
  export class SshConfigParser {
6
24
  /**
7
- * Parse ssh config string into DSH remote profiles
8
25
  * @param {string} text
9
- * @returns {Array<any>}
26
+ * @param {{ baseDir?: string, expandInclude?: Function, readFile?: Function, seen?: Set<string>, knownIds?: Set<string> }} [options]
10
27
  */
11
- static parse(text) {
12
- if (!text || typeof text !== 'string') return [];
28
+ static parse(text, options = {}) {
29
+ return this.parseReport(text, options).profiles;
30
+ }
13
31
 
14
- const lines = text.split('\n');
32
+ static parseReport(text, options = {}) {
33
+ const baseDir = options.baseDir || path.join(os.homedir(), '.ssh');
34
+ const expandInclude = options.expandInclude || ((pattern) => defaultExpandInclude(pattern, baseDir));
35
+ const readFile = options.readFile || ((file) => fs.readFileSync(file, 'utf8'));
36
+ const seen = options.seen || new Set();
37
+ const known = options.knownIds || new Set();
15
38
  const profiles = [];
39
+ const skipped = [];
16
40
  let current = null;
17
41
 
18
- for (let rawLine of lines) {
42
+ const pushCurrent = () => {
43
+ if (!current || !current.host) {
44
+ current = null;
45
+ return;
46
+ }
47
+ if (known.has(current.id)) {
48
+ skipped.push({ name: current.name, reason: 'duplicate' });
49
+ } else {
50
+ known.add(current.id);
51
+ profiles.push(current);
52
+ }
53
+ current = null;
54
+ };
55
+
56
+ const lines = String(text || '').split('\n');
57
+ for (const rawLine of lines) {
19
58
  const line = rawLine.trim();
20
59
  if (!line || line.startsWith('#')) continue;
21
-
22
- // Key Value pair (can be separated by whitespace or '=')
23
60
  const match = line.match(/^(\w+)(?:\s+|=)(.+)$/);
24
61
  if (!match) continue;
25
-
26
62
  const key = match[1].toLowerCase();
27
63
  const val = match[2].trim().replace(/^["']|["']$/g, '');
28
64
 
29
- if (key === 'host') {
30
- // Skip wildcards like Host *
31
- if (val === '*' || val.includes('*') || val.includes('?')) {
32
- current = null;
65
+ if (key === 'include') {
66
+ pushCurrent();
67
+ const files = expandInclude(val, baseDir);
68
+ if (!files.length) {
69
+ skipped.push({ name: val, reason: 'missing-include' });
33
70
  continue;
34
71
  }
35
-
36
- if (current && current.host) {
37
- profiles.push(current);
72
+ for (const file of files) {
73
+ const abs = path.resolve(file);
74
+ if (seen.has(abs)) {
75
+ skipped.push({ name: abs, reason: 'duplicate' });
76
+ continue;
77
+ }
78
+ seen.add(abs);
79
+ let nested = '';
80
+ try { nested = readFile(abs); } catch {
81
+ skipped.push({ name: abs, reason: 'missing-include' });
82
+ continue;
83
+ }
84
+ const child = this.parseReport(nested, {
85
+ baseDir: path.dirname(abs),
86
+ expandInclude: options.expandInclude,
87
+ readFile,
88
+ seen,
89
+ knownIds: known
90
+ });
91
+ profiles.push(...child.profiles);
92
+ skipped.push(...child.skipped);
38
93
  }
94
+ continue;
95
+ }
96
+
97
+ if (key === 'match') {
98
+ pushCurrent();
99
+ skipped.push({ name: val, reason: 'match' });
100
+ continue;
101
+ }
39
102
 
103
+ if (key === 'host') {
104
+ pushCurrent();
105
+ const names = val.split(/\s+/).filter(Boolean);
106
+ const concrete = [];
107
+ for (const name of names) {
108
+ if (name === '*' || name.includes('*') || name.includes('?')) {
109
+ skipped.push({ name, reason: 'wildcard' });
110
+ } else {
111
+ concrete.push(name);
112
+ }
113
+ }
114
+ if (!concrete.length) continue;
115
+ const name = concrete[0];
40
116
  current = {
41
- id: `ssh-cfg-${val.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`,
42
- name: val,
43
- host: val,
117
+ id: `ssh-cfg-${name.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`,
118
+ name,
119
+ host: name,
44
120
  port: 22,
45
121
  username: os.userInfo().username || 'root',
46
122
  authType: 'key',
@@ -48,42 +124,28 @@ export class SshConfigParser {
48
124
  remoteProjectDir: '/root',
49
125
  jumpHostId: ''
50
126
  };
51
- } else if (current) {
52
- if (key === 'hostname') {
53
- current.host = val;
54
- } else if (key === 'user') {
55
- current.username = val;
56
- } else if (key === 'port') {
57
- const p = parseInt(val, 10);
58
- if (p > 0 && p < 65536) current.port = p;
59
- } else if (key === 'identityfile') {
60
- current.authType = 'key';
61
- current.privateKey = val;
62
- } else if (key === 'proxyjump') {
63
- current.jumpHostId = val;
64
- }
127
+ continue;
65
128
  }
66
- }
67
129
 
68
- if (current && current.host) {
69
- profiles.push(current);
130
+ if (!current) continue;
131
+ if (key === 'hostname') current.host = val;
132
+ else if (key === 'user') current.username = val;
133
+ else if (key === 'port') {
134
+ const port = parseInt(val, 10);
135
+ if (port > 0 && port < 65536) current.port = port;
136
+ } else if (key === 'identityfile') {
137
+ current.authType = 'key';
138
+ current.privateKey = val;
139
+ } else if (key === 'proxyjump') current.jumpHostId = val;
140
+ else if (key === 'proxycommand') current.proxyCommand = val;
70
141
  }
71
-
72
- return profiles;
142
+ pushCurrent();
143
+ return { profiles, skipped };
73
144
  }
74
145
 
75
- /**
76
- * Load local default ~/.ssh/config if exists
77
- */
78
146
  static readDefaultConfig() {
79
147
  const configPath = path.join(os.homedir(), '.ssh', 'config');
80
- if (fs.existsSync(configPath)) {
81
- try {
82
- return fs.readFileSync(configPath, 'utf8');
83
- } catch {
84
- return '';
85
- }
86
- }
87
- return '';
148
+ if (!fs.existsSync(configPath)) return '';
149
+ try { return fs.readFileSync(configPath, 'utf8'); } catch { return ''; }
88
150
  }
89
151
  }
@@ -2,6 +2,10 @@ import { Client } from 'ssh2';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
+ import { expandProxyCommand, openProxyCommand } from './proxy-command.js';
6
+ import { openJumpChain, resolveJumpHops } from './jump-chain.js';
7
+ import { resolveAgentEndpoint } from './agent-endpoint.js';
8
+ import { runWithReconnect } from './exec-retry.js';
5
9
 
6
10
  /**
7
11
  * Utility to strip ANSI escape sequences from terminal output for clean LLM ingestion
@@ -25,6 +29,11 @@ export class SshService {
25
29
  this.sftpSessions = new Map();
26
30
  /** @type {Map<string, any>} */
27
31
  this.terminalSessions = new Map();
32
+ /** @type {Map<string, { view: any, finish: Function, timer: NodeJS.Timeout }>} */
33
+ this.authPrompts = new Map();
34
+ /** @type {Map<string, { lastUsed: number, inFlight: number, pinned: boolean }>} */
35
+ this.connMeta = new Map();
36
+ this.idleTimeoutMs = 30 * 60 * 1000;
28
37
  this.getJumpProfile = null;
29
38
  }
30
39
 
@@ -51,6 +60,86 @@ export class SshService {
51
60
  this.getJumpProfile = resolver;
52
61
  }
53
62
 
63
+
64
+ touch(profileId) {
65
+ if (!profileId) return;
66
+ const meta = this.connMeta.get(profileId) || { lastUsed: 0, inFlight: 0, pinned: false };
67
+ meta.lastUsed = Date.now();
68
+ this.connMeta.set(profileId, meta);
69
+ }
70
+
71
+ bumpInFlight(profileId, delta) {
72
+ if (!profileId) return;
73
+ const meta = this.connMeta.get(profileId) || { lastUsed: Date.now(), inFlight: 0, pinned: false };
74
+ meta.inFlight = Math.max(0, (meta.inFlight || 0) + delta);
75
+ meta.lastUsed = Date.now();
76
+ this.connMeta.set(profileId, meta);
77
+ }
78
+
79
+ setPinned(profileId, pinned) {
80
+ if (!profileId) return;
81
+ const meta = this.connMeta.get(profileId) || { lastUsed: Date.now(), inFlight: 0, pinned: false };
82
+ meta.pinned = Boolean(pinned);
83
+ this.connMeta.set(profileId, meta);
84
+ }
85
+
86
+ sweepIdle(now = Date.now()) {
87
+ const closed = [];
88
+ for (const [id] of this.connections) {
89
+ const meta = this.connMeta.get(id) || { lastUsed: now, inFlight: 0, pinned: false };
90
+ if (meta.pinned || meta.inFlight > 0) continue;
91
+ if (now - meta.lastUsed < this.idleTimeoutMs) continue;
92
+ this.disconnect(id);
93
+ this.connMeta.delete(id);
94
+ closed.push(id);
95
+ }
96
+ return closed;
97
+ }
98
+
99
+ connectDedicated(profile, sock) {
100
+ const hydrated = this.vaultService ? this.vaultService.hydrateProfile(profile) : profile;
101
+ return this._dial(hydrated, sock, null, false);
102
+ }
103
+
104
+ beginKeyboardPrompt(profileId, view, finish, timeoutMs = 60000) {
105
+ const id = profileId || '';
106
+ this.clearKeyboardPrompt(id);
107
+ const timer = setTimeout(() => {
108
+ if (!this.authPrompts.has(id)) return;
109
+ this.authPrompts.delete(id);
110
+ try { finish([]); } catch { /* the attempt already ended */ }
111
+ }, timeoutMs);
112
+ timer.unref?.();
113
+ this.authPrompts.set(id, { view, finish, timer });
114
+ }
115
+
116
+ listKeyboardPrompts() {
117
+ return [...this.authPrompts.entries()].map(([profileId, pending]) => ({
118
+ profileId,
119
+ instructions: pending.view.instructions,
120
+ prompts: pending.view.prompts
121
+ }));
122
+ }
123
+
124
+ submitKeyboardPrompt(profileId, answers) {
125
+ const pending = this.authPrompts.get(profileId || '');
126
+ if (!pending) return false;
127
+ clearTimeout(pending.timer);
128
+ this.authPrompts.delete(profileId || '');
129
+ const values = Array.isArray(answers) ? answers.map((item) => String(item ?? '')) : [];
130
+ pending.finish(values);
131
+ return true;
132
+ }
133
+
134
+ clearKeyboardPrompt(profileId) {
135
+ const pending = this.authPrompts.get(profileId || '');
136
+ if (!pending) return;
137
+ clearTimeout(pending.timer);
138
+ this.authPrompts.delete(profileId || '');
139
+ }
140
+
141
+
142
+
54
143
  resolvePrivateKey(profile) {
55
144
  if (profile.privateKey && profile.privateKey.trim()) {
56
145
  return profile.privateKey;
@@ -78,27 +167,49 @@ export class SshService {
78
167
 
79
168
  const existing = this.connections.get(connId);
80
169
  if (existing && existing._sock && !existing._sock.destroyed) {
170
+ this.touch(connId);
81
171
  return existing;
82
172
  }
83
173
 
84
174
  this.connections.delete(connId);
85
175
  this.sftpSessions.delete(connId);
86
176
 
87
- // If jumpHost is configured, obtain socket through the jump host first
177
+ // ProxyCommand replaces a direct TCP socket. A jump host is used only when no command is set.
88
178
  let sock = undefined;
89
- if (profile.jumpHostId && this.getJumpProfile) {
90
- const jumpProfile = this.getJumpProfile(profile.jumpHostId);
91
- if (jumpProfile) {
92
- const jumpConn = await this.getConnection(jumpProfile);
93
- sock = await new Promise((resolve, reject) => {
94
- jumpConn.forwardOut('127.0.0.1', 0, profile.host, profile.port || 22, (err, stream) => {
95
- if (err) return reject(new Error(`ProxyJump via ${jumpProfile.host} failed: ${err.message}`));
96
- resolve(stream);
97
- });
179
+ let releaseTransport = null;
180
+ const proxyCommand = typeof profile.proxyCommand === 'string' ? profile.proxyCommand.trim() : '';
181
+ if (proxyCommand) {
182
+ const expanded = expandProxyCommand(proxyCommand, {
183
+ host: profile.host,
184
+ port: profile.port || 22,
185
+ user: profile.username || 'root',
186
+ alias: profile.name || profile.id || ''
187
+ });
188
+ const opened = openProxyCommand(expanded);
189
+ sock = opened.sock;
190
+ releaseTransport = opened.stop;
191
+ } else {
192
+ const hopIds = resolveJumpHops(profile);
193
+ if (hopIds.length && this.getJumpProfile) {
194
+ const hopProfiles = hopIds.map((id) => {
195
+ const hop = this.getJumpProfile(id);
196
+ if (!hop) throw new Error(`Jump host "${id}" was not found`);
197
+ return hop;
198
+ });
199
+ const opened = await openJumpChain(profile, hopProfiles, {
200
+ connectPooled: (hop) => this.getConnection(hop),
201
+ connectDedicated: (hop, stream) => this.connectDedicated(hop, stream)
98
202
  });
203
+ sock = opened.sock;
204
+ releaseTransport = opened.release;
99
205
  }
100
206
  }
101
207
 
208
+ return this._dial(profile, sock, releaseTransport, true);
209
+ }
210
+
211
+ _dial(profile, sock, releaseTransport, pool) {
212
+ const connId = profile.id;
102
213
  return new Promise((resolve, reject) => {
103
214
  const conn = new Client();
104
215
  const connConfig = {
@@ -107,7 +218,8 @@ export class SshService {
107
218
  username: profile.username || 'root',
108
219
  keepaliveInterval: 15000,
109
220
  keepaliveCountMax: 3,
110
- readyTimeout: 20000
221
+ readyTimeout: 20000,
222
+ tryKeyboard: true
111
223
  };
112
224
 
113
225
  if (sock) {
@@ -117,7 +229,8 @@ export class SshService {
117
229
  if (profile.authType === 'password') {
118
230
  connConfig.password = profile.password || '';
119
231
  } else if (profile.authType === 'agent') {
120
- connConfig.agent = process.env.SSH_AUTH_SOCK;
232
+ const agent = resolveAgentEndpoint(profile);
233
+ if (agent) connConfig.agent = agent;
121
234
  } else {
122
235
  const privateKey = this.resolvePrivateKey(profile);
123
236
  if (privateKey) {
@@ -130,20 +243,39 @@ export class SshService {
130
243
  }
131
244
  }
132
245
 
246
+ conn.on('keyboard-interactive', (_name, instructions, _lang, prompts, finish) => {
247
+ this.beginKeyboardPrompt(profile.id, {
248
+ instructions: String(instructions || ''),
249
+ prompts: (prompts || []).map((item) => ({
250
+ prompt: String(item.prompt || ''),
251
+ echo: Boolean(item.echo)
252
+ }))
253
+ }, finish);
254
+ });
255
+
133
256
  conn.on('ready', () => {
134
- this.connections.set(connId, conn);
257
+ if (pool && connId) {
258
+ this.connections.set(connId, conn);
259
+ this.touch(connId);
260
+ }
135
261
  resolve(conn);
136
262
  });
137
263
 
138
264
  conn.on('error', (err) => {
139
- this.connections.delete(connId);
140
- this.sftpSessions.delete(connId);
265
+ if (releaseTransport) releaseTransport();
266
+ if (pool) {
267
+ this.connections.delete(connId);
268
+ this.sftpSessions.delete(connId);
269
+ }
141
270
  reject(err);
142
271
  });
143
272
 
144
273
  conn.on('close', () => {
145
- this.connections.delete(connId);
146
- this.sftpSessions.delete(connId);
274
+ if (releaseTransport) releaseTransport();
275
+ if (pool && this.connections.get(connId) === conn) {
276
+ this.connections.delete(connId);
277
+ this.sftpSessions.delete(connId);
278
+ }
147
279
  });
148
280
 
149
281
  conn.connect(connConfig);
@@ -201,6 +333,17 @@ export class SshService {
201
333
  }
202
334
 
203
335
  async exec(profile, command, optionsOrCwd = null, legacyOnData = null) {
336
+ const idempotent = !(optionsOrCwd && typeof optionsOrCwd === 'object' && optionsOrCwd.idempotent === false);
337
+ return runWithReconnect(
338
+ () => this.execAttempt(profile, command, optionsOrCwd, legacyOnData),
339
+ {
340
+ idempotent,
341
+ onRetry: () => this.disconnect(profile && profile.id)
342
+ }
343
+ );
344
+ }
345
+
346
+ async execAttempt(profile, command, optionsOrCwd = null, legacyOnData = null) {
204
347
  let cwd = null;
205
348
  let onData = legacyOnData;
206
349
  let pty = false;
@@ -217,7 +360,14 @@ export class SshService {
217
360
  cleanAnsi = Boolean(optionsOrCwd.cleanAnsi);
218
361
  }
219
362
 
220
- const conn = await this.getConnection(profile);
363
+ this.bumpInFlight(profile.id, 1);
364
+ let conn;
365
+ try {
366
+ conn = await this.getConnection(profile);
367
+ } catch (err) {
368
+ this.bumpInFlight(profile.id, -1);
369
+ throw err;
370
+ }
221
371
  const shellCmd = cwd ? `cd ${JSON.stringify(cwd)} && ${command}` : command;
222
372
 
223
373
  const execOptions = {};
@@ -233,7 +383,10 @@ export class SshService {
233
383
  let timer = null;
234
384
 
235
385
  conn.exec(shellCmd, execOptions, (err, stream) => {
236
- if (err) return reject(err);
386
+ if (err) {
387
+ this.bumpInFlight(profile.id, -1);
388
+ return reject(err);
389
+ }
237
390
  let stdout = '';
238
391
  let stderr = '';
239
392
 
@@ -244,7 +397,8 @@ export class SshService {
244
397
  stream.close();
245
398
  } catch (err) { /* best-effort cleanup */ }
246
399
  const errTimeout = new Error(`Command timed out after ${timeoutMs / 1000}s: ${command}`);
247
- errTimeout.code = 'ETIMEDOUT';
400
+ errTimeout.code = 'COMMAND_TIMEOUT';
401
+ errTimeout.commandTimeout = true;
248
402
  errTimeout.stdout = cleanAnsi ? stripAnsi(stdout) : stdout;
249
403
  errTimeout.stderr = cleanAnsi ? stripAnsi(stderr) : stderr;
250
404
  reject(errTimeout);
@@ -265,6 +419,7 @@ export class SshService {
265
419
 
266
420
  stream.on('close', (code, signal) => {
267
421
  if (timer) clearTimeout(timer);
422
+ this.bumpInFlight(profile.id, -1);
268
423
  resolve({
269
424
  code: code ?? 0,
270
425
  signal,
@@ -316,7 +471,7 @@ export class SshService {
316
471
  if (typeof options === 'number') {
317
472
  options = { cols: options, rows: arguments[2] };
318
473
  }
319
- const conn = await this.getConnection(profile);
474
+ const conn = await this.connectDedicated(profile);
320
475
  const cols = options.cols || 80;
321
476
  const rows = options.rows || 24;
322
477
  const term = options.term || 'xterm-256color';
@@ -340,7 +495,10 @@ export class SshService {
340
495
  try { stream.setWindow(r, c, 0, 0); } catch (err) { /* best-effort cleanup */ }
341
496
  },
342
497
  close() {
498
+ if (session.closed) return;
499
+ session.closed = true;
343
500
  try { stream.end(); } catch (err) { /* best-effort cleanup */ }
501
+ try { conn.end(); } catch (err) { /* best-effort cleanup */ }
344
502
  }
345
503
  };
346
504
 
@@ -354,6 +512,7 @@ export class SshService {
354
512
  });
355
513
 
356
514
  stream.on('close', () => {
515
+ session.close();
357
516
  this.terminalSessions.delete(sessionId);
358
517
  });
359
518
 
@@ -0,0 +1,12 @@
1
+ const FONT_PATTERN = /^[A-Za-z0-9 ,."'_-]{1,80}$/;
2
+
3
+ export function normalizeTerminalFont(value) {
4
+ const text = String(value || '').trim();
5
+ if (!text) return '';
6
+ if (!FONT_PATTERN.test(text)) {
7
+ const err = new Error('Terminal font contains unsupported characters');
8
+ err.code = 'INVALID_FONT';
9
+ throw err;
10
+ }
11
+ return text;
12
+ }
package/lib/tools.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { renderHostTable } from './host-projection.js';
3
+ import { filterClusterProfiles, runCluster } from './cluster-service.js';
2
4
 
3
- export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService, diagnoseService, envService, tarSyncService) {
5
+ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService, diagnoseService, envService, tarSyncService, listProfiles = null) {
4
6
  if (!ctx.tools || typeof ctx.tools.register !== 'function') {
5
7
  return;
6
8
  }
@@ -323,5 +325,54 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
323
325
  }
324
326
  }
325
327
  }));
328
+
329
+ ctx.tools.register(defineTool({
330
+ name: 'remote_cluster',
331
+ description: 'Run one command on many hosts filtered by alias, environment, or tags',
332
+ parameters: {
333
+ command: { type: 'string', required: true, description: 'Shell command to run on each selected host' },
334
+ environment: { type: 'string', description: 'Only hosts in this environment' },
335
+ tags: { type: 'string', description: 'Comma-separated tags; every tag must match' },
336
+ aliases: { type: 'string', description: 'Comma-separated host ids or names' },
337
+ maxWorkers: { type: 'number', description: 'Parallel host limit, default 8' }
338
+ },
339
+ output: genericOutput,
340
+ async execute(args) {
341
+ const profiles = typeof listProfiles === 'function' ? (listProfiles() || []) : [];
342
+ const selected = filterClusterProfiles(profiles, {
343
+ environment: args.environment,
344
+ tags: String(args.tags || '').split(',').map((item) => item.trim()).filter(Boolean),
345
+ aliases: String(args.aliases || '').split(',').map((item) => item.trim()).filter(Boolean)
346
+ });
347
+ const results = await runCluster(selected, args.command, {
348
+ maxWorkers: args.maxWorkers,
349
+ exec: (profile, command) => sshService.exec(profile, command, { timeout: 60, cleanAnsi: true })
350
+ });
351
+ return { count: results.length, results };
352
+ }
353
+ }));
354
+
355
+ ctx.tools.register(defineTool({
356
+ name: 'remote_hosts',
357
+ description: 'List configured remote hosts as a compact table without secrets or bastion commands',
358
+ parameters: {
359
+ query: { type: 'string', required: true, description: 'Text matched against alias, host, environment, or tags. Pass an empty string to list every host.' }
360
+ },
361
+ output: {
362
+ schema: { type: 'object', additionalProperties: true },
363
+ render: (_args, value) => ({ type: 'text', content: value && value.table ? value.table : '' })
364
+ },
365
+ async execute(args) {
366
+ const profiles = typeof listProfiles === 'function' ? (listProfiles() || []) : [];
367
+ const query = String(args.query || '').trim().toLowerCase();
368
+ const matched = query
369
+ ? profiles.filter((profile) => {
370
+ const haystack = [profile.name, profile.host, profile.environment, ...(profile.tags || [])].join(' ').toLowerCase();
371
+ return haystack.includes(query);
372
+ })
373
+ : profiles;
374
+ return { count: matched.length, table: renderHostTable(matched) };
375
+ }
376
+ }));
326
377
  }
327
378
  }
@@ -93,6 +93,7 @@ export class TunnelService {
93
93
 
94
94
  server.listen(localPort, '127.0.0.1', () => {
95
95
  this.activeTunnels.set(tunnelId, tunnelMeta);
96
+ if (this.sshService.setPinned) this.sshService.setPinned(profile.id, true);
96
97
  resolve({
97
98
  success: true,
98
99
  tunnelId,
@@ -111,6 +112,8 @@ export class TunnelService {
111
112
  tunnel.server.close();
112
113
  } catch (err) { /* best-effort cleanup */ }
113
114
  this.activeTunnels.delete(tunnelId);
115
+ const stillPinned = [...this.activeTunnels.values()].some((item) => item.profileId === tunnel.profileId);
116
+ if (!stillPinned && this.sshService.setPinned) this.sshService.setPinned(tunnel.profileId, false);
114
117
  return true;
115
118
  }
116
119
 
package/lib/types.js CHANGED
@@ -6,11 +6,18 @@
6
6
  * @property {number} port
7
7
  * @property {string} username
8
8
  * @property {'key'|'password'|'agent'} authType
9
+ * @property {string} [agentPath]
9
10
  * @property {string} [privateKeyPath]
10
11
  * @property {string} [privateKey]
11
12
  * @property {string} [passphrase]
12
13
  * @property {string} [password]
13
14
  * @property {string} [jumpHost]
15
+ * @property {string[]} [jumpHosts]
16
+ * @property {string} [proxyCommand]
17
+ * @property {string} [environment]
18
+ * @property {string[]} [tags]
19
+ * @property {string} [location]
20
+ * @property {string} [description]
14
21
  * @property {string} [remoteWorkspace]
15
22
  * @property {string} [localMirrorPath]
16
23
  * @property {number} [lastPingMs]
@@ -0,0 +1,5 @@
1
+ export function attachWorkspaceNode(column, node) {
2
+ if (!column || !node || typeof column.appendChild !== 'function') return false;
3
+ if (node.parentElement !== column) column.appendChild(node);
4
+ return node.parentElement === column;
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Enterprise Remote Workspace Plugin for DeepSeek Harness: SSH2 connection pool, SFTP operations, 3-way conflict-aware mirror sync, port forwarding, and native DSH UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",