@goodandready/dsh-remote-workspace 0.3.10 → 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;
@@ -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}`);
package/lib/routes/env.js CHANGED
@@ -1,6 +1,6 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } 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',
@@ -12,7 +12,8 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
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
19
  writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
@@ -28,10 +29,21 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
28
29
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
29
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
49
  writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
@@ -47,11 +59,12 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
47
59
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
48
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
69
  writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
57
70
  }
package/lib/routes.js CHANGED
@@ -44,7 +44,7 @@ export function registerApiRoutes(
44
44
 
45
45
  registerProfileRoutes(sctx, webServer, { store, sshService, alertService, tunnelService });
46
46
  registerFileRoutes(sctx, webServer, { remoteFs, store, sshService });
47
- registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store });
47
+ registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store, remoteFs });
48
48
  registerTerminalRoutes(sctx, webServer, { sshService, store });
49
49
  registerTunnelRoutes(sctx, webServer, { tunnelService, store });
50
50
  registerDockerRoutes(sctx, webServer, { dockerService, healthService, store });
@@ -93,6 +93,21 @@ export class SshService {
93
93
  this.connMeta.delete(id);
94
94
  closed.push(id);
95
95
  }
96
+ this.sweepAbandonedTerminalSessions(now);
97
+ return closed;
98
+ }
99
+
100
+ sweepAbandonedTerminalSessions(now = Date.now(), idleTimeoutMs = 10 * 60 * 1000) {
101
+ const closed = [];
102
+ for (const [id, session] of this.terminalSessions.entries()) {
103
+ const hasSubscribers = session.subscribers && session.subscribers.size > 0;
104
+ const lastActive = session.lastActive || session.createdAt || now;
105
+ if (!hasSubscribers && (now - lastActive >= idleTimeoutMs)) {
106
+ try { session.close(); } catch (err) { /* best-effort cleanup */ }
107
+ this.terminalSessions.delete(id);
108
+ closed.push(id);
109
+ }
110
+ }
96
111
  return closed;
97
112
  }
98
113
 
@@ -368,7 +383,8 @@ export class SshService {
368
383
  this.bumpInFlight(profile.id, -1);
369
384
  throw err;
370
385
  }
371
- const shellCmd = cwd ? `cd ${JSON.stringify(cwd)} && ${command}` : command;
386
+ const safeCwd = cwd ? "'" + String(cwd).replace(/'/g, "'\\''") + "'" : '';
387
+ const shellCmd = cwd ? `cd ${safeCwd} && ${command}` : command;
372
388
 
373
389
  const execOptions = {};
374
390
  if (pty) {
@@ -486,12 +502,15 @@ export class SshService {
486
502
  profileId: profile.id,
487
503
  stream,
488
504
  createdAt: Date.now(),
505
+ lastActive: Date.now(),
489
506
  buffer: [],
490
507
  subscribers: new Set(),
491
508
  write(data) {
509
+ session.lastActive = Date.now();
492
510
  try { stream.write(data); } catch (err) { session.status = 'disconnected'; const lost = '\r\n\x1b[31m[Connection lost]\x1b[0m\r\n'; session.buffer.push(lost); for (const sub of session.subscribers) { try { sub(lost); } catch (e) { /* best-effort notify */ } } }
493
511
  },
494
512
  resize(c, r) {
513
+ session.lastActive = Date.now();
495
514
  try { stream.setWindow(r, c, 0, 0); } catch (err) { /* best-effort cleanup */ }
496
515
  },
497
516
  close() {
@@ -503,6 +522,7 @@ export class SshService {
503
522
  };
504
523
 
505
524
  stream.on('data', (d) => {
525
+ session.lastActive = Date.now();
506
526
  const chunk = d.toString('utf8');
507
527
  session.buffer.push(chunk);
508
528
  if (session.buffer.length > 500) session.buffer.shift();
@@ -40,14 +40,27 @@ export class TarSyncService {
40
40
 
41
41
  localTar.on('close', (code) => {
42
42
  if (code !== 0) {
43
+ try { stream.destroy(); } catch (_) {}
43
44
  reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
44
45
  } else {
45
46
  resolve({ ok: true, direction: 'pull', remoteDir, localDir });
46
47
  }
47
48
  });
48
49
 
49
- stream.on('error', reject);
50
- localTar.stdin.on('error', reject);
50
+ localTar.on('error', (err) => {
51
+ try { stream.destroy(); } catch (_) {}
52
+ reject(err);
53
+ });
54
+
55
+ stream.on('error', (err) => {
56
+ try { localTar.kill('SIGTERM'); } catch (_) {}
57
+ reject(err);
58
+ });
59
+
60
+ localTar.stdin.on('error', (err) => {
61
+ try { stream.destroy(); } catch (_) {}
62
+ reject(err);
63
+ });
51
64
  });
52
65
  });
53
66
  }
@@ -80,14 +93,22 @@ export class TarSyncService {
80
93
 
81
94
  stream.on('close', (code) => {
82
95
  if (code !== 0) {
96
+ try { localTar.kill('SIGTERM'); } catch (_) {}
83
97
  reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
84
98
  } else {
85
99
  resolve({ ok: true, direction: 'push', localDir, remoteDir });
86
100
  }
87
101
  });
88
102
 
89
- stream.on('error', reject);
90
- localTar.on('error', reject);
103
+ stream.on('error', (err) => {
104
+ try { localTar.kill('SIGTERM'); } catch (_) {}
105
+ reject(err);
106
+ });
107
+
108
+ localTar.on('error', (err) => {
109
+ try { stream.destroy(); } catch (_) {}
110
+ reject(err);
111
+ });
91
112
  });
92
113
  });
93
114
  }
@@ -73,8 +73,13 @@ export class TunnelService {
73
73
  clientSocket.pipe(stream);
74
74
  stream.pipe(clientSocket);
75
75
 
76
+ let cleaned = false;
76
77
  const cleanup = () => {
78
+ if (cleaned) return;
79
+ cleaned = true;
77
80
  tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
81
+ try { clientSocket.destroy(); } catch (err) { /* best-effort */ }
82
+ try { stream.destroy(); } catch (err) { /* best-effort */ }
78
83
  };
79
84
  clientSocket.on('close', cleanup);
80
85
  clientSocket.on('error', cleanup);
@@ -7,6 +7,7 @@ export class WatcherService {
7
7
  this.watchers = new Map();
8
8
  /** @type {Map<string, NodeJS.Timeout>} */
9
9
  this.debounceTimers = new Map();
10
+ this.inFlight = new Set();
10
11
  this.debounceMs = 350;
11
12
  this.onSyncCallback = null;
12
13
  }
@@ -52,19 +53,33 @@ export class WatcherService {
52
53
  clearTimeout(this.debounceTimers.get(profile.id));
53
54
  }
54
55
 
55
- const timer = setTimeout(async () => {
56
+ const runSync = async () => {
56
57
  this.debounceTimers.delete(profile.id);
58
+ if (this.inFlight.has(profile.id)) {
59
+ const nextTimer = setTimeout(runSync, this.debounceMs);
60
+ this.debounceTimers.set(profile.id, nextTimer);
61
+ return;
62
+ }
63
+ this.inFlight.add(profile.id);
57
64
  try {
58
65
  const syncRes = await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace);
59
66
  if (this.onSyncCallback) {
60
67
  this.onSyncCallback({ profileId: profile.id, syncRes });
61
68
  }
62
69
  } catch (err) { /* best-effort cleanup */ }
63
- }, this.debounceMs);
70
+ finally {
71
+ this.inFlight.delete(profile.id);
72
+ }
73
+ };
64
74
 
75
+ const timer = setTimeout(runSync, this.debounceMs);
65
76
  this.debounceTimers.set(profile.id, timer);
66
77
  });
67
78
 
79
+ watcher.on('error', () => {
80
+ this.stop(profile.id);
81
+ });
82
+
68
83
  this.watchers.set(profile.id, { watcher, profile, running: true });
69
84
  return { success: true };
70
85
  } catch (err) {
@@ -82,6 +97,7 @@ export class WatcherService {
82
97
  try {
83
98
  item.watcher.close();
84
99
  } catch (err) { /* best-effort cleanup */ }
100
+ this.inFlight.delete(profileId);
85
101
  this.watchers.delete(profileId);
86
102
  return true;
87
103
  }
@@ -107,5 +123,6 @@ export class WatcherService {
107
123
  }
108
124
  this.watchers.clear();
109
125
  this.debounceTimers.clear();
126
+ this.inFlight.clear();
110
127
  }
111
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
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",