@goodandready/dsh-remote-workspace 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ export class MirrorSyncService {
11
11
  this.remoteFs = remoteFs;
12
12
  this.sshService = sshService;
13
13
  this.defaultIgnore = [
14
+ '.dsh-sync-snapshot.json',
14
15
  '.git',
15
16
  'node_modules',
16
17
  '.worktrees',
@@ -96,7 +97,7 @@ export class MirrorSyncService {
96
97
  baseRemoteSize === remoteSize
97
98
  ) {
98
99
  // Check if local was modified
99
- const localContent = fs.readFileSync(localFullPath, 'utf8');
100
+ const localContent = fs.readFileSync(localFullPath);
100
101
  const localHash = this.computeHash(localContent);
101
102
  if (localHash === baseHash) {
102
103
  // File unchanged on both ends
@@ -107,7 +108,7 @@ export class MirrorSyncService {
107
108
  // Download content only when potentially changed
108
109
  let remoteContent = '';
109
110
  try {
110
- remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
111
+ remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, null);
111
112
  } catch (err) {
112
113
  continue; // skip unreadable files
113
114
  }
@@ -130,7 +131,7 @@ export class MirrorSyncService {
130
131
  updated.push(relPath);
131
132
  if (!dryRun) {
132
133
  fs.mkdirSync(path.dirname(localFullPath), { recursive: true });
133
- fs.writeFileSync(localFullPath, remoteContent, 'utf8');
134
+ fs.writeFileSync(localFullPath, remoteContent);
134
135
  }
135
136
  }
136
137
 
@@ -195,11 +196,12 @@ export class MirrorSyncService {
195
196
  };
196
197
 
197
198
  const localFiles = walkLocal(localDir, localDir);
199
+ const createdDirs = new Set(['.', '/', '']);
198
200
  for (const rel of localFiles) {
199
201
  const localFull = path.join(localDir, rel);
200
202
  const remoteFull = path.posix.join(remoteDir, rel.replace(/\\/g, '/'));
201
203
  const stat = fs.statSync(localFull);
202
- const content = fs.readFileSync(localFull, 'utf8');
204
+ const content = fs.readFileSync(localFull);
203
205
  const localHash = this.computeHash(content);
204
206
 
205
207
  const baseEntry = snapshot[rel];
@@ -211,8 +213,11 @@ export class MirrorSyncService {
211
213
 
212
214
  if (!dryRun) {
213
215
  const parent = path.posix.dirname(remoteFull);
214
- await this.remoteFs.mkdir(profile, parent);
215
- await this.remoteFs.writeFile(profile, remoteFull, content, 'utf8');
216
+ if (!createdDirs.has(parent)) {
217
+ await this.remoteFs.mkdir(profile, parent);
218
+ createdDirs.add(parent);
219
+ }
220
+ await this.remoteFs.writeFile(profile, remoteFull, content);
216
221
  }
217
222
  pushed.push(rel);
218
223
 
@@ -33,6 +33,10 @@ export class RemoteFsService {
33
33
  /**
34
34
  * Read directory entries with in-memory TTL cache
35
35
  */
36
+ async list(profile, remotePath, bypassCache = false) {
37
+ return this.listDir(profile, remotePath, bypassCache);
38
+ }
39
+
36
40
  async listDir(profile, remotePath, bypassCache = false) {
37
41
  const key = this.cacheKey(profile.id, 'list', remotePath);
38
42
  if (!bypassCache) {
@@ -7,6 +7,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
7
7
  path: '/dsh-remote-workspace/health',
8
8
  handler: async (req, res) => {
9
9
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
10
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
10
11
  try {
11
12
  const url = new URL(req.url, 'http://localhost');
12
13
  const profileId = url.searchParams.get('profileId');
@@ -27,6 +28,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
27
28
  path: '/dsh-remote-workspace/docker/list',
28
29
  handler: async (req, res) => {
29
30
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
31
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
30
32
  try {
31
33
  const url = new URL(req.url, 'http://localhost');
32
34
  const profileId = url.searchParams.get('profileId');
@@ -9,11 +9,24 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
9
9
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
10
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
11
  try {
12
- const { profileId, remotePath } = await readBody(req);
13
- const profile = store.getProfile(profileId);
12
+ const body = await readBody(req);
13
+ let profile = null;
14
+ if (body.profileId) {
15
+ profile = store.getProfile(body.profileId);
16
+ } else if (typeof body.profile === 'string') {
17
+ profile = store.getProfile(body.profile);
18
+ } else if (body.profile && typeof body.profile === 'object' && body.profile.host) {
19
+ profile = body.profile;
20
+ } else if (body.profile?.id) {
21
+ profile = store.getProfile(body.profile.id);
22
+ } else {
23
+ profile = store.getActiveProfile();
24
+ }
14
25
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
- const items = await remoteFs.list(profile, remotePath || profile.remotePath || '.');
16
- writeJson(res, 200, { ok: true, items });
26
+
27
+ const targetPath = body.remotePath || body.path || profile.remoteWorkspace || profile.remotePath || '.';
28
+ const entries = await remoteFs.listDir(profile, targetPath);
29
+ writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
17
30
  } catch (err) {
18
31
  writeJson(res, 500, { ok: false, error: err.message });
19
32
  }
@@ -25,14 +25,56 @@ export function readBody(req) {
25
25
  });
26
26
  }
27
27
 
28
+ export function parseCookies(cookieHeader) {
29
+ const cookies = {};
30
+ if (typeof cookieHeader !== 'string') return cookies;
31
+ for (const pair of cookieHeader.split(';')) {
32
+ const idx = pair.indexOf('=');
33
+ if (idx !== -1) {
34
+ const key = pair.slice(0, idx).trim();
35
+ const val = pair.slice(idx + 1).trim();
36
+ cookies[key] = decodeURIComponent(val);
37
+ }
38
+ }
39
+ return cookies;
40
+ }
41
+
28
42
  export function isTrustedSettingsRequest(req) {
43
+ const secFetch = req.headers['sec-fetch-site'];
44
+ // Fail-closed: unconditionally reject cross-site and cross-origin requests
45
+ if (secFetch && secFetch !== 'same-origin') {
46
+ return false;
47
+ }
48
+
49
+ // If origin is present, ensure it matches the request host
50
+ const origin = req.headers['origin'];
51
+ const host = req.headers['host'];
52
+ if (origin && host) {
53
+ try {
54
+ const url = new URL(origin);
55
+ if (url.host !== host) {
56
+ return false;
57
+ }
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
29
63
  const rawAuth = req.headers['authorization'];
30
- if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) return true;
31
64
  const cookieHeader = req.headers['cookie'];
32
- if (typeof cookieHeader === 'string' && (cookieHeader.includes('token=') || cookieHeader.includes('dsh_token='))) {
33
- return true;
65
+ const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
66
+
67
+ if (expectedToken) {
68
+ if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
69
+ const token = rawAuth.slice(7).trim();
70
+ if (token && token === expectedToken) return true;
71
+ }
72
+ if (typeof cookieHeader === 'string') {
73
+ const cookies = parseCookies(cookieHeader);
74
+ const token = cookies['token'] || cookies['dsh_token'];
75
+ if (token && token === expectedToken) return true;
76
+ }
34
77
  }
35
- const secFetch = req.headers['sec-fetch-site'];
36
78
  if (secFetch === 'same-origin') return true;
37
79
  const ip = req.socket?.remoteAddress || '';
38
80
  if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
@@ -1,6 +1,15 @@
1
1
  import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
2
  import { SshConfigParser } from '../ssh-config-parser.js';
3
3
 
4
+ function maskProfile(p) {
5
+ if (!p || typeof p !== 'object') return p;
6
+ const masked = { ...p };
7
+ if (masked.password) masked.password = '••••••••';
8
+ if (masked.privateKey) masked.privateKey = '••••••••';
9
+ if (masked.passphrase) masked.passphrase = '••••••••';
10
+ return masked;
11
+ }
12
+
4
13
  export function registerProfileRoutes(sctx, webServer, { store, sshService, alertService, tunnelService }) {
5
14
  // 1. GET /dsh-remote-workspace/state
6
15
  sctx.effect(() => webServer.register({
@@ -8,13 +17,14 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
8
17
  path: '/dsh-remote-workspace/state',
9
18
  handler: async (req, res) => {
10
19
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
20
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
21
  try {
12
22
  const profiles = store.getProfiles();
13
23
  const activeId = store.getActiveId() || (profiles[0]?.id || '');
14
24
  const tunnels = tunnelService.listActiveTunnels();
15
25
  const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
16
26
  const alerts = alertService ? alertService.getAlerts() : [];
17
- const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : p));
27
+ const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : maskProfile(p)));
18
28
  writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts });
19
29
  } catch (err) {
20
30
  writeJson(res, 500, { ok: false, error: err.message });
@@ -36,7 +46,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
36
46
  }
37
47
  sshService.invalidate(profile.id);
38
48
  await store.saveProfile(profile);
39
- const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : profile;
49
+ const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
40
50
  writeJson(res, 200, { ok: true, profile: sanitized });
41
51
  } catch (err) {
42
52
  writeJson(res, 500, { ok: false, error: err.message });
@@ -10,9 +10,9 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
10
10
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
11
  try {
12
12
  const { profileId, cols, rows } = await readBody(req);
13
- const profile = store.getProfile(profileId);
13
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
14
14
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
- const session = await sshService.createTerminalSession(profile, cols || 80, rows || 24);
15
+ const session = await sshService.createTerminalSession(profile, { cols: cols || 80, rows: rows || 24 });
16
16
  writeJson(res, 200, { ok: true, sessionId: session.id });
17
17
  } catch (err) {
18
18
  writeJson(res, 500, { ok: false, error: err.message });
@@ -50,6 +50,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
50
50
  path: '/dsh-remote-workspace/terminal/stream',
51
51
  handler: async (req, res) => {
52
52
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
53
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
53
54
  try {
54
55
  const url = new URL(req.url, 'http://localhost');
55
56
  const sessionId = url.searchParams.get('sessionId');
@@ -49,6 +49,7 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
49
49
  path: '/dsh-remote-workspace/tunnels/telemetry',
50
50
  handler: async (req, res) => {
51
51
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
52
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
52
53
  try {
53
54
  writeJson(res, 200, { ok: true, tunnels: tunnelService.listActiveTunnels() });
54
55
  } catch (err) {
@@ -313,6 +313,9 @@ export class SshService {
313
313
  }
314
314
 
315
315
  async createTerminalSession(profile, options = {}) {
316
+ if (typeof options === 'number') {
317
+ options = { cols: options, rows: arguments[2] };
318
+ }
316
319
  const conn = await this.getConnection(profile);
317
320
  const cols = options.cols || 80;
318
321
  const rows = options.rows || 24;
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
 
5
+ export function shellQuote(arg) {
6
+ if (typeof arg !== 'string') arg = String(arg || '');
7
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
8
+ }
9
+
5
10
  export class TarSyncService {
6
11
  /**
7
12
  * @param {import('./ssh-service.js').SshService} sshService
@@ -17,11 +22,13 @@ export class TarSyncService {
17
22
  */
18
23
  async pullTar(profile, remoteDir, localDir) {
19
24
  if (!profile) throw new Error('No profile provided');
25
+ if (!remoteDir || typeof remoteDir !== 'string') throw new Error('remoteDir must be a non-empty string');
20
26
  fs.mkdirSync(localDir, { recursive: true });
21
27
 
22
28
  const client = await this.sshService.getConnection(profile);
23
29
  return new Promise((resolve, reject) => {
24
- const remoteCmd = `tar --exclude='.git' --exclude='node_modules' -czf - -C "${remoteDir}" .`;
30
+ const safeRemoteDir = shellQuote(remoteDir);
31
+ const remoteCmd = `tar --exclude='.git' --exclude='node_modules' -czf - -C ${safeRemoteDir} .`;
25
32
  client.exec(remoteCmd, (err, stream) => {
26
33
  if (err) return reject(err);
27
34
 
@@ -52,14 +59,16 @@ export class TarSyncService {
52
59
  */
53
60
  async pushTar(profile, localDir, remoteDir) {
54
61
  if (!profile) throw new Error('No profile provided');
62
+ if (!remoteDir || typeof remoteDir !== 'string') throw new Error('remoteDir must be a non-empty string');
55
63
  if (!fs.existsSync(localDir)) throw new Error(`Local dir does not exist: ${localDir}`);
56
64
 
65
+ const safeRemoteDir = shellQuote(remoteDir);
57
66
  const client = await this.sshService.getConnection(profile);
58
67
  // Ensure remoteDir exists
59
- await this.sshService.exec(profile, `mkdir -p "${remoteDir}"`);
68
+ await this.sshService.exec(profile, `mkdir -p ${safeRemoteDir}`);
60
69
 
61
70
  return new Promise((resolve, reject) => {
62
- const remoteCmd = `tar -xzf - -C "${remoteDir}"`;
71
+ const remoteCmd = `tar -xzf - -C ${safeRemoteDir}`;
63
72
  client.exec(remoteCmd, (err, stream) => {
64
73
  if (err) return reject(err);
65
74
 
@@ -31,21 +31,9 @@ function isLoopback(value) {
31
31
  )
32
32
  }
33
33
 
34
- function isPrivateLan(value) {
35
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
36
- if (!address) return false
37
- const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
38
- return (
39
- ipv4.startsWith('192.168.') ||
40
- ipv4.startsWith('10.') ||
41
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
42
- )
43
- }
44
-
45
34
  export function isTrustedUpdateRequest(request) {
46
35
  if (header(request, UPDATE_HEADER) !== '1') return false
47
- const remote = request.socket?.remoteAddress
48
- if (!isLoopback(remote) && !isPrivateLan(remote)) return false
36
+ if (!isLoopback(request.socket?.remoteAddress)) return false
49
37
  const site = header(request, 'sec-fetch-site')
50
38
  if (site !== undefined && site !== 'same-origin') return false
51
39
  const origin = header(request, 'origin')
@@ -53,11 +41,8 @@ export function isTrustedUpdateRequest(request) {
53
41
  if (origin === undefined || host === undefined) return false
54
42
  try {
55
43
  const url = new URL(origin)
56
- return (
57
- (url.protocol === 'http:' || url.protocol === 'https:') &&
58
- (isLoopback(url.hostname) || isPrivateLan(url.hostname)) &&
59
- url.host === host
60
- )
44
+ return (url.protocol === 'http:' || url.protocol === 'https:')
45
+ && isLoopback(url.hostname) && url.host === host
61
46
  } catch (err) { /* best-effort fallback */
62
47
  return false
63
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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",
@@ -16,7 +16,6 @@
16
16
  "README.md",
17
17
  "README.ru.md",
18
18
  "README.zh.md",
19
- "docs/design/DESIGN.md",
20
19
  "LICENSE"
21
20
  ],
22
21
  "scripts": {
@@ -1,91 +0,0 @@
1
- # DESIGN.md — dsh-remote-workspace
2
-
3
- ## Product / Purpose
4
- - **Назначение**: Полнофункциональный корпоративный плагин удалённой разработки для DeepSeek Harness (DSH). Обеспечивает подключение к удалённым серверам по SSH (ключ / пароль), SFTP-навигацию, 3-way синхронизацию локального зеркала, SSH-туннелирование (port forwarding) и компактный набор инструментов для LLM агента (
5
- emote_exec,
6
- emote_fs,
7
- emote_sync,
8
- emote_tunnel).
9
- - **Аудитория**: Разработчики и инженеры, использующие DSH для работы с проектами на VPS, удаленных серверах, облачных инстансах и MiniPC.
10
- - **Статус**: Стабильный релиз v0.3.3 (One-Click Updater, Cross-Plugin API, Clean Tokens, Modular Routes).
11
-
12
- ## User Surfaces
13
- - **Web/UI**: Нативная карточка настроек профилей хостов (settings.plugin.item), удаленный SFTP браузер каталогов, чип статуса подключения в шапке сессии (conversation.session.header.utilities).
14
- - **DSH UI / settings / slots**:
15
- - Карточка плагина: settings.plugin.item для @goodandready/dsh-remote-workspace.
16
- - Статусный чип сессии: conversation.session.header.utilities (order: 30).
17
- - Запрещённые слоты: settings.section (запрещён стандартом authoring, все настройки только внутри settings.plugin.item).
18
- - **API маршруты**:
19
- - GET /dsh-remote-workspace/state: получение профилей, активного хоста и туннелей.
20
- - POST /dsh-remote-workspace/profiles/save: сохранение профиля (с валидацией и сбросом старых соединений).
21
- - POST /dsh-remote-workspace/profiles/delete: удаление профиля.
22
- - POST /dsh-remote-workspace/profiles/active: переключение активного профиля.
23
- - POST /dsh-remote-workspace/test: live-проверка связи (Ping, OS, Latency).
24
- - POST /dsh-remote-workspace/browse: чтение содержимого удалённой папки (SFTP).
25
- - POST /dsh-remote-workspace/sync: запуск двусторонней 3-way синхронизации зеркала (pull/push).
26
- - POST /dsh-remote-workspace/tunnels/start: создание локального SSH-туннеля.
27
- - POST /dsh-remote-workspace/tunnels/stop: остановка активного SSH-туннеля.
28
- - **CLI / Model Tools**:
29
- -
30
- emote_exec: выполнение команд на сервере.
31
- -
32
- emote_fs: чтение, атомарная запись, stat, mkdir, remove.
33
- -
34
- emote_sync: запуск 3-way синхронизации с контролем конфликтов.
35
- -
36
- emote_tunnel: создание/остановка/листинг туннелей.
37
-
38
- ## Visual Direction (Единый стиль dsh-clinebot)
39
- - **Атмосфера**: Премиальный инженерный интерфейс уровня VS Code Remote / Linear. Строгая типографика, плотные функциональные карточки, чистые переходы и отсутствие лишних теней.
40
- - **Токены ядра DSH**:
41
- - Фоны: ar(--dsw-alias-bg-layer-3) (карточки), ar(--dsw-alias-bg-layer-2) (поля ввода, вложенные блоки, таблицы), ar(--dsw-alias-bg-layer-1) (сегменты, подложка).
42
- - Границы: ar(--dsw-alias-border-l2).
43
- - Текст: ar(--dsw-alias-label-primary), ar(--dsw-alias-label-secondary), ar(--dsw-alias-label-dimmed).
44
- - Статусы: ar(--dsw-alias-state-success-primary), ar(--dsw-alias-state-warning-primary), ar(--dsw-alias-state-error-primary), ar(--dsw-alias-state-brand-primary).
45
- - **Шапка карточки**:
46
- - Название плагина, подзаголовок, статусный аккордеонный шеврон.
47
- - Статусные пилюли (Badges): Host online / Offline, Active Profile, Active Tunnels count.
48
-
49
- ## Components And States
50
- 1. **Секция 1: Профили серверов (SSH Profiles)**:
51
- - Таблица настроенных машин с бейджами типа аутентификации (🔑 key / 🔒 pwd), меткой активного сервера, кнопками «Сделать активным», «Редактировать», «Удалить».
52
- - Пустое состояние: ясное сообщение и кнопка + Добавить хост.
53
- 2. **Секция 2: Интерактивный редактор профиля**:
54
- - Название, Хост/IP, Порт, Пользователь.
55
- - Сегментированный переключатель авторизации (SSH-ключ vs Пароль).
56
- - Поля авторизации: путь к приватному ключу, парольная фраза, либо пароль (маскированный).
57
- - Директории: Удалённый каталог проекта (с кнопкой вызова SFTP браузера) и **Локальная папка зеркала** (с подсказкой назначения).
58
- - Кнопки: «⚡ Проверить связь (Ping)», «Отмена», «Сохранить».
59
- - Результат пинга: отображение Latency: X ms | OS: Y или понятный текст ошибки.
60
- 3. **Секция 3: Диагностика активного подключения**:
61
- - Четырёхколоночная сетка метрик активного сервера (Хост, Удалённая папка, Метод авторизации, Статус ядра).
62
- 4. **Секция 4: Синхронизация зеркала (Mirror Sync)**:
63
- - Кнопки Pull (Remote -> Local) и Push (Local -> Remote) с индикацией процесса, результатом и предупреждением при конфликтах.
64
- 5. **Секция 5: SSH-туннели (Port Forwarding)**:
65
- - Таблица активных туннелей (ID, Локальный порт -> Удалённый порт) с кнопкой закрытия.
66
- - Форма создания нового туннеля (Local Port, Remote Port, кнопка «Открыть туннель»).
67
-
68
- ## Security & Reliability
69
- - Защита всех мутирующих эндпоинтов проверкой isTrustedSettingsRequest(req) (защита от cross-site атак).
70
- - Безопасное чтение Cordis сервисов через .get() с fallback.
71
- - Изоляция жизненного цикла стримов в TunnelService (прослушивание сокетных и потоковых ошибок).
72
-
73
- ### v0.3.3 Client Architecture Modularization (Refs: #14)
74
- - Client-side code decomposed into modular ES components under `src/client/`:
75
- - `src/client/styles.js` — CSS styling with DSH theme variables
76
- - `src/client/locales.js` — English and Chinese translation dictionaries
77
- - `src/client/env-editor.js` — Remote .env manager tab component
78
- - `src/client/docker.js` — Container inspector tab component
79
- - `src/client/terminal.js` — Web terminal tab component
80
- - `src/client/file-browser.js` — Remote file explorer tab component
81
- - `src/client/settings-view.js` — Profiles, tunnels, and sync settings component
82
- - `src/client/updater-section.js` — One-click updater section component
83
- - `src/client/plugin-card.js` — Plugin card and status chip components
84
- - `src/client/entry.js` — Cordis client plugin apply and slot registration
85
- - Added native builder `scripts/build-client.mjs` hooked into `prepack` and `npm test`.
86
-
87
- ### v0.3.6 Model Tools JSON Schema Standardization (Refs: #33)
88
- - Все 9 инструментов (`remote_exec`, `remote_fs`, `remote_sync`, `remote_tunnel`, `remote_docker`, `remote_service`, `remote_transfer`, `remote_diagnose`, `remote_env`) переведены на компиляцию через `defineTool()` из `@deepseek-ai/dsh-tools`.
89
- - Параметры инструментов компилируются в строгий JSON Schema с корнем `{ type: 'object', properties: ..., required: [...] }`.
90
- - Предотвращены ошибки валидации схем в OpenAI-совместимых провайдерах (`Invalid schema: schema must be a JSON Schema of 'type: "object"', got 'type: null'`).
91
- - Схема вывода стандартизирована с явным `{ type: 'object', additionalProperties: true }`.