@goodandready/dsh-remote-workspace 0.3.1 → 0.3.3

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.
@@ -32,6 +32,21 @@ export class SshService {
32
32
  this.vaultService = vault;
33
33
  }
34
34
 
35
+ setProfileResolver(resolver) {
36
+ this.profileResolver = resolver;
37
+ }
38
+
39
+ async execById(profileId, command, cwd = null, onData = null) {
40
+ if (typeof this.profileResolver !== 'function') {
41
+ throw new Error('Profile resolver not configured on SshService');
42
+ }
43
+ const profile = this.profileResolver(profileId);
44
+ if (!profile) {
45
+ throw new Error(`Remote profile "${profileId}" not found`);
46
+ }
47
+ return this.exec(profile, command, cwd, onData);
48
+ }
49
+
35
50
  setJumpProfileResolver(resolver) {
36
51
  this.getJumpProfile = resolver;
37
52
  }
@@ -49,7 +64,7 @@ export class SshService {
49
64
  if (fs.existsSync(resolvedPath)) {
50
65
  return fs.readFileSync(resolvedPath, 'utf8');
51
66
  }
52
- } catch (_) {}
67
+ } catch (err) { /* best-effort cleanup */ }
53
68
  }
54
69
  return undefined;
55
70
  }
@@ -227,7 +242,7 @@ export class SshService {
227
242
  try {
228
243
  stream.signal('SIGTERM');
229
244
  stream.close();
230
- } catch (_) {}
245
+ } catch (err) { /* best-effort cleanup */ }
231
246
  const errTimeout = new Error(`Command timed out after ${timeoutMs / 1000}s: ${command}`);
232
247
  errTimeout.code = 'ETIMEDOUT';
233
248
  errTimeout.stdout = cleanAnsi ? stripAnsi(stdout) : stdout;
@@ -316,13 +331,13 @@ export class SshService {
316
331
  buffer: [],
317
332
  subscribers: new Set(),
318
333
  write(data) {
319
- try { stream.write(data); } catch (_) {}
334
+ 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 */ } } }
320
335
  },
321
336
  resize(c, r) {
322
- try { stream.setWindow(r, c, 0, 0); } catch (_) {}
337
+ try { stream.setWindow(r, c, 0, 0); } catch (err) { /* best-effort cleanup */ }
323
338
  },
324
339
  close() {
325
- try { stream.end(); } catch (_) {}
340
+ try { stream.end(); } catch (err) { /* best-effort cleanup */ }
326
341
  }
327
342
  };
328
343
 
@@ -331,7 +346,7 @@ export class SshService {
331
346
  session.buffer.push(chunk);
332
347
  if (session.buffer.length > 500) session.buffer.shift();
333
348
  for (const sub of session.subscribers) {
334
- try { sub(chunk); } catch (_) {}
349
+ try { sub(chunk); } catch (err) { /* best-effort cleanup */ }
335
350
  }
336
351
  });
337
352
 
@@ -366,7 +381,7 @@ export class SshService {
366
381
  disconnect(profileId) {
367
382
  const conn = this.connections.get(profileId);
368
383
  if (conn) {
369
- try { conn.end(); } catch (_) {}
384
+ try { conn.end(); } catch (err) { /* best-effort cleanup */ }
370
385
  this.connections.delete(profileId);
371
386
  this.sftpSessions.delete(profileId);
372
387
  }
@@ -374,12 +389,12 @@ export class SshService {
374
389
 
375
390
  disconnectAll() {
376
391
  for (const [id, conn] of this.connections.entries()) {
377
- try { conn.end(); } catch (_) {}
392
+ try { conn.end(); } catch (err) { /* best-effort cleanup */ }
378
393
  }
379
394
  this.connections.clear();
380
395
  this.sftpSessions.clear();
381
396
  for (const [id, term] of this.terminalSessions.entries()) {
382
- try { term.close(); } catch (_) {}
397
+ try { term.close(); } catch (err) { /* best-effort cleanup */ }
383
398
  }
384
399
  this.terminalSessions.clear();
385
400
  }
@@ -109,7 +109,7 @@ export class TunnelService {
109
109
  if (!tunnel) return false;
110
110
  try {
111
111
  tunnel.server.close();
112
- } catch (_) {}
112
+ } catch (err) { /* best-effort cleanup */ }
113
113
  this.activeTunnels.delete(tunnelId);
114
114
  return true;
115
115
  }
@@ -134,7 +134,7 @@ export class TunnelService {
134
134
  for (const [id, tunnel] of this.activeTunnels.entries()) {
135
135
  try {
136
136
  tunnel.server.close();
137
- } catch (_) {}
137
+ } catch (err) { /* best-effort cleanup */ }
138
138
  }
139
139
  this.activeTunnels.clear();
140
140
  }
@@ -0,0 +1,339 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * Host-side one-click updater for @goodandready/dsh-remote-workspace.
10
+ * Implements the unified DSH plugin updater specification.
11
+ */
12
+
13
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
14
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
15
+ const VERSION_CACHE_MS = 5 * 60_000
16
+ let latestCache = undefined
17
+
18
+ function header(request, name) {
19
+ const value = request?.headers?.[name]
20
+ return Array.isArray(value) ? value[0] : value
21
+ }
22
+
23
+ function isLoopback(value) {
24
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
+ return (
26
+ address === 'localhost' ||
27
+ address === 'localhost.' ||
28
+ address === '::1' ||
29
+ address?.startsWith('127.') === true ||
30
+ address?.startsWith('::ffff:127.') === true
31
+ )
32
+ }
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
+ export function isTrustedUpdateRequest(request) {
46
+ if (header(request, UPDATE_HEADER) !== '1') return false
47
+ const remote = request.socket?.remoteAddress
48
+ if (!isLoopback(remote) && !isPrivateLan(remote)) return false
49
+ const site = header(request, 'sec-fetch-site')
50
+ if (site !== undefined && site !== 'same-origin') return false
51
+ const origin = header(request, 'origin')
52
+ const host = header(request, 'host')
53
+ if (origin === undefined || host === undefined) return false
54
+ try {
55
+ 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
+ )
61
+ } catch (err) { /* best-effort fallback */
62
+ return false
63
+ }
64
+ }
65
+
66
+ function validProfileName(value) {
67
+ return (
68
+ typeof value === 'string' &&
69
+ value !== '' &&
70
+ value !== '.' &&
71
+ value !== '..' &&
72
+ !value.includes('/') &&
73
+ !value.includes('\\') &&
74
+ !/[\0-\x1f\x7f]/.test(value)
75
+ )
76
+ }
77
+
78
+ function profileNameFromArgv(argv) {
79
+ for (let index = 0; index < argv.length; index += 1) {
80
+ const arg = argv[index]
81
+ if (arg === '--profile' || arg === '-p') {
82
+ const next = argv[index + 1]
83
+ if (validProfileName(next)) return next
84
+ }
85
+ if (typeof arg === 'string' && arg.startsWith('--profile=')) {
86
+ const value = arg.slice('--profile='.length)
87
+ if (validProfileName(value)) return value
88
+ }
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ function findDshCliEntry() {
94
+ const entry = process.argv[1]
95
+ if (typeof entry !== 'string' || entry === '') return undefined
96
+ if (!existsSync(entry)) return undefined
97
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
98
+ const manifestPath = resolve(directory, 'package.json')
99
+ if (existsSync(manifestPath)) {
100
+ try {
101
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
102
+ const bin =
103
+ typeof manifest.bin === 'string'
104
+ ? manifest.bin
105
+ : typeof manifest.bin === 'object' && manifest.bin !== null
106
+ ? manifest.bin.dsh
107
+ : undefined
108
+ if (
109
+ manifest.name === '@deepseek-ai/dsh' &&
110
+ typeof bin === 'string' &&
111
+ !isAbsolute(bin) &&
112
+ resolve(directory, bin) === resolve(entry)
113
+ ) {
114
+ return entry
115
+ }
116
+ } catch (err) { /* best-effort fallback */
117
+ // Continue searching parent directories
118
+ }
119
+ }
120
+ const parent = dirname(directory)
121
+ if (parent === directory) return undefined
122
+ }
123
+ }
124
+
125
+ function runtime() {
126
+ const profileDir = resolve(
127
+ process.env.DSH_PROFILE_DIR ?? resolve(homedir(), '.dsh', 'profiles', 'web')
128
+ )
129
+ const selected = profileNameFromArgv(process.argv)
130
+ const profileName = validProfileName(selected)
131
+ ? selected
132
+ : validProfileName(basename(profileDir))
133
+ ? basename(profileDir)
134
+ : 'web'
135
+ const cliEntry = findDshCliEntry()
136
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
137
+ }
138
+
139
+ function parseSemver(value) {
140
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
141
+ if (match === null) return undefined
142
+ return {
143
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
144
+ prerelease: match[4]?.split('.') ?? [],
145
+ }
146
+ }
147
+
148
+ function comparePrerelease(left, right) {
149
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
150
+ const length = Math.max(left.length, right.length)
151
+ for (let index = 0; index < length; index += 1) {
152
+ const a = left[index]
153
+ const b = right[index]
154
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
155
+ if (a === b) continue
156
+ const aNumeric = /^\d+$/.test(a)
157
+ const bNumeric = /^\d+$/.test(b)
158
+ if (aNumeric && bNumeric) {
159
+ const aNumber = BigInt(a)
160
+ const bNumber = BigInt(b)
161
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
162
+ continue
163
+ }
164
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
165
+ return a > b ? 1 : -1
166
+ }
167
+ return 0
168
+ }
169
+
170
+ export function isNewerVersion(currentValue, candidateValue) {
171
+ const current = parseSemver(currentValue)
172
+ const candidate = parseSemver(candidateValue)
173
+ if (current === undefined || candidate === undefined) return false
174
+ for (let index = 0; index < 3; index += 1) {
175
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
176
+ }
177
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
178
+ }
179
+
180
+ async function latestVersion(packageName, registry) {
181
+ if (
182
+ latestCache?.packageName === packageName &&
183
+ latestCache.registry === registry &&
184
+ Date.now() < latestCache.expiresAt
185
+ ) {
186
+ return latestCache.version
187
+ }
188
+ try {
189
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
190
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
191
+ })
192
+ if (!response.ok) return undefined
193
+ const value = await response.json()
194
+ if (typeof value?.version !== 'string' || value.version === '') return undefined
195
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
196
+ return value.version
197
+ } catch (err) { /* best-effort fallback */
198
+ return undefined
199
+ }
200
+ }
201
+
202
+ async function currentVersion(manifestUrl) {
203
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
204
+ if (typeof value?.version !== 'string' || value.version === '') {
205
+ throw new Error('Cannot read current plugin version.')
206
+ }
207
+ return value.version
208
+ }
209
+
210
+ export async function status(options, target) {
211
+ const current = await currentVersion(options.manifestUrl)
212
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
213
+ return {
214
+ packageName: options.packageName,
215
+ currentVersion: current,
216
+ ...(latest === undefined ? {} : { latestVersion: latest }),
217
+ latestCheckFailed: latest === undefined,
218
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
219
+ profileName: target.profileName,
220
+ canAutoUpdate: target.cliEntry !== undefined,
221
+ }
222
+ }
223
+
224
+ async function installExact(target, packageSpec, options) {
225
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
226
+ await new Promise((resolvePromise, reject) => {
227
+ const child = spawn(
228
+ process.execPath,
229
+ [
230
+ target.cliEntry,
231
+ 'plugin',
232
+ '--profile',
233
+ target.profileName,
234
+ 'add',
235
+ '--config.minimumReleaseAge=0',
236
+ packageSpec,
237
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
238
+ ],
239
+ {
240
+ cwd: target.profileDir,
241
+ windowsHide: true,
242
+ stdio: ['ignore', 'pipe', 'pipe'],
243
+ env: { ...process.env, NO_COLOR: '1' },
244
+ }
245
+ )
246
+ let detail = ''
247
+ child.stdout?.on('data', (chunk) => {
248
+ detail = (detail + String(chunk)).slice(-4000)
249
+ })
250
+ child.stderr?.on('data', (chunk) => {
251
+ detail = (detail + String(chunk)).slice(-4000)
252
+ })
253
+ const timer = setTimeout(() => {
254
+ child.kill()
255
+ reject(new Error('Update timed out; use the normal DSH update flow.'))
256
+ }, UPDATE_TIMEOUT_MS)
257
+ child.once('error', (error) => {
258
+ clearTimeout(timer)
259
+ reject(error)
260
+ })
261
+ child.once('exit', (code) => {
262
+ clearTimeout(timer)
263
+ if (code === 0) resolvePromise()
264
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
265
+ })
266
+ })
267
+ }
268
+
269
+ function json(response, statusCode, value) {
270
+ response.writeHead(statusCode, {
271
+ 'content-type': 'application/json; charset=utf-8',
272
+ 'cache-control': 'no-store',
273
+ })
274
+ response.end(JSON.stringify(value))
275
+ }
276
+
277
+ export function registerPluginUpdater(ctx, options) {
278
+ let installing = false
279
+ const webServer = ctx?.get ? (ctx.get('webServer') ?? ctx?.webServer) : ctx?.webServer;
280
+ if (!webServer || typeof webServer.register !== 'function') {
281
+ ctx?.logger?.warn?.('[dsh-remote-workspace] webServer service unavailable, updater skipped')
282
+ return () => {}
283
+ }
284
+
285
+ return webServer.register({
286
+ kind: 'exact',
287
+ path: options.endpoint,
288
+ handler: async (request, response) => {
289
+ try {
290
+ const target = runtime()
291
+ if (request.method === 'GET' || request.method === 'HEAD') {
292
+ const payload = await status(options, target)
293
+ response.writeHead(200, {
294
+ 'content-type': 'application/json; charset=utf-8',
295
+ 'cache-control': 'no-store',
296
+ })
297
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
298
+ return
299
+ }
300
+ if (request.method !== 'POST') {
301
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
302
+ response.end()
303
+ return
304
+ }
305
+ if (!isTrustedUpdateRequest(request)) {
306
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
307
+ return
308
+ }
309
+ if (installing) {
310
+ json(response, 409, { error: 'This plugin is already updating.' })
311
+ return
312
+ }
313
+ installing = true
314
+ try {
315
+ const before = await status(options, target)
316
+ if (before.latestVersion === undefined) {
317
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' })
318
+ return
319
+ }
320
+ if (!before.updateAvailable) {
321
+ json(response, 200, before)
322
+ return
323
+ }
324
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
325
+ json(response, 200, {
326
+ ...before,
327
+ updatedVersion: before.latestVersion,
328
+ restartRequired: true,
329
+ })
330
+ } finally {
331
+ installing = false
332
+ }
333
+ } catch (error) {
334
+ ctx?.logger?.warn?.(`[dsh-remote-workspace] plugin updater failed: ${String(error)}`)
335
+ json(response, 503, { error: 'Plugin update failed; see server logs.' })
336
+ }
337
+ },
338
+ })
339
+ }
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  */
9
9
  export class VaultService {
10
10
  constructor(customPath = null) {
11
+ this.permissionError = null;
11
12
  if (customPath) {
12
13
  this.envPath = customPath;
13
14
  } else {
@@ -17,7 +18,16 @@ export class VaultService {
17
18
  if (!fs.existsSync(secretsDir)) {
18
19
  fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
19
20
  }
20
- } catch (_) {}
21
+ if (fs.existsSync(secretsDir)) {
22
+ try {
23
+ fs.chmodSync(secretsDir, 0o700);
24
+ } catch (chmodErr) {
25
+ this.permissionError = `Failed to set 0700 permissions on secrets dir: ${chmodErr.message}`;
26
+ }
27
+ }
28
+ } catch (mkdirErr) {
29
+ this.permissionError = `Failed to initialize secrets directory: ${mkdirErr.message}`;
30
+ }
21
31
  this.envPath = path.join(secretsDir, 'dsh-remote-workspace.env');
22
32
  }
23
33
  this.ensureEnvFile();
@@ -29,9 +39,23 @@ export class VaultService {
29
39
  fs.writeFileSync(this.envPath, '# DSH Remote Workspace Encrypted Secrets Vault\n', { mode: 0o600, encoding: 'utf8' });
30
40
  } else {
31
41
  // Ensure file permissions are restricted to owner only
32
- try { fs.chmodSync(this.envPath, 0o600); } catch (_) {}
42
+ try {
43
+ fs.chmodSync(this.envPath, 0o600);
44
+ } catch (chmodErr) {
45
+ this.permissionError = `Failed to restrict permissions on ${this.envPath}: ${chmodErr.message}`;
46
+ }
33
47
  }
34
- } catch (_) {}
48
+ } catch (err) {
49
+ this.permissionError = `Failed to ensure vault file: ${err.message}`;
50
+ }
51
+ }
52
+
53
+ getPermissionStatus() {
54
+ return {
55
+ secure: !this.permissionError,
56
+ error: this.permissionError,
57
+ path: this.envPath
58
+ };
35
59
  }
36
60
 
37
61
  /**
@@ -61,14 +85,14 @@ export class VaultService {
61
85
  currentKey = key;
62
86
  currentValue = val.slice(1) + '\n';
63
87
  } else if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
64
- store[key] = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\r/g, '\r');
88
+ store[key] = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
65
89
  } else {
66
90
  store[key] = val;
67
91
  }
68
92
  } else {
69
93
  if (line.endsWith('"')) {
70
94
  currentValue += line.slice(0, -1);
71
- store[currentKey] = currentValue.replace(/\\n/g, '\n').replace(/\\r/g, '\r');
95
+ store[currentKey] = currentValue.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
72
96
  isMultiline = false;
73
97
  currentKey = null;
74
98
  currentValue = '';
@@ -59,7 +59,7 @@ export class WatcherService {
59
59
  if (this.onSyncCallback) {
60
60
  this.onSyncCallback({ profileId: profile.id, syncRes });
61
61
  }
62
- } catch (_) {}
62
+ } catch (err) { /* best-effort cleanup */ }
63
63
  }, this.debounceMs);
64
64
 
65
65
  this.debounceTimers.set(profile.id, timer);
@@ -81,7 +81,7 @@ export class WatcherService {
81
81
  if (item) {
82
82
  try {
83
83
  item.watcher.close();
84
- } catch (_) {}
84
+ } catch (err) { /* best-effort cleanup */ }
85
85
  this.watchers.delete(profileId);
86
86
  return true;
87
87
  }
@@ -100,7 +100,7 @@ export class WatcherService {
100
100
  for (const [id, item] of this.watchers.entries()) {
101
101
  try {
102
102
  item.watcher.close();
103
- } catch (_) {}
103
+ } catch (err) { /* best-effort cleanup */ }
104
104
  if (this.debounceTimers.has(id)) {
105
105
  clearTimeout(this.debounceTimers.get(id));
106
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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,12 +16,14 @@
16
16
  "README.md",
17
17
  "README.ru.md",
18
18
  "README.zh.md",
19
- "docs/",
19
+ "docs/design/DESIGN.md",
20
20
  "LICENSE"
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsup",
24
- "test": "node --test test/*.test.mjs"
24
+ "test": "node scripts/build-client.mjs && node --test test/*.test.mjs test/*.test.js",
25
+ "build:client": "node scripts/build-client.mjs",
26
+ "prepack": "node scripts/build-client.mjs"
25
27
  },
26
28
  "keywords": [
27
29
  "deepseek-harness",
@@ -49,7 +51,10 @@
49
51
  },
50
52
  "client": {
51
53
  "platform": "web",
52
- "inject": []
54
+ "inject": [
55
+ "@deepseek-ai/dsh-client-locale",
56
+ "@deepseek-ai/dsh-client-ui-settings"
57
+ ]
53
58
  }
54
59
  },
55
60
  "dependencies": {
@@ -1,19 +0,0 @@
1
- # findings.md — dsh-remote-workspace
2
-
3
- ## Ключевые выводы анализа dsh-remote
4
- 1. Преимущество 3-way sync: snapshot хешей предотвращает перезапись изменений при одновременном редактировании на сервере и локально.
5
- 2. Проблема 21 инструмента rw_*: перегружает LLM prompt. В новом плагине объединяем в 4 сбалансированных инструмента:
6
- emote_exec,
7
- emote_fs,
8
- emote_sync,
9
- emote_tunnel.
10
- 3. Модульность: вместо спагетти в index.js реализуем 4 отдельных Cordis сервиса.
11
- 4. UI: использование нативных CSS переменных DSH, чистый React, поддержка i18n (EN/RU/ZH).
12
-
13
- ## Результаты аудита стабильности и UI (Фаза 8)
14
- 1. **Баг localMirrorPath**: в форме профиля отсутствовало поле ввода localMirrorPath, что блокировало синхронизацию зеркала (400 Error).
15
- 2. **Баг рассинхронизации полей testConnection**: бэкенд отдавал latency/os, клиент ожидал latencyMs/
16
- emoteOs.
17
- 3. **Утечка кэшированных SSH-соединений**: при смене хоста или ключа/пароля в профиле старое соединение с тем же id не сбрасывалось из пула SshService.
18
- 4. **Отсутствие UI для SSH-туннелей**: туннели были поддержаны на бэкенде, но не имели интерфейса в карточке настроек.
19
- 5. **Безопасность API**: отсутствовал фильтр isTrustedSettingsRequest(req) на мутирующих маршрутах.
@@ -1,15 +0,0 @@
1
- # progress.md — dsh-remote-workspace
2
-
3
- ## Журнал выполнения
4
- - **2026-09-08**:
5
- - Создан репозиторий goodandready/dsh-remote-workspace в Gitea.
6
- - Создана задача Issue #1: [critical] Initial implementation of dsh-remote-workspace plugin.
7
- - Оформлен дизайн-контракт docs/design/DESIGN.md.
8
- - Реализованы 4 сервиса и 4 инструмента модели.
9
- - Пройдены первые 12 тестов.
10
- - **2026-09-09**:
11
- - Обогащена документация по стандарту dsh-documentation-standard (EN/RU/ZH).
12
- - **2026-09-10**:
13
- - Создано worktree stability-and-clinebot-ui для реализации Фазы 8.
14
- - Выполнен детальный аудит стабильности кода и визуального слоя.
15
- - Подготовлен и утвержден комплексный план.
@@ -1,21 +0,0 @@
1
- # task_plan.md — dsh-remote-workspace (v0.3.1)
2
-
3
- ## Цель
4
- Реализовать 5 ключевых возможностей в рамках релиза v0.3.1:
5
- 1. Smart Tarball Sync (пакетное ускорение передачи каталогов).
6
- 2. remote_diagnose (комплексный агентский инструмент диагностики сервера).
7
- 3. Import from ~/.ssh/config (парсинг хостов в .env vault).
8
- 4. remote_env & Remote Environment Manager (безопасный редактор .env).
9
- 5. AlertService (фоновый мониторинг аномалий и события Cordis).
10
-
11
- ## Статус фаз
12
- - [ ] **Фаза 1**: Backend-сервисы (lib/tar-sync-service.js, lib/diagnose-service.js, lib/ssh-config-parser.js, lib/env-service.js, lib/alert-service.js)
13
- - [ ] **Фаза 2**: Интеграция в инструменты модели (remote_diagnose, remote_env, smart sync) и API маршруты
14
- - [ ] **Фаза 3**: Обновление клиентского интерфейса (кнопка импорта SSH, вкладка/модалка .env, индикатор алертов)
15
- - [ ] **Фаза 4**: Модульные и интеграционные тесты (test/phase4.test.mjs)
16
- - [ ] **Фаза 5**: Документация (README EN/RU/ZH, DESIGN.md) и проверка лимитов размера (<250 KiB)
17
- - [ ] **Фаза 6**: Тестовый гейт на MiniPC (dsh-test-plugin) и боевая верификация на MiniAI
18
-
19
- ## Next Step
20
- Создание backend-сервисов в lib/.
21
- EOF