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.
@@ -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