@linxin666/dsh-ssh 0.1.20 → 0.2.1

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.
@@ -1,20 +1,17 @@
1
1
  /**
2
- * Sidebar entry injection.
2
+ * Sidebar entry injection — package-specific wiring over the shared core.
3
3
  *
4
- * dsh's sidebar shell exposes no slot an external plugin can register into,
5
- * so — following the task-board precedent of DOM-level extension — the entry
6
- * row is injected between the shell's New Session button and the workspace
7
- * browser. The injection self-heals: a MutationObserver watches the sidebar
8
- * root and re-inserts the row whenever a React re-render displaces it
9
- * (re-insertion happens in the same frame, before paint, so no flicker).
10
- *
11
- * The row is plain DOM (no React tree) so it can never disturb the shell's
12
- * reconciliation; the panel view it toggles is a separate React root mounted
13
- * in the center column (see mount.tsx).
4
+ * The DOM injection / self-healing / idempotency logic lives exactly once in
5
+ * shared/client/sidebar-entry-core.ts (synced copy); this wrapper supplies the
6
+ * ssh icon, copy, CSS module, and the panel toggle. The row is plain DOM (no
7
+ * React tree) so it can never disturb the shell's reconciliation; the panel
8
+ * view it toggles is a separate React root mounted in the center column
9
+ * (see mount.tsx).
14
10
  */
15
11
  import type { PanelController } from './panel/controller.ts'
16
12
  import { tt } from './panel/helpers.ts'
17
13
  import css from './panel/panel.module.css'
14
+ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-core.ts'
18
15
 
19
16
  /** Stable data attribute identifying the injected entry row. */
20
17
  export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
@@ -22,64 +19,6 @@ export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
22
19
  /** Inline icon (matches the shell's 16px nav-icon look): a terminal prompt glyph. */
23
20
  const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M4.5 5.5l2.5 2.5-2.5 2.5"/><path d="M8.5 10.5h3"/></svg>'
24
21
 
25
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
26
- function sidebarRoot(): HTMLElement | undefined {
27
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
28
- if (column === null) return undefined
29
- // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
30
- // Prefer the element that owns the logo row — the real sidebar UI root —
31
- // and fall back to the column's first child for legacy shells.
32
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
33
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
34
- }
35
-
36
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
37
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
38
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
39
- if (nested !== null) return nested
40
- for (const child of root.children) {
41
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
42
- }
43
- return undefined
44
- }
45
-
46
- /** Build the entry row (a detached button; insert once the shell is up). */
47
- function createEntry(controller: PanelController): HTMLButtonElement {
48
- const entry = document.createElement('button')
49
- entry.type = 'button'
50
- entry.dataset.dshSshEntry = ''
51
- entry.className = css.entry
52
- entry.setAttribute('aria-label', tt('entry.label'))
53
- entry.setAttribute('title', tt('entry.tooltip'))
54
- entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + tt('entry.label') + '</span>'
55
- entry.addEventListener('click', () => { controller.toggle() })
56
- return entry
57
- }
58
-
59
- /** Re-insert the entry after the New Session row (before the browser region). */
60
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
61
- const button = newSessionButton(root)
62
- if (button === undefined) return false
63
- if (entry.parentElement !== root) {
64
- // Position relative to the family block (entries injected by sibling
65
- // plugins), never relative to transient logoRow geometry: every family
66
- // plugin that self-heals during a re-render then lands in the same
67
- // relative order, so the entries cannot swap positions regardless of
68
- // observer callback order or of shell wrapper changes. There is no
69
- // append-to-end fallback: appending at the end would randomly reorder
70
- // the block after a shell re-render.
71
- const row = button.closest('[class*="logoRow"]')
72
- const base = (row !== null && row.parentElement === root) ? row : button
73
- const family = Array.from(root.children).filter(
74
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
75
- )
76
- // ssh sits after the whole family block.
77
- const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
78
- root.insertBefore(entry, anchor)
79
- }
80
- return true
81
- }
82
-
83
22
  /**
84
23
  * Mount the sidebar entry, waiting for the shell to render and self-healing
85
24
  * on later React re-renders.
@@ -87,74 +26,20 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
87
26
  * @returns disposer removing the entry and its observers.
88
27
  */
89
28
  export function mountSidebarEntry(controller: PanelController): () => void {
90
- const entry = createEntry(controller)
91
- let root: HTMLElement | undefined
92
- let placed = false
93
-
94
- const tryPlace = (): void => {
95
- if (root !== undefined && !root.isConnected) {
96
- // The shell rebuilt the sidebar pane (whole-tree teardown); the root
97
- // observer is gone with the old tree, so detach it and re-query from
98
- // scratch. The new pane is later noticed by the body-level watcher.
99
- rootObserver.disconnect()
100
- root = undefined
101
- placed = false
102
- }
103
- if (placed) {
104
- // Cheap short-circuit: entry still lives in a mountable subtree.
105
- if (document.body.contains(entry)) return
106
- // Entry was torn down together with the old tree; reset and re-place.
107
- rootObserver.disconnect()
108
- root = undefined
109
- placed = false
110
- }
111
- root ??= sidebarRoot()
112
- if (root === undefined) return
113
- placed = placeEntry(root, entry)
114
- if (placed) {
115
- rootObserver.observe(root, { childList: true, subtree: true })
116
- }
117
- }
118
-
119
- // Body-level watcher retained as the "whole rebuild" fallback: when the shell
120
- // tears down the whole sidebar pane, the root observer is gone with it and
121
- // only this body observation can notice the new pane mounting. It is no
122
- // longer disconnected after placement; the placed-and-still-mounted case
123
- // short-circuits through the cheap document.body.contains(entry) check, so
124
- // unrelated app mutations (e.g. chat streaming) cost one contains check
125
- // instead of churning the full re-query.
126
- const waitObserver = new MutationObserver(() => { tryPlace() })
127
- waitObserver.observe(document.body, { childList: true, subtree: true })
128
-
129
- // Self-heal: if a React re-render displaces the row, re-insert it in the
130
- // same frame (microtask before paint -> no visible flicker).
131
- const rootObserver = new MutationObserver(() => {
132
- if (root === undefined || !root.isConnected) {
133
- placed = false
134
- tryPlace()
135
- return
136
- }
137
- if (!root.contains(entry)) {
138
- placed = placeEntry(root, entry)
139
- }
29
+ return mountSharedSidebarEntry({
30
+ rowAttribute: 'data-dsh-ssh-entry',
31
+ rowSelector: ENTRY_SELECTOR,
32
+ plugin: 'ssh',
33
+ icon: ICON,
34
+ css,
35
+ label: () => tt('entry.label'),
36
+ tooltip: () => tt('entry.tooltip'),
37
+ onToggle: () => { controller.toggle() },
38
+ position: 'after',
39
+ familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
40
+ active: {
41
+ subscribe: (listener) => controller.subscribe(listener),
42
+ isOpen: () => controller.getSnapshot().panelOpen,
43
+ },
140
44
  })
141
-
142
- // Reflect the panel's open state on the row (active highlight). Note: assigning
143
- // undefined to dataset.active materializes data-active="undefined" and keeps the
144
- // row permanently highlighted — delete the attribute instead.
145
- const syncActive = () => {
146
- if (controller.getSnapshot().panelOpen) entry.dataset.active = 'true'
147
- else delete entry.dataset.active
148
- }
149
- const unsubscribe = controller.subscribe(syncActive)
150
- syncActive()
151
-
152
- tryPlace()
153
-
154
- return () => {
155
- waitObserver.disconnect()
156
- rootObserver.disconnect()
157
- unsubscribe()
158
- entry.remove()
159
- }
160
45
  }
@@ -140,28 +140,34 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
140
140
  const hops: Client[] = []
141
141
  let sock: ConnectConfig['sock']
142
142
  const chain = entry.proxyJump
143
- for (let index = 0; index < chain.length; index += 1) {
144
- const hopAlias = chain[index]
145
- const hop = engine.store.find(hopAlias)
146
- if (hop === undefined) {
147
- for (const client of hops) client.end()
148
- throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
149
- }
150
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
151
- hops.push(hopClient)
152
- const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
153
- const nextHost = next !== undefined ? next.host : entry.host
154
- const nextPort = next !== undefined ? next.port : entry.port
155
- sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
156
- hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
157
- if (error !== undefined) {
158
- for (const client of hops) client.end()
159
- reject(error)
160
- } else {
161
- resolve(stream)
162
- }
143
+ try {
144
+ for (let index = 0; index < chain.length; index += 1) {
145
+ const hopAlias = chain[index]
146
+ const hop = engine.store.find(hopAlias)
147
+ if (hop === undefined) {
148
+ throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
149
+ }
150
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
151
+ hops.push(hopClient)
152
+ const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
153
+ const nextHost = next !== undefined ? next.host : entry.host
154
+ const nextPort = next !== undefined ? next.port : entry.port
155
+ sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
156
+ hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
157
+ if (error !== undefined) {
158
+ reject(error)
159
+ } else {
160
+ resolve(stream)
161
+ }
162
+ })
163
163
  })
164
- })
164
+ }
165
+ } catch (error) {
166
+ // A missing alias, a failed hop connect, or a failed forwardOut must all
167
+ // close the hops already connected, so a failed ProxyJump never leaks a
168
+ // middle-hop connection.
169
+ for (const client of hops) client.end()
170
+ throw error
165
171
  }
166
172
  let target: Client | undefined
167
173
  try {
package/src/routes.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * deployments must not serve them.
8
8
  */
9
9
 
10
- import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
10
+ import { closeSync, createReadStream, createWriteStream, mkdirSync, openSync } from 'node:fs'
11
11
  import { unlink } from 'node:fs/promises'
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
13
  import { basename, join } from 'node:path'
@@ -93,7 +93,9 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
93
93
  const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
94
94
  // The upload route stages request bodies here; it must exist before the
95
95
  // first request (a missing dir would hang the first upload forever).
96
- mkdirSync(staging, { recursive: true })
96
+ // 0700 keeps in-flight transfers unreadable to other local users (the
97
+ // staged files below are 0600; downloads are pre-created 0600 as well).
98
+ mkdirSync(staging, { recursive: true, mode: 0o700 })
97
99
 
98
100
  /** Guard helper: fence + method check. */
99
101
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
@@ -353,7 +355,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
353
355
  }
354
356
  // Stage the uploaded bytes, then SFTP them out with progress frames.
355
357
  const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
356
- const sink = createWriteStream(tmp)
358
+ const sink = createWriteStream(tmp, { mode: 0o600 })
357
359
  let settled = false
358
360
  // Every terminal path (sink error, client abort, response loss) must
359
361
  // emit a result frame, end the response, and remove the tmp file.
@@ -361,9 +363,22 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
361
363
  if (settled) return
362
364
  settled = true
363
365
  emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
364
- try { sink.destroy() } catch { /* closed */ }
365
- void unlink(tmp).catch(() => undefined)
366
- try { res.end() } catch { /* closed */ }
366
+ // End the response only after the tmp file is gone, and unlink only
367
+ // after the sink is fully closed: destroying a WriteStream whose
368
+ // fs.open is still pending lets the open RE-CREATE the file after
369
+ // an early unlink, leaving staging populated (this raced the
370
+ // response end and made the byte-cap test flaky).
371
+ const cleanup = (): void => {
372
+ void unlink(tmp).catch(() => undefined).finally(() => {
373
+ try { res.end() } catch { /* closed */ }
374
+ })
375
+ }
376
+ if (sink.destroyed) {
377
+ cleanup()
378
+ } else {
379
+ sink.once('close', cleanup)
380
+ try { sink.destroy() } catch { cleanup() }
381
+ }
367
382
  }
368
383
  const done = (): void => {
369
384
  if (settled) return
@@ -379,11 +394,18 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
379
394
  // header-less requests: count the bytes as they actually arrive and
380
395
  // abort the moment the cap is exceeded.
381
396
  let received = 0
397
+ let capped = false
382
398
  req.on('data', (chunk: Buffer) => {
383
399
  received += chunk.byteLength
384
- if (received > maxUploadBytes) {
400
+ if (received > maxUploadBytes && !capped) {
401
+ capped = true
385
402
  fail('upload body too large')
386
- try { req.destroy() } catch { /* closed */ }
403
+ // Keep the socket alive until the response flush finishes:
404
+ // destroying the request here races res.end() and the client
405
+ // sees a hang-up instead of the result frame. Drain the rest of
406
+ // the body so the socket can close cleanly afterwards.
407
+ res.on('finish', () => { try { req.destroy() } catch { /* closed */ } })
408
+ req.resume()
387
409
  }
388
410
  })
389
411
  req.pipe(sink)
@@ -417,6 +439,9 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
417
439
  }
418
440
  const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
419
441
  try {
442
+ // Pre-create with 0600: ssh2's fastGet opens the destination with
443
+ // umask-default permissions and a pre-created file keeps the mode.
444
+ closeSync(openSync(tmp, 'w', 0o600))
420
445
  const outcome = await engine.download(alias, remotePath, tmp)
421
446
  res.writeHead(200, {
422
447
  'content-type': 'application/octet-stream',