@goodandready/dsh-remote-workspace 0.3.13 → 0.3.15

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/CHANGELOG.md ADDED
@@ -0,0 +1,132 @@
1
+ # Changelog
2
+
3
+ ## 0.3.15
4
+
5
+ ### Security & Hardening
6
+ - **Shell command injection prevention (#90)**: Validated service action against allowlist and strictly shell-quoted service name, journal lines, and unit arguments in `remote_service`.
7
+ - **Environment variable key validation (#96)**: Added strict regex validation (`^[A-Za-z_][A-Za-z0-9_]*$`) for environment variable keys across routes and services to prevent shell/env injection.
8
+ - **ProxyCommand and bastion safety (#97)**: Validated ProxyCommand template tokens and shell-quoted `%h` / `%p` substitutions to eliminate command injection through SSH proxy configurations.
9
+ - **Buffer caps against memory exhaustion (#99)**: Implemented maximum buffer size caps on exec stream accumulation and file read buffers with early truncation and clean teardown to prevent OOM.
10
+ - **Cross-origin and loopback enforcement (#110)**: Strengthened `isTrustedSettingsRequest` by rejecting untrusted origins and foreign hosts across all settings endpoints.
11
+
12
+ ### Stability & Reliability
13
+ - **Config volatile unwrap (#89)**: Unwrapped incoming volatile boxes in plugin `apply()` entry point, preventing empty profiles and state loss on config reload.
14
+ - **AlertService profile lifecycle (#92)**: Integrated profile resolver and full lifecycle management into `AlertService` to properly start, update, and stop alert polling.
15
+ - **3-way mirror sync conflict resolution (#91)**: Added 3-way hash-based conflict detection comparing local, remote, and baseline states to prevent silent overwrites.
16
+ - **Remote filesystem path containment (#93)**: Confined all `remote_fs` operations strictly to workspace root with path traversal blocking.
17
+ - **Route path traversal defense (#94)**: Added strict path normalization and root containment checks across all HTTP filesystem routes.
18
+ - **SSH config include safety (#95)**: Restricted `Include` directives in SSH config parser to user directory boundaries to prevent arbitrary file reading.
19
+ - **Cache invalidation on write (#98)**: Ensured cache invalidation across file writes, deletes, and remote sync operations.
20
+ - **Terminal session lifecycle (#100)**: Hardened terminal cleanup and session tracking to prevent dangling sockets and connection leaks.
21
+ - **Honest vault permissions status (#102, #107)**: Resolved `$DSH_HOME` path correctly and reported honest owner-only file permissions (0600) instead of misleading encryption labels.
22
+ - **Secret masking in remote env tool (#103)**: Masked sensitive environment values (`••••••••`) during bulk reads while permitting targeted retrieval by specific key.
23
+ - **Clean module exports (#104)**: Cleaned up unused and circular exports across services.
24
+ - **Tool render shape contract (#105)**: Conformed tool render output strictly to `[{ type: "text", text }]` shape per DSH framework contract.
25
+ - **Supply chain protection in updater (#106)**: Preserved pnpm `minimumReleaseAge` supply chain guard during plugin auto-updates.
26
+ - **Canonical theme tokens in UI (#108)**: Replaced non-canonical theme variables in client UI with standard DSH design tokens and proper fallbacks.
27
+ - **Dead import elimination (#109)**: Removed unreferenced imports and dead variables across codebase.
28
+ - **Preflight cleanups (#116)**: Annotated stream and process cleanup catch statements to ensure clean preflight verification.
29
+
30
+ ## 0.3.12
31
+
32
+ ### Fixed
33
+ - **Peer gate on DSH 0.2.0-rc.1** (#58): DSH skips a profile bundle whose `peerDependencies` exclude the running version, so this plugin was absent from the profile with no error in the UI. Every `@deepseek-ai/dsh-*` peer now names both the 0.1.7-rc.2 and 0.2.0-rc.1 lines, because semver does not admit a prerelease of the next minor into a range that does not name it.
34
+
35
+ Notable changes to `@goodandready/dsh-remote-workspace`.
36
+
37
+ ## 0.3.11
38
+
39
+ ### Fixed & Enhanced
40
+ - **DSH 0.2.0-rc.1 volatile Config and configForms support**: Patched Schemastery prototype with volatile markers for Config leaves to prevent persistence race conditions; migrated client to configForms.whileServed(['dsh-remote-workspace'], ...) with backwards-compatible fallback. (Refs: #76)
41
+ - **Env and Diagnose routes signature fix**: Corrected /env/view and /env/save endpoints to call nvService.readEnv(profile, targetPath) and
42
+ emoteFs.writeFile; updated /diagnose route to correctly supply the profile object. (Refs: #77)
43
+ - **PTY terminal session idle reaper**: Added background idle session sweeper to sweep abandoned PTY sessions and terminate dedicated SSH connections after 10 minutes with zero subscribers. (Refs: #78)
44
+ - **Tunnel socket cleanup and telemetry guard**: Added mutual stream/socket destruction on error or close and guarded single cleanup invocation to prevent telemetry counter drift. (Refs: #79)
45
+ - **Tar sync process and stream error handlers**: Added error listeners to local tar processes and streams in TarSyncService to prevent unhandled exceptions and dangling child processes. (Refs: #80)
46
+ - **Shell argument sanitization**: Hardened remote command invocations by strictly POSIX-quoting paths and coercing numeric parameters (ports, tail line limits) to integers. (Refs: #81)
47
+ - **Binary file integrity in mirror sync pull**: Switched remote file pulls to read raw binary buffers instead of utf-8 strings, eliminating checksum mismatch and binary corruption. (Refs: #82)
48
+ - **Filesystem watcher error handling and push lock**: Added error listener to s.watch against ENOSPC / OS errors and per-profile concurrency lock preventing overlapping pushes. (Refs: #83)
49
+ - **Poll gate and host projection leakage guard**: Wired tab visibility poll gate into client to pause background polling when tab is hidden; added verification tests for secret masking in host projections. (Refs: #84)
50
+
51
+ ## 0.3.10
52
+
53
+ ### Security & Fixed
54
+ - **Session-aware connection authentication**: all API routes are guarded by `authorizeRequest()` using DSH `connection.requestRejection(req)`, failing closed (503) if connection is unavailable and rejecting non-loopback clients with forged Origin/Sec-Fetch headers (403/401) before calling sensitive services. (Refs: #72)
55
+ - **Bounded body reader**: `readBody()` checks `Content-Length` upfront, enforces a 5 MiB ceiling, and immediately stops accumulating chunks and removes listeners upon cap violation with HTTP 413, preventing memory exhaustion and OOM. (Refs: #73)
56
+ - **Timer lifecycle cleanup**: `clearInterval(idleTimer)` added to plugin `dispose` cleanup, preventing lingering background interval timers on plugin reload. (Refs: #74)
57
+
58
+ ## 0.3.9
59
+
60
+ ### Added
61
+ - **ProxyCommand and ProxyJump**: a profile can dial through an OpenSSH proxy command or a chain of bastions. The first jump uses the connection pool. Later jumps use their own connections and close when the target closes.
62
+ - **SSH agent and keyboard sign-in**: authentication can use an agent socket, Pageant, or `SSH_AUTH_SOCK`. A keyboard-interactive prompt appears on the settings card and expires after 60 seconds.
63
+ - **SSH config import**: importing `~/.ssh/config` follows `Include` and reports skipped wildcard, Match, duplicate, and missing-include blocks.
64
+ - **Host groups and cluster commands**: hosts can be grouped by environment or tag and tested together. `remote_cluster` runs one command across the hosts that match an environment, tags, and aliases, with up to 8 workers by default.
65
+ - **Compact host table**: `remote_hosts` gives the model a markdown table. Passwords, keys, and proxy commands are omitted.
66
+ - **Idle sessions**: a pooled SSH connection closes after 30 minutes without use. A tunnel or a running command keeps its connection.
67
+ - **Command reconnect**: a command that loses the connection before any output is tried up to three times. Output that already started, a command timeout, and `idempotent: false` are not retried.
68
+ - **Dedicated terminal**: each terminal opens its own SSH connection and closes it with the session.
69
+ - **Terminal font**: `terminalFontFamily` changes the terminal face. Characters that could alter the stylesheet are rejected.
70
+ - **Quieter polling**: the settings card waits while the browser tab is hidden and does not overlap status requests.
71
+ - **Browser file transfer**: a file can be uploaded or downloaded with byte progress and cancel. The limit is 512MB. An upload replaces the remote file only after the temporary upload finishes.
72
+ - **Center workspace**: a sidebar button opens hosts, terminal, files, containers, tunnels, and cluster in the center column. Leaving for chat hides the column and keeps the terminal buffer.
73
+
74
+ ## 0.3.8
75
+
76
+ ### Fixed
77
+ - **Connection test payload**: the Test button posts the profile fields, so the route receives `host` and can open SSH. (Refs: #52)
78
+ - **Loopback-only plugin update**: `POST` update accepts a loopback same-origin request with the update header. A private-network address alone is not trusted. (Refs: #53)
79
+ - **Design contract stays internal**: `docs/design/DESIGN.md` is no longer included in the npm package or the public GitHub tree. (Refs: #54)
80
+ - **Visible action errors**: save, delete, activation, directory browse, and tunnel stop show the server error in an alert. (Refs: #55)
81
+ - **Connection badge**: the card header reads `/dsh-remote-workspace/state` and no longer shows a permanent Ready label. (Refs: #56)
82
+ - **Shared chevron**: the header uses `IconChevronDownOutline14` when the primitives package provides it. (Refs: #57)
83
+ - **Settings modules**: profile, sync, and tunnel cards are separate client modules. (Refs: #58)
84
+ - **Tarball ignore**: `.gitignore` matches `*.tgz` without surrounding spaces. (Refs: #59)
85
+ - **Localized plugin-list label**: the list title comes from the English and Chinese dictionaries. (Refs: #60)
86
+ - **Updater version**: the row shows the installed version from the status response, or "Version unknown". It no longer prints a hardcoded `0.3.1`. (Refs: #61)
87
+
88
+ ## 0.3.7
89
+
90
+ ### Fixed
91
+ - **Clean npm pack output**: Suppressed compilation progress to stderr in `scripts/build-client.mjs`, ensuring `npm pack --dry-run --json` parses cleanly without JSON syntax errors. (Refs: #28)
92
+ - **File browsing API contract**: Resolved `remoteFs.list` method mismatch in `/browse` endpoint to use `remoteFs.listDir`, added backwards-compatible `RemoteFsService.prototype.list` alias, returned both `entries` and `items` along with `currentPath`, and supported inline draft profiles from settings modals. (Refs: #24)
93
+ - **Terminal session argument passing**: Fixed `POST /terminal/create` to pass `{ cols, rows }` options object to `createTerminalSession`, added positional parameter fallback in `SshService`, and added active profile fallback. (Refs: #29)
94
+ - **Shell metacharacter sanitization in tar sync**: Implemented POSIX `shellQuote` escaping in `TarSyncService` for all `remoteDir` paths, preventing command injection in `tar` and `mkdir` execution, with non-empty input validation. (Refs: #26)
95
+ - **Binary data integrity in mirror sync**: Handled file synchronizations with raw `Buffer` streams instead of forced `utf8` strings, preventing binary corruption of assets (images, archives, compiled artifacts), and added `.dsh-sync-snapshot.json` to default ignore list. (Refs: #27)
96
+ - **Performance in mirror sync push**: Deduplicated redundant sequential `mkdir -p` SSH executions during `push` operations via parent directory tracking Set. (Refs: #30)
97
+ - **CSRF loopback protection**: Eliminated loopback bypass in `isTrustedSettingsRequest` by rejecting requests where `Sec-Fetch-Site !== 'same-origin'`, and validating `Origin` against request `Host`. (Refs: #25)
98
+ - **Authorization token validation**: Enforced strict comparison of incoming Bearer and cookie tokens against configured server auth token (`DSH_AUTH_TOKEN` / `DSH_TOKEN`), rejecting arbitrary tokens and substrings from untrusted sources. (Refs: #32)
99
+ - **GET endpoints source authorization & credential masking**: Enforced `isTrustedSettingsRequest` across 5 sensitive read endpoints (`/state`, `/health`, `/docker/list`, `/terminal/stream`, `/tunnels/telemetry`), and added fallback credential masking (`••••••••`) when vault service is not configured. (Refs: #31)
100
+
101
+ ## 0.3.6
102
+
103
+ ### Fixed
104
+ - **Object-root JSON Schema for all remote tools**: Wrapped all 9 tool registrations (`remote_exec`, `remote_fs`, `remote_sync`, `remote_tunnel`, `remote_docker`, `remote_service`, `remote_transfer`, `remote_diagnose`, `remote_env`) with canonical `defineTool()` from `@deepseek-ai/dsh-tools`. Parameters are now compiled into standard JSON Schema objects with root `type: "object"`, preventing rejection by OpenAI-compatible LLM providers (`schema must be a JSON Schema of 'type: "object"', got 'type: null'`). Output schema standardized with `additionalProperties: true`. (Refs: #33)
105
+
106
+ ## 0.3.5
107
+
108
+ ### Fixed
109
+ - **Settings reachable again on the plugin's own page**: the current DSH core
110
+ (0.1.6-alpha.2) renders a plugin's configuration page only for entries registered
111
+ in the plugin-list seat `plugins.item`. The view-aware card is now registered there
112
+ (`id: 'dsh-remote-workspace'`, order 60, static label); the row seat and the legacy
113
+ `settings.plugin.item` card stay as fallbacks. Sources edited in `src/client/entry.js`,
114
+ `lib/client.js` regenerated.
115
+
116
+ ## 0.3.4
117
+
118
+ ### Fixed
119
+ - **Settings reachable again**: the card registered into `settings.plugin.item`, a
120
+ slot the current DSH core (0.1.6-alpha.2) no longer renders, so the plugin's
121
+ settings were unreachable. The surface now registers into the Plugins page row
122
+ seat `plugins.row.config` first, keyed
123
+ `@goodandready/dsh-remote-workspace#dsh-remote-workspace`
124
+ (`rowConfigKey(package, rowId)`): the plugin's row gains a configure control whose
125
+ page is the settings form (`view: 'page'`, open and without our card chrome — the
126
+ host page draws the title, icon, crumb and padding) plus a one-line state for
127
+ `view: 'summary'`. The legacy seat stays registered as a fallback for older cores.
128
+ - The change lives in `src/client/*` and the build prefix; `lib/client.js` is
129
+ regenerated by `npm run build:client` (also run by `npm test`).
130
+
131
+ ### Added
132
+ - This changelog.
@@ -4,10 +4,11 @@ export class AlertService {
4
4
  * @param {import('./health-service.js').HealthService} healthService
5
5
  * @param {import('./docker-service.js').DockerService} dockerService
6
6
  */
7
- constructor(ctx, healthService, dockerService) {
7
+ constructor(ctx, healthService, dockerService, profileResolver = null) {
8
8
  this.ctx = ctx;
9
9
  this.healthService = healthService;
10
10
  this.dockerService = dockerService;
11
+ this.profileResolver = typeof profileResolver === 'function' ? profileResolver : null;
11
12
  /** @type {NodeJS.Timeout|null} */
12
13
  this.timer = null;
13
14
  /** @type {Array<any>} */
@@ -16,11 +17,21 @@ export class AlertService {
16
17
  this.enabled = false;
17
18
  }
18
19
 
19
- start(intervalMs = 300000) {
20
+ setProfileResolver(fn) {
21
+ this.profileResolver = typeof fn === 'function' ? fn : null;
22
+ }
23
+
24
+ start(intervalMs = 300000, profileResolver = null) {
20
25
  this.stop();
26
+ if (typeof profileResolver === 'function') {
27
+ this.profileResolver = profileResolver;
28
+ }
21
29
  this.checkIntervalMs = intervalMs;
22
30
  this.enabled = true;
23
- this.timer = setInterval(() => this.check(), this.checkIntervalMs);
31
+ this.timer = setInterval(() => {
32
+ this.check().catch(() => {});
33
+ }, this.checkIntervalMs);
34
+ this.timer?.unref?.();
24
35
  }
25
36
 
26
37
  stop() {
@@ -34,8 +45,10 @@ export class AlertService {
34
45
  /**
35
46
  * Run single alert check across active profile
36
47
  */
37
- async check(profile) {
38
- if (!profile) return [];
48
+ async check(profile = null) {
49
+ const target = profile || (typeof this.profileResolver === 'function' ? this.profileResolver() : null);
50
+ if (!target) return [];
51
+ profile = target;
39
52
 
40
53
  const alerts = [];
41
54
  try {
package/lib/client.js CHANGED
@@ -34,23 +34,23 @@ window.__ModuleLoader__.load({
34
34
  .drw-grid-4{display:grid;grid-template-columns:repeat(auto-fit, minmax(190px, 1fr));gap:10px}
35
35
 
36
36
  .drw-badge{font-size:11px;padding:3px 9px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500;font-family:monospace}
37
- .drw-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-bg, var(--dsw-alias-bg-layer-2))}
38
- .drw-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-state-warning-bg, var(--dsw-alias-bg-layer-2))}
39
- .drw-badge-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-state-error-bg, var(--dsw-alias-bg-layer-2))}
37
+ .drw-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-secondary, var(--dsw-alias-bg-layer-2))}
38
+ .drw-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-secondary, var(--dsw-alias-bg-layer-2))}
39
+ .drw-badge-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-state-error-secondary, var(--dsw-alias-bg-layer-2))}
40
40
 
41
41
  .drw-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;width:100%;box-sizing:border-box}
42
- .drw-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}
42
+ .drw-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary, var(--dsw-alias-border-brand))}
43
43
  .drw-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);margin-bottom:2px}
44
44
  .drw-hint{font-size:11px;color:var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary));line-height:1.3}
45
45
  .drw-field{display:flex;flex-direction:column;gap:3px;flex:1;min-width:180px}
46
46
 
47
47
  .drw-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}
48
- .drw-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
48
+ .drw-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
49
49
  .drw-btn:disabled{opacity:0.6;cursor:not-allowed}
50
50
  .drw-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
51
51
  .drw-btn-primary:hover:not(:disabled){opacity:0.9}
52
- .drw-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-border, var(--dsw-alias-border-l2))}
53
- .drw-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-bg, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-state-error-border, var(--dsw-alias-border-l2))}
52
+ .drw-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-secondary, var(--dsw-alias-border-l2))}
53
+ .drw-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-secondary, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-state-error-secondary, var(--dsw-alias-border-l2))}
54
54
 
55
55
  .drw-segmented{display:inline-flex;border-radius:8px;padding:2px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);gap:2px}
56
56
  .drw-segmented-item{appearance:none;border:none;background:none;cursor:pointer;padding:5px 12px;font-size:12px;font-weight:500;border-radius:6px;color:var(--dsw-alias-label-secondary);transition:all .15s ease}
@@ -73,14 +73,14 @@ window.__ModuleLoader__.load({
73
73
 
74
74
  .drw-tabs{display:flex;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:10px;margin-bottom:14px;overflow-x:auto}
75
75
  .drw-tab-btn{appearance:none;border:1px solid transparent;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);padding:6px 14px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:all .15s ease}
76
- .drw-tab-btn:hover{background:var(--dsw-alias-bg-layer-4);color:var(--dsw-alias-label-primary)}
77
- .drw-tab-btn.active{background:var(--dsw-alias-bg-layer-4);border-color:var(--dsw-alias-state-brand-primary);color:var(--dsw-alias-state-brand-primary)}
76
+ .drw-tab-btn:hover{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}
77
+ .drw-tab-btn.active{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-brand-primary, var(--dsw-alias-border-brand));color:var(--dsw-alias-brand-primary)}
78
78
 
79
79
  .drw-term-window{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;display:flex;flex-direction:column;height:340px;overflow:hidden;box-shadow:0 4px 14px var(--dsw-alias-border-l1)}
80
80
  .drw-term-header{background:var(--dsw-alias-bg-layer-2);padding:6px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12px;color:var(--dsw-alias-label-secondary);font-family:var(--drw-terminal-font,monospace)}
81
81
  .drw-term-body{flex:1;padding:10px 12px;overflow-y:auto;font-family:var(--drw-terminal-font,ui-monospace,SFMono-Regular,Consolas,monospace);font-size:12.5px;line-height:1.45;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-all}
82
82
  .drw-term-footer{display:flex;background:var(--dsw-alias-bg-layer-2);border-top:1px solid var(--dsw-alias-border-l2);padding:6px}
83
- .drw-term-input{flex:1;background:transparent;border:none;color:var(--dsw-alias-link-primary, var(--dsw-alias-label-primary));font-family:var(--drw-terminal-font,monospace);font-size:13px;padding:4px 8px;outline:none}
83
+ .drw-term-input{flex:1;background:transparent;border:none;color:var(--dsw-alias-link, var(--dsw-alias-label-primary));font-family:var(--drw-terminal-font,monospace);font-size:13px;padding:4px 8px;outline:none}
84
84
 
85
85
  .drw-explorer-box{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:12px;display:flex;flex-direction:column;gap:10px}
86
86
  .drw-explorer-nav{display:flex;align-items:center;gap:8px;font-family:monospace;font-size:12px;background:var(--dsw-alias-bg-layer-1);padding:6px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2)}
@@ -89,7 +89,7 @@ window.__ModuleLoader__.load({
89
89
  .drw-file-row:hover{background:var(--dsw-alias-bg-layer-3)}
90
90
  .drw-file-meta{display:flex;align-items:center;gap:12px;color:var(--dsw-alias-label-secondary);font-size:11.5px}
91
91
 
92
- .drw-modal-overlay{position:fixed;inset:0;background:var(--dsw-alias-mask, var(--dsw-alias-bg-layer-1));z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px}
92
+ .drw-modal-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-1, var(--dsw-alias-bg-layer-1));z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px}
93
93
  .drw-modal-dialog{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;width:100%;max-width:800px;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 32px var(--dsw-alias-border-l1)}
94
94
  .drw-modal-hdr{padding:12px 16px;border-bottom:1px solid var(--dsw-alias-border-l2);display:flex;align-items:center;justify-content:space-between;font-weight:600}
95
95
  .drw-modal-body{flex:1;padding:12px 16px;overflow-y:auto}
@@ -105,8 +105,8 @@ window.__ModuleLoader__.load({
105
105
  .drw-env-table{width:100%;border-collapse:collapse}
106
106
  .drw-env-row{display:grid;grid-template-columns:220px 1fr 90px;gap:8px;padding:6px 0;align-items:center;border-bottom:1px solid var(--dsw-alias-border-l2)}
107
107
  .drw-alert-badge{padding:4px 8px;border-radius:6px;font-size:11px;font-family:monospace;display:flex;align-items:center;gap:6px}
108
- .drw-alert-critical{background:var(--dsw-alias-state-error-bg, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-border, var(--dsw-alias-border-l2))}
109
- .drw-alert-warning{background:var(--dsw-alias-state-warning-bg, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-border, var(--dsw-alias-border-l2))}
108
+ .drw-alert-critical{background:var(--dsw-alias-state-error-secondary, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-secondary, var(--dsw-alias-border-l2))}
109
+ .drw-alert-warning{background:var(--dsw-alias-state-warn-secondary, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-warn-primary);border:1px solid var(--dsw-alias-state-warn-secondary, var(--dsw-alias-border-l2))}
110
110
 
111
111
 
112
112
  .drw-ws{min-height:100%;box-sizing:border-box;color:var(--dsw-alias-label-primary)}
@@ -1029,7 +1029,7 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
1029
1029
  ? React.createElement(
1030
1030
  'form',
1031
1031
  { className: 'drw-term-footer', onSubmit: sendCommand },
1032
- React.createElement('span', { style: { color: 'var(--dsw-alias-link-primary)', padding: '4px 6px', fontFamily: 'inherit' } }, '$'),
1032
+ React.createElement('span', { style: { color: 'var(--dsw-alias-link, var(--dsw-alias-label-primary))', padding: '4px 6px', fontFamily: 'inherit' } }, '$'),
1033
1033
  React.createElement('input', {
1034
1034
  className: 'drw-term-input',
1035
1035
  type: 'text',
@@ -1249,7 +1249,7 @@ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[dat
1249
1249
  '⬆️ Up'
1250
1250
  ),
1251
1251
  React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, 'Path:'),
1252
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-brand-primary)' } }, currentPath)
1252
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary))' } }, currentPath)
1253
1253
  ),
1254
1254
  React.createElement(
1255
1255
  'div',
@@ -2839,10 +2839,10 @@ function shouldPoll({ hidden = false, inFlight = false } = {}) {
2839
2839
  null,
2840
2840
  React.createElement('span', { style: { fontWeight: 500 } }, (updateStatus?.currentVersion ? 'v' + updateStatus.currentVersion : t('versionUnknown'))),
2841
2841
  updateStatus?.updateAvailable
2842
- ? React.createElement('span', { style: { marginLeft: '8px', color: 'var(--dsw-alias-state-warning-text)' } }, '→ v' + updateStatus.latestVersion)
2842
+ ? React.createElement('span', { style: { marginLeft: '8px', color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } }, '→ v' + updateStatus.latestVersion)
2843
2843
  : null,
2844
2844
  msg
2845
- ? React.createElement('div', { style: { fontSize: '11px', marginTop: '3px', color: msg.ok ? 'var(--dsw-alias-state-success-text)' : 'var(--dsw-alias-state-error-text)' } }, msg.text)
2845
+ ? React.createElement('div', { style: { fontSize: '11px', marginTop: '3px', color: msg.ok ? 'var(--dsw-alias-state-success-primary, #22c55e)' : 'var(--dsw-alias-state-error-primary, #ef4444)' } }, msg.text)
2846
2846
  : null
2847
2847
  )
2848
2848
  ),
@@ -73,7 +73,12 @@ export class EnvService {
73
73
  * Set or update variable in remote .env file atomically
74
74
  */
75
75
  async setEnvVar(profile, filePath, key, value) {
76
- if (!key || typeof key !== 'string') throw new Error('Key must be non-empty string');
76
+ const VALID_KEY_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
77
+ if (!key || typeof key !== 'string' || !VALID_KEY_REGEX.test(key.trim())) {
78
+ throw new Error(`Invalid environment variable key: "${key}". Key must match /^[A-Za-z_][A-Za-z0-9_]*$/ and contain no newlines or delimiters.`);
79
+ }
80
+ const cleanKey = key.trim();
81
+ const strVal = String(value ?? '');
77
82
 
78
83
  let currentContent = '';
79
84
  try {
@@ -86,14 +91,19 @@ export class EnvService {
86
91
  let updated = false;
87
92
  const newLines = [];
88
93
 
94
+ const formatValue = (v) => {
95
+ if (v.includes(' ') || v.includes('\n') || v.includes('"') || v.includes('=')) {
96
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}"`;
97
+ }
98
+ return v;
99
+ };
100
+
89
101
  for (const line of lines) {
90
102
  const trimmed = line.trim();
91
103
  if (!trimmed.startsWith('#') && trimmed.includes('=')) {
92
104
  const k = trimmed.slice(0, trimmed.indexOf('=')).trim();
93
- if (k === key) {
94
- // Quote if contains spaces or special symbols
95
- const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
96
- newLines.push(`${key}=${formattedVal}`);
105
+ if (k === cleanKey) {
106
+ newLines.push(`${cleanKey}=${formatValue(strVal)}`);
97
107
  updated = true;
98
108
  continue;
99
109
  }
@@ -102,11 +112,10 @@ export class EnvService {
102
112
  }
103
113
 
104
114
  if (!updated) {
105
- const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
106
115
  if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
107
116
  newLines.push('');
108
117
  }
109
- newLines.push(`${key}=${formattedVal}`);
118
+ newLines.push(`${cleanKey}=${formatValue(strVal)}`);
110
119
  }
111
120
 
112
121
  const finalContent = newLines.join('\n');
@@ -115,7 +124,7 @@ export class EnvService {
115
124
  return {
116
125
  ok: true,
117
126
  filePath,
118
- key,
127
+ key: cleanKey,
119
128
  updated
120
129
  };
121
130
  }
@@ -1,12 +1,5 @@
1
1
  const MAX_BYTES = 512 * 1024 * 1024;
2
2
 
3
- export function transferPercent(loaded, total) {
4
- const done = Number(loaded) || 0;
5
- const size = Number(total) || 0;
6
- if (size <= 0) return 0;
7
- return Math.max(0, Math.min(100, Math.round((done / size) * 100)));
8
- }
9
-
10
3
  export function attachmentName(filePath) {
11
4
  const base = String(filePath || '').split('/').filter(Boolean).pop() || 'download';
12
5
  const clean = base.replace(/[\r\n"\\]/g, '');
@@ -36,7 +36,3 @@ export function renderHostTable(profiles) {
36
36
  return [header, ...lines].join('\n');
37
37
  }
38
38
 
39
- export function hostProjectionLeaks(value) {
40
- const text = typeof value === 'string' ? value : JSON.stringify(value);
41
- return SECRET.some((key) => text.includes(key));
42
- }
package/lib/index.js CHANGED
@@ -71,7 +71,7 @@ export const Config = z.object({
71
71
  });
72
72
 
73
73
  export function apply(ctx, config = {}) {
74
- const cfg = config || {};
74
+ const cfg = plainConfig(config || {});
75
75
  let settingsApi = null;
76
76
  let currentConfig = {
77
77
  profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
@@ -91,6 +91,11 @@ export function apply(ctx, config = {}) {
91
91
  const dockerService = new DockerService(sshService);
92
92
  const healthService = new HealthService(sshService);
93
93
  const watcherService = new WatcherService();
94
+ watcherService.setOnSync(({ profileId, syncRes }) => {
95
+ if (ctx && typeof ctx.emit === 'function') {
96
+ ctx.emit('remote-workspace/sync', { profileId, syncRes });
97
+ }
98
+ });
94
99
  const tarSyncService = new TarSyncService(sshService);
95
100
  const diagnoseService = new DiagnoseService(sshService);
96
101
  const envService = new EnvService(remoteFs);
@@ -117,7 +122,7 @@ export function apply(ctx, config = {}) {
117
122
  const persistConfig = async (actionLabel) => {
118
123
  if (settingsApi?.replace) {
119
124
  try {
120
- await settingsApi.replace(Config(currentConfig));
125
+ await settingsApi.replace(plainConfig(Config(currentConfig)));
121
126
  } catch (err) {
122
127
  if (ctx.logger?.warn) {
123
128
  ctx.logger.warn(`[dsh-remote-workspace] Failed to persist config after ${actionLabel}: ${err.message}`);
@@ -143,6 +148,13 @@ export function apply(ctx, config = {}) {
143
148
  return p ? vaultService.hydrateProfile(p) : undefined;
144
149
  },
145
150
  isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
151
+ isAlertMonitoringEnabled() { return Boolean(currentConfig.alertMonitoringActive); },
152
+ async setAlertMonitoringEnabled(enabled) {
153
+ currentConfig.alertMonitoringActive = Boolean(enabled);
154
+ if (enabled) alertService.start();
155
+ else alertService.stop();
156
+ await persistConfig('update');
157
+ },
146
158
  getTerminalFont() { return currentConfig.terminalFontFamily || ''; },
147
159
  async setTerminalFont(fontFamily) {
148
160
  currentConfig.terminalFontFamily = normalizeTerminalFont(fontFamily);
@@ -237,12 +249,19 @@ export function apply(ctx, config = {}) {
237
249
  }
238
250
  };
239
251
 
252
+ alertService.setProfileResolver(() => store.getActiveProfile());
253
+
240
254
  // If autoSync was enabled on restart, resume watcher for active profile
241
255
  if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
242
256
  const active = store.getActiveProfile();
243
257
  if (active) watcherService.start(active, mirrorSync);
244
258
  }
245
259
 
260
+ // If alert monitoring was enabled on restart, start alertService
261
+ if (currentConfig.alertMonitoringActive) {
262
+ alertService.start();
263
+ }
264
+
246
265
 
247
266
 
248
267
  // Register settings namespace if settings service is available
@@ -258,7 +277,11 @@ export function apply(ctx, config = {}) {
258
277
  if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
259
278
  if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
260
279
  if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
261
- if (snap.alertMonitoringActive !== undefined) currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
280
+ if (snap.alertMonitoringActive !== undefined) {
281
+ currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
282
+ if (snap.alertMonitoringActive) alertService.start();
283
+ else alertService.stop();
284
+ }
262
285
  }
263
286
  } else if (typeof settingsService.configure === 'function') {
264
287
  try {
@@ -211,6 +211,26 @@ export class MirrorSyncService {
211
211
  continue; // Unchanged locally
212
212
  }
213
213
 
214
+ // 3-way conflict detection: if not forcing, check if remote was modified independently
215
+ if (!force && baseHash) {
216
+ let remoteContent = null;
217
+ let remoteHash = null;
218
+ try {
219
+ remoteContent = await this.remoteFs.readFile(profile, remoteFull, null);
220
+ remoteHash = this.computeHash(remoteContent);
221
+ } catch (_) {
222
+ // File does not exist remotely, safe to push
223
+ }
224
+
225
+ if (remoteHash && remoteHash !== baseHash && remoteHash !== localHash) {
226
+ conflicts.push({
227
+ path: rel,
228
+ reason: 'Modified on both remote and local'
229
+ });
230
+ continue; // Do not overwrite without force
231
+ }
232
+ }
233
+
214
234
  if (!dryRun) {
215
235
  const parent = path.posix.dirname(remoteFull);
216
236
  if (!createdDirs.has(parent)) {
@@ -233,7 +253,7 @@ export class MirrorSyncService {
233
253
  }
234
254
 
235
255
  return {
236
- success: true,
256
+ success: conflicts.length === 0,
237
257
  pushed,
238
258
  conflicts,
239
259
  dryRun
@@ -3,20 +3,52 @@ import { Duplex } from 'node:stream';
3
3
 
4
4
  const TOKEN = /%[%hprn]/g;
5
5
 
6
+ /**
7
+ * Validate that a ProxyCommand template does not contain dangerous shell chaining or redirection metacharacters.
8
+ * @param {string} template
9
+ */
10
+ export function validateProxyCommandTemplate(template) {
11
+ if (!template || typeof template !== 'string') return;
12
+ const trimmed = template.trim();
13
+ if (!trimmed) return;
14
+ if (/[\n\r;&|`$><]/.test(trimmed)) {
15
+ const err = new Error('ProxyCommand must not contain shell chaining or interpolation metacharacters (;, &, |, `, $, >, <, newlines)');
16
+ err.statusCode = 400;
17
+ throw err;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Shell-quote a token value for safe interpolation into ProxyCommand.
23
+ * @param {any} arg
24
+ */
25
+ export function shellQuoteToken(arg) {
26
+ if (arg === '' || arg === undefined || arg === null) return "''";
27
+ const str = String(arg);
28
+ if (/^[A-Za-z0-9_.:-]+$/.test(str)) {
29
+ return str;
30
+ }
31
+ return `'${str.replace(/'/g, "'\\''")}'`;
32
+ }
33
+
6
34
  /**
7
35
  * Expand OpenSSH ProxyCommand tokens. %% stays a literal percent.
8
36
  * @param {string} template
9
37
  * @param {{ host?: string, port?: number|string, user?: string, alias?: string }} tokens
10
38
  */
11
39
  export function expandProxyCommand(template, tokens = {}) {
40
+ if (!template || typeof template !== 'string') return '';
41
+ validateProxyCommandTemplate(template);
42
+
43
+ const safePort = String(parseInt(tokens.port, 10) || (tokens.port ? String(tokens.port).replace(/\D/g, '') : '22'));
12
44
  const map = {
13
- '%h': String(tokens.host ?? ''),
14
- '%p': String(tokens.port ?? ''),
15
- '%r': String(tokens.user ?? ''),
16
- '%n': String(tokens.alias ?? ''),
45
+ '%h': shellQuoteToken(tokens.host),
46
+ '%p': safePort,
47
+ '%r': shellQuoteToken(tokens.user),
48
+ '%n': shellQuoteToken(tokens.alias),
17
49
  '%%': '%'
18
50
  };
19
- return String(template ?? '').replace(TOKEN, (token) => (token in map ? map[token] : token));
51
+ return String(template).replace(TOKEN, (token) => (token in map ? map[token] : token));
20
52
  }
21
53
 
22
54
  /**
@@ -25,6 +57,13 @@ export function expandProxyCommand(template, tokens = {}) {
25
57
  * @param {string} command
26
58
  */
27
59
  export function openProxyCommand(command) {
60
+ if (!command || typeof command !== 'string' || !command.trim()) {
61
+ throw new Error('ProxyCommand must be a non-empty string');
62
+ }
63
+ if (/[\n\r]/.test(command)) {
64
+ throw new Error('ProxyCommand must not contain newlines');
65
+ }
66
+
28
67
  const windows = process.platform === 'win32';
29
68
  const shell = windows ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
30
69
  const args = windows ? ['/d', '/s', '/c', command] : ['-c', command];
@@ -25,11 +25,22 @@ export class RemoteFsService {
25
25
  this.cache.clear();
26
26
  return;
27
27
  }
28
- const prefix = subPath ? `${profileId}:` : `${profileId}:`;
28
+ const prefix = `${profileId}:`;
29
29
  for (const key of this.cache.keys()) {
30
30
  if (key.startsWith(prefix)) {
31
- if (!subPath || key.includes(subPath)) {
31
+ if (!subPath) {
32
32
  this.cache.delete(key);
33
+ } else {
34
+ const secondColon = key.indexOf(':', prefix.length);
35
+ if (secondColon !== -1) {
36
+ const cachedPath = key.slice(secondColon + 1);
37
+ const normSub = path.posix.normalize(subPath);
38
+ const normCached = path.posix.normalize(cachedPath);
39
+ const subPrefix = normSub.endsWith('/') ? normSub : normSub + '/';
40
+ if (normCached === normSub || normCached.startsWith(subPrefix)) {
41
+ this.cache.delete(key);
42
+ }
43
+ }
33
44
  }
34
45
  }
35
46
  }
@@ -100,17 +111,34 @@ export class RemoteFsService {
100
111
  /**
101
112
  * Read file content with buffer/string conversion
102
113
  */
103
- async readFile(profile, remotePath, encoding = 'utf8') {
114
+ async readFile(profile, remotePath, encoding = 'utf8', maxBytes = 20 * 1024 * 1024) {
104
115
  const sftp = await this.sshService.getSftp(profile);
105
116
  return new Promise((resolve, reject) => {
106
117
  const chunks = [];
118
+ let totalBytes = 0;
119
+ let destroyed = false;
107
120
  const stream = sftp.createReadStream(remotePath);
108
- stream.on('data', (chunk) => chunks.push(chunk));
121
+ stream.on('data', (chunk) => {
122
+ if (destroyed) return;
123
+ totalBytes += chunk.length;
124
+ if (totalBytes > maxBytes) {
125
+ destroyed = true;
126
+ try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
127
+ const err = new Error(`ERR_FILE_TOO_LARGE: File size exceeds maximum allowed buffer of ${maxBytes / (1024 * 1024)}MB: ${remotePath}`);
128
+ err.code = 'ERR_FILE_TOO_LARGE';
129
+ return reject(err);
130
+ }
131
+ chunks.push(chunk);
132
+ });
109
133
  stream.on('end', () => {
134
+ if (destroyed) return;
110
135
  const buf = Buffer.concat(chunks);
111
136
  resolve(encoding ? buf.toString(encoding) : buf);
112
137
  });
113
- stream.on('error', (err) => reject(err));
138
+ stream.on('error', (err) => {
139
+ if (destroyed) return;
140
+ reject(err);
141
+ });
114
142
  });
115
143
  }
116
144
 
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
1
+ import { writeJson, readBody, authorizeRequest } from './helpers.js';
2
2
 
3
3
  export function registerDockerRoutes(sctx, webServer, { dockerService, healthService, store }) {
4
4
  // 21. GET /dsh-remote-workspace/health