@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.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/switch/switch-fs.ts
CHANGED
|
@@ -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
|
-
/**
|
|
74
|
-
*
|
|
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.
|
|
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
|
|
161
|
-
|
|
162
|
-
return decoded
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
25
|
-
|
|
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.
|
|
124
|
+
return this.effectiveRuntime(spec).spawn(spec)
|
|
51
125
|
}
|
|
52
126
|
|
|
53
127
|
/** @inheritdoc */
|
|
54
128
|
spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
|
|
55
|
-
return this.
|
|
129
|
+
return this.effectiveRuntime(spec).spawnTerminal(spec)
|
|
56
130
|
}
|
|
57
131
|
}
|
|
58
132
|
|