@goodandready/dsh-remote-workspace 0.3.6 → 0.3.7

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.
@@ -11,6 +11,7 @@ export class MirrorSyncService {
11
11
  this.remoteFs = remoteFs;
12
12
  this.sshService = sshService;
13
13
  this.defaultIgnore = [
14
+ '.dsh-sync-snapshot.json',
14
15
  '.git',
15
16
  'node_modules',
16
17
  '.worktrees',
@@ -96,7 +97,7 @@ export class MirrorSyncService {
96
97
  baseRemoteSize === remoteSize
97
98
  ) {
98
99
  // Check if local was modified
99
- const localContent = fs.readFileSync(localFullPath, 'utf8');
100
+ const localContent = fs.readFileSync(localFullPath);
100
101
  const localHash = this.computeHash(localContent);
101
102
  if (localHash === baseHash) {
102
103
  // File unchanged on both ends
@@ -107,7 +108,7 @@ export class MirrorSyncService {
107
108
  // Download content only when potentially changed
108
109
  let remoteContent = '';
109
110
  try {
110
- remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
111
+ remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, null);
111
112
  } catch (err) {
112
113
  continue; // skip unreadable files
113
114
  }
@@ -130,7 +131,7 @@ export class MirrorSyncService {
130
131
  updated.push(relPath);
131
132
  if (!dryRun) {
132
133
  fs.mkdirSync(path.dirname(localFullPath), { recursive: true });
133
- fs.writeFileSync(localFullPath, remoteContent, 'utf8');
134
+ fs.writeFileSync(localFullPath, remoteContent);
134
135
  }
135
136
  }
136
137
 
@@ -195,11 +196,12 @@ export class MirrorSyncService {
195
196
  };
196
197
 
197
198
  const localFiles = walkLocal(localDir, localDir);
199
+ const createdDirs = new Set(['.', '/', '']);
198
200
  for (const rel of localFiles) {
199
201
  const localFull = path.join(localDir, rel);
200
202
  const remoteFull = path.posix.join(remoteDir, rel.replace(/\\/g, '/'));
201
203
  const stat = fs.statSync(localFull);
202
- const content = fs.readFileSync(localFull, 'utf8');
204
+ const content = fs.readFileSync(localFull);
203
205
  const localHash = this.computeHash(content);
204
206
 
205
207
  const baseEntry = snapshot[rel];
@@ -211,8 +213,11 @@ export class MirrorSyncService {
211
213
 
212
214
  if (!dryRun) {
213
215
  const parent = path.posix.dirname(remoteFull);
214
- await this.remoteFs.mkdir(profile, parent);
215
- await this.remoteFs.writeFile(profile, remoteFull, content, 'utf8');
216
+ if (!createdDirs.has(parent)) {
217
+ await this.remoteFs.mkdir(profile, parent);
218
+ createdDirs.add(parent);
219
+ }
220
+ await this.remoteFs.writeFile(profile, remoteFull, content);
216
221
  }
217
222
  pushed.push(rel);
218
223
 
@@ -33,6 +33,10 @@ export class RemoteFsService {
33
33
  /**
34
34
  * Read directory entries with in-memory TTL cache
35
35
  */
36
+ async list(profile, remotePath, bypassCache = false) {
37
+ return this.listDir(profile, remotePath, bypassCache);
38
+ }
39
+
36
40
  async listDir(profile, remotePath, bypassCache = false) {
37
41
  const key = this.cacheKey(profile.id, 'list', remotePath);
38
42
  if (!bypassCache) {
@@ -7,6 +7,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
7
7
  path: '/dsh-remote-workspace/health',
8
8
  handler: async (req, res) => {
9
9
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
10
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
10
11
  try {
11
12
  const url = new URL(req.url, 'http://localhost');
12
13
  const profileId = url.searchParams.get('profileId');
@@ -27,6 +28,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
27
28
  path: '/dsh-remote-workspace/docker/list',
28
29
  handler: async (req, res) => {
29
30
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
31
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
30
32
  try {
31
33
  const url = new URL(req.url, 'http://localhost');
32
34
  const profileId = url.searchParams.get('profileId');
@@ -9,11 +9,24 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
9
9
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
10
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
11
  try {
12
- const { profileId, remotePath } = await readBody(req);
13
- const profile = store.getProfile(profileId);
12
+ const body = await readBody(req);
13
+ let profile = null;
14
+ if (body.profileId) {
15
+ profile = store.getProfile(body.profileId);
16
+ } else if (typeof body.profile === 'string') {
17
+ profile = store.getProfile(body.profile);
18
+ } else if (body.profile && typeof body.profile === 'object' && body.profile.host) {
19
+ profile = body.profile;
20
+ } else if (body.profile?.id) {
21
+ profile = store.getProfile(body.profile.id);
22
+ } else {
23
+ profile = store.getActiveProfile();
24
+ }
14
25
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
- const items = await remoteFs.list(profile, remotePath || profile.remotePath || '.');
16
- writeJson(res, 200, { ok: true, items });
26
+
27
+ const targetPath = body.remotePath || body.path || profile.remoteWorkspace || profile.remotePath || '.';
28
+ const entries = await remoteFs.listDir(profile, targetPath);
29
+ writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
17
30
  } catch (err) {
18
31
  writeJson(res, 500, { ok: false, error: err.message });
19
32
  }
@@ -25,14 +25,56 @@ export function readBody(req) {
25
25
  });
26
26
  }
27
27
 
28
+ export function parseCookies(cookieHeader) {
29
+ const cookies = {};
30
+ if (typeof cookieHeader !== 'string') return cookies;
31
+ for (const pair of cookieHeader.split(';')) {
32
+ const idx = pair.indexOf('=');
33
+ if (idx !== -1) {
34
+ const key = pair.slice(0, idx).trim();
35
+ const val = pair.slice(idx + 1).trim();
36
+ cookies[key] = decodeURIComponent(val);
37
+ }
38
+ }
39
+ return cookies;
40
+ }
41
+
28
42
  export function isTrustedSettingsRequest(req) {
43
+ const secFetch = req.headers['sec-fetch-site'];
44
+ // Fail-closed: unconditionally reject cross-site and cross-origin requests
45
+ if (secFetch && secFetch !== 'same-origin') {
46
+ return false;
47
+ }
48
+
49
+ // If origin is present, ensure it matches the request host
50
+ const origin = req.headers['origin'];
51
+ const host = req.headers['host'];
52
+ if (origin && host) {
53
+ try {
54
+ const url = new URL(origin);
55
+ if (url.host !== host) {
56
+ return false;
57
+ }
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
29
63
  const rawAuth = req.headers['authorization'];
30
- if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) return true;
31
64
  const cookieHeader = req.headers['cookie'];
32
- if (typeof cookieHeader === 'string' && (cookieHeader.includes('token=') || cookieHeader.includes('dsh_token='))) {
33
- return true;
65
+ const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
66
+
67
+ if (expectedToken) {
68
+ if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
69
+ const token = rawAuth.slice(7).trim();
70
+ if (token && token === expectedToken) return true;
71
+ }
72
+ if (typeof cookieHeader === 'string') {
73
+ const cookies = parseCookies(cookieHeader);
74
+ const token = cookies['token'] || cookies['dsh_token'];
75
+ if (token && token === expectedToken) return true;
76
+ }
34
77
  }
35
- const secFetch = req.headers['sec-fetch-site'];
36
78
  if (secFetch === 'same-origin') return true;
37
79
  const ip = req.socket?.remoteAddress || '';
38
80
  if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
@@ -1,6 +1,15 @@
1
1
  import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
2
  import { SshConfigParser } from '../ssh-config-parser.js';
3
3
 
4
+ function maskProfile(p) {
5
+ if (!p || typeof p !== 'object') return p;
6
+ const masked = { ...p };
7
+ if (masked.password) masked.password = '••••••••';
8
+ if (masked.privateKey) masked.privateKey = '••••••••';
9
+ if (masked.passphrase) masked.passphrase = '••••••••';
10
+ return masked;
11
+ }
12
+
4
13
  export function registerProfileRoutes(sctx, webServer, { store, sshService, alertService, tunnelService }) {
5
14
  // 1. GET /dsh-remote-workspace/state
6
15
  sctx.effect(() => webServer.register({
@@ -8,13 +17,14 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
8
17
  path: '/dsh-remote-workspace/state',
9
18
  handler: async (req, res) => {
10
19
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
20
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
21
  try {
12
22
  const profiles = store.getProfiles();
13
23
  const activeId = store.getActiveId() || (profiles[0]?.id || '');
14
24
  const tunnels = tunnelService.listActiveTunnels();
15
25
  const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
16
26
  const alerts = alertService ? alertService.getAlerts() : [];
17
- const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : p));
27
+ const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : maskProfile(p)));
18
28
  writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts });
19
29
  } catch (err) {
20
30
  writeJson(res, 500, { ok: false, error: err.message });
@@ -36,7 +46,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
36
46
  }
37
47
  sshService.invalidate(profile.id);
38
48
  await store.saveProfile(profile);
39
- const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : profile;
49
+ const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
40
50
  writeJson(res, 200, { ok: true, profile: sanitized });
41
51
  } catch (err) {
42
52
  writeJson(res, 500, { ok: false, error: err.message });
@@ -10,9 +10,9 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
10
10
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
11
  try {
12
12
  const { profileId, cols, rows } = await readBody(req);
13
- const profile = store.getProfile(profileId);
13
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
14
14
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
- const session = await sshService.createTerminalSession(profile, cols || 80, rows || 24);
15
+ const session = await sshService.createTerminalSession(profile, { cols: cols || 80, rows: rows || 24 });
16
16
  writeJson(res, 200, { ok: true, sessionId: session.id });
17
17
  } catch (err) {
18
18
  writeJson(res, 500, { ok: false, error: err.message });
@@ -50,6 +50,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
50
50
  path: '/dsh-remote-workspace/terminal/stream',
51
51
  handler: async (req, res) => {
52
52
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
53
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
53
54
  try {
54
55
  const url = new URL(req.url, 'http://localhost');
55
56
  const sessionId = url.searchParams.get('sessionId');
@@ -49,6 +49,7 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
49
49
  path: '/dsh-remote-workspace/tunnels/telemetry',
50
50
  handler: async (req, res) => {
51
51
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
52
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
52
53
  try {
53
54
  writeJson(res, 200, { ok: true, tunnels: tunnelService.listActiveTunnels() });
54
55
  } catch (err) {
@@ -313,6 +313,9 @@ export class SshService {
313
313
  }
314
314
 
315
315
  async createTerminalSession(profile, options = {}) {
316
+ if (typeof options === 'number') {
317
+ options = { cols: options, rows: arguments[2] };
318
+ }
316
319
  const conn = await this.getConnection(profile);
317
320
  const cols = options.cols || 80;
318
321
  const rows = options.rows || 24;
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
 
5
+ export function shellQuote(arg) {
6
+ if (typeof arg !== 'string') arg = String(arg || '');
7
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
8
+ }
9
+
5
10
  export class TarSyncService {
6
11
  /**
7
12
  * @param {import('./ssh-service.js').SshService} sshService
@@ -17,11 +22,13 @@ export class TarSyncService {
17
22
  */
18
23
  async pullTar(profile, remoteDir, localDir) {
19
24
  if (!profile) throw new Error('No profile provided');
25
+ if (!remoteDir || typeof remoteDir !== 'string') throw new Error('remoteDir must be a non-empty string');
20
26
  fs.mkdirSync(localDir, { recursive: true });
21
27
 
22
28
  const client = await this.sshService.getConnection(profile);
23
29
  return new Promise((resolve, reject) => {
24
- const remoteCmd = `tar --exclude='.git' --exclude='node_modules' -czf - -C "${remoteDir}" .`;
30
+ const safeRemoteDir = shellQuote(remoteDir);
31
+ const remoteCmd = `tar --exclude='.git' --exclude='node_modules' -czf - -C ${safeRemoteDir} .`;
25
32
  client.exec(remoteCmd, (err, stream) => {
26
33
  if (err) return reject(err);
27
34
 
@@ -52,14 +59,16 @@ export class TarSyncService {
52
59
  */
53
60
  async pushTar(profile, localDir, remoteDir) {
54
61
  if (!profile) throw new Error('No profile provided');
62
+ if (!remoteDir || typeof remoteDir !== 'string') throw new Error('remoteDir must be a non-empty string');
55
63
  if (!fs.existsSync(localDir)) throw new Error(`Local dir does not exist: ${localDir}`);
56
64
 
65
+ const safeRemoteDir = shellQuote(remoteDir);
57
66
  const client = await this.sshService.getConnection(profile);
58
67
  // Ensure remoteDir exists
59
- await this.sshService.exec(profile, `mkdir -p "${remoteDir}"`);
68
+ await this.sshService.exec(profile, `mkdir -p ${safeRemoteDir}`);
60
69
 
61
70
  return new Promise((resolve, reject) => {
62
- const remoteCmd = `tar -xzf - -C "${remoteDir}"`;
71
+ const remoteCmd = `tar -xzf - -C ${safeRemoteDir}`;
63
72
  client.exec(remoteCmd, (err, stream) => {
64
73
  if (err) return reject(err);
65
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
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",