@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 +12 -0
- package/README.ru.md +12 -0
- package/README.zh.md +12 -0
- package/lib/client.js +47 -37
- package/lib/diagnose-service.js +4 -3
- package/lib/docker-service.js +5 -2
- package/lib/index.js +40 -14
- package/lib/mirror-sync-service.js +1 -1
- package/lib/remote-fs-service.js +7 -2
- package/lib/routes/env.js +21 -8
- package/lib/routes.js +1 -1
- package/lib/ssh-service.js +21 -1
- package/lib/tar-sync-service.js +25 -4
- package/lib/tunnel-service.js +5 -0
- package/lib/watcher-service.js +19 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
//
|
|
3243
|
-
|
|
3244
|
-
|
|
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 =
|
|
3255
|
-
?
|
|
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
|
|
3271
|
-
|
|
3272
|
-
|
|
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
|
-
|
|
3296
|
-
|
|
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
|
},
|
package/lib/diagnose-service.js
CHANGED
|
@@ -18,13 +18,14 @@ export class DiagnoseService {
|
|
|
18
18
|
let cmd = '';
|
|
19
19
|
switch (category) {
|
|
20
20
|
case 'ports': {
|
|
21
|
-
const
|
|
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
|
|
27
|
-
cmd = `df -h ${
|
|
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': {
|
package/lib/docker-service.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
242
|
+
if (!settingsService) return;
|
|
230
243
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
120
|
+
localContent = fs.readFileSync(localFullPath);
|
|
121
121
|
localHash = this.computeHash(localContent);
|
|
122
122
|
}
|
|
123
123
|
|
package/lib/remote-fs-service.js
CHANGED
|
@@ -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 ${
|
|
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 ${
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
54
|
-
|
|
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 });
|
package/lib/ssh-service.js
CHANGED
|
@@ -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
|
|
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();
|
package/lib/tar-sync-service.js
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
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',
|
|
90
|
-
|
|
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
|
}
|
package/lib/tunnel-service.js
CHANGED
|
@@ -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);
|
package/lib/watcher-service.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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",
|