@goodandready/dsh-remote-workspace 0.3.14 → 0.3.15

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/tools.js CHANGED
@@ -1,7 +1,29 @@
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 resolveWorkspacePath(workspace, targetPath) {
9
+ if (!workspace || typeof workspace !== 'string' || !workspace.trim()) {
10
+ throw new Error('Remote workspace is not configured for active profile');
11
+ }
12
+ if (!targetPath || typeof targetPath !== 'string' || targetPath.includes('\0')) {
13
+ throw new Error('Path must be a non-empty string without null bytes');
14
+ }
15
+ const normWorkspace = path.posix.normalize(workspace.trim());
16
+ const resolved = path.posix.isAbsolute(targetPath)
17
+ ? path.posix.normalize(targetPath)
18
+ : path.posix.resolve(normWorkspace, targetPath);
19
+
20
+ const prefix = normWorkspace.endsWith('/') ? normWorkspace : normWorkspace + '/';
21
+ if (resolved !== normWorkspace && !resolved.startsWith(prefix)) {
22
+ throw new Error(`Access denied: path "${targetPath}" is outside configured remoteWorkspace "${workspace}"`);
23
+ }
24
+ return resolved;
25
+ }
26
+
5
27
  export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService, diagnoseService, envService, tarSyncService, listProfiles = null) {
6
28
  if (!ctx.tools || typeof ctx.tools.register !== 'function') {
7
29
  return;
@@ -9,7 +31,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
9
31
 
10
32
  const genericOutput = {
11
33
  schema: { type: 'object', additionalProperties: true },
12
- render: (_args, value) => ({ type: 'text', content: typeof value === 'string' ? value : JSON.stringify(value, null, 2) })
34
+ render: (_args, value) => [{
35
+ type: 'text',
36
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2)
37
+ }]
13
38
  };
14
39
 
15
40
  // 1. remote_exec
@@ -68,28 +93,33 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
68
93
  if (!profile) throw new Error('No active remote workspace profile selected');
69
94
  const { action, path: targetPath, content, recursive, bypassCache } = args;
70
95
 
96
+ const resolvedPath = resolveWorkspacePath(profile.remoteWorkspace, targetPath);
97
+ if (action === 'remove' && resolvedPath === path.posix.normalize(profile.remoteWorkspace.trim())) {
98
+ throw new Error('Cannot remove root workspace directory');
99
+ }
100
+
71
101
  switch (action) {
72
102
  case 'read': {
73
- const data = await remoteFs.readFile(profile, targetPath, 'utf8');
103
+ const data = await remoteFs.readFile(profile, resolvedPath, 'utf8');
74
104
  return { content: data };
75
105
  }
76
106
  case 'write': {
77
107
  if (content === undefined) throw new Error('Content is required for write action');
78
- const res = await remoteFs.writeFile(profile, targetPath, content, 'utf8');
108
+ const res = await remoteFs.writeFile(profile, resolvedPath, content, 'utf8');
79
109
  return res;
80
110
  }
81
111
  case 'stat': {
82
- return await remoteFs.stat(profile, targetPath, Boolean(bypassCache));
112
+ return await remoteFs.stat(profile, resolvedPath, Boolean(bypassCache));
83
113
  }
84
114
  case 'list': {
85
- const entries = await remoteFs.listDir(profile, targetPath, Boolean(bypassCache));
115
+ const entries = await remoteFs.listDir(profile, resolvedPath, Boolean(bypassCache));
86
116
  return { entries };
87
117
  }
88
118
  case 'mkdir': {
89
- return await remoteFs.mkdir(profile, targetPath);
119
+ return await remoteFs.mkdir(profile, resolvedPath);
90
120
  }
91
121
  case 'remove': {
92
- return await remoteFs.remove(profile, targetPath, recursive);
122
+ return await remoteFs.remove(profile, resolvedPath, recursive);
93
123
  }
94
124
  default:
95
125
  throw new Error(`Unknown action: ${action}`);
@@ -216,18 +246,32 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
216
246
  const profile = getActiveProfile();
217
247
  if (!profile) throw new Error('No active remote workspace profile selected');
218
248
 
219
- const mgr = args.manager || 'systemd';
249
+ const ALLOWED_ACTIONS = ['status', 'start', 'stop', 'restart', 'reload', 'enable', 'disable', 'logs'];
250
+ if (!args.action || !ALLOWED_ACTIONS.includes(args.action)) {
251
+ throw new Error(`Invalid service action: "${args.action}". Allowed actions: ${ALLOWED_ACTIONS.join(', ')}`);
252
+ }
253
+
254
+ const mgr = (args.manager || 'systemd').toLowerCase();
255
+ if (!['systemd', 'pm2'].includes(mgr)) {
256
+ throw new Error(`Invalid service manager: "${args.manager}". Allowed managers: systemd, pm2`);
257
+ }
258
+
220
259
  const svc = args.service;
221
- const lines = args.lines || 40;
260
+ if (!svc || typeof svc !== 'string' || svc.trim() === '') {
261
+ throw new Error('Service name is required');
262
+ }
263
+
264
+ const lines = Math.max(1, Math.min(10000, parseInt(args.lines, 10) || 40));
265
+ const safeSvc = shellQuote(svc.trim());
222
266
 
223
267
  let cmd = '';
224
268
  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)}`;
269
+ if (args.action === 'logs') cmd = `pm2 logs ${safeSvc} --lines ${lines} --nostream`;
270
+ else cmd = `pm2 ${args.action} ${safeSvc}`;
227
271
  } 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)}`;
272
+ if (args.action === 'logs') cmd = `journalctl -u ${safeSvc} -n ${lines} --no-pager`;
273
+ else if (args.action === 'status') cmd = `systemctl status ${safeSvc} --no-pager`;
274
+ else cmd = `sudo systemctl ${args.action} ${safeSvc} || systemctl ${args.action} ${safeSvc}`;
231
275
  }
232
276
 
233
277
  const res = await sshService.exec(profile, cmd, { cleanAnsi: true });
@@ -303,7 +347,7 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
303
347
  },
304
348
  key: {
305
349
  type: 'string',
306
- description: 'Environment variable key (required for set)'
350
+ description: 'Environment variable key (optional for read to retrieve a specific variable; required for set)'
307
351
  },
308
352
  value: {
309
353
  type: 'string',
@@ -316,7 +360,32 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
316
360
  if (!profile) throw new Error('No active remote workspace profile selected');
317
361
 
318
362
  if (args.action === 'read') {
319
- return await envService.readEnv(profile, args.filePath);
363
+ const res = await envService.readEnv(profile, args.filePath);
364
+ if (args.key) {
365
+ const entry = (res.entries || []).find((e) => e.key === args.key);
366
+ const val = entry ? entry.value : (res.variables ? res.variables[args.key] : undefined);
367
+ return {
368
+ filePath: res.filePath || args.filePath,
369
+ key: args.key,
370
+ exists: entry !== undefined || (res.variables && res.variables[args.key] !== undefined),
371
+ value: val !== undefined ? val : null
372
+ };
373
+ }
374
+ const keys = res.entries
375
+ ? res.entries.filter((e) => e.key).map((e) => e.key)
376
+ : (res.variables ? Object.keys(res.variables) : []);
377
+ const sanitizedEntries = (res.entries || []).map((e) => ({
378
+ key: e.key,
379
+ isComment: Boolean(e.isComment),
380
+ hasValue: Boolean(e.value),
381
+ value: e.value ? '••••••••' : ''
382
+ }));
383
+ return {
384
+ filePath: res.filePath || args.filePath,
385
+ keys,
386
+ entries: sanitizedEntries,
387
+ note: 'Values are masked to prevent secret leakage into LLM context. Pass "key" argument to read a specific variable.'
388
+ };
320
389
  } else if (args.action === 'set') {
321
390
  if (!args.key) throw new Error('key is required for set action');
322
391
  return await envService.setEnvVar(profile, args.filePath, args.key, args.value || '');
@@ -360,7 +429,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
360
429
  },
361
430
  output: {
362
431
  schema: { type: 'object', additionalProperties: true },
363
- render: (_args, value) => ({ type: 'text', content: value && value.table ? value.table : '' })
432
+ render: (_args, value) => [{
433
+ type: 'text',
434
+ text: value && value.table ? value.table : ''
435
+ }]
364
436
  },
365
437
  async execute(args) {
366
438
  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.15",
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
- }