@goodandready/dsh-remote-workspace 0.1.0 → 0.1.1

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/index.js CHANGED
@@ -14,11 +14,11 @@ export const inject = ['tools'];
14
14
 
15
15
  export const Config = z.object({
16
16
  profiles: z.array(z.object({
17
- id: z.string(),
18
- name: z.string(),
19
- host: z.string(),
17
+ id: z.string().default(''),
18
+ name: z.string().default(''),
19
+ host: z.string().default(''),
20
20
  port: z.number().default(22),
21
- username: z.string(),
21
+ username: z.string().default('root'),
22
22
  authType: z.string().default('key'),
23
23
  privateKeyPath: z.string().default(''),
24
24
  privateKey: z.string().default(''),
@@ -38,6 +38,11 @@ export function apply(ctx, config = {}) {
38
38
  activeProfileId: cfg.activeProfileId || null
39
39
  };
40
40
 
41
+ const sshService = new SshService(ctx);
42
+ const remoteFs = new RemoteFsService(sshService);
43
+ const mirrorSync = new MirrorSyncService(remoteFs, sshService);
44
+ const tunnelService = new TunnelService(sshService);
45
+
41
46
  const store = {
42
47
  getProfiles() { return currentConfig.profiles || []; },
43
48
  getProfile(id) { return (currentConfig.profiles || []).find((p) => p.id === id); },
@@ -52,8 +57,9 @@ export function apply(ctx, config = {}) {
52
57
  if (!currentConfig.activeProfileId && list.length === 1) {
53
58
  currentConfig.activeProfileId = p.id;
54
59
  }
60
+ sshService.invalidate(p.id);
55
61
  if (settingsApi?.replace) {
56
- await settingsApi.replace(Config(currentConfig));
62
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
57
63
  }
58
64
  },
59
65
  async deleteProfile(id) {
@@ -61,23 +67,27 @@ export function apply(ctx, config = {}) {
61
67
  if (currentConfig.activeProfileId === id) {
62
68
  currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
63
69
  }
70
+ sshService.disconnect(id);
64
71
  if (settingsApi?.replace) {
65
- await settingsApi.replace(Config(currentConfig));
72
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
66
73
  }
67
74
  },
68
75
  async setActiveId(id) {
69
76
  currentConfig.activeProfileId = id;
70
77
  if (settingsApi?.replace) {
71
- await settingsApi.replace(Config(currentConfig));
78
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
72
79
  }
73
80
  }
74
81
  };
75
82
 
76
83
  // Register settings namespace if settings service is available
77
84
  ctx.inject(['settings'], (sctx) => {
78
- const scope = sctx.settings.register(NS, Config, { base: cfg });
85
+ const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
86
+ if (!settingsService || typeof settingsService.register !== 'function') return;
87
+
88
+ const scope = settingsService.register(NS, Config, { base: cfg });
79
89
  settingsApi = scope;
80
- const snap = scope.get();
90
+ const snap = scope.get ? scope.get() : null;
81
91
  if (snap) {
82
92
  if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
83
93
  if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
@@ -87,11 +97,6 @@ export function apply(ctx, config = {}) {
87
97
  });
88
98
  });
89
99
 
90
- const sshService = new SshService(ctx);
91
- const remoteFs = new RemoteFsService(sshService);
92
- const mirrorSync = new MirrorSyncService(remoteFs, sshService);
93
- const tunnelService = new TunnelService(sshService);
94
-
95
100
  ctx.provide('remoteSsh');
96
101
  ctx.provide('remoteFs');
97
102
  ctx.provide('remoteSync');
@@ -57,22 +57,28 @@ export class MirrorSyncService {
57
57
  const updated = [];
58
58
  const newSnapshot = { ...lastSnapshot };
59
59
 
60
- // Find remote files using find over ssh for speed
61
- const findCmd = `find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*' -printf '%P\\t%s\\t%T@\\n'`;
60
+ // Try GNU find with printf first; fallback to standard find if unsupported
61
+ let findCmd = `find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*' -printf '%P\\t%s\\t%T@\\n' 2>/dev/null || find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*'`;
62
62
  const execRes = await this.sshService.exec(profile, findCmd, remoteDir);
63
- if (execRes.code !== 0) {
64
- throw new Error(`Failed to scan remote dir: ${execRes.stderr}`);
63
+ if (execRes.code !== 0 && !execRes.stdout.trim()) {
64
+ throw new Error(`Failed to scan remote dir: ${execRes.stderr || 'No response from remote host'}`);
65
65
  }
66
66
 
67
67
  const lines = execRes.stdout.split('\n').filter(Boolean);
68
68
  for (const line of lines) {
69
- const [relPath] = line.split('\t');
69
+ let relPath = line.split('\t')[0].trim();
70
+ if (relPath.startsWith('./')) relPath = relPath.slice(2);
70
71
  if (!relPath || this.isIgnored(relPath)) continue;
71
72
 
72
73
  const remoteFullPath = path.posix.join(remoteDir, relPath);
73
74
  const localFullPath = path.join(localDir, relPath);
74
75
 
75
- const remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
76
+ let remoteContent = '';
77
+ try {
78
+ remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
79
+ } catch (err) {
80
+ continue; // skip unreadable files
81
+ }
76
82
  const remoteHash = this.computeHash(remoteContent);
77
83
  const baseHash = lastSnapshot[relPath];
78
84
 
@@ -117,6 +123,16 @@ export class MirrorSyncService {
117
123
  * 3-way conflict aware push: Local Mirror -> Remote
118
124
  */
119
125
  async push(profile, localDir, remoteDir, force = false, dryRun = false) {
126
+ if (!fs.existsSync(localDir)) {
127
+ fs.mkdirSync(localDir, { recursive: true });
128
+ return {
129
+ success: true,
130
+ pushed: [],
131
+ conflicts: [],
132
+ dryRun
133
+ };
134
+ }
135
+
120
136
  const snapshotFile = path.join(localDir, '.dsh-sync-snapshot.json');
121
137
  let snapshot = {};
122
138
  if (fs.existsSync(snapshotFile)) {
package/lib/routes.js CHANGED
@@ -1,11 +1,11 @@
1
- function writeJson(res, code, data) {
1
+ export function writeJson(res, code, data) {
2
2
  try {
3
- res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
3
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
4
4
  res.end(JSON.stringify(data));
5
5
  } catch {}
6
6
  }
7
7
 
8
- function readBody(req, maxBytes = 256 * 1024) {
8
+ export function readBody(req, maxBytes = 256 * 1024) {
9
9
  return new Promise((resolve, reject) => {
10
10
  const chunks = [];
11
11
  let size = 0;
@@ -30,14 +30,22 @@ function readBody(req, maxBytes = 256 * 1024) {
30
30
  });
31
31
  }
32
32
 
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
+ }
37
+
33
38
  export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
34
39
  ctx.inject(['webServer'], (sctx) => {
40
+ const webServer = sctx.get ? (sctx.get('webServer') ?? sctx.webServer) : sctx.webServer;
41
+ if (!webServer || typeof webServer.register !== 'function') return;
42
+
35
43
  // 1. GET /dsh-remote-workspace/state
36
- sctx.effect(() => sctx.webServer.register({
44
+ sctx.effect(() => webServer.register({
37
45
  kind: 'exact',
38
46
  path: '/dsh-remote-workspace/state',
39
47
  handler: async (req, res) => {
40
- if (req.method !== 'GET') return writeJson(res, 405, { error: 'GET only' });
48
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
41
49
  const profiles = store.getProfiles();
42
50
  const activeId = store.getActiveId();
43
51
  const tunnels = tunnelService.listActiveTunnels();
@@ -46,16 +54,18 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
46
54
  }), 'dsh-remote-workspace: /state');
47
55
 
48
56
  // 2. POST /dsh-remote-workspace/profiles/save
49
- sctx.effect(() => sctx.webServer.register({
57
+ sctx.effect(() => webServer.register({
50
58
  kind: 'exact',
51
59
  path: '/dsh-remote-workspace/profiles/save',
52
60
  handler: async (req, res) => {
53
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
61
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
62
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
54
63
  try {
55
64
  const profile = await readBody(req);
56
65
  if (!profile.id || !profile.host) {
57
66
  return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
58
67
  }
68
+ sshService.invalidate(profile.id);
59
69
  await store.saveProfile(profile);
60
70
  writeJson(res, 200, { ok: true, profile });
61
71
  } catch (err) {
@@ -65,14 +75,16 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
65
75
  }), 'dsh-remote-workspace: /profiles/save');
66
76
 
67
77
  // 3. POST /dsh-remote-workspace/profiles/delete
68
- sctx.effect(() => sctx.webServer.register({
78
+ sctx.effect(() => webServer.register({
69
79
  kind: 'exact',
70
80
  path: '/dsh-remote-workspace/profiles/delete',
71
81
  handler: async (req, res) => {
72
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
82
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
83
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
73
84
  try {
74
85
  const { id } = await readBody(req);
75
86
  if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
87
+ sshService.disconnect(id);
76
88
  await store.deleteProfile(id);
77
89
  writeJson(res, 200, { ok: true });
78
90
  } catch (err) {
@@ -82,11 +94,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
82
94
  }), 'dsh-remote-workspace: /profiles/delete');
83
95
 
84
96
  // 4. POST /dsh-remote-workspace/profiles/active
85
- sctx.effect(() => sctx.webServer.register({
97
+ sctx.effect(() => webServer.register({
86
98
  kind: 'exact',
87
99
  path: '/dsh-remote-workspace/profiles/active',
88
100
  handler: async (req, res) => {
89
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
101
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
102
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
90
103
  try {
91
104
  const { id } = await readBody(req);
92
105
  await store.setActiveId(id || null);
@@ -98,11 +111,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
98
111
  }), 'dsh-remote-workspace: /profiles/active');
99
112
 
100
113
  // 5. POST /dsh-remote-workspace/test
101
- sctx.effect(() => sctx.webServer.register({
114
+ sctx.effect(() => webServer.register({
102
115
  kind: 'exact',
103
116
  path: '/dsh-remote-workspace/test',
104
117
  handler: async (req, res) => {
105
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
118
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
119
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
106
120
  try {
107
121
  const body = await readBody(req);
108
122
  const profile = body.profile || (body.id ? store.getProfile(body.id) : body);
@@ -110,7 +124,14 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
110
124
  return writeJson(res, 400, { ok: false, error: 'Invalid profile configuration' });
111
125
  }
112
126
  const result = await sshService.testConnection(profile);
113
- writeJson(res, 200, { ok: result.success, ...result });
127
+ writeJson(res, 200, {
128
+ ok: result.success,
129
+ success: result.success,
130
+ latency: result.latency,
131
+ latencyMs: result.latency,
132
+ os: result.os,
133
+ remoteOs: result.os
134
+ });
114
135
  } catch (err) {
115
136
  writeJson(res, 200, { ok: false, error: err.message });
116
137
  }
@@ -118,11 +139,12 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
118
139
  }), 'dsh-remote-workspace: /test');
119
140
 
120
141
  // 6. POST /dsh-remote-workspace/browse
121
- sctx.effect(() => sctx.webServer.register({
142
+ sctx.effect(() => webServer.register({
122
143
  kind: 'exact',
123
144
  path: '/dsh-remote-workspace/browse',
124
145
  handler: async (req, res) => {
125
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
146
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
147
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
126
148
  try {
127
149
  const body = await readBody(req);
128
150
  const profile = body.profile || (body.profileId ? store.getProfile(body.profileId) : store.getActiveProfile());
@@ -139,17 +161,18 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
139
161
  }), 'dsh-remote-workspace: /browse');
140
162
 
141
163
  // 7. POST /dsh-remote-workspace/sync
142
- sctx.effect(() => sctx.webServer.register({
164
+ sctx.effect(() => webServer.register({
143
165
  kind: 'exact',
144
166
  path: '/dsh-remote-workspace/sync',
145
167
  handler: async (req, res) => {
146
- if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
168
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
169
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
147
170
  try {
148
171
  const { profileId, direction } = await readBody(req);
149
- const profile = store.getProfile(profileId) || store.getActiveProfile();
172
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
150
173
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
151
174
  if (!profile.remoteWorkspace || !profile.localMirrorPath) {
152
- return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be set' });
175
+ return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be configured' });
153
176
  }
154
177
  const resSync = direction === 'push'
155
178
  ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace)
@@ -160,5 +183,47 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
160
183
  }
161
184
  }
162
185
  }), 'dsh-remote-workspace: /sync');
186
+
187
+ // 8. POST /dsh-remote-workspace/tunnels/start
188
+ sctx.effect(() => webServer.register({
189
+ kind: 'exact',
190
+ path: '/dsh-remote-workspace/tunnels/start',
191
+ handler: async (req, res) => {
192
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
193
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
194
+ try {
195
+ const { profileId, localPort, targetHost, remotePort } = await readBody(req);
196
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
197
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
198
+ const lPort = parseInt(localPort, 10);
199
+ const rPort = parseInt(remotePort, 10);
200
+ if (!lPort || !rPort) {
201
+ return writeJson(res, 400, { ok: false, error: 'Valid localPort and remotePort required' });
202
+ }
203
+ const result = await tunnelService.startLocalTunnel(profile, lPort, targetHost || '127.0.0.1', rPort);
204
+ writeJson(res, 200, { ok: result.success, ...result });
205
+ } catch (err) {
206
+ writeJson(res, 500, { ok: false, error: err.message });
207
+ }
208
+ }
209
+ }), 'dsh-remote-workspace: /tunnels/start');
210
+
211
+ // 9. POST /dsh-remote-workspace/tunnels/stop
212
+ sctx.effect(() => webServer.register({
213
+ kind: 'exact',
214
+ path: '/dsh-remote-workspace/tunnels/stop',
215
+ handler: async (req, res) => {
216
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
217
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
218
+ try {
219
+ const { tunnelId } = await readBody(req);
220
+ if (!tunnelId) return writeJson(res, 400, { ok: false, error: 'tunnelId required' });
221
+ const stopped = tunnelService.stopTunnel(tunnelId);
222
+ writeJson(res, 200, { ok: stopped });
223
+ } catch (err) {
224
+ writeJson(res, 500, { ok: false, error: err.message });
225
+ }
226
+ }
227
+ }), 'dsh-remote-workspace: /tunnels/stop');
163
228
  });
164
229
  }
@@ -20,13 +20,15 @@ export class SshService {
20
20
  return profile.privateKey;
21
21
  }
22
22
  if (profile.privateKeyPath) {
23
- let resolvedPath = profile.privateKeyPath;
23
+ let resolvedPath = profile.privateKeyPath.trim();
24
24
  if (resolvedPath.startsWith('~')) {
25
25
  resolvedPath = path.join(os.homedir(), resolvedPath.slice(1));
26
26
  }
27
- if (fs.existsSync(resolvedPath)) {
28
- return fs.readFileSync(resolvedPath, 'utf8');
29
- }
27
+ try {
28
+ if (fs.existsSync(resolvedPath)) {
29
+ return fs.readFileSync(resolvedPath, 'utf8');
30
+ }
31
+ } catch (_) {}
30
32
  }
31
33
  return undefined;
32
34
  }
@@ -53,7 +55,7 @@ export class SshService {
53
55
  };
54
56
 
55
57
  if (profile.authType === 'password') {
56
- connConfig.password = profile.password;
58
+ connConfig.password = profile.password || '';
57
59
  } else if (profile.authType === 'agent') {
58
60
  connConfig.agent = process.env.SSH_AUTH_SOCK;
59
61
  } else {
@@ -92,6 +94,10 @@ export class SshService {
92
94
  * Test connection and measure ping / detect OS
93
95
  */
94
96
  async testConnection(profile) {
97
+ // Invalidate any existing connection for this profile id to ensure fresh credentials are used
98
+ if (profile.id) {
99
+ this.disconnect(profile.id);
100
+ }
95
101
  const start = Date.now();
96
102
  const conn = await this.getConnection(profile);
97
103
  const latency = Date.now() - start;
@@ -99,12 +105,25 @@ export class SshService {
99
105
  return new Promise((resolve) => {
100
106
  conn.exec('uname -s -r -m || ver', (err, stream) => {
101
107
  if (err) {
102
- return resolve({ success: true, latency, os: 'Unknown' });
108
+ return resolve({
109
+ success: true,
110
+ latency,
111
+ latencyMs: latency,
112
+ os: 'Unknown',
113
+ remoteOs: 'Unknown'
114
+ });
103
115
  }
104
116
  let output = '';
105
117
  stream.on('data', (d) => { output += d.toString(); });
106
118
  stream.on('close', () => {
107
- resolve({ success: true, latency, os: output.trim() });
119
+ const osStr = output.trim() || 'Unknown';
120
+ resolve({
121
+ success: true,
122
+ latency,
123
+ latencyMs: latency,
124
+ os: osStr,
125
+ remoteOs: osStr
126
+ });
108
127
  });
109
128
  });
110
129
  });
@@ -157,10 +176,14 @@ export class SshService {
157
176
  });
158
177
  }
159
178
 
179
+ invalidate(profileId) {
180
+ this.disconnect(profileId);
181
+ }
182
+
160
183
  disconnect(profileId) {
161
184
  const conn = this.connections.get(profileId);
162
185
  if (conn) {
163
- conn.end();
186
+ try { conn.end(); } catch (_) {}
164
187
  this.connections.delete(profileId);
165
188
  this.sftpSessions.delete(profileId);
166
189
  }
@@ -6,7 +6,7 @@ export class TunnelService {
6
6
  */
7
7
  constructor(sshService) {
8
8
  this.sshService = sshService;
9
- /** @type {Map<string, { server?: net.Server, profileId: string, localPort: number, remotePort: number }>} */
9
+ /** @type {Map<string, { server?: net.Server, profileId: string, localPort: number, targetHost: string, remotePort: number }>} */
10
10
  this.activeTunnels = new Map();
11
11
  }
12
12
 
@@ -16,7 +16,7 @@ export class TunnelService {
16
16
  async startLocalTunnel(profile, localPort, targetHost = '127.0.0.1', targetPort) {
17
17
  const tunnelId = `${profile.id}:${localPort}->${targetPort}`;
18
18
  if (this.activeTunnels.has(tunnelId)) {
19
- return { success: true, message: 'Tunnel already active', tunnelId };
19
+ return { success: true, message: 'Tunnel already active', tunnelId, localPort, remotePort: targetPort };
20
20
  }
21
21
 
22
22
  const conn = await this.sshService.getConnection(profile);
@@ -30,27 +30,34 @@ export class TunnelService {
30
30
  targetPort,
31
31
  (err, stream) => {
32
32
  if (err) {
33
- socket.destroy();
33
+ try { socket.destroy(); } catch (_) {}
34
34
  return;
35
35
  }
36
+ stream.on('error', () => {
37
+ try { socket.destroy(); } catch (_) {}
38
+ });
39
+ socket.on('error', () => {
40
+ try { stream.destroy(); } catch (_) {}
41
+ });
36
42
  socket.pipe(stream).pipe(socket);
37
43
  }
38
44
  );
39
45
  });
40
46
 
47
+ server.on('error', (err) => {
48
+ this.activeTunnels.delete(tunnelId);
49
+ reject(err);
50
+ });
51
+
41
52
  server.listen(localPort, '127.0.0.1', () => {
42
53
  this.activeTunnels.set(tunnelId, {
43
54
  server,
44
55
  profileId: profile.id,
45
56
  localPort,
57
+ targetHost,
46
58
  remotePort: targetPort
47
59
  });
48
- resolve({ success: true, tunnelId, localPort, targetPort });
49
- });
50
-
51
- server.on('error', (err) => {
52
- this.activeTunnels.delete(tunnelId);
53
- reject(err);
60
+ resolve({ success: true, tunnelId, localPort, targetHost, remotePort: targetPort });
54
61
  });
55
62
  });
56
63
  }
@@ -61,7 +68,9 @@ export class TunnelService {
61
68
  stopTunnel(tunnelId) {
62
69
  const tunnel = this.activeTunnels.get(tunnelId);
63
70
  if (tunnel && tunnel.server) {
64
- tunnel.server.close();
71
+ try {
72
+ tunnel.server.close();
73
+ } catch (_) {}
65
74
  this.activeTunnels.delete(tunnelId);
66
75
  return true;
67
76
  }
@@ -73,6 +82,7 @@ export class TunnelService {
73
82
  id,
74
83
  profileId: t.profileId,
75
84
  localPort: t.localPort,
85
+ targetHost: t.targetHost || '127.0.0.1',
76
86
  remotePort: t.remotePort
77
87
  }));
78
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",