@goodandready/dsh-remote-workspace 0.3.8 → 0.3.10

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.
@@ -0,0 +1,57 @@
1
+ export function filterClusterProfiles(profiles, filter = {}) {
2
+ const aliases = new Set((filter.aliases || []).map((item) => String(item)));
3
+ const tags = (filter.tags || []).map((item) => String(item).toLowerCase()).filter(Boolean);
4
+ const environment = String(filter.environment || '').trim().toLowerCase();
5
+ return (profiles || []).filter((profile) => {
6
+ if (aliases.size && !aliases.has(String(profile.id)) && !aliases.has(String(profile.name || ''))) return false;
7
+ if (environment && String(profile.environment || '').toLowerCase() !== environment) return false;
8
+ if (tags.length) {
9
+ const have = (Array.isArray(profile.tags) ? profile.tags : []).map((item) => String(item).toLowerCase());
10
+ if (!tags.every((tag) => have.includes(tag))) return false;
11
+ }
12
+ return true;
13
+ });
14
+ }
15
+
16
+ export async function runCluster(profiles, command, options = {}) {
17
+ const list = profiles || [];
18
+ if (!list.length) return [];
19
+ const limit = Math.max(1, Math.min(Number(options.maxWorkers) || 8, list.length));
20
+ const exec = options.exec;
21
+ const results = new Array(list.length);
22
+ let cursor = 0;
23
+ const worker = async () => {
24
+ while (cursor < list.length) {
25
+ const index = cursor;
26
+ cursor += 1;
27
+ const profile = list[index];
28
+ const started = Date.now();
29
+ try {
30
+ const res = await exec(profile, command);
31
+ results[index] = {
32
+ alias: profile.name || profile.id || '',
33
+ host: profile.host || '',
34
+ success: (res.code ?? 0) === 0,
35
+ exitCode: res.code ?? 0,
36
+ durationMs: Date.now() - started,
37
+ stdout: res.stdout || '',
38
+ stderr: res.stderr || '',
39
+ error: ''
40
+ };
41
+ } catch (err) {
42
+ results[index] = {
43
+ alias: profile.name || profile.id || '',
44
+ host: profile.host || '',
45
+ success: false,
46
+ exitCode: null,
47
+ durationMs: Date.now() - started,
48
+ stdout: '',
49
+ stderr: '',
50
+ error: err && err.message ? err.message : 'failed'
51
+ };
52
+ }
53
+ }
54
+ };
55
+ await Promise.all(Array.from({ length: limit }, () => worker()));
56
+ return results;
57
+ }
@@ -0,0 +1,24 @@
1
+ export function isRetryableConnectionError(err) {
2
+ if (!err || err.commandTimeout) return false;
3
+ if (err.stdout) return false;
4
+ const code = err.code || '';
5
+ if (['ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENOTFOUND', 'EPIPE'].includes(code)) return true;
6
+ return /connection reset|not connected|No response from server|socket hang up/i.test(String(err.message || ''));
7
+ }
8
+
9
+ export async function runWithReconnect(run, options = {}) {
10
+ const attempts = options.idempotent === false ? 1 : 3;
11
+ const wait = options.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
12
+ let lastError = null;
13
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
14
+ try {
15
+ return await run();
16
+ } catch (err) {
17
+ lastError = err;
18
+ if (attempt >= attempts || !isRetryableConnectionError(err)) throw err;
19
+ if (options.onRetry) options.onRetry(err, attempt);
20
+ await wait(50 * attempt);
21
+ }
22
+ }
23
+ throw lastError;
24
+ }
@@ -0,0 +1,56 @@
1
+ const MAX_BYTES = 512 * 1024 * 1024;
2
+
3
+ export function transferPercent(loaded, total) {
4
+ const done = Number(loaded) || 0;
5
+ const size = Number(total) || 0;
6
+ if (size <= 0) return 0;
7
+ return Math.max(0, Math.min(100, Math.round((done / size) * 100)));
8
+ }
9
+
10
+ export function attachmentName(filePath) {
11
+ const base = String(filePath || '').split('/').filter(Boolean).pop() || 'download';
12
+ const clean = base.replace(/[\r\n"\\]/g, '');
13
+ if (!clean || clean === '.' || clean === '..') return 'download';
14
+ return clean.slice(0, 180);
15
+ }
16
+
17
+ export function contentDisposition(filePath) {
18
+ const name = attachmentName(filePath);
19
+ const ascii = name.replace(/[^\x20-\x7E]/g, '_') || 'download';
20
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
21
+ }
22
+
23
+ export function pipeWithProgress(source, dest) {
24
+ return new Promise((resolve, reject) => {
25
+ let loaded = 0;
26
+ let settled = false;
27
+ const finish = (err) => {
28
+ if (settled) return;
29
+ settled = true;
30
+ if (err) reject(err);
31
+ else resolve(loaded);
32
+ };
33
+ source.on('data', (chunk) => {
34
+ loaded += chunk.length;
35
+ if (loaded > MAX_BYTES) {
36
+ const err = new Error('File exceeds the 512MB transfer limit');
37
+ err.code = 'LIMIT';
38
+ source.destroy();
39
+ dest.destroy();
40
+ finish(err);
41
+ }
42
+ });
43
+ source.on('error', finish);
44
+ dest.on('error', finish);
45
+ dest.on('finish', () => finish());
46
+ source.on('aborted', () => {
47
+ const err = new Error('Transfer aborted');
48
+ err.code = 'ABORTED';
49
+ dest.destroy();
50
+ finish(err);
51
+ });
52
+ source.pipe(dest);
53
+ });
54
+ }
55
+
56
+ export { MAX_BYTES };
@@ -0,0 +1,42 @@
1
+ const SECRET = ['password', 'privateKey', 'privateKeyPath', 'passphrase', 'agentPath', 'proxyCommand'];
2
+
3
+ /**
4
+ * One host row safe to place in a model context.
5
+ * @param {any} profile
6
+ */
7
+ export function toAgentHostRow(profile) {
8
+ const jump = Boolean(
9
+ (typeof profile?.jumpHostId === 'string' && profile.jumpHostId.trim())
10
+ || (Array.isArray(profile?.jumpHosts) && profile.jumpHosts.length)
11
+ );
12
+ const proxy = Boolean(typeof profile?.proxyCommand === 'string' && profile.proxyCommand.trim());
13
+ const access = [jump ? 'jump' : '', proxy ? 'proxy' : ''].filter(Boolean).join(',') || 'direct';
14
+ return {
15
+ alias: String(profile?.name || profile?.id || ''),
16
+ host: String(profile?.host || ''),
17
+ port: profile?.port || 22,
18
+ user: String(profile?.username || ''),
19
+ auth: String(profile?.authType || 'key'),
20
+ access,
21
+ env: String(profile?.environment || ''),
22
+ tags: Array.isArray(profile?.tags) ? profile.tags.map((item) => String(item)).join(',') : '',
23
+ description: String(profile?.description || '')
24
+ };
25
+ }
26
+
27
+ /**
28
+ * @param {any[]} profiles
29
+ */
30
+ export function renderHostTable(profiles) {
31
+ const header = 'alias | host | port | user | auth | jump/proxy | env | tags | description';
32
+ const lines = (profiles || []).map((profile) => {
33
+ const row = toAgentHostRow(profile);
34
+ return [row.alias, row.host, row.port, row.user, row.auth, row.access, row.env, row.tags, row.description].join(' | ');
35
+ });
36
+ return [header, ...lines].join('\n');
37
+ }
38
+
39
+ export function hostProjectionLeaks(value) {
40
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
41
+ return SECRET.some((key) => text.includes(key));
42
+ }
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
+ import { normalizeTerminalFont } from './terminal-font.js';
2
3
  import { VaultService } from './vault-service.js';
3
4
  import { SshService } from './ssh-service.js';
4
5
  import { RemoteFsService } from './remote-fs-service.js';
@@ -28,17 +29,25 @@ export const Config = z.object({
28
29
  port: z.number().default(22),
29
30
  username: z.string().default('root'),
30
31
  authType: z.string().default('key'),
32
+ agentPath: z.string().default(''),
31
33
  privateKeyPath: z.string().default(''),
32
34
  privateKey: z.string().default(''),
33
35
  passphrase: z.string().default(''),
34
36
  password: z.string().default(''),
35
37
  remoteWorkspace: z.string().default(''),
36
38
  localMirrorPath: z.string().default(''),
37
- jumpHostId: z.string().default('')
39
+ jumpHostId: z.string().default(''),
40
+ jumpHosts: z.array(z.string()).default([]),
41
+ proxyCommand: z.string().default(''),
42
+ environment: z.string().default(''),
43
+ tags: z.array(z.string()).default([]),
44
+ location: z.string().default(''),
45
+ description: z.string().default('')
38
46
  })).default([]).description('List of configured remote machines and workspaces.'),
39
47
  activeProfileId: z.string().default('').description('ID of active remote profile.'),
40
48
  autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.'),
41
- alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.')
49
+ alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.'),
50
+ terminalFontFamily: z.string().default('').description('CSS font family used by the terminal. Empty keeps the default monospace stack.')
42
51
  });
43
52
 
44
53
  export function apply(ctx, config = {}) {
@@ -48,11 +57,14 @@ export function apply(ctx, config = {}) {
48
57
  profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
49
58
  activeProfileId: cfg.activeProfileId || null,
50
59
  autoSyncActive: Boolean(cfg.autoSyncActive),
51
- alertMonitoringActive: Boolean(cfg.alertMonitoringActive)
60
+ alertMonitoringActive: Boolean(cfg.alertMonitoringActive),
61
+ terminalFontFamily: typeof cfg.terminalFontFamily === 'string' ? cfg.terminalFontFamily : ''
52
62
  };
53
63
 
54
64
  const vaultService = new VaultService();
55
65
  const sshService = new SshService(ctx, vaultService);
66
+ const idleTimer = setInterval(() => sshService.sweepIdle(), 60 * 1000);
67
+ idleTimer.unref?.();
56
68
  const remoteFs = new RemoteFsService(sshService);
57
69
  const mirrorSync = new MirrorSyncService(remoteFs, sshService);
58
70
  const tunnelService = new TunnelService(sshService);
@@ -111,6 +123,12 @@ export function apply(ctx, config = {}) {
111
123
  return p ? vaultService.hydrateProfile(p) : undefined;
112
124
  },
113
125
  isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
126
+ getTerminalFont() { return currentConfig.terminalFontFamily || ''; },
127
+ async setTerminalFont(fontFamily) {
128
+ currentConfig.terminalFontFamily = normalizeTerminalFont(fontFamily);
129
+ await persistConfig('terminal-font');
130
+ return currentConfig.terminalFontFamily;
131
+ },
114
132
  async setAutoSyncEnabled(enabled) {
115
133
  currentConfig.autoSyncActive = Boolean(enabled);
116
134
  const active = store.getActiveProfile();
@@ -264,7 +282,8 @@ export function apply(ctx, config = {}) {
264
282
  dockerService,
265
283
  diagnoseService,
266
284
  envService,
267
- tarSyncService
285
+ tarSyncService,
286
+ () => store.getProfiles()
268
287
  );
269
288
 
270
289
  // API Routes
@@ -286,6 +305,7 @@ export function apply(ctx, config = {}) {
286
305
 
287
306
  // Cleanup on unload
288
307
  ctx.on('dispose', () => {
308
+ clearInterval(idleTimer);
289
309
  alertService.stop();
290
310
  watcherService.stopAll();
291
311
  tunnelService.stopAll();
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Jump ids from an explicit list, or from a comma-separated jumpHostId.
3
+ * @param {any} profile
4
+ * @returns {string[]}
5
+ */
6
+ export function resolveJumpHops(profile) {
7
+ if (Array.isArray(profile?.jumpHosts) && profile.jumpHosts.length) {
8
+ return profile.jumpHosts.map((item) => String(item).trim()).filter(Boolean);
9
+ }
10
+ if (typeof profile?.jumpHostId === 'string' && profile.jumpHostId.trim()) {
11
+ return profile.jumpHostId.split(',').map((item) => item.trim()).filter(Boolean);
12
+ }
13
+ return [];
14
+ }
15
+
16
+ /**
17
+ * @param {any} client
18
+ * @param {string} host
19
+ * @param {number} port
20
+ */
21
+ export function forwardOut(client, host, port) {
22
+ return new Promise((resolve, reject) => {
23
+ client.forwardOut('127.0.0.1', 0, host, port || 22, (err, stream) => {
24
+ if (err) reject(err);
25
+ else resolve(stream);
26
+ });
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Connect through bastions in order. Only clients created after the first hop
32
+ * are released with the target; the first hop stays in the shared pool.
33
+ */
34
+ export async function openJumpChain(target, hopProfiles, deps) {
35
+ if (!hopProfiles.length) throw new Error('Jump chain is empty');
36
+ const intermediates = [];
37
+ const release = () => {
38
+ for (const extra of intermediates) {
39
+ try { extra.end(); } catch { /* already closed */ }
40
+ }
41
+ };
42
+ try {
43
+ let client = await deps.connectPooled(hopProfiles[0]);
44
+ for (let i = 1; i < hopProfiles.length; i += 1) {
45
+ const hop = hopProfiles[i];
46
+ const stream = await forwardOut(client, hop.host, hop.port || 22);
47
+ client = await deps.connectDedicated(hop, stream);
48
+ intermediates.push(client);
49
+ }
50
+ const sock = await forwardOut(client, target.host, target.port || 22);
51
+ return { sock, release };
52
+ } catch (err) {
53
+ release();
54
+ throw err;
55
+ }
56
+ }
@@ -0,0 +1,5 @@
1
+ export function shouldPoll({ hidden = false, inFlight = false } = {}) {
2
+ if (hidden) return false;
3
+ if (inFlight) return false;
4
+ return true;
5
+ }
@@ -0,0 +1,60 @@
1
+ export function normalizeTags(value) {
2
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
3
+ return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
4
+ }
5
+
6
+ function bucket(map, key, profile) {
7
+ const name = key || 'ungrouped';
8
+ if (!map.has(name)) map.set(name, []);
9
+ map.get(name).push(profile);
10
+ }
11
+
12
+ export function groupProfiles(profiles, mode) {
13
+ const list = Array.isArray(profiles) ? profiles : [];
14
+ if (mode === 'environment') {
15
+ const map = new Map();
16
+ for (const profile of list) bucket(map, String(profile.environment || '').trim(), profile);
17
+ return [...map.entries()].map(([key, items]) => ({ key, items }));
18
+ }
19
+ if (mode === 'tag') {
20
+ const map = new Map();
21
+ for (const profile of list) {
22
+ const tags = normalizeTags(profile.tags);
23
+ if (!tags.length) bucket(map, '', profile);
24
+ else for (const tag of tags) bucket(map, tag, profile);
25
+ }
26
+ return [...map.entries()].map(([key, items]) => ({ key, items }));
27
+ }
28
+ return [{ key: 'all', items: list }];
29
+ }
30
+
31
+ export async function testProfileGroup(profiles, testOne) {
32
+ const results = [];
33
+ const queue = [...(profiles || [])];
34
+ const workers = Array.from({ length: Math.min(4, Math.max(queue.length, 1)) }, async () => {
35
+ while (queue.length) {
36
+ const profile = queue.shift();
37
+ if (!profile) return;
38
+ const started = Date.now();
39
+ try {
40
+ const result = await testOne(profile);
41
+ results.push({
42
+ id: profile.id,
43
+ name: profile.name || profile.host,
44
+ ok: true,
45
+ latencyMs: result.latencyMs ?? result.latency ?? (Date.now() - started)
46
+ });
47
+ } catch (err) {
48
+ results.push({
49
+ id: profile.id,
50
+ name: profile.name || profile.host,
51
+ ok: false,
52
+ error: err && err.message ? err.message : 'Connection failed'
53
+ });
54
+ }
55
+ }
56
+ });
57
+ if (!queue.length) return results;
58
+ await Promise.all(workers);
59
+ return results;
60
+ }
@@ -0,0 +1,53 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Duplex } from 'node:stream';
3
+
4
+ const TOKEN = /%[%hprn]/g;
5
+
6
+ /**
7
+ * Expand OpenSSH ProxyCommand tokens. %% stays a literal percent.
8
+ * @param {string} template
9
+ * @param {{ host?: string, port?: number|string, user?: string, alias?: string }} tokens
10
+ */
11
+ export function expandProxyCommand(template, tokens = {}) {
12
+ const map = {
13
+ '%h': String(tokens.host ?? ''),
14
+ '%p': String(tokens.port ?? ''),
15
+ '%r': String(tokens.user ?? ''),
16
+ '%n': String(tokens.alias ?? ''),
17
+ '%%': '%'
18
+ };
19
+ return String(template ?? '').replace(TOKEN, (token) => (token in map ? map[token] : token));
20
+ }
21
+
22
+ /**
23
+ * Run a ProxyCommand and expose its stdio as a duplex socket for ssh2.
24
+ * POSIX uses `$SHELL -c`; Windows uses `cmd.exe /d /s /c`.
25
+ * @param {string} command
26
+ */
27
+ export function openProxyCommand(command) {
28
+ const windows = process.platform === 'win32';
29
+ const shell = windows ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
30
+ const args = windows ? ['/d', '/s', '/c', command] : ['-c', command];
31
+ const child = spawn(shell, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
32
+ const sock = Duplex.from({ writable: child.stdin, readable: child.stdout });
33
+ let stopped = false;
34
+
35
+ const stop = () => {
36
+ if (stopped) return;
37
+ stopped = true;
38
+ if (child.exitCode !== null || child.signalCode) return;
39
+ child.kill('SIGTERM');
40
+ const timer = setTimeout(() => {
41
+ if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
42
+ }, 2000);
43
+ timer.unref?.();
44
+ child.once('exit', () => clearTimeout(timer));
45
+ };
46
+
47
+ sock.on('close', stop);
48
+ child.on('error', (err) => sock.destroy(err));
49
+ child.on('exit', () => {
50
+ if (!sock.destroyed) sock.destroy();
51
+ });
52
+ return { sock, child, stop };
53
+ }
@@ -117,6 +117,31 @@ export class RemoteFsService {
117
117
  return sftp.createReadStream(remotePath);
118
118
  }
119
119
 
120
+ async beginUpload(profile, remotePath) {
121
+ const sftp = await this.sshService.getSftp(profile);
122
+ const tempPath = `${remotePath}.dsh-tmp.${Date.now()}`;
123
+ const stream = sftp.createWriteStream(tempPath);
124
+ return {
125
+ stream,
126
+ commit() {
127
+ return new Promise((resolve, reject) => {
128
+ sftp.rename(tempPath, remotePath, (err) => {
129
+ if (err) {
130
+ sftp.unlink(tempPath, () => {});
131
+ return reject(err);
132
+ }
133
+ resolve();
134
+ });
135
+ });
136
+ },
137
+ abort() {
138
+ return new Promise((resolve) => {
139
+ sftp.unlink(tempPath, () => resolve());
140
+ });
141
+ }
142
+ };
143
+ }
144
+
120
145
  /**
121
146
  * Atomic file write using temp file and rename
122
147
  */
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
 
3
3
  export function registerDockerRoutes(sctx, webServer, { dockerService, healthService, store }) {
4
4
  // 21. GET /dsh-remote-workspace/health
@@ -7,7 +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
+ if (!authorizeRequest(sctx, req, res)) return;
11
11
  try {
12
12
  const url = new URL(req.url, 'http://localhost');
13
13
  const profileId = url.searchParams.get('profileId');
@@ -17,7 +17,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
17
17
  const health = await healthService.getHealth(profile);
18
18
  writeJson(res, 200, { ok: true, health });
19
19
  } catch (err) {
20
- writeJson(res, 500, { ok: false, error: err.message });
20
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
21
21
  }
22
22
  }
23
23
  }), 'dsh-remote-workspace: /health');
@@ -28,7 +28,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
28
28
  path: '/dsh-remote-workspace/docker/list',
29
29
  handler: async (req, res) => {
30
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' });
31
+ if (!authorizeRequest(sctx, req, res)) return;
32
32
  try {
33
33
  const url = new URL(req.url, 'http://localhost');
34
34
  const profileId = url.searchParams.get('profileId');
@@ -38,7 +38,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
38
38
  const result = await dockerService.listContainers(profile, true);
39
39
  writeJson(res, 200, { ok: true, ...result });
40
40
  } catch (err) {
41
- writeJson(res, 500, { ok: false, error: err.message });
41
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
42
42
  }
43
43
  }
44
44
  }), 'dsh-remote-workspace: /docker/list');
@@ -49,7 +49,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
49
49
  path: '/dsh-remote-workspace/docker/action',
50
50
  handler: async (req, res) => {
51
51
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
52
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
52
+ if (!authorizeRequest(sctx, req, res)) return;
53
53
  try {
54
54
  const { profileId, containerId, action } = await readBody(req);
55
55
  const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
@@ -59,7 +59,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
59
59
  const result = await dockerService.containerAction(profile, containerId, action);
60
60
  writeJson(res, 200, { ok: result.success, ...result });
61
61
  } catch (err) {
62
- writeJson(res, 500, { ok: false, error: err.message });
62
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
63
63
  }
64
64
  }
65
65
  }), 'dsh-remote-workspace: /docker/action');
@@ -70,7 +70,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
70
70
  path: '/dsh-remote-workspace/docker/logs',
71
71
  handler: async (req, res) => {
72
72
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
73
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
73
+ if (!authorizeRequest(sctx, req, res)) return;
74
74
  try {
75
75
  const { profileId, containerId, tail } = await readBody(req);
76
76
  const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
@@ -80,7 +80,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
80
80
  const result = await dockerService.containerLogs(profile, containerId, tail || 100);
81
81
  writeJson(res, 200, { ok: result.success, ...result });
82
82
  } catch (err) {
83
- writeJson(res, 500, { ok: false, error: err.message });
83
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
84
84
  }
85
85
  }
86
86
  }), 'dsh-remote-workspace: /docker/logs');
package/lib/routes/env.js CHANGED
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
 
3
3
  export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store }) {
4
4
  // 10. POST /dsh-remote-workspace/env/view
@@ -7,7 +7,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
7
7
  path: '/dsh-remote-workspace/env/view',
8
8
  handler: async (req, res) => {
9
9
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
10
+ if (!authorizeRequest(sctx, req, res)) return;
11
11
  try {
12
12
  const { profileId, remotePath } = await readBody(req);
13
13
  const profile = store.getProfile(profileId);
@@ -15,7 +15,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
15
15
  const result = await envService.readRemoteEnv(sshService, profile, remotePath);
16
16
  writeJson(res, 200, { ok: true, ...result });
17
17
  } catch (err) {
18
- writeJson(res, 500, { ok: false, error: err.message });
18
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
19
19
  }
20
20
  }
21
21
  }), 'dsh-remote-workspace: /env/view');
@@ -26,7 +26,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
26
26
  path: '/dsh-remote-workspace/env/save',
27
27
  handler: async (req, res) => {
28
28
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
29
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
29
+ if (!authorizeRequest(sctx, req, res)) return;
30
30
  try {
31
31
  const { profileId, remotePath, content } = await readBody(req);
32
32
  const profile = store.getProfile(profileId);
@@ -34,7 +34,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
34
34
  const result = await envService.saveRemoteEnv(sshService, profile, remotePath, content);
35
35
  writeJson(res, 200, { ok: true, ...result });
36
36
  } catch (err) {
37
- writeJson(res, 500, { ok: false, error: err.message });
37
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
38
38
  }
39
39
  }
40
40
  }), 'dsh-remote-workspace: /env/save');
@@ -45,7 +45,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
45
45
  path: '/dsh-remote-workspace/diagnose',
46
46
  handler: async (req, res) => {
47
47
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
48
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
48
+ if (!authorizeRequest(sctx, req, res)) return;
49
49
  try {
50
50
  const { profileId, checks } = await readBody(req);
51
51
  const profile = store.getProfile(profileId);
@@ -53,7 +53,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
53
53
  const results = await diagnoseService.diagnose(sshService, profile, checks);
54
54
  writeJson(res, 200, { ok: true, results });
55
55
  } catch (err) {
56
- writeJson(res, 500, { ok: false, error: err.message });
56
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
57
57
  }
58
58
  }
59
59
  }), 'dsh-remote-workspace: /diagnose');