@tiphareth/dsh-hardssh 0.1.2-alpha

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.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Remote-environment scrubbing and caching for the SSH process and terminal
3
+ * launchers. Ported from UynajGI/dsh-ssh (MIT) — adapted to the dsh-ssh
4
+ * engine. The read side is cached per (engine, alias) with a short TTL and
5
+ * merged concurrent requests, so repeated spawns/terminals on one host stop
6
+ * issuing an `env` exec each time (P1-12).
7
+ */
8
+
9
+ import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
10
+ import type { SshEngine } from '../ssh/engine.ts'
11
+ import { shellQuote as quoteShellArg } from '../shell.ts'
12
+
13
+ /** Quote one argument for a POSIX login shell (canonical implementation in shell.ts). */
14
+ export { quoteShellArg }
15
+ /** Wrap a remote command so it runs from the given working directory. */
16
+ export function wrapCwd(cwd: string, command: string): string {
17
+ return `cd -- ${quoteShellArg(cwd)} && ${command}`
18
+ }
19
+
20
+ /** How long a scrubbed environment stays fresh per alias. */
21
+ const ENVIRONMENT_TTL_MS = 30_000
22
+
23
+ /** One cache entry: a settled value (until expiry) or an in-flight promise. */
24
+ interface EnvironmentCacheEntry {
25
+ expiresAt: number
26
+ value?: ReadonlyMap<string, string>
27
+ pending?: Promise<ReadonlyMap<string, string>>
28
+ }
29
+
30
+ /** Per-engine alias caches (WeakMap so engines are collected with the pool). */
31
+ const caches = new WeakMap<SshEngine, Map<string, EnvironmentCacheEntry>>()
32
+
33
+ function cacheFor(engine: SshEngine): Map<string, EnvironmentCacheEntry> {
34
+ let byAlias = caches.get(engine)
35
+ if (byAlias === undefined) {
36
+ byAlias = new Map()
37
+ caches.set(engine, byAlias)
38
+ }
39
+ return byAlias
40
+ }
41
+
42
+ /**
43
+ * Read + scrub the remote login environment, cached per (engine, alias).
44
+ * Concurrent requests share one fetch; failures are never cached.
45
+ */
46
+ export function readScrubbedRemoteEnvironment(
47
+ engine: SshEngine,
48
+ alias: string,
49
+ ): Promise<ReadonlyMap<string, string>> {
50
+ const byAlias = cacheFor(engine)
51
+ const now = Date.now()
52
+ const existing = byAlias.get(alias)
53
+ if (existing !== undefined && existing.expiresAt > now && existing.value !== undefined) {
54
+ return Promise.resolve(existing.value)
55
+ }
56
+ if (existing?.pending !== undefined) return existing.pending
57
+ const pending = fetchScrubbedEnvironment(engine, alias).then(
58
+ (value) => {
59
+ byAlias.set(alias, { expiresAt: Date.now() + ENVIRONMENT_TTL_MS, value })
60
+ return value
61
+ },
62
+ (error: unknown) => {
63
+ // A failed read must not poison the cache: drop it so the next call retries.
64
+ byAlias.delete(alias)
65
+ throw error
66
+ },
67
+ )
68
+ byAlias.set(alias, { expiresAt: 0, pending })
69
+ return pending
70
+ }
71
+
72
+ /** Drop cached environments for one alias (call after a host config change). */
73
+ export function invalidateRemoteEnvironment(engine: SshEngine, alias: string): void {
74
+ cacheFor(engine).delete(alias)
75
+ }
76
+
77
+ /** One `env -0` round-trip, scrubbed (NUL-delimited so values keep newlines/=). */
78
+ async function fetchScrubbedEnvironment(engine: SshEngine, alias: string): Promise<ReadonlyMap<string, string>> {
79
+ const result = await engine.exec(alias, 'env -0', 10_000)
80
+ if (!result.success) {
81
+ throw new Error(`subprocess-ssh: cannot read the remote environment: ${result.stderr.trim() || 'unknown error'}`)
82
+ }
83
+ const environment = new Map<string, string>()
84
+ for (const record of result.stdout.split('\0')) {
85
+ if (record === '') continue
86
+ const separator = record.indexOf('=')
87
+ if (separator <= 0) continue
88
+ const name = record.slice(0, separator)
89
+ if (name.includes('\0')) continue
90
+ environment.set(name, record.slice(separator + 1))
91
+ }
92
+ return scrubRemoteEnvironment(environment)
93
+ }
94
+
95
+ /**
96
+ * Remove harness-private and credential-shaped names from a remote environment.
97
+ */
98
+ export function scrubRemoteEnvironment(environment: ReadonlyMap<string, string>): Map<string, string> {
99
+ const scrubbed = new Map<string, string>()
100
+ for (const [name, value] of environment) {
101
+ if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue
102
+ scrubbed.set(name, value)
103
+ }
104
+ return scrubbed
105
+ }
106
+
107
+ /**
108
+ * Overlay explicit entries and serialize one validated environment for `env -i`.
109
+ */
110
+ export function serializeEnvironment(
111
+ scrubbed: ReadonlyMap<string, string>,
112
+ explicit: Readonly<NodeJS.ProcessEnv> | undefined,
113
+ ): string {
114
+ const environment = new Map(scrubbed)
115
+ for (const [name, value] of Object.entries(explicit ?? {})) {
116
+ if (name.length === 0 || name.includes('=') || name.includes('\0') || value?.includes('\0') === true) {
117
+ throw new Error('subprocess-ssh: environment entries require non-empty NUL-free names without = and NUL-free values')
118
+ }
119
+ if (value === undefined) environment.delete(name)
120
+ else environment.set(name, value)
121
+ }
122
+ return [...environment].map(([name, value]) => quoteShellArg(`${name}=${value}`)).join(' ')
123
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Bounded host-side projection of one remote output stream with a local
3
+ * spill file. Ported from UynajGI/dsh-ssh (MIT) — verbatim semantics.
4
+ */
5
+
6
+ import { randomBytes } from 'node:crypto'
7
+ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import type { CollectedOutput, SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
11
+
12
+ let spillCounter = 0
13
+ let defaultSpillDir: string | undefined
14
+
15
+ /** Private (0700) per-process spill directory under the OS tmpdir, created lazily. */
16
+ function privateSpillDir(): string {
17
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-ssh-'))
18
+ return defaultSpillDir
19
+ }
20
+
21
+ /**
22
+ * Collects one remote stream with a bounded in-memory tail. On first overflow
23
+ * a spill file is opened (when a spill cap is configured) and every chunk —
24
+ * already-collected ones included — is appended there while the full stream
25
+ * stays within the cap; without one, only the in-memory tail is retained.
26
+ */
27
+ export class SshOutputCollector implements SubprocessOutputReader {
28
+ private chunks: Buffer[] = []
29
+ private retained = 0
30
+ private total = 0
31
+ private dropped = false
32
+ private spillFd: number | undefined
33
+ private spillFile: string | undefined
34
+ private spillDisabled: boolean
35
+
36
+ constructor(
37
+ private readonly maxBytes: number,
38
+ private readonly maxSpillBytes: number | undefined,
39
+ private readonly label: string,
40
+ private readonly spillDir: string = privateSpillDir(),
41
+ ) {
42
+ this.spillDisabled = maxSpillBytes === undefined
43
+ }
44
+
45
+ /** Append one byte-faithful remote chunk, trimming the retained tail to the cap. */
46
+ push(chunk: Uint8Array): void {
47
+ if (chunk.length === 0) return
48
+ const buffer = Buffer.from(chunk)
49
+ this.total += buffer.length
50
+ const overflows = this.retained + buffer.length > this.maxBytes
51
+ if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(buffer)
52
+ this.chunks.push(buffer)
53
+ this.retained += buffer.length
54
+ while (this.retained > this.maxBytes) {
55
+ const head = this.chunks[0] as Buffer
56
+ const excess = this.retained - this.maxBytes
57
+ if (head.length <= excess) {
58
+ this.chunks.shift()
59
+ this.retained -= head.length
60
+ } else {
61
+ this.chunks[0] = head.subarray(excess)
62
+ this.retained -= excess
63
+ }
64
+ this.dropped = true
65
+ }
66
+ }
67
+
68
+ /** @inheritdoc */
69
+ readFrom(fromByte: number): SubprocessOutputRead {
70
+ const retained = Buffer.concat(this.chunks, this.retained)
71
+ const windowStart = this.total - this.retained
72
+ const lossy = fromByte < windowStart
73
+ const start = lossy ? 0 : Math.min(retained.length, Math.max(0, fromByte - windowStart))
74
+ return {
75
+ text: retained.subarray(start).toString('utf8'),
76
+ nextOffset: this.total,
77
+ lossy,
78
+ ...(this.spillFile !== undefined ? { spillPath: this.spillFile } : {}),
79
+ }
80
+ }
81
+
82
+ /** Open the spill file lazily and append one chunk (plus any prior chunks once). */
83
+ private spillAll(chunk: Buffer): void {
84
+ if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
85
+ this.discardSpill()
86
+ return
87
+ }
88
+ if (this.spillFd === undefined) {
89
+ this.spillFile = join(
90
+ this.spillDir,
91
+ `dsh-subprocess-ssh-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
92
+ )
93
+ this.spillFd = openSync(this.spillFile, 'wx', 0o600)
94
+ for (const prior of this.chunks) writeSync(this.spillFd, prior)
95
+ }
96
+ writeSync(this.spillFd, chunk)
97
+ }
98
+
99
+ /** Stop spilling and remove the file once it can no longer hold the complete stream. */
100
+ private discardSpill(): void {
101
+ const fd = this.spillFd
102
+ const file = this.spillFile
103
+ this.spillFd = undefined
104
+ this.spillFile = undefined
105
+ this.spillDisabled = true
106
+ if (fd !== undefined) {
107
+ try {
108
+ closeSync(fd)
109
+ } catch {
110
+ this.spillFd = fd
111
+ }
112
+ }
113
+ if (file !== undefined) {
114
+ try {
115
+ unlinkSync(file)
116
+ } catch {
117
+ // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
118
+ }
119
+ }
120
+ }
121
+
122
+ /** Close the spill file once the stream has ended; stop advertising it on a failed close. */
123
+ seal(): void {
124
+ if (this.spillFd === undefined) return
125
+ try {
126
+ closeSync(this.spillFd)
127
+ } catch {
128
+ this.spillFile = undefined
129
+ }
130
+ this.spillFd = undefined
131
+ }
132
+
133
+ /** Seal the spill and return the final collected output. */
134
+ finalize(): CollectedOutput {
135
+ this.seal()
136
+ return {
137
+ text: Buffer.concat(this.chunks).toString('utf8'),
138
+ truncated: this.dropped,
139
+ ...(this.spillFile !== undefined ? { spillPath: this.spillFile } : {}),
140
+ }
141
+ }
142
+ }