@tiphareth/dsh-hardssh 0.1.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 (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -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 +134 -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 +96 -0
  126. package/src/client/workspace-gate.ts +225 -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,253 @@
1
+ /**
2
+ * The `ctx.fs` switching facade: routes every filesystem call to the backend
3
+ * of the SESSION's workspace — a local session operates on the local sandbox,
4
+ * an SSH-bound workspace on the remote host the workspace is bound to.
5
+ *
6
+ * Routing anchor: the cwd the tool call was made under (`resolve`'s
7
+ * `opts.cwd`, the write/edit `sandboxPolicy.workspaceRoot`). The resolver
8
+ * ("which SSH workspace owns this cwd?") lives in the deps; a hit routes to
9
+ * that workspace's remote backend, a miss to the local backend. Later
10
+ * operations (stat/read/write) receive only the resolved `FsTarget`, so the
11
+ * backend identity is encoded into the target key — `ssh:<recordId>:<remoteKey>`
12
+ * for remote targets, bare local keys for local ones — and decoded on every
13
+ * dispatch. This keeps two SSH workspaces on DIFFERENT hosts (or different
14
+ * directories on the SAME host) from colliding, and never misroutes a local
15
+ * target.
16
+ *
17
+ * @module dsh-hardssh/switch-fs
18
+ */
19
+
20
+ import { FileSystem } from '@deepseek-ai/dsh-fs'
21
+ import type {
22
+ FsDirEntry,
23
+ FsEditOutcome,
24
+ FsEditRequest,
25
+ FsInfo,
26
+ FsPathInfo,
27
+ FsTarget,
28
+ FsWriteIntent,
29
+ FsWriteOutcome,
30
+ } from '@deepseek-ai/dsh-fs'
31
+ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
32
+ import type { Context } from '@deepseek-ai/cordis'
33
+
34
+ /** Key namespace: 'ssh:<recordId>:' (legacy) or 'wfs://<id>/' (current)
35
+ * for remote/workspace-bound targets. Both decode; new keys emit wfs://. */
36
+ export const REMOTE_PREFIX = 'ssh:'
37
+ /** New URI-style workspace namespace marker. */
38
+ export const WFS_PREFIX = 'wfs://'
39
+ /** Strict WFS key prefix (includes the double slash + separator). */
40
+ export const WFS_NAMESPACE_MARKER = `${WFS_PREFIX}`
41
+
42
+ /** Route one cwd anchor to a backend + namespace. */
43
+ export interface WorkspaceWorld {
44
+ backend: FileSystem
45
+ /** '' for local; 'wfs://<id>/' or legacy 'ssh:<recordId>:' for one workspace. */
46
+ namespace: string
47
+ /** The local anchor dir of the SSH workspace ('' for local) — used to
48
+ * translate a model-supplied anchor path into the remote root. */
49
+ anchorPath?: string
50
+ /** The remote root of the SSH workspace ('' for local). */
51
+ remoteRoot?: string
52
+ }
53
+
54
+ /** Routing resolver offered by the mount site. */
55
+ export interface SwitchFsDeps {
56
+ local: FileSystem
57
+ /** The world for a session cwd (undefined = local). */
58
+ worldFor(cwd: string | undefined): WorkspaceWorld
59
+ /** The world owning one namespaced target key (never undefined: every
60
+ * key this facade issued maps back). */
61
+ worldForNamespace(namespace: string): WorkspaceWorld | undefined
62
+ }
63
+
64
+ /** The routing filesystem facade. */
65
+ export class SwitchFileSystem extends FileSystem {
66
+ private readonly local: FileSystem
67
+
68
+ constructor(ctx: Context, private readonly deps: SwitchFsDeps) {
69
+ super(ctx)
70
+ this.local = deps.local
71
+ }
72
+
73
+ /** Sandbox default from the local backend (remote worlds are unconfined —
74
+ * the docs/doc of the tool layer reads this once at mount). */
75
+ override get sandboxMode(): SandboxMode | undefined {
76
+ return this.local.sandboxMode
77
+ }
78
+
79
+ /** Encode a raw key into the world's namespace. */
80
+ private encode(rawKey: string, namespace: string): string {
81
+ return namespace === '' ? rawKey : `${namespace}${rawKey}`
82
+ }
83
+
84
+ /** Decode a namespaced key back to (rawKey, world). Supports both the
85
+ * current `wfs://<id>/…` form and the legacy `ssh:<recordId>:…` form. */
86
+ private decode(key: string): { rawKey: string; world: WorkspaceWorld } | undefined {
87
+ if (key.startsWith(WFS_NAMESPACE_MARKER)) {
88
+ // wfs://<encodedId>/<raw path>
89
+ const rest = key.slice(WFS_NAMESPACE_MARKER.length)
90
+ const end = rest.indexOf('/')
91
+ if (end > 0) {
92
+ const namespace = `wfs://${rest.slice(0, end + 1)}` // 'wfs://<id>/'
93
+ const world = this.deps.worldForNamespace(namespace)
94
+ if (world !== undefined) return { rawKey: rest.slice(end + 1), world }
95
+ }
96
+ // Unknown workspace namespace: fail closed (never fall through to local).
97
+ return undefined
98
+ }
99
+ if (key.startsWith(REMOTE_PREFIX)) {
100
+ const end = key.indexOf(':', REMOTE_PREFIX.length)
101
+ if (end > 0) {
102
+ const namespace = key.slice(0, end + 1)
103
+ const world = this.deps.worldForNamespace(namespace)
104
+ if (world !== undefined) return { rawKey: key.slice(namespace.length), world }
105
+ }
106
+ // Unknown SSH namespace: the owning workspace was removed (or the key
107
+ // is stale from before a plugin reload). Fail closed — never fall
108
+ // through to the local backend with a remote key, which could silently
109
+ // misroute an operation to the wrong filesystem. Callers treat an
110
+ // undefined decode as "cannot route this target".
111
+ return undefined
112
+ }
113
+ return { rawKey: key, world: { backend: this.local, namespace: '' } }
114
+ }
115
+
116
+ override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
117
+ const world = this.deps.worldFor(opts?.cwd)
118
+ // Translate a model-supplied LOCAL ANCHOR path into the remote root when
119
+ // the call routes to an SSH workspace: the model sees the anchor as "the
120
+ // current directory" (the session cwd) and may pass it verbatim. Rewrite
121
+ // `<anchor>` / `<anchor>/…` to `<remoteRoot>` / `<remoteRoot>/…` so the
122
+ // remote backend resolves it cleanly instead of glueing a Windows path
123
+ // onto /data/….
124
+ const effectivePath = world.anchorPath !== undefined && world.remoteRoot !== undefined
125
+ ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path)
126
+ : path
127
+ const raw = await world.backend.resolve(effectivePath, opts)
128
+ return {
129
+ targetKey: this.encode(String(raw.targetKey), world.namespace) as FsTarget['targetKey'],
130
+ displayPath: raw.displayPath,
131
+ }
132
+ }
133
+
134
+ /** Rewrite `<anchor>/<rest>` to `<remoteRoot>/<rest>` when `path` is the
135
+ * anchor or under it; otherwise return `path` unchanged (remote absolute
136
+ * paths, relative paths, and foreign local paths all pass through). */
137
+ private translateAnchorPath(anchor: string, remoteRoot: string, path: string): string {
138
+ const norm = (value: string): string => value.replace(/[\\/]+$/, '')
139
+ const a = norm(anchor)
140
+ const isWin = a.includes('\\')
141
+ const normPath = (value: string): string => {
142
+ let out = norm(value)
143
+ if (isWin) out = out.replace(/\//g, '\\')
144
+ return out
145
+ }
146
+ const p = normPath(path)
147
+ const aa = isWin ? a.toLowerCase() : a
148
+ const pp = isWin ? p.toLowerCase() : p
149
+ if (pp === aa) return remoteRoot
150
+ if (pp.startsWith(`${aa}\\`) || pp.startsWith(`${aa}/`)) {
151
+ // Preserve the original case for the tail (the anchor prefix removed);
152
+ // normalize the tail to POSIX separators (the remote side is POSIX).
153
+ const tail = p.slice(a.length).replace(/^[\\/]+/, '').replace(/\\/g, '/')
154
+ return tail === '' ? remoteRoot : `${remoteRoot.replace(/\/+$/, '')}/${tail}`
155
+ }
156
+ return path
157
+ }
158
+
159
+ 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
163
+ }
164
+
165
+ override fileUrl(target: FsTarget): string {
166
+ const decoded = this.decode(String(target.targetKey))
167
+ if (decoded === undefined) return this.local.fileUrl(target)
168
+ return decoded.world.backend.fileUrl({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath })
169
+ }
170
+
171
+ override contains(parent: FsTarget, child: FsTarget): boolean {
172
+ const p = this.decode(String(parent.targetKey))
173
+ const c = this.decode(String(child.targetKey))
174
+ if (p === undefined || c === undefined || p.world.backend !== c.world.backend) return false
175
+ return p.world.backend.contains(
176
+ { targetKey: p.rawKey as FsTarget['targetKey'], displayPath: parent.displayPath },
177
+ { targetKey: c.rawKey as FsTarget['targetKey'], displayPath: child.displayPath },
178
+ )
179
+ }
180
+
181
+ override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
182
+ const decoded = this.decode(String(target.targetKey))
183
+ if (decoded === undefined) return undefined
184
+ return decoded.world.backend.stat({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, signal)
185
+ }
186
+
187
+ override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
188
+ const world = this.deps.worldFor(opts?.cwd)
189
+ const effectivePath = world.anchorPath !== undefined && world.remoteRoot !== undefined
190
+ ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path)
191
+ : path
192
+ return world.backend.lstat(effectivePath, opts, signal)
193
+ }
194
+
195
+ override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
196
+ const decoded = this.decode(String(target.targetKey))
197
+ if (decoded === undefined) throw new Error('fs-ssh: cannot route target')
198
+ return decoded.world.backend.readText({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, signal)
199
+ }
200
+
201
+ override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
202
+ const decoded = this.decode(String(target.targetKey))
203
+ if (decoded === undefined) throw new Error('fs-ssh: cannot route target')
204
+ return decoded.world.backend.streamText({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, signal)
205
+ }
206
+
207
+ override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
208
+ const decoded = this.decode(String(target.targetKey))
209
+ if (decoded === undefined) throw new Error('fs-ssh: cannot route target')
210
+ return decoded.world.backend.readBytes({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, signal, maxBytes)
211
+ }
212
+
213
+ override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
214
+ const decoded = this.decode(String(target.targetKey))
215
+ if (decoded === undefined) throw new Error('fs-ssh: cannot route target')
216
+ const entries = await decoded.world.backend.listDir({ targetKey: decoded.rawKey as FsTarget['targetKey'], displayPath: target.displayPath }, signal)
217
+ if (decoded.world.namespace === '') return entries
218
+ // Re-namespace child targets so they stay routable.
219
+ return entries.map((entry) => ({
220
+ ...entry,
221
+ target: {
222
+ targetKey: this.encode(String(entry.target.targetKey), decoded.world.namespace) as FsTarget['targetKey'],
223
+ displayPath: entry.target.displayPath,
224
+ },
225
+ }))
226
+ }
227
+
228
+ override async writeText(
229
+ target: FsTarget,
230
+ content: string,
231
+ expected?: FsWriteIntent,
232
+ signal?: AbortSignal,
233
+ sandboxPolicy?: SandboxExecutionPolicy,
234
+ ): Promise<FsWriteOutcome> {
235
+ const decoded = this.decode(String(target.targetKey))
236
+ 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)
238
+ }
239
+
240
+ override async editText(
241
+ target: FsTarget,
242
+ edit: FsEditRequest,
243
+ expected?: { version: ReturnType<typeof import('@deepseek-ai/dsh-fs').FsVersion> },
244
+ signal?: AbortSignal,
245
+ sandboxPolicy?: Parameters<FileSystem['editText']>[4],
246
+ ): Promise<FsEditOutcome> {
247
+ const decoded = this.decode(String(target.targetKey))
248
+ 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)
250
+ }
251
+ }
252
+
253
+ export default SwitchFileSystem
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The `ctx.subprocess` switching facade: routes every spawn to the execution
3
+ * world of the SESSION's workspace — local sessions spawn locally, SSH-bound
4
+ * workspaces spawn on the bound host. The routing anchor is the spawn spec's
5
+ * `cwd` (bash tools default it to the session cwd); the resolver lives in
6
+ * the deps. One instance provides `ctx.subprocess` after the profile patch
7
+ * disabled the plain subprocess row, like the fs facade.
8
+ *
9
+ * @module dsh-hardssh/switch-subprocess
10
+ */
11
+
12
+ import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
13
+ import type {
14
+ SubprocessHandle,
15
+ SubprocessSpawnSpec,
16
+ SubprocessTerminalHandle,
17
+ SubprocessTerminalSpawnSpec,
18
+ } from '@deepseek-ai/dsh-subprocess'
19
+ import type { Context } from '@deepseek-ai/cordis'
20
+
21
+ /** Route one spawn cwd to a runtime. */
22
+ export interface SwitchSubprocessDeps {
23
+ local: SubprocessRuntime
24
+ /** The runtime for a spawn cwd (undefined = local). */
25
+ worldFor(cwd: string | undefined): SubprocessRuntime
26
+ }
27
+
28
+ /** Workspace-routing subprocess facade. */
29
+ export class SwitchSubprocessRuntime extends SubprocessRuntime {
30
+ constructor(ctx: Context, private readonly deps: SwitchSubprocessDeps) {
31
+ super(ctx)
32
+ }
33
+
34
+ /** The runtime for one spec (by its cwd). */
35
+ private runtimeFor(cwd: string | undefined): SubprocessRuntime {
36
+ return this.deps.worldFor(cwd)
37
+ }
38
+
39
+ /** @inheritdoc */
40
+ resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string> {
41
+ // Executable resolution is not cwd-scoped; route by the caller's cwd is
42
+ // impossible here, so use the local runtime (bare PATH names on remote
43
+ // hosts are resolved inside the remote command anyway). Local-only
44
+ // resolution keeps `command -v` semantics on this machine.
45
+ return this.deps.local.resolveExecutable(command, env, signal)
46
+ }
47
+
48
+ /** @inheritdoc */
49
+ spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
50
+ return this.runtimeFor(spec.cwd).spawn(spec)
51
+ }
52
+
53
+ /** @inheritdoc */
54
+ spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
55
+ return this.runtimeFor(spec.cwd).spawnTerminal(spec)
56
+ }
57
+ }
58
+
59
+ export default SwitchSubprocessRuntime
package/src/tools.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Agent tools: the remote-workspace counterpart of the local fs tools. Every
3
+ * tool is bound to the SSH workspace OF THE CALLING SESSION: the tool's
4
+ * `exec` carries `agent.session.header.cwd` (the session's workspace), which
5
+ * the ledger resolves to a bound SSH workspace (alias + remote root). A
6
+ * session in a local workspace gets a clear "this workspace is not SSH-bound"
7
+ * error; sessions in SSH-bound workspaces operate on the remote host.
8
+ *
9
+ * Plain file operations (read / write / edit / mkdir / rm / rename) are NOT
10
+ * duplicated here: the fs seam routes them automatically by the session cwd
11
+ * (SFTP on the remote host). This surface keeps only the tools the seam does
12
+ * not cover: status (self-description), directory listing, and search.
13
+ *
14
+ * @module dsh-hardssh/tools
15
+ */
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
18
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
19
+ import type { SshEngine } from './ssh/engine.ts'
20
+ import { isInside } from './backend.ts'
21
+ import { RemoteSearchService } from './remote-search.ts'
22
+ import type { SshWorkspaceLedger } from './ledger.ts'
23
+ import type { SshWorkspaceRecord } from './protocol.ts'
24
+
25
+ /** One text content block (the only render shape these tools emit). */
26
+ function text(value: string): ContentBlock[] {
27
+ return [{ type: 'text', text: value }]
28
+ }
29
+
30
+ /** Tool-set dependencies. */
31
+ export interface WorkspaceToolsDeps {
32
+ engine: SshEngine
33
+ ledger: SshWorkspaceLedger
34
+ search: RemoteSearchService
35
+ }
36
+
37
+ /** Failure envelope shared by every tool. */
38
+ interface ToolFailure {
39
+ ok: false
40
+ error: string
41
+ }
42
+
43
+ /** The SSH bound workspace for the calling session's cwd. */
44
+ function boundWorkspace(ledger: SshWorkspaceLedger, exec: ToolRunContext): Promise<SshWorkspaceRecord | undefined> {
45
+ const cwd = exec.agent?.session?.header?.cwd
46
+ if (cwd === undefined || cwd === '') return Promise.resolve(undefined)
47
+ return ledger.findByAnchor(cwd)
48
+ }
49
+
50
+ /** True when `abs` is inside (or equals) the remote root. */
51
+ function insideRoot(root: string, abs: string): boolean {
52
+ return isInside(root, abs)
53
+ }
54
+
55
+ /** Build every remote_* tool (registered by the host half). */
56
+ export function makeWorkspaceTools(deps: WorkspaceToolsDeps) {
57
+ const { engine, ledger, search } = deps
58
+
59
+ /** The bound workspace for this call, or a failure when the session's
60
+ * workspace is not SSH-bound. */
61
+ const bound = async (exec: ToolRunContext): Promise<{ workspace: SshWorkspaceRecord } | ToolFailure> => {
62
+ const workspace = await boundWorkspace(ledger, exec)
63
+ if (workspace === undefined) {
64
+ return { ok: false, error: 'this session\'s workspace is not SSH-bound — create an SSH workspace and open a session in it first' }
65
+ }
66
+ return { workspace }
67
+ }
68
+
69
+ /** Gate one absolute remote path to the workspace's remote root. */
70
+ const gatePath = (workspace: SshWorkspaceRecord, abs: string): string | undefined => {
71
+ if (!insideRoot(workspace.remoteRoot, abs)) {
72
+ return `path '${abs}' is outside the remote root '${workspace.remoteRoot}' of workspace '${workspace.title}'`
73
+ }
74
+ return undefined
75
+ }
76
+
77
+ /** Run one remote op, catching errors into the failure envelope. */
78
+ const run = async <T>(operation: () => Promise<T>): Promise<T | ToolFailure> => {
79
+ try {
80
+ return await operation()
81
+ } catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error)
83
+ return { ok: false, error: message }
84
+ }
85
+ }
86
+
87
+ return [
88
+ defineTool({
89
+ name: 'remote_status',
90
+ description: 'List the SSH-bound workspaces and report whether the CALLING SESSION\'s workspace is SSH-bound (and to which host/root). Call this before any remote_* tool. Triggers: SSH mode, remote workspace, where am I working, which servers are bound.',
91
+ parameters: {},
92
+ output: {
93
+ schema: {
94
+ type: 'object',
95
+ additionalProperties: false,
96
+ properties: {
97
+ ok: { type: 'boolean', required: true },
98
+ bound: { type: 'boolean', required: true },
99
+ workspaceTitle: { type: 'string' },
100
+ alias: { type: 'string' },
101
+ remoteRoot: { type: 'string' },
102
+ anchorPath: { type: 'string' },
103
+ workspaces: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
104
+ id: { type: 'string', required: true }, title: { type: 'string', required: true }, alias: { type: 'string', required: true }, remoteRoot: { type: 'string', required: true }, anchorPath: { type: 'string', required: true },
105
+ } } },
106
+ error: { type: 'string' },
107
+ },
108
+ },
109
+ render: (_args, value) => {
110
+ if (value.ok !== true) return text(`remote_status failed: ${value.error ?? 'unknown error'}`)
111
+ const list = (value.workspaces ?? []).map((w: { title: string; alias: string; remoteRoot: string; anchorPath: string }) =>
112
+ `- ${w.title} (${w.alias} @ ${w.remoteRoot}, anchor ${w.anchorPath})`)
113
+ const bound = value.bound === true
114
+ ? `current session: BOUND -> ${value.workspaceTitle ?? ''} (${value.alias ?? ''} @ ${value.remoteRoot ?? ''})`
115
+ : 'current session: NOT bound (this workspace is local)'
116
+ return text([bound, '', 'SSH workspaces:', ...(list.length > 0 ? list : ['(none)'])].join('\n'))
117
+ },
118
+ },
119
+ async execute(_args, exec) {
120
+ const workspaces = await ledger.list()
121
+ const workspace = await boundWorkspace(ledger, exec)
122
+ return {
123
+ ok: true,
124
+ bound: workspace !== undefined,
125
+ workspaceTitle: workspace?.title,
126
+ alias: workspace?.alias,
127
+ remoteRoot: workspace?.remoteRoot,
128
+ anchorPath: workspace?.anchorPath,
129
+ workspaces: workspaces.map((w) => ({ id: w.id, title: w.title, alias: w.alias, remoteRoot: w.remoteRoot, anchorPath: w.anchorPath })),
130
+ }
131
+ },
132
+ }),
133
+
134
+ defineTool({
135
+ name: 'remote_ls',
136
+ description: 'List a directory on the SSH host bound to the CALLING SESSION\'s workspace (the path must be inside that workspace\'s remote root). Triggers: list remote directory, remote files, ls on the server.',
137
+ parameters: {
138
+ path: { type: 'string', required: true, description: 'Absolute remote directory path inside the workspace\'s remote root (e.g. /home/user/project/src).' },
139
+ },
140
+ output: {
141
+ schema: {
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: {
145
+ ok: { type: 'boolean', required: true },
146
+ path: { type: 'string' },
147
+ entries: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
148
+ name: { type: 'string', required: true }, type: { type: 'string', required: true }, size: { type: 'integer', required: true }, mtimeMs: { type: 'integer', required: true },
149
+ } } },
150
+ error: { type: 'string' },
151
+ },
152
+ },
153
+ render: (_args, value) => {
154
+ if (value.ok !== true) return text(`remote_ls failed: ${value.error ?? 'unknown error'}`)
155
+ const rows = (value.entries ?? []).map((entry: { name: string; type: string; size: number }) =>
156
+ `${entry.type === 'dir' ? 'dir ' : 'file'} ${entry.name}${entry.type === 'file' ? ` (${entry.size} bytes)` : ''}`)
157
+ return text([`${value.path}`, ...(rows.length > 0 ? rows : ['(empty)'])].join('\n'))
158
+ },
159
+ },
160
+ async execute(args, exec) {
161
+ const check = await bound(exec)
162
+ if ('error' in check) return check
163
+ const gate = gatePath(check.workspace, args.path)
164
+ if (gate !== undefined) return { ok: false, error: gate }
165
+ return run(async () => {
166
+ const entries = await engine.ls(check.workspace.alias, args.path)
167
+ return { ok: true, path: args.path, entries: entries.map((entry) => ({ name: entry.name, type: entry.type, size: entry.size, mtimeMs: entry.mtimeMs })) }
168
+ })
169
+ },
170
+ }),
171
+
172
+ defineTool({
173
+ name: 'remote_search',
174
+ description: 'Search the SSH workspace bound to the CALLING SESSION. mode="glob" matches FILE NAMES by pattern (root-relative, e.g. src/**/*.ts or *.log; max depth 6, capped at 200 hits); mode="grep" searches file CONTENTS for a FIXED STRING (not a regex; skips .git and node_modules; capped at 200 matches per file and 200KB of output). Triggers: find remote files by pattern, glob on the server, grep remote code, search remote contents.',
175
+ parameters: {
176
+ mode: { type: 'string', enum: ['glob', 'grep'], required: true, description: 'glob = match file names by pattern; grep = search file contents for a fixed string.' },
177
+ pattern: { type: 'string', required: true, description: 'glob: root-relative pattern like src/**/*.ts or *.log; grep: the literal text to find (not a regular expression).' },
178
+ },
179
+ output: {
180
+ schema: {
181
+ type: 'object',
182
+ additionalProperties: false,
183
+ properties: {
184
+ ok: { type: 'boolean', required: true },
185
+ mode: { type: 'string' },
186
+ hits: { type: 'array', items: { type: 'string' } },
187
+ lines: { type: 'array', items: { type: 'string' } },
188
+ truncated: { type: 'boolean' },
189
+ error: { type: 'string' },
190
+ },
191
+ },
192
+ render: (_args, value) => {
193
+ if (value.ok !== true) return text(`remote_search failed: ${value.error ?? 'unknown error'}`)
194
+ const tail = value.truncated === true
195
+ ? value.mode === 'grep' ? '\n[output truncated at 200KB]' : '\n[truncated at 200 hits]'
196
+ : ''
197
+ const body = value.mode === 'grep' ? (value.lines ?? []) : (value.hits ?? [])
198
+ return text(body.length > 0 ? body.join('\n') + tail : '(no matches)')
199
+ },
200
+ },
201
+ async execute(args, exec) {
202
+ const check = await bound(exec)
203
+ if ('error' in check) return check
204
+ return run(async () => {
205
+ if (args.mode === 'grep') {
206
+ const found = await search.grepFixed(
207
+ { alias: check.workspace.alias, root: check.workspace.remoteRoot },
208
+ args.pattern,
209
+ )
210
+ return { ok: true, mode: 'grep', lines: found.lines, hits: [], truncated: found.truncated }
211
+ }
212
+ const found = await search.glob(
213
+ { alias: check.workspace.alias, root: check.workspace.remoteRoot },
214
+ args.pattern,
215
+ )
216
+ return { ok: true, mode: 'glob', hits: found.hits, lines: [], truncated: found.truncated }
217
+ })
218
+ },
219
+ }),
220
+ ]
221
+ }