dsh-remote-workspaces 0.1.0
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 +21 -0
- package/README.md +149 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +25 -0
- package/package.json +44 -0
- package/src/anchor.js +67 -0
- package/src/client.js +756 -0
- package/src/containment.js +71 -0
- package/src/errors.js +17 -0
- package/src/fs-sftp.js +231 -0
- package/src/index.js +327 -0
- package/src/local-backend.js +224 -0
- package/src/machine-store.js +238 -0
- package/src/registry.js +76 -0
- package/src/routing-fs.js +230 -0
- package/src/search.js +286 -0
- package/src/shell-exec.js +423 -0
- package/src/ssh-config.js +52 -0
- package/src/ssh-uri.js +19 -0
- package/src/transport.js +401 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-write containment, mirrored from the harness's
|
|
3
|
+
* `@deepseek-ai/dsh-sandbox` (roots.ts) and `@deepseek-ai/dsh-fs-sandbox`
|
|
4
|
+
* (containment.ts) so the plugin's LOCAL half fences writes with the exact
|
|
5
|
+
* same semantics without importing harness packages (which would dual-package
|
|
6
|
+
* the Cordis context).
|
|
7
|
+
*
|
|
8
|
+
* `workspace-write` = "the policy's workspace root plus the platform temp
|
|
9
|
+
* areas", canonicalized; containment is lexical-prefix with a filesystem-
|
|
10
|
+
* identity fallback for Windows 8.3/case aliases.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { realpathSync } from 'node:fs'
|
|
14
|
+
import { stat } from 'node:fs/promises'
|
|
15
|
+
import { dirname, sep } from 'node:path'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
|
|
18
|
+
const MISSING_CODES = new Set(['ENOENT', 'ENOTDIR'])
|
|
19
|
+
|
|
20
|
+
function canonicalPath(path) {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync.native(path)
|
|
23
|
+
} catch {
|
|
24
|
+
// Missing root matches nothing until it exists — the conservative outcome.
|
|
25
|
+
return path
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The canonical writable roots for a policy (empty under anything but workspace-write). */
|
|
30
|
+
export function writableRoots(policy) {
|
|
31
|
+
if (policy === undefined || policy.mode !== 'workspace-write') return []
|
|
32
|
+
return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function comparablePath(path, caseSensitive) {
|
|
36
|
+
return caseSensitive ? path : path.toLowerCase()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isLexicallyUnder(path, root, caseSensitive) {
|
|
40
|
+
const target = comparablePath(path, caseSensitive)
|
|
41
|
+
const base = comparablePath(root, caseSensitive)
|
|
42
|
+
if (target === base) return true
|
|
43
|
+
const prefix = base.endsWith(sep) ? base : base + sep
|
|
44
|
+
return target.startsWith(prefix)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function statIfPresent(path) {
|
|
48
|
+
try {
|
|
49
|
+
return await stat(path, { bigint: true })
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (MISSING_CODES.has(error.code)) return undefined
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether `path` is `root` or a descendant of it (canonical spellings). */
|
|
57
|
+
export async function isPathUnder(path, root, caseSensitive = process.platform !== 'win32') {
|
|
58
|
+
if (isLexicallyUnder(path, root, caseSensitive)) return true
|
|
59
|
+
const rootInfo = await statIfPresent(root)
|
|
60
|
+
if (!rootInfo) return false
|
|
61
|
+
let ancestor = path
|
|
62
|
+
while (true) {
|
|
63
|
+
const info = await statIfPresent(ancestor)
|
|
64
|
+
if (info && info.dev === rootInfo.dev && info.ino === rootInfo.ino) return true
|
|
65
|
+
const parent = dirname(ancestor)
|
|
66
|
+
if (parent === ancestor) return false
|
|
67
|
+
ancestor = parent
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default { writableRoots, isPathUnder }
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal error shape the backends throw, mirroring `@deepseek-ai/dsh-fs`
|
|
3
|
+
* `FsError` (`name` + stable `code` + `message`). It is a PLAIN `Error`, not an
|
|
4
|
+
* instance of the harness's `FsError`/`HarnessError` (the plugin cannot import
|
|
5
|
+
* `@deepseek-ai/*` packages without dual-packaging the Cordis context), so the
|
|
6
|
+
* tool layer's `instanceof FsError` enrichments (the `[sandbox: …]` marker, the
|
|
7
|
+
* escalation hint, the stale-version remedy) do not apply — a denial surfaces
|
|
8
|
+
* as the plain error message. Consumers that read `.code` structurally still
|
|
9
|
+
* get the stable code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function fsError(code, message, cause) {
|
|
13
|
+
const error = new Error(message, cause === undefined ? undefined : { cause })
|
|
14
|
+
error.name = 'FsError'
|
|
15
|
+
error.code = code
|
|
16
|
+
return error
|
|
17
|
+
}
|
package/src/fs-sftp.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SFTP remote backend (the remote half of the routing provider).
|
|
3
|
+
*
|
|
4
|
+
* A plain backend over the ssh2 SFTP channel — does NOT extend the
|
|
5
|
+
* `FileSystem` service, so the routing provider can hold it without registering
|
|
6
|
+
* `ctx.fs`. Implements the same 12 operations on plain targets and version
|
|
7
|
+
* strings, using SFTP primitives (readdir/stat/readFile/writeFile/rename) so
|
|
8
|
+
* type detection and ENOENT handling are locale-independent (a shell `stat`
|
|
9
|
+
* localized to the remote's locale broke type/absence detection).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { posix } from 'node:path'
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { fsError } from './errors.js'
|
|
15
|
+
|
|
16
|
+
function isMissing(error) {
|
|
17
|
+
const code = error && error.code
|
|
18
|
+
const message = error && error.message ? String(error.message) : ''
|
|
19
|
+
return code === 2 || code === 'ENOENT' || /no such file|not exist/i.test(message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** File type from ssh2 Stats/attrs, with a mode-bits fallback. */
|
|
23
|
+
function typeOf(st) {
|
|
24
|
+
if (st && typeof st.isDirectory === 'function' && st.isDirectory()) return 'directory'
|
|
25
|
+
if (st && typeof st.isFile === 'function' && st.isFile()) return 'file'
|
|
26
|
+
if (st && typeof st.mode === 'number') {
|
|
27
|
+
if ((st.mode & 0o170000) === 0o040000) return 'directory'
|
|
28
|
+
if ((st.mode & 0o170000) === 0o100000) return 'file'
|
|
29
|
+
}
|
|
30
|
+
return 'other'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class SftpBackend {
|
|
34
|
+
constructor(client) {
|
|
35
|
+
this.client = client
|
|
36
|
+
this._sftp = undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Lazily open ONE persistent SFTP channel per backend (host). */
|
|
40
|
+
async sftp() {
|
|
41
|
+
if (this._sftp === undefined) this._sftp = await this.client.sftp()
|
|
42
|
+
return this._sftp
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async resolve(path, opts = {}) {
|
|
46
|
+
if (typeof path !== 'string' || path.trim() === '') {
|
|
47
|
+
throw fsError('FS_NOT_FOUND', 'file_path must be a non-empty string')
|
|
48
|
+
}
|
|
49
|
+
const base = opts.cwd ?? '/'
|
|
50
|
+
const displayPath = posix.resolve(base, path)
|
|
51
|
+
const sftp = await this.sftp()
|
|
52
|
+
let targetKey = displayPath
|
|
53
|
+
try { targetKey = await sftp.realpath(displayPath) } catch { /* about-to-be-created path keeps its spelling */ }
|
|
54
|
+
return { targetKey, displayPath }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
processPath(target) {
|
|
58
|
+
return target.targetKey
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fileUrl(target) {
|
|
62
|
+
return `ssh://${this.client.host}${target.targetKey}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
contains(parent, child) {
|
|
66
|
+
const rel = posix.relative(parent.targetKey, child.targetKey)
|
|
67
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('../') && !posix.isAbsolute(rel))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async stat(target) {
|
|
71
|
+
const sftp = await this.sftp()
|
|
72
|
+
let st
|
|
73
|
+
try {
|
|
74
|
+
st = await sftp.stat(target.targetKey)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (isMissing(error)) return undefined
|
|
77
|
+
throw fsError('FS_IO_ERROR', `cannot stat "${target.displayPath}": ${error.message}`, error)
|
|
78
|
+
}
|
|
79
|
+
const type = typeOf(st)
|
|
80
|
+
const mtimeMs = Math.round((typeof st.mtime === 'number' ? st.mtime : 0) * 1000)
|
|
81
|
+
return {
|
|
82
|
+
version: `mtime:${mtimeMs}:size:${st.size ?? 0}`,
|
|
83
|
+
type,
|
|
84
|
+
...(type === 'file' ? { size: st.size } : {}),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async lstat(path, opts = {}) {
|
|
89
|
+
// This facade has no no-follow stat; map to resolve + stat.
|
|
90
|
+
const target = await this.resolve(path, opts)
|
|
91
|
+
return this.stat(target)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async readText(target) {
|
|
95
|
+
const sftp = await this.sftp()
|
|
96
|
+
let buf
|
|
97
|
+
try {
|
|
98
|
+
buf = await sftp.readFile(target.targetKey)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw fsError('FS_IO_ERROR', `cannot read "${target.displayPath}": ${error.message}`, error)
|
|
101
|
+
}
|
|
102
|
+
const text = Buffer.from(buf).toString('utf8')
|
|
103
|
+
if (text.includes('\0')) throw fsError('FS_NOT_TEXT', `cannot read "${target.displayPath}": binary file`)
|
|
104
|
+
return text
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async streamText(target) {
|
|
108
|
+
const text = await this.readText(target)
|
|
109
|
+
return {
|
|
110
|
+
async *[Symbol.asyncIterator]() {
|
|
111
|
+
yield text
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async readBytes(target, signal, maxBytes) {
|
|
117
|
+
const info = await this.stat(target)
|
|
118
|
+
if (info === undefined) throw fsError('FS_NOT_FOUND', `cannot read "${target.displayPath}": not found`)
|
|
119
|
+
if (info.type !== 'file') throw fsError('FS_NOT_REGULAR_FILE', `cannot read "${target.displayPath}": not a regular file`)
|
|
120
|
+
if (info.size !== undefined && info.size > maxBytes) {
|
|
121
|
+
throw fsError('FS_TOO_LARGE', `cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`)
|
|
122
|
+
}
|
|
123
|
+
const sftp = await this.sftp()
|
|
124
|
+
const buf = await sftp.readFile(target.targetKey)
|
|
125
|
+
return new Uint8Array(Buffer.from(buf))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async listDir(target) {
|
|
129
|
+
const sftp = await this.sftp()
|
|
130
|
+
let entries
|
|
131
|
+
try {
|
|
132
|
+
entries = await sftp.readdir(target.targetKey)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (isMissing(error)) throw fsError('FS_NOT_FOUND', `cannot list "${target.displayPath}": not found`)
|
|
135
|
+
throw fsError('FS_IO_ERROR', `cannot list "${target.displayPath}": ${error.message}`, error)
|
|
136
|
+
}
|
|
137
|
+
return (Array.isArray(entries) ? entries : [])
|
|
138
|
+
.filter((e) => e && e.filename !== '.' && e.filename !== '..')
|
|
139
|
+
.map((e) => ({
|
|
140
|
+
name: e.filename,
|
|
141
|
+
type: typeOf(e.attrs ?? {}),
|
|
142
|
+
target: {
|
|
143
|
+
targetKey: posix.join(target.targetKey, e.filename),
|
|
144
|
+
displayPath: posix.join(target.displayPath, e.filename),
|
|
145
|
+
},
|
|
146
|
+
}))
|
|
147
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async writeText(target, content, expected) {
|
|
151
|
+
const existing = await this.stat(target)
|
|
152
|
+
if (existing !== undefined && existing.type !== 'file') {
|
|
153
|
+
throw fsError('FS_NOT_REGULAR_FILE', `cannot write "${target.displayPath}": not a regular file`)
|
|
154
|
+
}
|
|
155
|
+
if (expected && expected.kind === 'createIfAbsent' && existing !== undefined) {
|
|
156
|
+
throw fsError('FS_NOT_OBSERVED', `cannot overwrite existing "${target.displayPath}" without reading it first`)
|
|
157
|
+
}
|
|
158
|
+
if (expected && expected.kind === 'replaceIfVersion') {
|
|
159
|
+
if (existing === undefined || existing.version !== expected.version) {
|
|
160
|
+
throw fsError('FS_STALE_VERSION', `cannot write "${target.displayPath}": file changed since it was read`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
let before = null
|
|
164
|
+
if (existing !== undefined && existing.type === 'file') {
|
|
165
|
+
try { before = await this.readText(target) } catch { before = null }
|
|
166
|
+
}
|
|
167
|
+
await this.atomicWrite(target, content)
|
|
168
|
+
await this.verifyWrite(target, content)
|
|
169
|
+
const after = await this.stat(target)
|
|
170
|
+
return { operation: existing === undefined ? 'create' : 'update', version: after.version, before, after: content }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async editText(target, edit, expected) {
|
|
174
|
+
const existing = await this.stat(target)
|
|
175
|
+
if (existing === undefined) throw fsError('FS_STALE_VERSION', `cannot edit "${target.displayPath}": file changed since it was read`)
|
|
176
|
+
if (existing.type !== 'file') throw fsError('FS_NOT_REGULAR_FILE', `cannot edit "${target.displayPath}": not a regular file`)
|
|
177
|
+
if (expected && existing.version !== expected.version) {
|
|
178
|
+
throw fsError('FS_STALE_VERSION', `cannot edit "${target.displayPath}": file changed since it was read`)
|
|
179
|
+
}
|
|
180
|
+
const before = await this.readText(target)
|
|
181
|
+
const oldString = edit.oldString
|
|
182
|
+
if (!oldString) throw fsError('FS_EDIT_NOT_FOUND', `cannot edit "${target.displayPath}": old_string must be non-empty`)
|
|
183
|
+
let matches = 0
|
|
184
|
+
let offset = 0
|
|
185
|
+
while (true) {
|
|
186
|
+
const found = before.indexOf(oldString, offset)
|
|
187
|
+
if (found < 0) break
|
|
188
|
+
matches += 1
|
|
189
|
+
offset = found + oldString.length
|
|
190
|
+
}
|
|
191
|
+
if (matches === 0) throw fsError('FS_EDIT_NOT_FOUND', `cannot edit "${target.displayPath}": old_string was not found`)
|
|
192
|
+
if (!edit.replaceAll && matches !== 1) {
|
|
193
|
+
throw fsError('FS_AMBIGUOUS_EDIT', `cannot edit "${target.displayPath}": old_string matched ${matches} times`)
|
|
194
|
+
}
|
|
195
|
+
const after = edit.replaceAll ? before.split(oldString).join(edit.newString) : before.replace(oldString, edit.newString)
|
|
196
|
+
await this.atomicWrite(target, after)
|
|
197
|
+
await this.verifyWrite(target, after)
|
|
198
|
+
const st = await this.stat(target)
|
|
199
|
+
return { version: st.version, before, after }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Atomic write: temp file in the same dir, then rename over the target. */
|
|
203
|
+
async atomicWrite(target, content) {
|
|
204
|
+
const sftp = await this.sftp()
|
|
205
|
+
const tmp = posix.join(posix.dirname(target.targetKey), `.dsh-${posix.basename(target.targetKey)}.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
206
|
+
try {
|
|
207
|
+
await sftp.writeFile(tmp, Buffer.from(content, 'utf8'))
|
|
208
|
+
try { await sftp.unlink(target.targetKey) } catch { /* target absent — first upload */ }
|
|
209
|
+
await sftp.rename(tmp, target.targetKey)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
await sftp.unlink(tmp).catch(() => {})
|
|
212
|
+
throw fsError('FS_IO_ERROR', `cannot write "${target.targetKey}": ${error.message}`, error)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Post-write verification: compare the on-disk sha256 to the intended bytes
|
|
218
|
+
* (mirrors hermes-agent's write_file check). A mismatch is a hard error, so
|
|
219
|
+
* silent server-side truncation/corruption never reaches the caller. Skipped
|
|
220
|
+
* when the remote has no `sha256sum`/`shasum`.
|
|
221
|
+
*/
|
|
222
|
+
async verifyWrite(target, content) {
|
|
223
|
+
const expected = createHash('sha256').update(content, 'utf8').digest('hex')
|
|
224
|
+
const actual = await this.client.sha256(target.targetKey)
|
|
225
|
+
if (actual !== undefined && actual !== expected) {
|
|
226
|
+
throw fsError('FS_IO_ERROR', `post-write verification failed for "${target.displayPath}": on-disk content does not match what was written`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export default SftpBackend
|
package/src/index.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { SshClient, defaultSshConfigPath, hostsFromConfig, shellQuote, clientForHost } from './transport.js'
|
|
3
|
+
import { loadMachines, sanitizeMachine, upsertMachine, removeMachine, machineById, machineForRemote, ensureSecretsEncrypted } from './machine-store.js'
|
|
4
|
+
import { ensureAnchor } from './anchor.js'
|
|
5
|
+
import { RoutingFileSystem } from './routing-fs.js'
|
|
6
|
+
import { SshShellExecutor } from './shell-exec.js'
|
|
7
|
+
import { registerAnchor, unregisterAnchor, findByCwd } from './registry.js'
|
|
8
|
+
import { applySearchTools } from './search.js'
|
|
9
|
+
|
|
10
|
+
export { parseSshConfig, expandTilde } from './ssh-config.js'
|
|
11
|
+
export { SshClient, hostsFromConfig, shellQuote, defaultSshConfigPath, clientForHost } from './transport.js'
|
|
12
|
+
export { SftpBackend } from './fs-sftp.js'
|
|
13
|
+
export { LocalBackend } from './local-backend.js'
|
|
14
|
+
export { parseSshUri, isRemoteCwd } from './ssh-uri.js'
|
|
15
|
+
export { fsError } from './errors.js'
|
|
16
|
+
export { RoutingFileSystem } from './routing-fs.js'
|
|
17
|
+
export { SshShellExecutor } from './shell-exec.js'
|
|
18
|
+
export { loadMachines, sanitizeMachine, upsertMachine, removeMachine, machinesPath } from './machine-store.js'
|
|
19
|
+
export { registerAnchor, unregisterAnchor, findByCwd, loadAnchors } from './registry.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Host half of the SSH remote workspace bundle (`dsh-remote-workspaces`).
|
|
23
|
+
*
|
|
24
|
+
* Publishes the routing `ctx.fs` and `ctx.shell` (local half sandboxed through
|
|
25
|
+
* the harness's policy/sandbox/subprocess services; remote half SFTP/ssh2 exec),
|
|
26
|
+
* plus the `remoteWorkspaces` Remote namespace for the "远程工作区" settings
|
|
27
|
+
* page: a persistent multi-machine SSH registry, `~/.ssh/config` import,
|
|
28
|
+
* connection test, remote directory browsing, and opening a remote directory
|
|
29
|
+
* as a workspace (an empty LOCAL anchor directory registered in the routing
|
|
30
|
+
* registry — all file/command I/O then lands on the remote, never the anchor).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const name = 'dsh-remote-workspaces'
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Remote contract. The client half (src/client.js) keeps an identical copy.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
const PACKAGE = 'dsh-remote-workspaces'
|
|
39
|
+
const NAMESPACE = 'remoteWorkspaces'
|
|
40
|
+
|
|
41
|
+
const JSON_CODEC = Object.freeze({
|
|
42
|
+
mode: 'strict',
|
|
43
|
+
typeSymbol: 'JsonValue',
|
|
44
|
+
schema: Object.freeze({ parse(value) { return value } }),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function jsonParameter(name) {
|
|
48
|
+
return { name, wire: name, source: 'json', codec: JSON_CODEC }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function invocation(method, parameters = []) {
|
|
52
|
+
return {
|
|
53
|
+
id: `${NAMESPACE}/${method}`,
|
|
54
|
+
service: NAMESPACE,
|
|
55
|
+
namespace: NAMESPACE,
|
|
56
|
+
method,
|
|
57
|
+
invocation: { kind: 'direct' },
|
|
58
|
+
parameters,
|
|
59
|
+
result: JSON_CODEC,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const INVOCATIONS = [
|
|
64
|
+
invocation('listMachines'),
|
|
65
|
+
invocation('saveMachine', [jsonParameter('machine')]),
|
|
66
|
+
invocation('deleteMachine', [jsonParameter('id')]),
|
|
67
|
+
invocation('listSshAliases'),
|
|
68
|
+
invocation('sshAliasDetail', [jsonParameter('alias')]),
|
|
69
|
+
invocation('testConnection', [jsonParameter('machine')]),
|
|
70
|
+
invocation('listRemoteDir', [jsonParameter('machine'), jsonParameter('path')]),
|
|
71
|
+
invocation('openRemoteWorkspace', [jsonParameter('machine'), jsonParameter('path')]),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
/** Build an `SshClient` from a machine record (alias/host/port/user/identityFile). */
|
|
75
|
+
function sshClientFor(machine) {
|
|
76
|
+
const m = machine ?? {}
|
|
77
|
+
// Secrets never ride the browser wire (sanitizeMachine strips them), so a
|
|
78
|
+
// connection for a saved machine recovers its password/passphrase from the
|
|
79
|
+
// store by id. A machine being tested before saving may still carry them.
|
|
80
|
+
const stored = m.id !== undefined ? machineById(m.id) : undefined
|
|
81
|
+
return new SshClient({
|
|
82
|
+
alias: m.alias,
|
|
83
|
+
host: m.host,
|
|
84
|
+
user: m.user,
|
|
85
|
+
port: m.port,
|
|
86
|
+
identityFile: m.identityFile,
|
|
87
|
+
password: m.password ?? stored?.password,
|
|
88
|
+
passphrase: m.passphrase ?? stored?.passphrase,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function messageOf(error) {
|
|
93
|
+
return error instanceof Error ? error.message : String(error)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build an `SshClient` for a remote host, preferring a saved machine's
|
|
98
|
+
* credentials (password/identityFile/passphrase) and falling back to
|
|
99
|
+
* `~/.ssh/config`. The routing fs + shell providers use this so remote I/O
|
|
100
|
+
* authenticates exactly like the settings "test connection" path.
|
|
101
|
+
*/
|
|
102
|
+
function clientForRemote(host, user, port) {
|
|
103
|
+
const machine = machineForRemote({ host, port, user })
|
|
104
|
+
return machine ? sshClientFor(machine) : clientForHost(host)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a raw browse path (home, `~`, `~/x`, relative, or absolute) to an
|
|
109
|
+
* absolute remote path so the client can navigate "up" past home to `/` and
|
|
110
|
+
* display a real path.
|
|
111
|
+
*/
|
|
112
|
+
async function resolveRemotePath(client, raw) {
|
|
113
|
+
const trimmed = raw === undefined || raw === null ? '' : String(raw).trim()
|
|
114
|
+
let sftp
|
|
115
|
+
try {
|
|
116
|
+
if (trimmed === '' || trimmed === '~' || trimmed === '~/') {
|
|
117
|
+
sftp = await client.sftp()
|
|
118
|
+
return await sftp.realpath('.')
|
|
119
|
+
}
|
|
120
|
+
if (trimmed.startsWith('~/')) {
|
|
121
|
+
sftp = await client.sftp()
|
|
122
|
+
const home = String(await sftp.realpath('.')).replace(/\/+$/, '')
|
|
123
|
+
return `${home}/${trimmed.slice(2)}`
|
|
124
|
+
}
|
|
125
|
+
if (!trimmed.startsWith('/')) {
|
|
126
|
+
sftp = await client.sftp()
|
|
127
|
+
return await sftp.realpath(trimmed)
|
|
128
|
+
}
|
|
129
|
+
return trimmed
|
|
130
|
+
} finally {
|
|
131
|
+
if (sftp) sftp.end()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Host owner of the `remoteWorkspaces` Remote namespace. Every method returns
|
|
137
|
+
* only lossless-JSON data and never echoes stored secrets back to the browser.
|
|
138
|
+
*/
|
|
139
|
+
function remoteWorkspacesService() {
|
|
140
|
+
return {
|
|
141
|
+
listMachines() {
|
|
142
|
+
return { ok: true, machines: loadMachines().map(sanitizeMachine) }
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
saveMachine(machine) {
|
|
146
|
+
try {
|
|
147
|
+
const saved = upsertMachine(machine ?? {})
|
|
148
|
+
return { ok: true, machine: saved }
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return { ok: false, error: messageOf(error) }
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
deleteMachine(id) {
|
|
155
|
+
removeMachine(id)
|
|
156
|
+
return { ok: true }
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
listSshAliases() {
|
|
160
|
+
const path = defaultSshConfigPath()
|
|
161
|
+
if (!existsSync(path)) return { ok: true, path, aliases: [] }
|
|
162
|
+
try {
|
|
163
|
+
return { ok: true, path, aliases: hostsFromConfig(path).map((entry) => entry.alias) }
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return { ok: false, error: messageOf(error) }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
sshAliasDetail(alias) {
|
|
170
|
+
const host = hostsFromConfig(defaultSshConfigPath()).find((entry) => entry.alias === alias)
|
|
171
|
+
if (host === undefined) return { ok: false, error: `未在 ~/.ssh/config 找到别名 "${alias}"` }
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
machine: sanitizeMachine({ ...host, id: undefined, password: undefined, passphrase: undefined }),
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async testConnection(machine) {
|
|
179
|
+
const client = sshClientFor(machine)
|
|
180
|
+
const result = await client.run('echo ok')
|
|
181
|
+
if (result.ok) return { ok: true, ms: result.ms }
|
|
182
|
+
return { ok: false, ms: result.ms, error: (result.stderr ?? '').trim() || result.error || '连接失败' }
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async listRemoteDir(machine, path) {
|
|
186
|
+
// `-A` hides `.`/`..`, `-p` appends `/` to directories, `-1` one per line.
|
|
187
|
+
const client = sshClientFor(machine)
|
|
188
|
+
let absPath
|
|
189
|
+
try {
|
|
190
|
+
absPath = await resolveRemotePath(client, path)
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { ok: false, error: `无法解析目录:${messageOf(error)}` }
|
|
193
|
+
}
|
|
194
|
+
const command = absPath === '' ? 'ls -1Ap' : `ls -1Ap ${shellQuote(absPath)}`
|
|
195
|
+
const res = await client.run(command)
|
|
196
|
+
if (!res.ok) return { ok: false, error: (res.stderr ?? '').trim() || res.error || '列出目录失败' }
|
|
197
|
+
const entries = res.stdout
|
|
198
|
+
.split('\n')
|
|
199
|
+
.filter((name) => name !== '')
|
|
200
|
+
.map((name) => ({ name: name.replace(/\/$/, ''), dir: name.endsWith('/') }))
|
|
201
|
+
return { ok: true, entries, path: absPath }
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Open a remote directory as a workspace: create an EMPTY local anchor
|
|
206
|
+
* directory (the harness's workspace identity — `fs.realpath` must resolve
|
|
207
|
+
* it) and register it in the routing registry. The caller then adopts the
|
|
208
|
+
* anchor through `workspaceRegistry.create`; all file/command I/O routes
|
|
209
|
+
* to the remote. No mirroring, no sync.
|
|
210
|
+
*/
|
|
211
|
+
async openRemoteWorkspace(machine, path) {
|
|
212
|
+
const client = sshClientFor(machine)
|
|
213
|
+
let sftp
|
|
214
|
+
try {
|
|
215
|
+
sftp = await client.sftp()
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return { ok: false, error: `无法建立 SFTP 连接:${messageOf(error)}` }
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const rel = path === undefined || path === '' ? '.' : path
|
|
221
|
+
const remotePath = await sftp.realpath(rel)
|
|
222
|
+
const anchorPath = ensureAnchor(machine, remotePath)
|
|
223
|
+
registerAnchor({
|
|
224
|
+
anchorPath,
|
|
225
|
+
machineId: machine?.id,
|
|
226
|
+
host: machine?.host ?? null,
|
|
227
|
+
port: machine?.port ?? null,
|
|
228
|
+
user: machine?.user ?? null,
|
|
229
|
+
remotePath,
|
|
230
|
+
})
|
|
231
|
+
return { ok: true, localDir: anchorPath, remotePath }
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { ok: false, error: messageOf(error) }
|
|
234
|
+
} finally {
|
|
235
|
+
sftp.end()
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function apply(ctx) {
|
|
242
|
+
// Migrate any legacy plaintext secrets to the encrypted form once, at load.
|
|
243
|
+
try { ensureSecretsEncrypted() } catch { }
|
|
244
|
+
|
|
245
|
+
ctx.provide('sshClient', new SshClient())
|
|
246
|
+
|
|
247
|
+
// Routing providers. Their harness services are captured via inject
|
|
248
|
+
// (deferred until available) because the plugin's own `ctx.get` may not
|
|
249
|
+
// resolve root-scoped services directly.
|
|
250
|
+
const deps = { policy: undefined, sandbox: undefined, subprocess: undefined }
|
|
251
|
+
const remote = {
|
|
252
|
+
getPolicy: () => deps.policy,
|
|
253
|
+
getSandbox: () => deps.sandbox,
|
|
254
|
+
getSubprocess: () => deps.subprocess,
|
|
255
|
+
clientForRemote,
|
|
256
|
+
}
|
|
257
|
+
ctx.inject(['sandbox', 'subprocess', 'sandboxPolicy'], (sctx) => {
|
|
258
|
+
deps.policy = sctx.get('sandboxPolicy')
|
|
259
|
+
deps.sandbox = sctx.get('sandbox')
|
|
260
|
+
deps.subprocess = sctx.get('subprocess')
|
|
261
|
+
return () => { deps.policy = deps.sandbox = deps.subprocess = undefined }
|
|
262
|
+
})
|
|
263
|
+
ctx.provide('fs', new RoutingFileSystem(remote))
|
|
264
|
+
ctx.provide('shell', new SshShellExecutor(remote))
|
|
265
|
+
|
|
266
|
+
const service = remoteWorkspacesService()
|
|
267
|
+
service.typertRemote = Object.freeze({ service, serviceKey: NAMESPACE, namespace: NAMESPACE })
|
|
268
|
+
ctx.provide(NAMESPACE, service)
|
|
269
|
+
|
|
270
|
+
// Remote-aware grep/glob (replace the local ripgrep tool-fs-search), deferred
|
|
271
|
+
// until `tools` is available.
|
|
272
|
+
ctx.inject(['tools'], (toolsCtx) => {
|
|
273
|
+
applySearchTools(toolsCtx, remote)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// Shadow the harness's global `cwd` prompt variable per-agent so a remote
|
|
277
|
+
// workspace's persona line ("Your working directory is {{cwd}}") shows the
|
|
278
|
+
// REMOTE path instead of the empty local anchor. Local agents keep their cwd
|
|
279
|
+
// unchanged. Routing still uses `session.header.cwd` — this only rewrites the
|
|
280
|
+
// prompt text.
|
|
281
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
282
|
+
agent.ctx.inject(['systemPrompt'], (scope) => {
|
|
283
|
+
scope.systemPrompt.variable('cwd', (context) => {
|
|
284
|
+
const cwd = context.agent?.session?.header?.cwd
|
|
285
|
+
if (typeof cwd !== 'string' || cwd === '') return cwd
|
|
286
|
+
const hit = findByCwd(cwd)
|
|
287
|
+
if (hit === undefined) return cwd
|
|
288
|
+
return hit.remoteSubpath === '' ? hit.remotePath : `${hit.remotePath.replace(/\/+$/, '')}/${hit.remoteSubpath}`
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// Clarify remote workspaces to the model — only when the session's cwd is a
|
|
294
|
+
// registered remote anchor (empty text hides the section for local sessions).
|
|
295
|
+
ctx.inject(['systemPrompt'], (sctx) => {
|
|
296
|
+
const systemPrompt = sctx.get('systemPrompt')
|
|
297
|
+
if (systemPrompt === undefined) return
|
|
298
|
+
systemPrompt.section({
|
|
299
|
+
name: 'dsh-remote-workspaces:notice',
|
|
300
|
+
order: 10,
|
|
301
|
+
text: (context) => {
|
|
302
|
+
const cwd = context.agent?.session?.header?.cwd
|
|
303
|
+
if (typeof cwd !== 'string' || cwd === '') return ''
|
|
304
|
+
const hit = findByCwd(cwd)
|
|
305
|
+
if (hit === undefined) return ''
|
|
306
|
+
const host = hit.user ? `${hit.user}@${hit.host}` : hit.host
|
|
307
|
+
return `Remote workspace over SSH (${host}): file/search tools and shell commands run on the remote host; use relative paths (they route to the remote automatically).`
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// The typert registry activates after this dependency-free plugin, so the
|
|
313
|
+
// strict Remote contribution is deferred until `typert` is available.
|
|
314
|
+
ctx.inject(['typert'], (typertCtx) => {
|
|
315
|
+
const typert = typertCtx.get('typert')
|
|
316
|
+
if (typert === undefined) return
|
|
317
|
+
return typert.register({
|
|
318
|
+
package: PACKAGE,
|
|
319
|
+
face: 'host',
|
|
320
|
+
schemas: [],
|
|
321
|
+
model: { services: [], events: [], objects: [] },
|
|
322
|
+
invocations: INVOCATIONS,
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default apply
|