@goodandready/dsh-remote-workspace 0.3.9 → 0.3.11

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/README.md CHANGED
@@ -300,3 +300,15 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
300
300
  - **`📥 Import from ~/.ssh/config`**: One-click import of hosts, keys, and ProxyJump configurations directly into the encrypted `.env` vault.
301
301
  - **`🗃️ Remote Environment Manager (remote_env)`**: Inspect and atomically modify remote `.env` key-values with password masking and structural preservation.
302
302
  - **`📡 Background Anomaly Alerts`**: Proactive monitoring of disk (<10% free), memory (<5%), and restarting Docker containers via Cordis event bus (`remote-workspace/alert`).
303
+
304
+
305
+ ### 14. DSH 0.2.0-rc.1 Alignment & Reliability Hardening (v0.3.11)
306
+ - **`⚙️ DSH 0.2.0-rc.1 Settings Forms`**: Fully volatile `Config` schema leaves and dynamic `whileServed` client registration matching the DSH 0.2.0-rc.1 settings contract (#76).
307
+ - **`🛠️ Environment & Diagnostics Endpoints`**: Fixed service method bindings for `.env` inspection, atomic variable updating, and host diagnostics (#77).
308
+ - **`⏱️ Terminal Session Reaper`**: Automatic background sweeping of idle and abandoned PTY sessions to prevent remote connection exhaustion (#78).
309
+ - **`🛡️ Tunnel Lifecycle Hardening`**: Mutual socket/stream teardown on network disconnect and accurate telemetry counter tracking (#79).
310
+ - **`📦 Tar Stream Resilience`**: Uncaught exception protection for local `tar` extraction and zombie process termination (#80).
311
+ - **`🔒 Shell Command Sanitization`**: Strict numeric validation and POSIX single-quoted escaping across Docker, diagnostics, and remote filesystem operations (#81).
312
+ - **`💾 Binary Stream Sync`**: Raw buffer hashing in `MirrorSyncService.pull` preventing false conflicts and hash corruption on binary files (#82).
313
+ - **`👀 Watcher Error Recovery`**: Automatic `fs.watch` error recovery (`ENOSPC` handling) and per-profile concurrency locking (#83).
314
+ - **`⚡ Tab Visibility Polling Gate`**: Client-side background poll suppression when browser tab is hidden (#84).
package/README.ru.md CHANGED
@@ -300,3 +300,15 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
300
300
  - **`📥 Импорт из ~/.ssh/config`**: Автоматическое добавление хостов, ключей и bastion-узлов в зашифрованный `.env` vault.
301
301
  - **`🗃️ Менеджер переменных окружения (remote_env)`**: Атомарное изменение и безопасный просмотр удалённых `.env` файлов с маскированием паролей.
302
302
  - **`📡 Мониторинг аномалий`**: Оповещение о переполнении диска (<10%), дефиците ОЗУ (<5%) и циклических перезапусках контейнеров через шину событий Cordis (`remote-workspace/alert`).
303
+
304
+
305
+ ### 14. Совместимость с DSH 0.2.0-rc.1 и комплексное повышение надёжности (v0.3.11)
306
+ - **`⚙️ Формы настроек DSH 0.2.0-rc.1`**: Volatile-свойства схемы `Config` и динамическая регистрация `whileServed` в клиенте (#76).
307
+ - **`🛠️ Исправление маршрутов .env и диагностики`**: Восстановлена работоспособность эндпоинтов просмотра/сохранения `.env` и запуска системной диагностики (#77).
308
+ - **`⏱️ Сборщик простаивающих терминальных сессий`**: Автоматическое завершение брошенных PTY-сессий и освобождение выделенных SSH-каналов (#78).
309
+ - **`🛡️ Защита жизненного цикла туннелей`**: Взаимное закрытие сокетов и потоков при обрыве связи, точный счётчик активных подключений (#79).
310
+ - **`📦 Устойчивость tar-синхронизации`**: Перехват системных ошибок spawn утилиты `tar` и уничтожение зомби-процессов (#80).
311
+ - **`🔒 Экранирование команд shell`**: Строгая валидация числовых параметров и POSIX-экранирование одинарными кавычками в Docker, диагностике и файловых операциях (#81).
312
+ - **`💾 Синхронизация бинарных файлов`**: Чтение сырых Buffer в `MirrorSyncService.pull` без искажений кодировки UTF-8 (#82).
313
+ - **`👀 Устойчивость файлового вотчера`**: Обработка ошибок `fs.watch` (`ENOSPC`) и блокировка параллельных наложений синхронизации (#83).
314
+ - **`⚡ Гейт поллинга по видимости вкладки`**: Отключение фоновых опросов при скрытой вкладке браузера (#84).
package/README.zh.md CHANGED
@@ -300,3 +300,15 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
300
300
  - **`📥 导入 ~/.ssh/config`**:快速解析本机已有主机、私钥及跳板机配置至加密 `.env` 保管库。
301
301
  - **`🗃️ 远程环境变量管理 (remote_env)`**:支持远程 `.env` 的安全预览与原子化变量修改,自动遮蔽敏感密码。
302
302
  - **`📡 异常告警监控`**:当磁盘低于 10%、内存不足 5% 或容器异常重启时,主动触发 Cordis 事件总线 (`remote-workspace/alert`) 告警。
303
+
304
+
305
+ ### 14. DSH 0.2.0-rc.1 适配与全面可靠性加固 (v0.3.11)
306
+ - **`⚙️ DSH 0.2.0-rc.1 设置表单适配`**: `Config` 字段 volatile 标记及基于 `whileServed` 的客户端注册 (#76)。
307
+ - **`🛠️ 修复环境变量与诊断路由`**: 修复 `.env` 查看/保存及系统诊断的服务方法调用与参数签名 (#77)。
308
+ - **`⏱️ 终端会话自动清理`**: 自动回收无活跃连接且超时的后台 PTY 终端会话与 SSH 连接 (#78)。
309
+ - **`🛡️ 端口转发隧道生命周期加固`**: 连接断开时双向销毁流与套接字,精准维护活跃连接计数 (#79)。
310
+ - **`📦 Tar 流式同步容错增强`**: 捕获本地 `tar` 进程错误并及时清理僵尸进程 (#80)。
311
+ - **`🔒 Shell 命令注入防范与转义`**: 针对 Docker、系统诊断与远程文件系统操作实施严格数值校验与 POSIX 单引号转义 (#81)。
312
+ - **`💾 二进制文件同步哈希修复`**: `MirrorSyncService.pull` 采用原始 Buffer 计算哈希,避免 UTF-8 编码失真 (#82)。
313
+ - **`👀 文件监听器容错与并发锁`**: 监听器异常处理(防 ENOSPC 崩溃)及防止同一主机重复触发并发同步 (#83)。
314
+ - **`⚡ 标签页可见性轮询控制`**: 浏览器标签页隐藏时自动挂起状态轮询 (#84)。
package/lib/client.js CHANGED
@@ -2280,6 +2280,12 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
2280
2280
  )
2281
2281
  }
2282
2282
 
2283
+ function shouldPoll({ hidden = false, inFlight = false } = {}) {
2284
+ if (hidden) return false;
2285
+ if (inFlight) return false;
2286
+ return true;
2287
+ }
2288
+
2283
2289
  function SettingsView(props) {
2284
2290
  const [activeTab, setActiveTab] = React.useState('profiles')
2285
2291
  const [healthData, setHealthData] = React.useState(null)
@@ -2330,7 +2336,7 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
2330
2336
 
2331
2337
  const loadState = async (force) => {
2332
2338
  const hidden = typeof document !== 'undefined' && document.hidden
2333
- if (!force && (hidden || loadInFlight.current)) return
2339
+ if (!force && !shouldPoll({ hidden, inFlight: loadInFlight.current })) return
2334
2340
  loadInFlight.current = true
2335
2341
  try {
2336
2342
  const res = await fetch('/dsh-remote-workspace/state')
@@ -2876,6 +2882,10 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
2876
2882
  const t = props.t || (props.locale === 'zh' ? makeT(zh, en) : makeT(en, zh))
2877
2883
 
2878
2884
  const [conn, setConn] = React.useState('loading')
2885
+ const configForm = (props && props.ctx && props.ctx.configForms && typeof props.ctx.configForms.get === 'function')
2886
+ ? props.ctx.configForms.get(NS)
2887
+ : null;
2888
+
2879
2889
 
2880
2890
  React.useEffect(() => {
2881
2891
  ensureCss()
@@ -3236,23 +3246,20 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
3236
3246
  }
3237
3247
  }
3238
3248
 
3239
- if (ctx.slots && typeof ctx.slots.inject === 'function') {
3240
- // Plugin-list seat (plugins.item) first: the seat the current core
3241
- // (0.1.6-alpha.2) renders as the plugin's own page with its configuration.
3242
- // The label getter runs during page render. A locale-service lookup there
3243
- // crashes the client batch, so the title is read from the dictionaries
3244
- // already loaded in this bundle. English is the result when the active
3245
- // language cannot be read.
3246
- ctx.slots.inject('plugins.item', () =>
3247
- ctx.slots.register(
3249
+ const registerPluginSlots = (c) => {
3250
+ if (!c.slots || typeof c.slots.inject !== 'function') return;
3251
+
3252
+ // Plugin-list seat (plugins.item) first
3253
+ c.slots.inject('plugins.item', () =>
3254
+ c.slots.register(
3248
3255
  {
3249
3256
  name: 'plugins.item',
3250
3257
  id: ROW_ID,
3251
3258
  order: 60,
3252
3259
  label: () => {
3253
3260
  try {
3254
- const lang = ctx.locale && typeof ctx.locale.getLocale === 'function'
3255
- ? ctx.locale.getLocale()
3261
+ const lang = c.locale && typeof c.locale.getLocale === 'function'
3262
+ ? c.locale.getLocale()
3256
3263
  : ''
3257
3264
  if (typeof lang === 'string' && (lang === 'zh' || lang.indexOf('zh') === 0)) return zh.title
3258
3265
  } catch (err) {
@@ -3261,39 +3268,27 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
3261
3268
  return en.title
3262
3269
  },
3263
3270
  locale: NS,
3264
- inject: () => ({ ctx })
3271
+ inject: () => ({ ctx: c })
3265
3272
  },
3266
- (props) => React.createElement(PluginCard, Object.assign({}, props, { ctx }))
3273
+ (props) => React.createElement(PluginCard, Object.assign({}, props, { ctx: c }))
3267
3274
  )
3268
- )
3275
+ );
3269
3276
 
3270
- // Row seat and the legacy seat stay as fallbacks.
3271
- ctx.slots.inject('plugins.row.config', () =>
3272
- ctx.slots.register(
3277
+ // Row seat (plugins.row.config)
3278
+ c.slots.inject('plugins.row.config', () =>
3279
+ c.slots.register(
3273
3280
  {
3274
3281
  name: 'plugins.row.config',
3275
3282
  key: ROW_CONFIG_KEY,
3276
3283
  locale: NS,
3277
- inject: () => ({ ctx })
3278
- },
3279
- (props) => React.createElement(PluginCard, Object.assign({}, props, { ctx }))
3280
- )
3281
- )
3282
-
3283
- ctx.slots.inject('settings.plugin.item', () =>
3284
- ctx.slots.register(
3285
- {
3286
- name: 'settings.plugin.item',
3287
- key: NS,
3288
- locale: NS,
3289
- inject: () => ({ ctx })
3284
+ inject: () => ({ ctx: c })
3290
3285
  },
3291
- (props) => React.createElement(PluginCard, Object.assign({}, props, { ctx }))
3286
+ (props) => React.createElement(PluginCard, Object.assign({}, props, { ctx: c }))
3292
3287
  )
3293
- )
3288
+ );
3294
3289
 
3295
- ctx.slots.inject('conversation.session.header.utilities', () =>
3296
- ctx.slots.register(
3290
+ c.slots.inject('conversation.session.header.utilities', () =>
3291
+ c.slots.register(
3297
3292
  {
3298
3293
  name: 'conversation.session.header.utilities',
3299
3294
  id: '@goodandready/dsh-remote-workspace',
@@ -3301,11 +3296,26 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
3301
3296
  },
3302
3297
  (props) => React.createElement(RemoteWorkspaceChip, props)
3303
3298
  )
3304
- )
3299
+ );
3300
+ };
3301
+
3302
+ if (ctx.inject && typeof ctx.inject === 'function') {
3303
+ ctx.inject(['configForms'], (c) => {
3304
+ const forms = c.configForms;
3305
+ if (forms && typeof forms.whileServed === 'function') {
3306
+ c.effect(() => forms.whileServed([NS], () => {
3307
+ registerPluginSlots(c);
3308
+ }), 'dsh-remote-workspace: plugins card');
3309
+ } else {
3310
+ registerPluginSlots(c);
3311
+ }
3312
+ });
3313
+ } else {
3314
+ registerPluginSlots(ctx);
3305
3315
  }
3306
3316
  }
3307
3317
 
3308
- module.exports = { apply, inject: ['slots', 'locale'] }
3318
+ module.exports = { apply, inject: ['slots', 'locale', 'configForms'] }
3309
3319
  return module.exports
3310
3320
 
3311
3321
  },
@@ -18,13 +18,14 @@ export class DiagnoseService {
18
18
  let cmd = '';
19
19
  switch (category) {
20
20
  case 'ports': {
21
- const filter = target ? `grep ':${target} '` : 'cat';
21
+ const safePort = target ? String(target).replace(/[^0-9]/g, '') : '';
22
+ const filter = safePort ? `grep ':${safePort} '` : 'cat';
22
23
  cmd = `(ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null || lsof -i -P -n 2>/dev/null) | ${filter} | head -n 40`;
23
24
  break;
24
25
  }
25
26
  case 'disk': {
26
- const path = target || '/';
27
- cmd = `df -h ${path} 2>/dev/null; echo "---TOP 10 DIRS---"; du -ahx ${path} 2>/dev/null | sort -rh | head -n 10`;
27
+ const safePath = target ? "'" + String(target).replace(/'/g, "'\\''") + "'" : '/';
28
+ cmd = `df -h ${safePath} 2>/dev/null; echo "---TOP 10 DIRS---"; du -ahx ${safePath} 2>/dev/null | sort -rh | head -n 10`;
28
29
  break;
29
30
  }
30
31
  case 'memory': {
@@ -55,7 +55,8 @@ export class DockerService {
55
55
  if (!validActions.includes(action)) {
56
56
  throw new Error(`Invalid container action: ${action}`);
57
57
  }
58
- const cmd = `docker ${action} ${JSON.stringify(containerId)} 2>/dev/null || podman ${action} ${JSON.stringify(containerId)}`;
58
+ const safeContainerId = "'" + String(containerId || '').replace(/'/g, "'\\''") + "'";
59
+ const cmd = `docker ${action} ${safeContainerId} 2>/dev/null || podman ${action} ${safeContainerId}`;
59
60
  const res = await this.sshService.exec(profile, cmd);
60
61
  return {
61
62
  success: res.code === 0,
@@ -68,7 +69,9 @@ export class DockerService {
68
69
  * Fetch container logs
69
70
  */
70
71
  async containerLogs(profile, containerId, tail = 100) {
71
- const cmd = `docker logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1 || podman logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1`;
72
+ const safeTail = Math.max(1, Math.min(10000, parseInt(tail, 10) || 100));
73
+ const safeContainerId = "'" + String(containerId || '').replace(/'/g, "'\\''") + "'";
74
+ const cmd = `docker logs --tail ${safeTail} ${safeContainerId} 2>&1 || podman logs --tail ${safeTail} ${safeContainerId} 2>&1`;
72
75
  const res = await this.sshService.exec(profile, cmd);
73
76
  return {
74
77
  success: res.code === 0,
package/lib/index.js CHANGED
@@ -21,6 +21,17 @@ const NS = 'dsh-remote-workspace';
21
21
 
22
22
  export const inject = ['tools'];
23
23
 
24
+ // DSH SettingsForms describe only publishes fields marked volatile. Without them the
25
+ // namespace is omitted and the configuration page stays unavailable.
26
+ if (typeof z.prototype?.volatile !== 'function') {
27
+ if (z.prototype) {
28
+ z.prototype.volatile = function volatile() {
29
+ if (this.meta && this.meta.volatile) return this;
30
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this;
31
+ };
32
+ }
33
+ }
34
+
24
35
  export const Config = z.object({
25
36
  profiles: z.array(z.object({
26
37
  id: z.string().default(''),
@@ -43,11 +54,11 @@ export const Config = z.object({
43
54
  tags: z.array(z.string()).default([]),
44
55
  location: z.string().default(''),
45
56
  description: z.string().default('')
46
- })).default([]).description('List of configured remote machines and workspaces.'),
47
- activeProfileId: z.string().default('').description('ID of active remote profile.'),
48
- autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.'),
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.')
57
+ })).default([]).description('List of configured remote machines and workspaces.').volatile(),
58
+ activeProfileId: z.string().default('').description('ID of active remote profile.').volatile(),
59
+ autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.').volatile(),
60
+ alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.').volatile(),
61
+ terminalFontFamily: z.string().default('').description('CSS font family used by the terminal. Empty keeps the default monospace stack.').volatile()
51
62
  });
52
63
 
53
64
  export function apply(ctx, config = {}) {
@@ -223,19 +234,34 @@ export function apply(ctx, config = {}) {
223
234
  if (active) watcherService.start(active, mirrorSync);
224
235
  }
225
236
 
237
+
238
+
226
239
  // Register settings namespace if settings service is available
227
240
  ctx.inject(['settings'], (sctx) => {
228
241
  const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
229
- if (!settingsService || typeof settingsService.register !== 'function') return;
242
+ if (!settingsService) return;
230
243
 
231
- const scope = settingsService.register(NS, Config, { base: cfg });
232
- settingsApi = scope;
233
- const snap = scope.get ? scope.get() : null;
234
- if (snap) {
235
- if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
236
- if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
237
- if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
238
- if (snap.alertMonitoringActive !== undefined) currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
244
+ if (typeof settingsService.register === 'function') {
245
+ const scope = settingsService.register(NS, Config, { base: cfg });
246
+ settingsApi = scope;
247
+ const snap = scope.get ? scope.get() : null;
248
+ if (snap) {
249
+ if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
250
+ if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
251
+ if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
252
+ if (snap.alertMonitoringActive !== undefined) currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
253
+ }
254
+ } else if (typeof settingsService.configure === 'function') {
255
+ try {
256
+ settingsService.configure({ auto: false }, ctx.fiber);
257
+ } catch (err) { /* best-effort */ }
258
+ settingsApi = {
259
+ replace: async (newConfig) => {
260
+ if (typeof settingsService.replace === 'function') {
261
+ await settingsService.replace(NS, newConfig);
262
+ }
263
+ }
264
+ };
239
265
  }
240
266
  sctx.effect(() => () => {
241
267
  settingsApi = null;
@@ -305,6 +331,7 @@ export function apply(ctx, config = {}) {
305
331
 
306
332
  // Cleanup on unload
307
333
  ctx.on('dispose', () => {
334
+ clearInterval(idleTimer);
308
335
  alertService.stop();
309
336
  watcherService.stopAll();
310
337
  tunnelService.stopAll();
@@ -117,7 +117,7 @@ export class MirrorSyncService {
117
117
  let localContent = null;
118
118
  let localHash = null;
119
119
  if (localExists) {
120
- localContent = fs.readFileSync(localFullPath, 'utf8');
120
+ localContent = fs.readFileSync(localFullPath);
121
121
  localHash = this.computeHash(localContent);
122
122
  }
123
123
 
@@ -1,3 +1,8 @@
1
+ function shellQuote(arg) {
2
+ if (typeof arg !== 'string') arg = String(arg || '');
3
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
4
+ }
5
+
1
6
  import path from 'node:path';
2
7
 
3
8
  export class RemoteFsService {
@@ -173,7 +178,7 @@ export class RemoteFsService {
173
178
  * Create remote directory recursively
174
179
  */
175
180
  async mkdir(profile, remotePath) {
176
- const cmd = `mkdir -p ${JSON.stringify(remotePath)}`;
181
+ const cmd = `mkdir -p ${shellQuote(remotePath)}`;
177
182
  const res = await this.sshService.exec(profile, cmd);
178
183
  if (res.code !== 0) {
179
184
  throw new Error(`Failed to create remote dir ${remotePath}: ${res.stderr}`);
@@ -186,7 +191,7 @@ export class RemoteFsService {
186
191
  * Remove remote file or directory
187
192
  */
188
193
  async remove(profile, remotePath, recursive = false) {
189
- const cmd = recursive ? `rm -rf ${JSON.stringify(remotePath)}` : `rm -f ${JSON.stringify(remotePath)}`;
194
+ const cmd = recursive ? `rm -rf ${shellQuote(remotePath)}` : `rm -f ${shellQuote(remotePath)}`;
190
195
  const res = await this.sshService.exec(profile, cmd);
191
196
  if (res.code !== 0) {
192
197
  throw new Error(`Failed to remove remote path ${remotePath}: ${res.stderr}`);
@@ -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,21 +1,22 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, authorizeRequest } from './helpers.js';
2
2
 
3
- export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store }) {
3
+ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store, remoteFs }) {
4
4
  // 10. POST /dsh-remote-workspace/env/view
5
5
  sctx.effect(() => webServer.register({
6
6
  kind: 'exact',
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);
14
14
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
- const result = await envService.readRemoteEnv(sshService, profile, remotePath);
15
+ const targetPath = remotePath || (profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env');
16
+ const result = await envService.readEnv(profile, targetPath);
16
17
  writeJson(res, 200, { ok: true, ...result });
17
18
  } catch (err) {
18
- writeJson(res, 500, { ok: false, error: err.message });
19
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
19
20
  }
20
21
  }
21
22
  }), 'dsh-remote-workspace: /env/view');
@@ -26,15 +27,26 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
26
27
  path: '/dsh-remote-workspace/env/save',
27
28
  handler: async (req, res) => {
28
29
  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' });
30
+ if (!authorizeRequest(sctx, req, res)) return;
30
31
  try {
31
- const { profileId, remotePath, content } = await readBody(req);
32
+ const { profileId, remotePath, content, key, value } = await readBody(req);
32
33
  const profile = store.getProfile(profileId);
33
34
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
34
- const result = await envService.saveRemoteEnv(sshService, profile, remotePath, content);
35
+ const targetPath = remotePath || (profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env');
36
+ let result;
37
+ if (key) {
38
+ result = await envService.setEnvVar(profile, targetPath, key, value || '');
39
+ } else if (content !== undefined) {
40
+ if (remoteFs && typeof remoteFs.writeFile === 'function') {
41
+ await remoteFs.writeFile(profile, targetPath, content);
42
+ }
43
+ result = { ok: true, filePath: targetPath };
44
+ } else {
45
+ return writeJson(res, 400, { ok: false, error: 'Either key/value or content is required' });
46
+ }
35
47
  writeJson(res, 200, { ok: true, ...result });
36
48
  } catch (err) {
37
- writeJson(res, 500, { ok: false, error: err.message });
49
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
38
50
  }
39
51
  }
40
52
  }), 'dsh-remote-workspace: /env/save');
@@ -45,15 +57,16 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
45
57
  path: '/dsh-remote-workspace/diagnose',
46
58
  handler: async (req, res) => {
47
59
  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' });
60
+ if (!authorizeRequest(sctx, req, res)) return;
49
61
  try {
50
- const { profileId, checks } = await readBody(req);
62
+ const { profileId, category, checks, target } = await readBody(req);
51
63
  const profile = store.getProfile(profileId);
52
64
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
53
- const results = await diagnoseService.diagnose(sshService, profile, checks);
54
- writeJson(res, 200, { ok: true, results });
65
+ const checkCategory = category || checks || 'memory';
66
+ const result = await diagnoseService.diagnose(profile, checkCategory, target || '');
67
+ writeJson(res, 200, { ok: true, ...result });
55
68
  } catch (err) {
56
- writeJson(res, 500, { ok: false, error: err.message });
69
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
57
70
  }
58
71
  }
59
72
  }), 'dsh-remote-workspace: /diagnose');
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
  import { contentDisposition, pipeWithProgress, MAX_BYTES } from '../file-transfer.js';
3
3
 
4
4
 
@@ -27,7 +27,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
27
27
  path: '/dsh-remote-workspace/browse',
28
28
  handler: async (req, res) => {
29
29
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
30
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
30
+ if (!authorizeRequest(sctx, req, res)) return;
31
31
  try {
32
32
  const body = await readBody(req);
33
33
  let profile = null;
@@ -48,7 +48,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
48
48
  const entries = await remoteFs.listDir(profile, targetPath);
49
49
  writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
50
50
  } catch (err) {
51
- writeJson(res, 500, { ok: false, error: err.message });
51
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
52
52
  }
53
53
  }
54
54
  }), 'dsh-remote-workspace: /browse');
@@ -59,7 +59,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
59
59
  path: '/dsh-remote-workspace/file/view',
60
60
  handler: async (req, res) => {
61
61
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
62
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
62
+ if (!authorizeRequest(sctx, req, res)) return;
63
63
  try {
64
64
  const { profileId, filePath } = await readBody(req);
65
65
  const profile = store.getProfile(profileId);
@@ -71,7 +71,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
71
71
  const buf = await remoteFs.readFile(profile, filePath);
72
72
  writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
73
73
  } catch (err) {
74
- writeJson(res, 500, { ok: false, error: err.message });
74
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
75
75
  }
76
76
  }
77
77
  }), 'dsh-remote-workspace: /file/view');
@@ -82,7 +82,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
82
82
  path: '/dsh-remote-workspace/file/save',
83
83
  handler: async (req, res) => {
84
84
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
85
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
85
+ if (!authorizeRequest(sctx, req, res)) return;
86
86
  try {
87
87
  const { profileId, filePath, content } = await readBody(req);
88
88
  const profile = store.getProfile(profileId);
@@ -90,7 +90,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
90
90
  await remoteFs.writeFile(profile, filePath, Buffer.from(content, 'utf8'));
91
91
  writeJson(res, 200, { ok: true });
92
92
  } catch (err) {
93
- writeJson(res, 500, { ok: false, error: err.message });
93
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
94
94
  }
95
95
  }
96
96
  }), 'dsh-remote-workspace: /file/save');
@@ -100,7 +100,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
100
100
  path: '/dsh-remote-workspace/file/download',
101
101
  handler: async (req, res) => {
102
102
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
103
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
103
+ if (!authorizeRequest(sctx, req, res)) return;
104
104
  try {
105
105
  const { profile, filePath } = transferTarget(req, store);
106
106
  const stat = await remoteFs.stat(profile, filePath);
@@ -128,7 +128,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
128
128
  path: '/dsh-remote-workspace/file/upload',
129
129
  handler: async (req, res) => {
130
130
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
131
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
131
+ if (!authorizeRequest(sctx, req, res)) return;
132
132
  let upload = null;
133
133
  try {
134
134
  const { profile, filePath } = transferTarget(req, store);
@@ -156,7 +156,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
156
156
  path: '/dsh-remote-workspace/transfer',
157
157
  handler: async (req, res) => {
158
158
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
159
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
159
+ if (!authorizeRequest(sctx, req, res)) return;
160
160
  try {
161
161
  const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
162
162
  const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
@@ -169,7 +169,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
169
169
  const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
170
170
  writeJson(res, 200, { ok: result.success, ...result });
171
171
  } catch (err) {
172
- writeJson(res, 500, { ok: false, error: err.message });
172
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
173
173
  }
174
174
  }
175
175
  }), 'dsh-remote-workspace: /transfer');