@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.
- package/README.md +45 -0
- package/README.ru.md +45 -0
- package/README.zh.md +45 -0
- package/lib/agent-endpoint.js +16 -0
- package/lib/client.js +796 -14
- package/lib/cluster-service.js +57 -0
- package/lib/exec-retry.js +24 -0
- package/lib/file-transfer.js +56 -0
- package/lib/host-projection.js +42 -0
- package/lib/index.js +23 -4
- package/lib/jump-chain.js +56 -0
- package/lib/poll-gate.js +5 -0
- package/lib/profile-groups.js +60 -0
- package/lib/proxy-command.js +53 -0
- package/lib/remote-fs-service.js +25 -0
- package/lib/routes/files.js +75 -0
- package/lib/routes/profiles.js +82 -8
- package/lib/routes/terminal.js +19 -0
- package/lib/ssh-config-parser.js +111 -49
- package/lib/ssh-service.js +180 -21
- package/lib/terminal-font.js +12 -0
- package/lib/tools.js +52 -1
- package/lib/tunnel-service.js +3 -0
- package/lib/types.js +7 -0
- package/lib/workspace-mount.js +5 -0
- package/package.json +1 -1
package/lib/routes/terminal.js
CHANGED
|
@@ -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',
|
package/lib/ssh-config-parser.js
CHANGED
|
@@ -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
|
-
* @
|
|
26
|
+
* @param {{ baseDir?: string, expandInclude?: Function, readFile?: Function, seen?: Set<string>, knownIds?: Set<string> }} [options]
|
|
10
27
|
*/
|
|
11
|
-
static parse(text) {
|
|
12
|
-
|
|
28
|
+
static parse(text, options = {}) {
|
|
29
|
+
return this.parseReport(text, options).profiles;
|
|
30
|
+
}
|
|
13
31
|
|
|
14
|
-
|
|
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
|
-
|
|
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 === '
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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-${
|
|
42
|
-
name
|
|
43
|
-
host:
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/ssh-service.js
CHANGED
|
@@ -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
|
-
//
|
|
177
|
+
// ProxyCommand replaces a direct TCP socket. A jump host is used only when no command is set.
|
|
88
178
|
let sock = undefined;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
this.
|
|
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
|
-
|
|
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)
|
|
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 = '
|
|
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.
|
|
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
|
}
|
package/lib/tunnel-service.js
CHANGED
|
@@ -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]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-remote-workspace",
|
|
3
|
-
"version": "0.3.
|
|
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",
|