@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/CHANGELOG.md +132 -0
- package/lib/alert-service.js +18 -5
- package/lib/client.js +17 -17
- package/lib/env-service.js +17 -8
- package/lib/file-transfer.js +0 -7
- package/lib/host-projection.js +0 -4
- package/lib/index.js +25 -2
- package/lib/mirror-sync-service.js +21 -1
- package/lib/proxy-command.js +44 -5
- package/lib/remote-fs-service.js +33 -5
- package/lib/routes/docker.js +1 -1
- package/lib/routes/env.js +13 -4
- package/lib/routes/files.js +13 -11
- package/lib/routes/helpers.js +104 -28
- package/lib/routes/profiles.js +15 -3
- package/lib/routes/sync.js +1 -1
- package/lib/routes/terminal.js +1 -1
- package/lib/routes/tunnels.js +1 -1
- package/lib/ssh-config-parser.js +50 -7
- package/lib/ssh-service.js +27 -3
- package/lib/tar-sync-service.js +7 -7
- package/lib/tools.js +89 -17
- package/lib/updater-service.js +14 -11
- package/lib/vault-service.js +14 -5
- package/package.json +3 -2
- package/lib/workspace-mount.js +0 -5
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) =>
|
|
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,
|
|
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,
|
|
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,
|
|
112
|
+
return await remoteFs.stat(profile, resolvedPath, Boolean(bypassCache));
|
|
83
113
|
}
|
|
84
114
|
case 'list': {
|
|
85
|
-
const entries = await remoteFs.listDir(profile,
|
|
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,
|
|
119
|
+
return await remoteFs.mkdir(profile, resolvedPath);
|
|
90
120
|
}
|
|
91
121
|
case 'remove': {
|
|
92
|
-
return await remoteFs.remove(profile,
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
226
|
-
else cmd = `pm2 ${args.action} ${
|
|
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 ${
|
|
229
|
-
else if (args.action === 'status') cmd = `systemctl status ${
|
|
230
|
-
else cmd = `sudo systemctl ${args.action} ${
|
|
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
|
-
|
|
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) =>
|
|
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() || []) : [];
|
package/lib/updater-service.js
CHANGED
|
@@ -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,
|
package/lib/vault-service.js
CHANGED
|
@@ -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
|
|
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
|
|
16
|
-
const secretsDir = path.join(
|
|
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
|
|
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 -
|
|
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.
|
|
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",
|
package/lib/workspace-mount.js
DELETED