@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2

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 +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1165 -1329
  6. package/lib/fs.js +307 -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 +18 -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 +36 -11
  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 -99
  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 +137 -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 -232
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -59,6 +59,63 @@ export interface SwitchFsDeps {
59
59
  /** The world owning one namespaced target key (never undefined: every
60
60
  * key this facade issued maps back). */
61
61
  worldForNamespace(namespace: string): WorkspaceWorld | undefined
62
+ /** Client-side roots that must stay LOCAL even in a bound workspace (dsh's
63
+ * own `~/.dsh` with skills & harness state, plugin configs, …). Default
64
+ * routing in a remote world is REMOTE for everything else. */
65
+ localRoots?: ReadonlyArray<string>
66
+ /** Windows inside `localRoots` that must NEVER be treated as local
67
+ * infrastructure — the managed workspace ANCHOR ROOT. Anchors physically
68
+ * live under `~/.dsh`, so without this carve-out a path inside the anchors
69
+ * window (including a stale anchor dir with no ledger record) would fall
70
+ * into the local exception and a remote session would silently write to
71
+ * the client machine. Semantics: local exception = localRoots MINUS
72
+ * exclusions. */
73
+ localRootExclusions?: ReadonlyArray<string>
74
+ /** Roots whose contents are NEVER accessible through this seam — the
75
+ * encrypted credential store. Enforced on every dispatch path (see
76
+ * `assertNotDenied`), so a bound session's agent cannot read the vault
77
+ * ciphertext through the routed filesystem and attack it offline. It does
78
+ * NOT constrain a command that runs natively on this machine as the same
79
+ * user (e.g. the client-side `pwsh` tool). */
80
+ deniedRoots?: ReadonlyArray<string>
81
+ /** The world owning an ABSOLUTE workspace anchor path (the caller's own
82
+ * workspace or a SIBLING workspace's anchor), or undefined when no
83
+ * workspace claims that path. Consulted BEFORE the `localRoots`
84
+ * carve-out: managed SSH workspace anchors live under `~/.dsh` (a
85
+ * declared local root), so routing a sibling anchor to local would
86
+ * silently run a remote-workspace file operation on this machine. */
87
+ worldForAnchorPath?(path: string): WorkspaceWorld | undefined
88
+ }
89
+
90
+ function normalizeForEquality(path: string): string {
91
+ return path.replace(/\\/g, '/').replace(/\/+$/, '')
92
+ }
93
+
94
+ /** Whether `path` is `root` itself or under it. Case-folding is applied ONLY
95
+ * when the root is a Windows-style path (drive letter) — no OS guessing. */
96
+ function isUnder(root: string | undefined, path: string): boolean {
97
+ if (root === undefined || root === '') return false
98
+ const r = normalizeForEquality(root)
99
+ const p = normalizeForEquality(path)
100
+ const fold = /^[a-zA-Z]:\//.test(r)
101
+ const nr = fold ? r.toLowerCase() : r
102
+ const np = fold ? p.toLowerCase() : p
103
+ return np === nr || np.startsWith(nr + '/')
104
+ }
105
+
106
+ /** Membership test for a REMOTE world. Default: EVERYTHING routes remote —
107
+ * a bound session's work lives on the server (including absolute paths
108
+ * outside the workspace tree, e.g. another project dir on that host).
109
+ * The ONLY exclusion is the DECLARED client infrastructure (`localRoots`:
110
+ * dsh's own `~/.dsh` with skills + harness state, plugin configs, …),
111
+ * which stays local on any client OS. `localRootExclusions` subtract the
112
+ * workspace-anchor window from that exception, because anchors physically
113
+ * live under `~/.dsh` and must never be treated as local. No host-OS
114
+ * syntax guessing. */
115
+ function belongsToRemoteWorld(path: string, deps: Pick<SwitchFsDeps, 'localRoots' | 'localRootExclusions'>): boolean {
116
+ if (deps.localRootExclusions?.some((root) => isUnder(root, path)) === true) return true
117
+ if (deps.localRoots !== undefined && deps.localRoots.some((root) => isUnder(root, path))) return false
118
+ return true
62
119
  }
63
120
 
64
121
  /** The routing filesystem facade. */
@@ -70,8 +127,14 @@ export class SwitchFileSystem extends FileSystem {
70
127
  this.local = deps.local
71
128
  }
72
129
 
73
- /** Sandbox default from the local backend (remote worlds are unconfined
74
- * the docs/doc of the tool layer reads this once at mount). */
130
+ /** The deployment's local backend is still a sandboxed filesystem, and
131
+ * `dsh-tool-fs` reads this capability fact ONCE at apply() to decide whether
132
+ * to advertise `sandbox_permissions` / `justification` at all. Returning
133
+ * undefined here disabled that escalation entry for EVERY session,
134
+ * including purely local ones, so the local backend's mode is the honest
135
+ * answer. Remote worlds are not confined by the local sandbox, so a wider
136
+ * per-call policy is meaningless for them and is dropped at the call site
137
+ * instead of being silently swallowed by a 4-parameter remote backend. */
75
138
  override get sandboxMode(): SandboxMode | undefined {
76
139
  return this.local.sandboxMode
77
140
  }
@@ -110,11 +173,58 @@ export class SwitchFileSystem extends FileSystem {
110
173
  // undefined decode as "cannot route this target".
111
174
  return undefined
112
175
  }
176
+ // A plain key addresses the LOCAL world, and this is the funnel every
177
+ // target-based operation decodes through (stat/readText/streamText/
178
+ // readBytes/listDir/writeText/editText) — not just resolve/lstat. The deny
179
+ // check therefore lives here as well, so a protected client directory stays
180
+ // unreachable no matter how the target was obtained. The local backend keys
181
+ // targets by CANONICAL path, so a symlink into the protected directory is
182
+ // refused too.
183
+ this.assertNotDenied(key, '')
113
184
  return { rawKey: key, world: { backend: this.local, namespace: '' } }
114
185
  }
115
186
 
187
+ /**
188
+ * Refuse a protected client directory before any backend sees it.
189
+ *
190
+ * Only a LOCAL world is checked: for a remote world the path is a path on the
191
+ * SERVER, which may legitimately look like a client path (both can be
192
+ * `/home/me/.dsh/…`).
193
+ *
194
+ * @param path - the path (or canonical target key) about to be dispatched.
195
+ * @param namespace - the resolved world's namespace ('' = local).
196
+ */
197
+ private assertNotDenied(path: string, namespace: string): void {
198
+ if (namespace !== '') return
199
+ const denied = this.deps.deniedRoots?.find(root => isUnder(root, path))
200
+ if (denied !== undefined) {
201
+ throw new Error(`fs-hardssh: '${path}' is not accessible through the workspace filesystem (protected client directory)`)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The world owning one call: an explicit anchor path (this workspace's or a
207
+ * SIBLING's) wins over the session cwd, because anchors are physically under
208
+ * the local-infrastructure root and would otherwise be carved out as local.
209
+ * Falls back to the session cwd's world, then to the caller's default.
210
+ */
211
+ private worldForPath(path: string, cwd: string | undefined): WorkspaceWorld {
212
+ const anchorWorld = this.deps.worldForAnchorPath?.(path)
213
+ if (anchorWorld !== undefined) {
214
+ this.assertNotDenied(path, anchorWorld.namespace)
215
+ return anchorWorld
216
+ }
217
+ const pathWorld = this.deps.worldFor(path)
218
+ if (pathWorld.namespace !== '' && pathWorld.anchorPath !== undefined && pathWorld.remoteRoot !== undefined) {
219
+ return pathWorld
220
+ }
221
+ const chosen = this.deps.worldFor(cwd)
222
+ this.assertNotDenied(path, chosen.namespace)
223
+ return chosen
224
+ }
225
+
116
226
  override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
117
- const world = this.deps.worldFor(opts?.cwd)
227
+ const world = this.worldForPath(path, opts?.cwd)
118
228
  // Translate a model-supplied LOCAL ANCHOR path into the remote root when
119
229
  // the call routes to an SSH workspace: the model sees the anchor as "the
120
230
  // current directory" (the session cwd) and may pass it verbatim. Rewrite
@@ -124,6 +234,16 @@ export class SwitchFileSystem extends FileSystem {
124
234
  const effectivePath = world.anchorPath !== undefined && world.remoteRoot !== undefined
125
235
  ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path)
126
236
  : path
237
+ // Host-infrastructure roots are considered only after anchor translation;
238
+ // otherwise a valid remote anchor nested under ~/.dsh routes locally.
239
+ if (world.namespace !== '' && !belongsToRemoteWorld(effectivePath, this.deps)) {
240
+ // This is the point where the routing decision is finally LOCAL, so a
241
+ // protected client directory is refused here rather than handed to the
242
+ // local backend.
243
+ this.assertNotDenied(path, '')
244
+ const raw = await this.local.resolve(path, opts)
245
+ return { targetKey: String(raw.targetKey) as FsTarget['targetKey'], displayPath: raw.displayPath }
246
+ }
127
247
  const raw = await world.backend.resolve(effectivePath, opts)
128
248
  return {
129
249
  targetKey: this.encode(String(raw.targetKey), world.namespace) as FsTarget['targetKey'],
@@ -157,14 +277,14 @@ export class SwitchFileSystem extends FileSystem {
157
277
  }
158
278
 
159
279
  override processPath(target: FsTarget): string {
160
- const key = String(target.targetKey)
161
- const decoded = this.decode(key)
162
- return decoded?.world.backend.processPath({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }) ?? key
280
+ const decoded = this.decode(String(target.targetKey))
281
+ if (decoded === undefined) throw new Error('fs-ssh: cannot route stale workspace target')
282
+ return decoded.world.backend.processPath({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath })
163
283
  }
164
284
 
165
285
  override fileUrl(target: FsTarget): string {
166
286
  const decoded = this.decode(String(target.targetKey))
167
- if (decoded === undefined) return this.local.fileUrl(target)
287
+ if (decoded === undefined) throw new Error('fs-ssh: cannot route stale workspace target')
168
288
  return decoded.world.backend.fileUrl({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath })
169
289
  }
170
290
 
@@ -185,10 +305,14 @@ export class SwitchFileSystem extends FileSystem {
185
305
  }
186
306
 
187
307
  override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
188
- const world = this.deps.worldFor(opts?.cwd)
308
+ const world = this.worldForPath(path, opts?.cwd)
189
309
  const effectivePath = world.anchorPath !== undefined && world.remoteRoot !== undefined
190
310
  ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path)
191
311
  : path
312
+ if (world.namespace !== '' && !belongsToRemoteWorld(effectivePath, this.deps)) {
313
+ this.assertNotDenied(path, '')
314
+ return this.local.lstat(path, opts, signal)
315
+ }
192
316
  return world.backend.lstat(effectivePath, opts, signal)
193
317
  }
194
318
 
@@ -234,7 +358,12 @@ export class SwitchFileSystem extends FileSystem {
234
358
  ): Promise<FsWriteOutcome> {
235
359
  const decoded = this.decode(String(target.targetKey))
236
360
  if (decoded === undefined) throw new Error('fs-ssh: cannot route target')
237
- return decoded.world.backend.writeText({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, content, expected, signal, sandboxPolicy)
361
+ // A per-call escalation policy is a LOCAL sandbox concept: the remote
362
+ // backend takes no policy parameter (its writes are bounded by the
363
+ // workspace root, not by the client sandbox). Drop it explicitly for
364
+ // remote worlds so the intent is documented rather than silently ignored.
365
+ const policy = decoded.world.namespace === '' ? sandboxPolicy : undefined
366
+ return decoded.world.backend.writeText({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, content, expected, signal, policy)
238
367
  }
239
368
 
240
369
  override async editText(
@@ -246,7 +375,9 @@ export class SwitchFileSystem extends FileSystem {
246
375
  ): Promise<FsEditOutcome> {
247
376
  const decoded = this.decode(String(target.targetKey))
248
377
  if (decoded === undefined) throw new Error('fs-ssh: cannot route target')
249
- return decoded.world.backend.editText({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, edit, expected, signal, sandboxPolicy)
378
+ // Same local-only semantics as writeText above.
379
+ const policy = decoded.world.namespace === '' ? sandboxPolicy : undefined
380
+ return decoded.world.backend.editText({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, edit, expected, signal, policy)
250
381
  }
251
382
  }
252
383
 
@@ -21,8 +21,53 @@ import type { Context } from '@deepseek-ai/cordis'
21
21
  /** Route one spawn cwd to a runtime. */
22
22
  export interface SwitchSubprocessDeps {
23
23
  local: SubprocessRuntime
24
- /** The runtime for a spawn cwd (undefined = local). */
25
- worldFor(cwd: string | undefined): SubprocessRuntime
24
+ /**
25
+ * The runtime for a spawn cwd, or `undefined` for a LOCAL cwd.
26
+ *
27
+ * Deliberately `undefined` rather than "return the local runtime": deciding
28
+ * "is this local?" by IDENTITY-comparing the returned service against
29
+ * `deps.local` is not reliable, and getting it wrong made the client-search
30
+ * refusal below fire for LOCAL sessions — which broke glob/grep everywhere
31
+ * (found by real-machine testing). The routing answer is data, so it travels
32
+ * as data.
33
+ */
34
+ worldFor(cwd: string | undefined): SubprocessRuntime | undefined
35
+ /** Bare executable names that are CLIENT tools (run locally even in a
36
+ * bound workspace) — e.g. 'pwsh', 'powershell', 'cmd'. Windows-format
37
+ * executables (drive/backslash paths, `*.exe/*.cmd/*.bat/*.ps1`) are
38
+ * detected automatically as client binaries. */
39
+ clientToolNames?: ReadonlyArray<string>
40
+ }
41
+
42
+ const DEFAULT_CLIENT_TOOL_NAMES = ['pwsh', 'powershell', 'cmd'] as const
43
+
44
+ const CLIENT_EXECUTABLE_RE = /\.(exe|cmd|bat|ps1|com)$/i
45
+
46
+ /**
47
+ * Client-packaged helpers that search the WORKSPACE's files (the bundled
48
+ * ripgrep behind the glob/grep tools). They are client binaries, but they are
49
+ * not shells: running one locally while the session's world is remote searches
50
+ * the local anchor directory (an empty placeholder) and reports "no matches",
51
+ * and sending the client's absolute PATH to the server cannot work either.
52
+ * Either way the answer is wrong, so a path-shaped invocation is refused
53
+ * loudly. A BARE `rg` is left alone: that is an ordinary server command and
54
+ * belongs to the remote world like any other.
55
+ */
56
+ const CLIENT_WORKSPACE_SEARCH_RE = /^(rg|ripgrep)(\.exe)?$/i
57
+
58
+ /** True for a PATH-shaped (not bare-name) invocation of a search helper. */
59
+ function isClientSearchHelperPath(exe: string): boolean {
60
+ if (!exe.includes('/') && !exe.includes('\\')) return false
61
+ const base = exe.split(/[\\/]/).pop() ?? exe
62
+ return CLIENT_WORKSPACE_SEARCH_RE.test(base)
63
+ }
64
+
65
+ /** A client-native executable: Windows-format path/extension, or a bare name
66
+ * on the declared client-tool list (remote POSIX hosts never carry these). */
67
+ function isClientNativeExecutable(exe: string, names: ReadonlyArray<string>): boolean {
68
+ if (exe === '') return false
69
+ if (/^[a-zA-Z]:[\\/]/.test(exe) || exe.includes('\\') || CLIENT_EXECUTABLE_RE.test(exe)) return true
70
+ return names.includes(exe)
26
71
  }
27
72
 
28
73
  /** Workspace-routing subprocess facade. */
@@ -31,11 +76,40 @@ export class SwitchSubprocessRuntime extends SubprocessRuntime {
31
76
  super(ctx)
32
77
  }
33
78
 
34
- /** The runtime for one spec (by its cwd). */
35
- private runtimeFor(cwd: string | undefined): SubprocessRuntime {
79
+ /** The runtime for one spec (by its cwd); undefined means LOCAL. */
80
+ private runtimeFor(cwd: string | undefined): SubprocessRuntime | undefined {
36
81
  return this.deps.worldFor(cwd)
37
82
  }
38
83
 
84
+ /** Client binaries run on THIS machine even from a bound workspace — their
85
+ * executables cannot exist on the remote POSIX host, and the spawn cwd is
86
+ * the local anchor (which exists locally), so local execution is sound.
87
+ * Everything else runs in the session's world (remote on a bound host).
88
+ *
89
+ * ONE exception is refused rather than routed: a client-side SEARCH helper
90
+ * (rg) in a remote-bound session would search the empty local anchor and
91
+ * report "no matches" — a confidently wrong answer about the server's
92
+ * contents. There is no way to make a local index-of-files tool read the
93
+ * remote workspace, so the caller is told to use the remote tools.
94
+ *
95
+ * Locality comes from the routing answer (`undefined`), never from an
96
+ * identity comparison against `deps.local`: that comparison silently failed
97
+ * once and refused local ripgrep launches for every session. */
98
+ private effectiveRuntime(spec: { cwd?: string; argv?: readonly string[] }): SubprocessRuntime {
99
+ const runtime = this.runtimeFor(spec.cwd)
100
+ if (runtime === undefined) return this.deps.local
101
+ const names = this.deps.clientToolNames ?? DEFAULT_CLIENT_TOOL_NAMES
102
+ const exe = spec.argv !== undefined && spec.argv.length > 0 ? spec.argv[0] : ''
103
+ if (isClientSearchHelperPath(exe)) {
104
+ throw new Error(
105
+ `dsh-hardssh: '${exe.split(/[\\/]/).pop()}' is a client-side search tool and cannot read the remote workspace bound to this session `
106
+ + '(its working directory is the local anchor placeholder, not the server). '
107
+ + 'Search remote content with the remote_search tool (mode="glob" for file names, mode="grep" for content) or ssh_exec instead.',
108
+ )
109
+ }
110
+ return isClientNativeExecutable(exe, names) ? this.deps.local : runtime
111
+ }
112
+
39
113
  /** @inheritdoc */
40
114
  resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string> {
41
115
  // Executable resolution is not cwd-scoped; route by the caller's cwd is
@@ -47,12 +121,12 @@ export class SwitchSubprocessRuntime extends SubprocessRuntime {
47
121
 
48
122
  /** @inheritdoc */
49
123
  spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
50
- return this.runtimeFor(spec.cwd).spawn(spec)
124
+ return this.effectiveRuntime(spec).spawn(spec)
51
125
  }
52
126
 
53
127
  /** @inheritdoc */
54
128
  spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
55
- return this.runtimeFor(spec.cwd).spawnTerminal(spec)
129
+ return this.effectiveRuntime(spec).spawnTerminal(spec)
56
130
  }
57
131
  }
58
132