dsh-remote 0.8.53 → 0.8.60

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.
@@ -0,0 +1,169 @@
1
+ // dsh-remote — HTTP routes for the embedded terminal.
2
+ //
3
+ // Four small endpoints around one host-owned PTY session (see lib/terminal.js
4
+ // for why polling instead of a WebSocket):
5
+ //
6
+ // POST /dsh-remote/term/open {cols, rows} → {id}
7
+ // POST /dsh-remote/term/input {id, data} → {ok}
8
+ // GET /dsh-remote/term/output ?id&from=N → {chunks, next, lossy, closed}
9
+ // POST /dsh-remote/term/resize {id, cols, rows} → {ok}
10
+ // POST /dsh-remote/term/close {id} → {ok}
11
+ //
12
+ // Every route resolves the binding from the SESSION first (the same
13
+ // `resolveRequestBinding` the file routes use), so a terminal can only ever be
14
+ // opened on — and attached to — the machine that session is actually bound to.
15
+ // That is the same isolation rule that keeps one session from acting on
16
+ // another's host, applied to an interactive channel where a mistake would be
17
+ // even more visible.
18
+
19
+ /**
20
+ * Build the terminal routes.
21
+ *
22
+ * @param {object} deps
23
+ * @param {Function} deps.sendJson
24
+ * @param {Function} deps.readBody
25
+ * @param {Function} deps.resolveRequestBinding - session-aware binding resolver.
26
+ * @param {object} deps.terminals - the manager from createTerminalManager().
27
+ * @param {Function} deps.machineKey - (binding) → a stable machine identity.
28
+ * @returns {Array<object>} route descriptors for webServer.register().
29
+ */
30
+ export function createTerminalRoutes({ sendJson, readBody, resolveRequestBinding, terminals, machineKey }) {
31
+ /** Resolve the binding and derive the machine identity, or reply and return null. */
32
+ const bind = async (req, res, body = {}) => {
33
+ let b
34
+ try {
35
+ b = await resolveRequestBinding(req, body)
36
+ } catch (err) {
37
+ sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
38
+ return null
39
+ }
40
+ if (!b) return null
41
+ return { b, machine: machineKey(b) }
42
+ }
43
+
44
+ return [
45
+ {
46
+ kind: 'exact',
47
+ path: '/dsh-remote/term/open',
48
+ handler: async (req, res) => {
49
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
50
+ try {
51
+ const body = JSON.parse((await readBody(req)) || '{}')
52
+ const bound = await bind(req, res, body)
53
+ if (!bound) return
54
+ const r = await terminals.open({
55
+ pool: bound.b.pool,
56
+ machine: bound.machine,
57
+ cols: Number(body.cols) || 80,
58
+ rows: Number(body.rows) || 24,
59
+ })
60
+ if (!r.ok) return sendJson(res, 503, { ok: false, error: r.error })
61
+ return sendJson(res, 200, { ok: true, id: r.id })
62
+ } catch (err) {
63
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
64
+ }
65
+ },
66
+ },
67
+
68
+ {
69
+ kind: 'exact',
70
+ path: '/dsh-remote/term/input',
71
+ handler: async (req, res) => {
72
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
73
+ try {
74
+ const body = JSON.parse((await readBody(req)) || '{}')
75
+ const bound = await bind(req, res, body)
76
+ if (!bound) return
77
+ const { session, error } = terminals.owned(body.id, bound.machine)
78
+ if (error) return sendJson(res, 404, { ok: false, error })
79
+ const data = typeof body.data === 'string' ? body.data : ''
80
+ if (!data) return sendJson(res, 400, { ok: false, error: 'data is required' })
81
+ if (!session.write(data)) return sendJson(res, 409, { ok: false, error: 'session is closed' })
82
+ return sendJson(res, 200, { ok: true })
83
+ } catch (err) {
84
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
85
+ }
86
+ },
87
+ },
88
+
89
+ {
90
+ kind: 'exact',
91
+ path: '/dsh-remote/term/output',
92
+ handler: async (req, res) => {
93
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
94
+ try {
95
+ const q = new URL(req.url, 'http://localhost').searchParams
96
+ let body = {}
97
+ if (req.method === 'POST') body = JSON.parse((await readBody(req)) || '{}')
98
+ const bound = await bind(req, res, body)
99
+ if (!bound) return
100
+ const id = body.id || q.get('id') || ''
101
+ const from = Number(body.from != null ? body.from : q.get('from')) || 0
102
+ const { session, error } = terminals.owned(id, bound.machine)
103
+ if (error) return sendJson(res, 404, { ok: false, error })
104
+ const out = session.readFrom(from)
105
+ return sendJson(res, 200, { ok: true, ...out })
106
+ } catch (err) {
107
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
108
+ }
109
+ },
110
+ },
111
+
112
+ {
113
+ kind: 'exact',
114
+ path: '/dsh-remote/term/resize',
115
+ handler: async (req, res) => {
116
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
117
+ try {
118
+ const body = JSON.parse((await readBody(req)) || '{}')
119
+ const bound = await bind(req, res, body)
120
+ if (!bound) return
121
+ const { session, error } = terminals.owned(body.id, bound.machine)
122
+ if (error) return sendJson(res, 404, { ok: false, error })
123
+ session.resize(body.cols, body.rows)
124
+ return sendJson(res, 200, { ok: true, cols: session.cols, rows: session.rows })
125
+ } catch (err) {
126
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
127
+ }
128
+ },
129
+ },
130
+
131
+ {
132
+ kind: 'exact',
133
+ path: '/dsh-remote/term/close',
134
+ handler: async (req, res) => {
135
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
136
+ try {
137
+ const body = JSON.parse((await readBody(req)) || '{}')
138
+ const bound = await bind(req, res, body)
139
+ if (!bound) return
140
+ const r = terminals.close(body.id, bound.machine)
141
+ if (!r.ok) return sendJson(res, 404, { ok: false, error: r.error })
142
+ return sendJson(res, 200, { ok: true })
143
+ } catch (err) {
144
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
145
+ }
146
+ },
147
+ },
148
+
149
+ {
150
+ kind: 'exact',
151
+ path: '/dsh-remote/term/list',
152
+ handler: async (req, res) => {
153
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
154
+ try {
155
+ const bound = await bind(req, res, {})
156
+ if (!bound) return
157
+ // Only THIS machine's sessions: a listing that leaks other hosts'
158
+ // session ids would let a caller probe them (ids are guessable UUIDs
159
+ // only in the sense that they are opaque; the machine fence is the
160
+ // real boundary).
161
+ const mine = terminals.list().filter((s) => s.machine === bound.machine)
162
+ return sendJson(res, 200, { ok: true, sessions: mine })
163
+ } catch (err) {
164
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
165
+ }
166
+ },
167
+ },
168
+ ]
169
+ }
@@ -0,0 +1,205 @@
1
+ // dsh-remote — embedded interactive terminal sessions.
2
+ //
3
+ // Why polling and not a WebSocket: the plugin registers plain HTTP routes on
4
+ // `webServer`, and the browser half is a DSH client UI that must not depend on a
5
+ // socket upgrade path the host may or may not expose. A long-lived SSH channel
6
+ // owned by the HOST, with the browser polling for output deltas, keeps the
7
+ // transport boring (any HTTP stack works, no upgrade negotiation, no proxy
8
+ // surprises) while the expensive part — the PTY session — stays open exactly
9
+ // like a real ssh session.
10
+ //
11
+ // The trade is latency, and it is bounded by the poll interval the client
12
+ // chooses (the UI uses ~120ms idle, which is below the threshold where typing
13
+ // feels laggy). Keystrokes are written immediately on their own request, so
14
+ // input never waits for the poll cadence; only OUTPUT is batched.
15
+ //
16
+ // Sessions are host-owned and per-MACHINE: closing the browser does not kill the
17
+ // remote shell (a half-finished `vim` survives a page reload), but every session
18
+ // is bounded by an idle timeout and by the plugin's own disposal, so nothing
19
+ // leaks past an unload.
20
+
21
+ import { randomUUID } from 'node:crypto'
22
+
23
+ /** Output retained per session (bytes). A terminal is a stream, not a file:
24
+ * past this the oldest bytes drop and the reader is told it went lossy. */
25
+ export const TERMINAL_RING_BYTES = 256 * 1024
26
+
27
+ /** A session with no attach/poll/write for this long is closed. */
28
+ export const TERMINAL_IDLE_MS = 30 * 60 * 1000
29
+
30
+ /** Hard cap on concurrent sessions per process (protects the remote host's
31
+ * MaxSessions as much as our own memory). */
32
+ export const TERMINAL_MAX_SESSIONS = 8
33
+
34
+ /**
35
+ * One PTY-backed remote shell plus its output ring.
36
+ *
37
+ * The ring keeps a monotonic byte counter (`total`) so a poller can ask for
38
+ * "everything after offset N" without the server tracking per-client cursors —
39
+ * that is what lets one session be observed by a reloaded page, or by two tabs.
40
+ */
41
+ class TerminalSession {
42
+ constructor({ id, stream, machine, cols, rows }) {
43
+ this.id = id
44
+ this.stream = stream
45
+ this.machine = machine
46
+ this.cols = cols
47
+ this.rows = rows
48
+ this.createdAt = Date.now()
49
+ this.lastSeen = Date.now()
50
+ this.total = 0 // bytes ever produced
51
+ this.chunks = [] // [{ at, text }] within the retained window
52
+ this.retained = 0
53
+ this.closed = false
54
+ this.exitNote = ''
55
+ stream.on('data', (d) => this._push(d.toString('utf8')))
56
+ stream.stderr && stream.stderr.on('data', (d) => this._push(d.toString('utf8')))
57
+ stream.on('close', () => this._close('session closed'))
58
+ stream.on('error', (e) => this._close('session error: ' + ((e && e.message) || e)))
59
+ }
60
+
61
+ _push(text) {
62
+ if (!text) return
63
+ this.chunks.push({ at: this.total, text })
64
+ this.total += Buffer.byteLength(text, 'utf8')
65
+ this.retained += text.length
66
+ // Drop from the head until back inside the budget. A single huge chunk is
67
+ // kept whole (truncating a UTF-8 string mid-chunk would corrupt it); the
68
+ // lossy flag already tells the reader bytes are missing.
69
+ while (this.retained > TERMINAL_RING_BYTES && this.chunks.length > 1) {
70
+ const dropped = this.chunks.shift()
71
+ this.retained -= dropped.text.length
72
+ }
73
+ }
74
+
75
+ _close(note) {
76
+ if (this.closed) return
77
+ this.closed = true
78
+ this.exitNote = note || 'session closed'
79
+ }
80
+
81
+ /**
82
+ * Everything produced after `from` (a byte offset from a previous read).
83
+ * @param {number} from
84
+ * @returns {{chunks: string[], next: number, lossy: boolean, closed: boolean, note: string}}
85
+ */
86
+ readFrom(from) {
87
+ const start = Number(from) || 0
88
+ const oldest = this.chunks.length ? this.chunks[0].at : this.total
89
+ const lossy = start < oldest
90
+ const chunks = this.chunks.filter((c) => c.at + Buffer.byteLength(c.text, 'utf8') > start).map((c) => c.text)
91
+ return { chunks, next: this.total, lossy, closed: this.closed, note: this.exitNote }
92
+ }
93
+
94
+ write(data) {
95
+ if (this.closed) return false
96
+ try { this.stream.write(data) } catch { return false }
97
+ return true
98
+ }
99
+
100
+ resize(cols, rows) {
101
+ this.cols = Math.max(2, Math.min(Number(cols) || this.cols, 1000))
102
+ this.rows = Math.max(2, Math.min(Number(rows) || this.rows, 1000))
103
+ try { this.stream.setWindow(this.rows, this.cols, 0, 0) } catch { /* channel gone */ }
104
+ }
105
+
106
+ end() {
107
+ this._close('closed by request')
108
+ try { this.stream.end() } catch { /* already gone */ }
109
+ try { this.stream.close() } catch { /* already gone */ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Owns every terminal session in this process.
115
+ *
116
+ * Per-machine keying matters: a session belongs to a MACHINE identity, so a
117
+ * session bound to one host can never be attached from a request that resolves
118
+ * to a different one (the same isolation rule the file tools follow).
119
+ */
120
+ export function createTerminalManager() {
121
+ /** @type {Map<string, TerminalSession>} */
122
+ const sessions = new Map()
123
+ let sweeper = null
124
+
125
+ const startSweeper = () => {
126
+ if (sweeper) return
127
+ // One timer for the whole manager; unref'd so it never holds the process
128
+ // open on its own.
129
+ sweeper = setInterval(() => {
130
+ const now = Date.now()
131
+ for (const [id, s] of sessions) {
132
+ if (now - s.lastSeen > TERMINAL_IDLE_MS) {
133
+ try { s.end() } catch { /* already gone */ }
134
+ sessions.delete(id)
135
+ }
136
+ }
137
+ }, 60 * 1000)
138
+ if (typeof sweeper.unref === 'function') sweeper.unref()
139
+ }
140
+
141
+ /**
142
+ * Open a new terminal session on a machine.
143
+ * @param {object} arg
144
+ * @param {object} arg.pool - the machine's SshPool (must expose shell()).
145
+ * @param {string} arg.machine - a stable identity string for the machine.
146
+ * @param {number} [arg.cols]
147
+ * @param {number} [arg.rows]
148
+ * @returns {Promise<{ok: boolean, id?: string, error?: string}>}
149
+ */
150
+ const open = async ({ pool, machine, cols, rows }) => {
151
+ if (sessions.size >= TERMINAL_MAX_SESSIONS) {
152
+ return { ok: false, error: `too many terminal sessions (max ${TERMINAL_MAX_SESSIONS}) — close one first` }
153
+ }
154
+ if (!pool || typeof pool.shell !== 'function') {
155
+ return { ok: false, error: 'this machine cannot open an interactive shell (no shell() on its pool)' }
156
+ }
157
+ let stream
158
+ try {
159
+ stream = await pool.shell({ cols, rows })
160
+ } catch (err) {
161
+ return { ok: false, error: String((err && err.message) || err) }
162
+ }
163
+ const id = randomUUID()
164
+ sessions.set(id, new TerminalSession({ id, stream, machine: String(machine || ''), cols: Number(cols) || 80, rows: Number(rows) || 24 }))
165
+ startSweeper()
166
+ return { ok: true, id }
167
+ }
168
+
169
+ /**
170
+ * Look up a session, enforcing the machine boundary.
171
+ * @param {string} id
172
+ * @param {string} machine
173
+ * @returns {{session?: TerminalSession, error?: string}}
174
+ */
175
+ const owned = (id, machine) => {
176
+ const s = sessions.get(String(id || ''))
177
+ if (!s) return { error: 'no such terminal session (it may have exited or timed out)' }
178
+ if (String(machine || '') !== s.machine) return { error: 'this terminal belongs to a different machine' }
179
+ s.lastSeen = Date.now()
180
+ return { session: s }
181
+ }
182
+
183
+ return {
184
+ open,
185
+ owned,
186
+ has: (id) => sessions.has(String(id || '')),
187
+ list: () => [...sessions.values()].map((s) => ({
188
+ id: s.id, machine: s.machine, cols: s.cols, rows: s.rows, createdAt: s.createdAt, closed: s.closed,
189
+ })),
190
+ close: (id, machine) => {
191
+ const { session, error } = owned(id, machine)
192
+ if (error) return { ok: false, error }
193
+ session.end()
194
+ sessions.delete(session.id)
195
+ return { ok: true }
196
+ },
197
+ /** Close everything (plugin unload). */
198
+ dispose: () => {
199
+ for (const s of sessions.values()) { try { s.end() } catch { /* already gone */ } }
200
+ sessions.clear()
201
+ if (sweeper) { try { clearInterval(sweeper) } catch { /* gone */ } sweeper = null }
202
+ },
203
+ size: () => sessions.size,
204
+ }
205
+ }
package/lib/web-deploy.js CHANGED
@@ -595,7 +595,13 @@ function q(s) {
595
595
  */
596
596
  export function buildInstallPlan(spec) {
597
597
  const facts = spec.facts || {}
598
- const prefix = String(spec.prefix || '').replace(/\/+$/, '')
598
+ const prefix = String(spec.prefix || '').trim().replace(/\/+$/, '')
599
+ // An empty prefix emits `mkdir -p ''` / `cd ''` / `--prefix ''`: commands that
600
+ // either fail confusingly or (worse) touch the filesystem root. Refuse to build
601
+ // such a plan at all — the caller must resolve a prefix first.
602
+ if (!prefix || prefix === '/') {
603
+ throw new Error('buildInstallPlan: refusing to build a plan without a private install prefix')
604
+ }
599
605
  const version = String(spec.version || DEFAULT_INSTALL_VERSION)
600
606
  const registry = String(spec.registry || '')
601
607
  const env = spec.env && typeof spec.env === 'object' ? spec.env : undefined
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.53",
4
- "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
3
+ "version": "0.8.60",
4
+ "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive, multi-hop ProxyJump), pick a remote workspace, operate on it with 22 rw_* tools, conflict-aware mirror sync, an embedded PTY terminal, one-click remote dsh deploy + web UI attach, port forwarding, host grouping, remote sandbox fencing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",
7
7
  "readme": "README.md",
@@ -17,6 +17,8 @@
17
17
  "keywords": [
18
18
  "deepseek-harness",
19
19
  "dsh",
20
+ "sandbox",
21
+ "terminal",
20
22
  "dsh-plugin",
21
23
  "remote",
22
24
  "ssh",
@@ -90,6 +92,8 @@
90
92
  "semver": "^7.8.5"
91
93
  },
92
94
  "scripts": {
93
- "test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=20000 --test-force-exit"
95
+ "test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=40000 --test-force-exit",
96
+ "test:coverage": "node scripts/coverage-union.mjs",
97
+ "test:coverage:gate": "node scripts/coverage-union.mjs --gate 70"
94
98
  }
95
99
  }