@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -14,6 +14,9 @@
14
14
  * @module @tiphareth/dsh-hardssh/providers/ssh
15
15
  */
16
16
 
17
+ import { posix } from 'node:path'
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import { WORKSPACE_PROVIDER_API_VERSION } from '../../base/model.ts'
17
20
  import type {
18
21
  WorkspaceCapabilityMap,
19
22
  WorkspaceConnection,
@@ -25,231 +28,231 @@ import type {
25
28
  import type {
26
29
  WorkspaceFileSystem,
27
30
  WorkspaceProcessRuntime,
28
- WorkspaceSearchService,
29
- WorkspaceStat,
30
- WorkspaceTerminalService,
31
- WorkspaceDirEntry,
32
31
  WorkspaceSearchHit,
32
+ WorkspaceSearchService,
33
33
  } from '../../base/capability.ts'
34
34
  import type { SshEngine } from '../../ssh/engine.ts'
35
+ import type { WorkspaceState } from '../../protocol.ts'
36
+ import { decodeCanonicalPath, SshFileSystem } from '../../remote/remote-fs.ts'
37
+ import { SshSubprocessRuntime } from '../../remote/remote-subprocess.ts'
35
38
  import { RemoteSearchService } from '../../remote-search.ts'
36
39
 
37
- /** The ssh provider manifest. */
40
+ /** The ssh provider manifest (provider API v2, no separate terminal capability — terminals live on `workspace.process`). */
38
41
  export const sshProviderManifest: WorkspaceProviderManifest = {
39
42
  id: 'ssh',
40
43
  version: '0.1.0',
41
- apiVersion: 1,
44
+ apiVersion: WORKSPACE_PROVIDER_API_VERSION,
42
45
  displayName: 'SSH remote workspace',
43
- capabilities: ['workspace.fs', 'workspace.process', 'workspace.terminal', 'workspace.search'],
46
+ capabilities: ['workspace.fs', 'workspace.process', 'workspace.search'],
44
47
  }
45
48
 
46
49
  /** One open SSH workspace connection. */
47
50
  export class SshWorkspaceConnection implements WorkspaceConnection {
48
51
  readonly providerId = 'ssh'
49
52
 
53
+ private state: 'connecting' | 'ready' | 'degraded' | 'closed' = 'ready'
54
+ private fsInstance: unknown
55
+ private processInstance: unknown
56
+ private searchInstance: unknown
57
+ /** One workspace is one fixed remote execution world for every capability instance. */
58
+ private readonly stateOf: () => WorkspaceState
59
+
50
60
  constructor(
51
61
  readonly workspaceId: string,
52
62
  private readonly engine: SshEngine,
53
63
  private readonly alias: string,
54
64
  private readonly remoteRoot: string,
55
- ) {}
65
+ private readonly cordisContext: Context,
66
+ ) {
67
+ // Assigned in the constructor body so the closure captures the parameter
68
+ // properties after they are initialized.
69
+ this.stateOf = () => ({ mode: 'remote', alias: this.alias, remoteRoot: this.remoteRoot })
70
+ }
56
71
 
57
72
  get<K extends keyof WorkspaceCapabilityMap>(capability: K): WorkspaceCapabilityMap[K] | undefined {
73
+ if (this.state === 'closed') return undefined
58
74
  switch (capability) {
59
75
  case 'workspace.fs': return this.fs() as WorkspaceCapabilityMap[K]
60
76
  case 'workspace.process': return this.process() as WorkspaceCapabilityMap[K]
61
- case 'workspace.terminal': return this.terminal() as WorkspaceCapabilityMap[K]
62
77
  case 'workspace.search': return this.search() as WorkspaceCapabilityMap[K]
63
78
  default: return undefined
64
79
  }
65
80
  }
66
81
 
67
82
  private fs(): WorkspaceFileSystem {
68
- return new SshWorkspaceFileSystem(this.engine, this.alias, this.remoteRoot)
83
+ if (this.fsInstance === undefined) {
84
+ // The capability is the production DSH FileSystem that DSH fs consumers
85
+ // (read/write/edit, glob/grep) already speak: remote/remote-fs.ts
86
+ // SshFileSystem (atomic writes, versions, streams). The workspace root is
87
+ // passed as the confinement boundary, so the capability can never escape
88
+ // `location.root` — lexically or canonically.
89
+ this.fsInstance = new SshFileSystem(this.cordisContext.isolate('fs'), this.engine, this.stateOf, this.remoteRoot)
90
+ }
91
+ return this.fsInstance as WorkspaceFileSystem
69
92
  }
70
93
 
71
94
  private process(): WorkspaceProcessRuntime {
72
- return new SshWorkspaceProcess(this.engine, this.alias, this.remoteRoot)
73
- }
74
-
75
- private terminal(): WorkspaceTerminalService {
76
- return new SshWorkspaceTerminal(this.engine, this.alias, this.remoteRoot)
95
+ if (this.processInstance === undefined) {
96
+ // The capability is the production DSH SubprocessRuntime:
97
+ // remote/remote-subprocess.ts SshSubprocessRuntime owns the full
98
+ // structured argv/env/stdio/terminal protocol plus handle teardown.
99
+ this.processInstance = new SshSubprocessRuntime(this.cordisContext.isolate('subprocess'), this.engine, this.stateOf)
100
+ }
101
+ return this.processInstance as WorkspaceProcessRuntime
77
102
  }
78
103
 
79
- private search(): WorkspaceSearchService {
80
- return new SshWorkspaceSearch(this.engine, this.alias, this.remoteRoot)
104
+ private search(): SshWorkspaceSearch {
105
+ if (this.searchInstance === undefined) {
106
+ // SshWorkspaceSearch is stateless apart from engine/alias/root, so one
107
+ // cached wrapper serves every get() (RemoteSearchService is shared too).
108
+ this.searchInstance = new SshWorkspaceSearch(this.engine, this.alias, this.remoteRoot)
109
+ }
110
+ return this.searchInstance as SshWorkspaceSearch
81
111
  }
82
112
 
83
113
  status(): 'connecting' | 'ready' | 'degraded' | 'closed' {
84
- // The engine owns the connection pool; a resolved workspace whose host
85
- // is configured is treated as ready (connectivity is verified lazily by
86
- // the exec/search calls, which report degraded results).
87
- return 'ready'
114
+ // Derived from this connection's own state instead of a constant: the
115
+ // object only exists after open() resolved, so 'ready' is truthful here and
116
+ // close() moves it to 'closed'. 'connecting' is therefore unobservable for
117
+ // this provider, and 'degraded' is never faked — the engine establishes and
118
+ // re-establishes connections lazily per operation, so this connection has
119
+ // no observable health signal to report.
120
+ return this.state
88
121
  }
89
122
 
90
123
  async close(): Promise<void> {
91
- // The engine owns the shared connection pool; per-workspace close is a
92
- // no-op here (pool invalidation is host-store driven).
124
+ if (this.state === 'closed') return
125
+ this.state = 'closed'
126
+ const processInstance = this.processInstance
127
+ if (processInstance instanceof SshSubprocessRuntime) {
128
+ // Releases only this workspace's own live processes/PTYs/streams;
129
+ // SshEngine.dispose() is deliberately NOT called because the shared
130
+ // connection pool belongs to the engine (plan §5.5/§6.3).
131
+ await processInstance.close()
132
+ }
133
+ this.fsInstance = undefined
134
+ this.processInstance = undefined
135
+ this.searchInstance = undefined
93
136
  }
94
137
  }
95
138
 
96
139
  /** POSIX-join a remote root with a relative path, confining to the root. */
97
140
  export function joinRemoteRoot(root: string, path: string): string {
98
- if (path.startsWith('/')) {
99
- // Absolute path must still be under the root? SSH workspaces allow
100
- // arbitrary absolute remote paths (the root is the model's own habit,
101
- // not a sandbox). Keep absolute paths verbatim for the SSH provider.
102
- return root === '/' ? path : path
103
- }
104
- const base = root === '/' ? '' : root
105
- return `${base}/${path}`.replace(/\/{2,}/g, '/')
106
- }
107
-
108
- /** Wrap engine FS calls as the generic WorkspaceFileSystem. */
109
- export class SshWorkspaceFileSystem implements WorkspaceFileSystem {
110
- constructor(
111
- private readonly engine: SshEngine,
112
- private readonly alias: string,
113
- private readonly root: string,
114
- ) {}
115
-
116
- private full(path: string): string {
117
- return joinRemoteRoot(this.root, path)
118
- }
119
-
120
- async stat(path: string, signal?: AbortSignal): Promise<WorkspaceStat | undefined> {
121
- const result = await this.engine.stat(this.alias, this.full(path))
122
- return { type: result.type, size: result.size, mtimeMs: result.mtimeMs, mode: result.mode }
123
- }
124
-
125
- async list(path: string, signal?: AbortSignal): Promise<WorkspaceDirEntry[]> {
126
- return this.engine.ls(this.alias, this.full(path))
127
- }
128
-
129
- async readFile(path: string, signal?: AbortSignal): Promise<Uint8Array> {
130
- const { content } = await this.engine.readFile(this.alias, this.full(path))
131
- return content
132
- }
133
-
134
- async writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void> {
135
- await this.engine.writeFile(this.alias, this.full(path), Buffer.from(data))
136
- }
137
-
138
- async mkdir(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
139
- await this.engine.mkdir(this.alias, this.full(path))
140
- }
141
-
142
- async rm(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
143
- await this.engine.rm(this.alias, this.full(path), options?.recursive ?? false)
144
- }
145
-
146
- async rename(from: string, to: string, signal?: AbortSignal): Promise<void> {
147
- await this.engine.rename(this.alias, this.full(from), this.full(to))
141
+ const normalizedRoot = posix.resolve('/', root)
142
+ const candidate = path.startsWith('/') ? posix.resolve('/', path) : posix.resolve(normalizedRoot, path)
143
+ if (normalizedRoot !== '/' && candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}/`)) {
144
+ throw new Error(`workspace.ssh-outside-root: '${path}' is outside '${normalizedRoot}'`)
148
145
  }
146
+ return candidate
149
147
  }
150
148
 
151
- /** Wrap engine exec as the generic WorkspaceProcessRuntime. */
152
- export class SshWorkspaceProcess implements WorkspaceProcessRuntime {
153
- constructor(
154
- private readonly engine: SshEngine,
155
- private readonly alias: string,
156
- private readonly root: string,
157
- ) {}
158
-
159
- async exec(command: string, options?: { timeoutMs?: number; cwd?: string; signal?: AbortSignal }): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut?: boolean; durationMs?: number }> {
160
- const cwd = options?.cwd !== undefined ? this.full(options.cwd) : this.root
161
- // cd into the resolved cwd (relative paths resolved against the root).
162
- const result = await this.engine.exec(this.alias, `cd ${quote(cwd)} && ${command}`, options?.timeoutMs)
163
- return {
164
- stdout: result.stdout,
165
- stderr: result.stderr,
166
- exitCode: result.exitCode ?? 1,
167
- timedOut: result.timedOut,
168
- durationMs: result.durationMs,
169
- }
170
- }
171
-
172
- private full(path: string): string {
173
- return joinRemoteRoot(this.root, path)
149
+ async function canonicalRemotePath(
150
+ engine: SshEngine,
151
+ alias: string,
152
+ root: string,
153
+ path: string,
154
+ signal?: AbortSignal,
155
+ ): Promise<string> {
156
+ signal?.throwIfAborted()
157
+ const candidate = joinRemoteRoot(root, path)
158
+ const canonical = async (value: string): Promise<string> => {
159
+ const result = await engine.exec(alias, `set -o pipefail; realpath -mz -- ${quote(value)} | base64 -w0`, 10_000)
160
+ signal?.throwIfAborted()
161
+ if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for '${value}'`)
162
+ return decodeCanonicalPath(result.stdout.trim())
174
163
  }
175
- }
176
-
177
- /** Wrap engine PTY as the generic WorkspaceTerminalService. */
178
- export class SshWorkspaceTerminal implements WorkspaceTerminalService {
179
- constructor(
180
- private readonly engine: SshEngine,
181
- private readonly alias: string,
182
- private readonly root: string,
183
- ) {}
184
-
185
- async openTerminal(cols: number, rows: number): Promise<import('../../base/capability.ts').WorkspaceTerminalHandle> {
186
- const session = await this.engine.openShell(this.alias, { cols, rows })
187
- let exited = false
188
- const exitHandlers = new Set<(event: { exitCode: number }) => void>()
189
- const onDataHandlers = new Set<(data: string) => void>()
190
- const enc = new TextDecoder()
191
- session.onData = (data: Buffer) => {
192
- for (const handler of onDataHandlers) handler(enc.decode(data))
193
- }
194
- session.onExit = (code: number | null) => {
195
- exited = true
196
- for (const handler of exitHandlers) handler({ exitCode: code ?? -1 })
197
- }
198
- return {
199
- shell: 'ssh',
200
- write(data: string) { if (!exited) session.send(data) },
201
- resize(c: number, r: number) { session.resize(c, r) },
202
- kill() {
203
- try { session.signal('KILL') } catch { /* closed */ }
204
- try { session.close() } catch { /* closed */ }
205
- },
206
- onData(handler) {
207
- onDataHandlers.add(handler)
208
- return { dispose: () => { onDataHandlers.delete(handler) } }
209
- },
210
- onExit(handler) {
211
- exitHandlers.add(handler)
212
- return { dispose: () => { exitHandlers.delete(handler) } }
213
- },
214
- }
164
+ const [canonicalRoot, canonicalCandidate] = await Promise.all([canonical(joinRemoteRoot(root, '.')), canonical(candidate)])
165
+ if (canonicalRoot !== '/' && canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}/`)) {
166
+ throw new Error(`workspace.ssh-outside-root: '${path}' resolves outside '${canonicalRoot}'`)
215
167
  }
168
+ return canonicalCandidate
216
169
  }
217
170
 
218
- /** Wrap the remote search service as the generic WorkspaceSearchService. */
171
+ /** Wrap the shared RemoteSearchService as the generic WorkspaceSearchService. */
219
172
  export class SshWorkspaceSearch implements WorkspaceSearchService {
220
173
  private readonly service: RemoteSearchService
221
174
 
222
175
  constructor(
223
176
  private readonly engine: SshEngine,
224
177
  private readonly alias: string,
225
- private readonly root: string,
178
+ private readonly remoteRoot: string,
226
179
  ) {
227
180
  this.service = new RemoteSearchService(engine)
228
181
  }
229
182
 
183
+ /** Workspace-relative POSIX search base → absolute remote root. */
184
+ private searchBase(workspaceRelative: string | undefined, signal?: AbortSignal): Promise<string> {
185
+ return canonicalRemotePath(this.engine, this.alias, this.remoteRoot, workspaceRelative ?? '.', signal)
186
+ }
187
+
188
+ /** Absolute path → path relative to `base`, or undefined when outside it. */
189
+ private relativeTo(absPath: string, base: string): string | undefined {
190
+ if (base === '/') return absPath.replace(/^\/+/, '')
191
+ if (absPath === base) return ''
192
+ const prefix = base.endsWith('/') ? base : `${base}/`
193
+ if (!absPath.startsWith(prefix)) return undefined
194
+ return absPath.slice(prefix.length)
195
+ }
196
+
230
197
  async glob(pattern: string, options?: { root?: string; maxDepth?: number; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }> {
231
- const result = await this.service.glob({ alias: this.alias, root: this.root }, pattern)
232
- return {
233
- hits: result.hits.map(path => ({ path, rel: path, isDir: false })),
234
- truncated: result.truncated,
198
+ options?.signal?.throwIfAborted()
199
+ const [base, workspaceRoot] = await Promise.all([
200
+ this.searchBase(options?.root, options?.signal),
201
+ this.searchBase('.', options?.signal),
202
+ ])
203
+ const result = await this.service.glob({ alias: this.alias, root: base }, pattern, options?.signal)
204
+ options?.signal?.throwIfAborted()
205
+ const hits: WorkspaceSearchHit[] = []
206
+ for (const absPath of result.hits) {
207
+ // RemoteSearchService.glob caps depth/bytes itself but takes no
208
+ // root/maxDepth/signal options, so the wrapper enforces them here: only
209
+ // hits under the requested search base survive, deeper-than-maxDepth
210
+ // results are clipped, and the abort signal is checked around the call.
211
+ const baseRelative = this.relativeTo(absPath, base)
212
+ if (baseRelative === undefined) continue
213
+ if (options?.maxDepth !== undefined && this.depthOf(baseRelative) > options.maxDepth) continue
214
+ const rel = this.relativeTo(absPath, workspaceRoot)
215
+ if (rel === undefined) continue
216
+ hits.push({ path: absPath, rel, isDir: false })
235
217
  }
218
+ return { hits, truncated: result.truncated }
236
219
  }
237
220
 
238
221
  async grep(fixedPhrase: string, options?: { root?: string; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }> {
239
- const result = await this.service.grepFixed({ alias: this.alias, root: this.root }, fixedPhrase)
240
- return {
241
- hits: result.lines.map(line => {
242
- const colon = line.indexOf(':')
243
- const path = colon >= 0 ? line.slice(0, colon) : line
244
- return { path, rel: path, isDir: false }
245
- }),
246
- truncated: result.truncated,
222
+ options?.signal?.throwIfAborted()
223
+ const [base, workspaceRoot] = await Promise.all([
224
+ this.searchBase(options?.root, options?.signal),
225
+ this.searchBase('.', options?.signal),
226
+ ])
227
+ const result = await this.service.grepFixed({ alias: this.alias, root: base }, fixedPhrase, options?.signal)
228
+ options?.signal?.throwIfAborted()
229
+ const hits: WorkspaceSearchHit[] = []
230
+ for (const line of result.lines) {
231
+ // grepFixed returns `absolute-path:line:content` records; the first
232
+ // colon splits the path from the line/content remainder. The whole raw
233
+ // record is preserved as `match` so capability consumers (e.g. the
234
+ // remote_search agent tool) can render matched snippets.
235
+ const colon = line.indexOf(':')
236
+ const absPath = colon >= 0 ? line.slice(0, colon) : line
237
+ if (this.relativeTo(absPath, base) === undefined) continue
238
+ const rel = this.relativeTo(absPath, workspaceRoot)
239
+ if (rel === undefined) continue
240
+ hits.push({ path: absPath, rel, isDir: false, match: line })
247
241
  }
242
+ return { hits, truncated: result.truncated }
243
+ }
244
+
245
+ /** Depth of a base-relative path (root-level entries are depth 1, find -maxdepth style). */
246
+ private depthOf(baseRelative: string): number {
247
+ if (baseRelative === '') return 0
248
+ return baseRelative.split('/').length
248
249
  }
249
250
  }
250
251
 
251
- /** The ssh provider factory. */
252
- export function createSshWorkspaceProvider(engine: SshEngine): WorkspaceProvider {
252
+ /** The ssh provider factory. `context` is mandatory: the provider serves only
253
+ * the real DSH FileSystem/SubprocessRuntime capabilities, which need a Cordis
254
+ * scope to isolate each workspace's resources. */
255
+ export function createSshWorkspaceProvider(engine: SshEngine, context: Context): WorkspaceProvider {
253
256
  return {
254
257
  manifest: sshProviderManifest,
255
258
  validate(record: WorkspaceRecord): void {
@@ -259,9 +262,9 @@ export function createSshWorkspaceProvider(engine: SshEngine): WorkspaceProvider
259
262
  }
260
263
  if (record.location.root === '') throw new Error('ssh workspace record is missing location.root')
261
264
  },
262
- async open(record: WorkspaceRecord, context?: WorkspaceOpenContext): Promise<WorkspaceConnection> {
265
+ async open(record: WorkspaceRecord, _context: WorkspaceOpenContext): Promise<WorkspaceConnection> {
263
266
  const alias = record.provider.connectionRef!.id
264
- return new SshWorkspaceConnection(record.id, engine, alias, record.location.root)
267
+ return new SshWorkspaceConnection(record.id, engine, alias, record.location.root, context)
265
268
  },
266
269
  }
267
270
  }
@@ -271,4 +274,4 @@ function quote(arg: string): string {
271
274
  if (arg === '') return "''"
272
275
  if (/^[a-zA-Z0-9_\-./:=@]+$/.test(arg)) return arg
273
276
  return `'${arg.replace(/'/g, `'\\''`)}'`
274
- }
277
+ }
@@ -104,8 +104,28 @@ export function scrubRemoteEnvironment(environment: ReadonlyMap<string, string>)
104
104
  return scrubbed
105
105
  }
106
106
 
107
+ /**
108
+ * Login-critical variables emitted FIRST, before the (potentially multi-KB)
109
+ * rest of a cluster environment. Both launchers hand the whole environment to
110
+ * `env -i` as one command line typed into a remote shell; if that line is ever
111
+ * truncated or mangled on the way (PTY input limits, flow control, shell
112
+ * line-buffer quirks), a shell with no HOME resolves every `$HOME/...` path to
113
+ * `/`, which makes site startup scripts run `ssh-keygen` into `/.ssh` and
114
+ * otherwise misbehave. Ordering these first makes the session survive it.
115
+ */
116
+ const CRITICAL_ENV_ORDER = ['HOME', 'USER', 'LOGNAME', 'SHELL', 'PATH', 'PWD', 'TERM', 'LANG', 'TZ'] as const
117
+
118
+ function priorityOf(name: string): number {
119
+ const index = CRITICAL_ENV_ORDER.indexOf(name as typeof CRITICAL_ENV_ORDER[number])
120
+ if (index >= 0) return index
121
+ // Locale/terminal hints stay near the front too, but after the hard basics.
122
+ return /^(LC_|LANGUAGE$)/.test(name) ? CRITICAL_ENV_ORDER.length : CRITICAL_ENV_ORDER.length + 1
123
+ }
124
+
107
125
  /**
108
126
  * Overlay explicit entries and serialize one validated environment for `env -i`.
127
+ * Login-critical names are emitted first (see CRITICAL_ENV_ORDER); everything
128
+ * else keeps the remote's own order.
109
129
  */
110
130
  export function serializeEnvironment(
111
131
  scrubbed: ReadonlyMap<string, string>,
@@ -119,5 +139,6 @@ export function serializeEnvironment(
119
139
  if (value === undefined) environment.delete(name)
120
140
  else environment.set(name, value)
121
141
  }
122
- return [...environment].map(([name, value]) => quoteShellArg(`${name}=${value}`)).join(' ')
142
+ const ordered = [...environment].sort(([a], [b]) => priorityOf(a) - priorityOf(b))
143
+ return ordered.map(([name, value]) => quoteShellArg(`${name}=${value}`)).join(' ')
123
144
  }