@goodandready/dsh-remote-workspace 0.3.14 → 0.3.16

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.
@@ -40,7 +40,7 @@ export class TarSyncService {
40
40
 
41
41
  localTar.on('close', (code) => {
42
42
  if (code !== 0) {
43
- try { stream.destroy(); } catch (_) {}
43
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
44
44
  reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
45
45
  } else {
46
46
  resolve({ ok: true, direction: 'pull', remoteDir, localDir });
@@ -48,17 +48,17 @@ export class TarSyncService {
48
48
  });
49
49
 
50
50
  localTar.on('error', (err) => {
51
- try { stream.destroy(); } catch (_) {}
51
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
52
52
  reject(err);
53
53
  });
54
54
 
55
55
  stream.on('error', (err) => {
56
- try { localTar.kill('SIGTERM'); } catch (_) {}
56
+ try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
57
57
  reject(err);
58
58
  });
59
59
 
60
60
  localTar.stdin.on('error', (err) => {
61
- try { stream.destroy(); } catch (_) {}
61
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
62
62
  reject(err);
63
63
  });
64
64
  });
@@ -93,7 +93,7 @@ export class TarSyncService {
93
93
 
94
94
  stream.on('close', (code) => {
95
95
  if (code !== 0) {
96
- try { localTar.kill('SIGTERM'); } catch (_) {}
96
+ try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
97
97
  reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
98
98
  } else {
99
99
  resolve({ ok: true, direction: 'push', localDir, remoteDir });
@@ -101,12 +101,12 @@ export class TarSyncService {
101
101
  });
102
102
 
103
103
  stream.on('error', (err) => {
104
- try { localTar.kill('SIGTERM'); } catch (_) {}
104
+ try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
105
105
  reject(err);
106
106
  });
107
107
 
108
108
  localTar.on('error', (err) => {
109
- try { stream.destroy(); } catch (_) {}
109
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
110
110
  reject(err);
111
111
  });
112
112
  });
package/lib/tools.js CHANGED
@@ -1,7 +1,80 @@
1
+ import path from 'node:path';
2
+ import { shellQuote } from './tar-sync-service.js';
1
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
4
  import { renderHostTable } from './host-projection.js';
3
5
  import { filterClusterProfiles, runCluster } from './cluster-service.js';
4
6
 
7
+
8
+ export function isWithinWorkspace(normWorkspace, candidatePath) {
9
+ const normCandidate = path.posix.normalize(candidatePath);
10
+ const prefix = normWorkspace.endsWith('/') ? normWorkspace : normWorkspace + '/';
11
+ return normCandidate === normWorkspace || normCandidate.startsWith(prefix);
12
+ }
13
+
14
+ export function resolveWorkspacePath(workspace, targetPath) {
15
+ if (!workspace || typeof workspace !== 'string' || !workspace.trim()) {
16
+ throw new Error('Remote workspace is not configured for active profile');
17
+ }
18
+ const trimmedWorkspace = workspace.trim();
19
+ if (trimmedWorkspace.includes('\0') || !path.posix.isAbsolute(trimmedWorkspace)) {
20
+ throw new Error('Remote workspace must be an absolute path starting with "/"');
21
+ }
22
+ if (!targetPath || typeof targetPath !== 'string' || targetPath.includes('\0')) {
23
+ throw new Error('Path must be a non-empty string without null bytes');
24
+ }
25
+ const normWorkspace = path.posix.normalize(trimmedWorkspace);
26
+ const resolved = path.posix.isAbsolute(targetPath)
27
+ ? path.posix.normalize(targetPath)
28
+ : path.posix.resolve(normWorkspace, targetPath);
29
+
30
+ if (!isWithinWorkspace(normWorkspace, resolved)) {
31
+ throw new Error(`Access denied: path "${targetPath}" is outside configured remoteWorkspace "${workspace}"`);
32
+ }
33
+ return resolved;
34
+ }
35
+
36
+ export async function verifyWorkspaceRealpath(remoteFs, profile, workspace, resolvedPath, targetPath) {
37
+ if (!remoteFs || typeof remoteFs.realpath !== 'function') {
38
+ return resolvedPath;
39
+ }
40
+ const normWorkspace = path.posix.normalize(workspace.trim());
41
+ let realWorkspace = normWorkspace;
42
+ try {
43
+ const resolvedWs = await remoteFs.realpath(profile, normWorkspace);
44
+ if (resolvedWs && typeof resolvedWs === 'string') {
45
+ realWorkspace = path.posix.normalize(resolvedWs);
46
+ }
47
+ } catch (_err) {
48
+ // Fallback to normalized configured workspace if workspace root itself cannot be resolved yet
49
+ }
50
+
51
+ let checkPath = resolvedPath;
52
+ let realCandidate = null;
53
+ try {
54
+ realCandidate = await remoteFs.realpath(profile, checkPath);
55
+ } catch (_err) {
56
+ // For non-existent leaf targets (e.g., write / mkdir), verify the parent directory realpath
57
+ const parentPath = path.posix.dirname(checkPath);
58
+ try {
59
+ const realParent = await remoteFs.realpath(profile, parentPath);
60
+ if (realParent && typeof realParent === 'string') {
61
+ realCandidate = path.posix.join(realParent, path.posix.basename(checkPath));
62
+ }
63
+ } catch (_parentErr) {
64
+ realCandidate = null;
65
+ }
66
+ }
67
+
68
+ if (realCandidate && typeof realCandidate === 'string') {
69
+ const normReal = path.posix.normalize(realCandidate);
70
+ if (!isWithinWorkspace(realWorkspace, normReal)) {
71
+ throw new Error(`Access denied: symlink target "${normReal}" for path "${targetPath}" escapes configured remoteWorkspace "${workspace}"`);
72
+ }
73
+ return normReal;
74
+ }
75
+ return resolvedPath;
76
+ }
77
+
5
78
  export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService, diagnoseService, envService, tarSyncService, listProfiles = null) {
6
79
  if (!ctx.tools || typeof ctx.tools.register !== 'function') {
7
80
  return;
@@ -9,7 +82,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
9
82
 
10
83
  const genericOutput = {
11
84
  schema: { type: 'object', additionalProperties: true },
12
- render: (_args, value) => ({ type: 'text', content: typeof value === 'string' ? value : JSON.stringify(value, null, 2) })
85
+ render: (_args, value) => [{
86
+ type: 'text',
87
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2)
88
+ }]
13
89
  };
14
90
 
15
91
  // 1. remote_exec
@@ -68,28 +144,38 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
68
144
  if (!profile) throw new Error('No active remote workspace profile selected');
69
145
  const { action, path: targetPath, content, recursive, bypassCache } = args;
70
146
 
147
+ const lexicalPath = resolveWorkspacePath(profile.remoteWorkspace, targetPath);
148
+ const normWs = path.posix.normalize(profile.remoteWorkspace.trim());
149
+ if (action === 'remove' && lexicalPath === normWs) {
150
+ throw new Error('Cannot remove root workspace directory');
151
+ }
152
+ const resolvedPath = await verifyWorkspaceRealpath(remoteFs, profile, profile.remoteWorkspace, lexicalPath, targetPath);
153
+ if (action === 'remove' && resolvedPath === normWs) {
154
+ throw new Error('Cannot remove root workspace directory');
155
+ }
156
+
71
157
  switch (action) {
72
158
  case 'read': {
73
- const data = await remoteFs.readFile(profile, targetPath, 'utf8');
159
+ const data = await remoteFs.readFile(profile, resolvedPath, 'utf8');
74
160
  return { content: data };
75
161
  }
76
162
  case 'write': {
77
163
  if (content === undefined) throw new Error('Content is required for write action');
78
- const res = await remoteFs.writeFile(profile, targetPath, content, 'utf8');
164
+ const res = await remoteFs.writeFile(profile, resolvedPath, content, 'utf8');
79
165
  return res;
80
166
  }
81
167
  case 'stat': {
82
- return await remoteFs.stat(profile, targetPath, Boolean(bypassCache));
168
+ return await remoteFs.stat(profile, resolvedPath, Boolean(bypassCache));
83
169
  }
84
170
  case 'list': {
85
- const entries = await remoteFs.listDir(profile, targetPath, Boolean(bypassCache));
171
+ const entries = await remoteFs.listDir(profile, resolvedPath, Boolean(bypassCache));
86
172
  return { entries };
87
173
  }
88
174
  case 'mkdir': {
89
- return await remoteFs.mkdir(profile, targetPath);
175
+ return await remoteFs.mkdir(profile, resolvedPath);
90
176
  }
91
177
  case 'remove': {
92
- return await remoteFs.remove(profile, targetPath, recursive);
178
+ return await remoteFs.remove(profile, resolvedPath, recursive);
93
179
  }
94
180
  default:
95
181
  throw new Error(`Unknown action: ${action}`);
@@ -216,18 +302,32 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
216
302
  const profile = getActiveProfile();
217
303
  if (!profile) throw new Error('No active remote workspace profile selected');
218
304
 
219
- const mgr = args.manager || 'systemd';
305
+ const ALLOWED_ACTIONS = ['status', 'start', 'stop', 'restart', 'reload', 'enable', 'disable', 'logs'];
306
+ if (!args.action || !ALLOWED_ACTIONS.includes(args.action)) {
307
+ throw new Error(`Invalid service action: "${args.action}". Allowed actions: ${ALLOWED_ACTIONS.join(', ')}`);
308
+ }
309
+
310
+ const mgr = (args.manager || 'systemd').toLowerCase();
311
+ if (!['systemd', 'pm2'].includes(mgr)) {
312
+ throw new Error(`Invalid service manager: "${args.manager}". Allowed managers: systemd, pm2`);
313
+ }
314
+
220
315
  const svc = args.service;
221
- const lines = args.lines || 40;
316
+ if (!svc || typeof svc !== 'string' || svc.trim() === '') {
317
+ throw new Error('Service name is required');
318
+ }
319
+
320
+ const lines = Math.max(1, Math.min(10000, parseInt(args.lines, 10) || 40));
321
+ const safeSvc = shellQuote(svc.trim());
222
322
 
223
323
  let cmd = '';
224
324
  if (mgr === 'pm2') {
225
- if (args.action === 'logs') cmd = `pm2 logs ${JSON.stringify(svc)} --lines ${lines} --nostream`;
226
- else cmd = `pm2 ${args.action} ${JSON.stringify(svc)}`;
325
+ if (args.action === 'logs') cmd = `pm2 logs ${safeSvc} --lines ${lines} --nostream`;
326
+ else cmd = `pm2 ${args.action} ${safeSvc}`;
227
327
  } else {
228
- if (args.action === 'logs') cmd = `journalctl -u ${JSON.stringify(svc)} -n ${lines} --no-pager`;
229
- else if (args.action === 'status') cmd = `systemctl status ${JSON.stringify(svc)} --no-pager`;
230
- else cmd = `sudo systemctl ${args.action} ${JSON.stringify(svc)} || systemctl ${args.action} ${JSON.stringify(svc)}`;
328
+ if (args.action === 'logs') cmd = `journalctl -u ${safeSvc} -n ${lines} --no-pager`;
329
+ else if (args.action === 'status') cmd = `systemctl status ${safeSvc} --no-pager`;
330
+ else cmd = `sudo systemctl ${args.action} ${safeSvc} || systemctl ${args.action} ${safeSvc}`;
231
331
  }
232
332
 
233
333
  const res = await sshService.exec(profile, cmd, { cleanAnsi: true });
@@ -303,7 +403,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
303
403
  },
304
404
  key: {
305
405
  type: 'string',
306
- description: 'Environment variable key (required for set)'
406
+ description: 'Environment variable key (optional for read to retrieve a specific variable; required for set)'
307
407
  },
308
408
  value: {
309
409
  type: 'string',
@@ -316,7 +416,32 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
316
416
  if (!profile) throw new Error('No active remote workspace profile selected');
317
417
 
318
418
  if (args.action === 'read') {
319
- return await envService.readEnv(profile, args.filePath);
419
+ const res = await envService.readEnv(profile, args.filePath);
420
+ if (args.key) {
421
+ const entry = (res.entries || []).find((e) => e.key === args.key);
422
+ const val = entry ? entry.value : (res.variables ? res.variables[args.key] : undefined);
423
+ return {
424
+ filePath: res.filePath || args.filePath,
425
+ key: args.key,
426
+ exists: entry !== undefined || (res.variables && res.variables[args.key] !== undefined),
427
+ value: val !== undefined ? val : null
428
+ };
429
+ }
430
+ const keys = res.entries
431
+ ? res.entries.filter((e) => e.key).map((e) => e.key)
432
+ : (res.variables ? Object.keys(res.variables) : []);
433
+ const sanitizedEntries = (res.entries || []).map((e) => ({
434
+ key: e.key,
435
+ isComment: Boolean(e.isComment),
436
+ hasValue: Boolean(e.value),
437
+ value: e.value ? '••••••••' : ''
438
+ }));
439
+ return {
440
+ filePath: res.filePath || args.filePath,
441
+ keys,
442
+ entries: sanitizedEntries,
443
+ note: 'Values are masked to prevent secret leakage into LLM context. Pass "key" argument to read a specific variable.'
444
+ };
320
445
  } else if (args.action === 'set') {
321
446
  if (!args.key) throw new Error('key is required for set action');
322
447
  return await envService.setEnvVar(profile, args.filePath, args.key, args.value || '');
@@ -360,7 +485,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
360
485
  },
361
486
  output: {
362
487
  schema: { type: 'object', additionalProperties: true },
363
- render: (_args, value) => ({ type: 'text', content: value && value.table ? value.table : '' })
488
+ render: (_args, value) => [{
489
+ type: 'text',
490
+ text: value && value.table ? value.table : ''
491
+ }]
364
492
  },
365
493
  async execute(args) {
366
494
  const profiles = typeof listProfiles === 'function' ? (listProfiles() || []) : [];
@@ -3,7 +3,6 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
6
 
8
7
  /**
9
8
  * Host-side one-click updater for @goodandready/dsh-remote-workspace.
@@ -206,21 +205,25 @@ export async function status(options, target) {
206
205
  }
207
206
  }
208
207
 
208
+ export function buildInstallArgs(target, packageSpec, options = {}) {
209
+ // Note: Do NOT add --config.minimumReleaseAge=0 per issue #214 / dsh-plugin-guard#65
210
+ return [
211
+ target.cliEntry,
212
+ 'plugin',
213
+ '--profile',
214
+ target.profileName,
215
+ 'add',
216
+ packageSpec,
217
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
218
+ ];
219
+ }
220
+
209
221
  async function installExact(target, packageSpec, options) {
210
222
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
211
223
  await new Promise((resolvePromise, reject) => {
212
224
  const child = spawn(
213
225
  process.execPath,
214
- [
215
- target.cliEntry,
216
- 'plugin',
217
- '--profile',
218
- target.profileName,
219
- 'add',
220
- '--config.minimumReleaseAge=0',
221
- packageSpec,
222
- `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
223
- ],
226
+ buildInstallArgs(target, packageSpec, options),
224
227
  {
225
228
  cwd: target.profileDir,
226
229
  windowsHide: true,
@@ -2,9 +2,17 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
 
5
+ /**
6
+ * Resolve DSH home directory, respecting custom DSH_HOME environment variable.
7
+ */
8
+ export function resolveDshHome() {
9
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
10
+ }
11
+
5
12
  /**
6
13
  * VaultService manages credentials (password, privateKey, passphrase)
7
- * stored in an isolated, secure .env file instead of DSH profiles JSON.
14
+ * stored in an isolated, secure .env file with owner-only (0600) permissions
15
+ * instead of DSH profiles JSON.
8
16
  */
9
17
  export class VaultService {
10
18
  constructor(customPath = null) {
@@ -12,8 +20,8 @@ export class VaultService {
12
20
  if (customPath) {
13
21
  this.envPath = customPath;
14
22
  } else {
15
- const homeDir = os.homedir();
16
- const secretsDir = path.join(homeDir, '.dsh', 'secrets');
23
+ const dshHome = resolveDshHome();
24
+ const secretsDir = path.join(dshHome, 'secrets');
17
25
  try {
18
26
  if (!fs.existsSync(secretsDir)) {
19
27
  fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
@@ -36,7 +44,7 @@ export class VaultService {
36
44
  ensureEnvFile() {
37
45
  try {
38
46
  if (!fs.existsSync(this.envPath)) {
39
- fs.writeFileSync(this.envPath, '# DSH Remote Workspace Encrypted Secrets Vault\n', { mode: 0o600, encoding: 'utf8' });
47
+ fs.writeFileSync(this.envPath, '# DSH Remote Workspace Secrets Vault (Owner-Only Permissions 0600)\n', { mode: 0o600, encoding: 'utf8' });
40
48
  } else {
41
49
  // Ensure file permissions are restricted to owner only
42
50
  try {
@@ -53,6 +61,7 @@ export class VaultService {
53
61
  getPermissionStatus() {
54
62
  return {
55
63
  secure: !this.permissionError,
64
+ protection: 'owner-only-permissions',
56
65
  error: this.permissionError,
57
66
  path: this.envPath
58
67
  };
@@ -109,7 +118,7 @@ export class VaultService {
109
118
 
110
119
  writeAll(store) {
111
120
  this.ensureEnvFile();
112
- const lines = ['# DSH Remote Workspace Secrets Vault - Auto Generated', ''];
121
+ const lines = ['# DSH Remote Workspace Secrets Vault (Owner-Only Permissions 0600)', ''];
113
122
  for (const [key, val] of Object.entries(store)) {
114
123
  if (val === undefined || val === null || val === '') continue;
115
124
  const strVal = String(val);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
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",
@@ -16,7 +16,8 @@
16
16
  "README.md",
17
17
  "README.ru.md",
18
18
  "README.zh.md",
19
- "LICENSE"
19
+ "LICENSE",
20
+ "CHANGELOG.md"
20
21
  ],
21
22
  "scripts": {
22
23
  "build": "tsup",
@@ -1,5 +0,0 @@
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
- }