@tiphareth/dsh-hardssh 0.1.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- 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
|
+
}
|