dsh-remote-workspaces 0.2.1 → 0.3.1
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/lib/client.js +7723 -0
- package/package.json +11 -3
- package/src/client.js +540 -23
- package/src/index.js +126 -2
- package/src/local-browse.js +168 -0
- package/src/registry.js +20 -11
- package/src/routing-fs.js +54 -7
- package/src/shell-sessions.js +180 -0
- package/src/transport.js +73 -0
package/src/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { RoutingFileSystem } from './routing-fs.js'
|
|
|
6
6
|
import { SshShellExecutor } from './shell-exec.js'
|
|
7
7
|
import { registerAnchor, unregisterAnchor, findByCwd, updateAnchorOs } from './registry.js'
|
|
8
8
|
import { applySearchTools } from './search.js'
|
|
9
|
+
import { createShellSessions } from './shell-sessions.js'
|
|
10
|
+
import { expandLocalTarget, localHome, localDriveRoots, localLevelListing, localListError, LOCAL_DRIVES } from './local-browse.js'
|
|
9
11
|
|
|
10
12
|
export { parseSshConfig, expandTilde } from './ssh-config.js'
|
|
11
13
|
export { SshClient, hostsFromConfig, shellQuote, defaultSshConfigPath, clientForHost } from './transport.js'
|
|
@@ -68,7 +70,16 @@ const INVOCATIONS = [
|
|
|
68
70
|
invocation('sshAliasDetail', [jsonParameter('alias')]),
|
|
69
71
|
invocation('testConnection', [jsonParameter('machine')]),
|
|
70
72
|
invocation('listRemoteDir', [jsonParameter('machine'), jsonParameter('path')]),
|
|
73
|
+
invocation('listLocalDir', [jsonParameter('path')]),
|
|
71
74
|
invocation('openRemoteWorkspace', [jsonParameter('machine'), jsonParameter('path')]),
|
|
75
|
+
invocation('openShellLocal', [jsonParameter('opts')]),
|
|
76
|
+
invocation('openShellRemote', [jsonParameter('machine'), jsonParameter('opts')]),
|
|
77
|
+
invocation('openShellAt', [jsonParameter('cwd'), jsonParameter('opts')]),
|
|
78
|
+
invocation('shellWrite', [jsonParameter('id'), jsonParameter('data')]),
|
|
79
|
+
invocation('shellRead', [jsonParameter('id')]),
|
|
80
|
+
invocation('shellResize', [jsonParameter('id'), jsonParameter('rows'), jsonParameter('cols')]),
|
|
81
|
+
invocation('shellClose', [jsonParameter('id')]),
|
|
82
|
+
invocation('shellList'),
|
|
72
83
|
]
|
|
73
84
|
|
|
74
85
|
/** Build an `SshClient` from a machine record (alias/host/port/user/identityFile). */
|
|
@@ -183,7 +194,11 @@ async function resolveRemotePath(client, raw, sharedSftp) {
|
|
|
183
194
|
* Host owner of the `remoteWorkspaces` Remote namespace. Every method returns
|
|
184
195
|
* only lossless-JSON data and never echoes stored secrets back to the browser.
|
|
185
196
|
*/
|
|
186
|
-
function remoteWorkspacesService() {
|
|
197
|
+
function remoteWorkspacesService(remote) {
|
|
198
|
+
const shells = createShellSessions({
|
|
199
|
+
getSubprocess: () => remote.getSubprocess(),
|
|
200
|
+
openRemote: (machine, opts) => sshClientFor(machine).openShell(opts),
|
|
201
|
+
})
|
|
187
202
|
return {
|
|
188
203
|
listMachines() {
|
|
189
204
|
return { ok: true, machines: loadMachines().map(sanitizeMachine) }
|
|
@@ -261,6 +276,28 @@ function remoteWorkspacesService() {
|
|
|
261
276
|
}
|
|
262
277
|
},
|
|
263
278
|
|
|
279
|
+
async listLocalDir(path) {
|
|
280
|
+
// Virtual Windows drive-selection level: '上一级' at a drive root.
|
|
281
|
+
if (typeof path === 'string' && path === LOCAL_DRIVES) {
|
|
282
|
+
try {
|
|
283
|
+
const roots = await localDriveRoots()
|
|
284
|
+
const entries = roots.map((r) => ({ name: r.slice(0, 2), dir: true }))
|
|
285
|
+
return { ok: true, path: '', entries, truncated: false }
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return { ok: false, error: '无法读取盘符:' + messageOf(error) }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
let target = ''
|
|
291
|
+
try {
|
|
292
|
+
const home = localHome()
|
|
293
|
+
target = expandLocalTarget(path, home)
|
|
294
|
+
const level = await localLevelListing(target, {})
|
|
295
|
+
return { ok: true, path: level.path, entries: level.entries, truncated: level.truncated }
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return { ok: false, error: localListError(error, target || (typeof path === 'string' ? path : '')) }
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
|
|
264
301
|
/**
|
|
265
302
|
* Open a remote directory as a workspace: create an EMPTY local anchor
|
|
266
303
|
* directory (the harness's workspace identity — `fs.realpath` must resolve
|
|
@@ -306,6 +343,93 @@ function remoteWorkspacesService() {
|
|
|
306
343
|
sftp.end()
|
|
307
344
|
}
|
|
308
345
|
},
|
|
346
|
+
|
|
347
|
+
// Shell tool: interactive local shell (ctx.subprocess.spawnTerminal) and
|
|
348
|
+
// remote shell (ssh2 conn.shell + pty). No approval (D6/D7): opening a
|
|
349
|
+
// shell is a user GUI action, equivalent to xshell — a separate line from
|
|
350
|
+
// the agent-side sandboxPolicy/approval gate. Credentials never ride the
|
|
351
|
+
// wire: the client sends only the sanitized machine (no secrets) and the
|
|
352
|
+
// host recovers password/passphrase from the store by id.
|
|
353
|
+
async openShellLocal(opts) {
|
|
354
|
+
try {
|
|
355
|
+
const session = await shells.openLocal(opts ?? {})
|
|
356
|
+
return { ok: true, ...session }
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return { ok: false, error: messageOf(error) }
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async openShellRemote(machine, opts) {
|
|
363
|
+
try {
|
|
364
|
+
const session = await shells.openRemote(machine ?? {}, opts ?? {})
|
|
365
|
+
return { ok: true, ...session }
|
|
366
|
+
} catch (error) {
|
|
367
|
+
return { ok: false, error: messageOf(error) }
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Open a shell AT the current workspace cwd, resolving the target without
|
|
373
|
+
* the user choosing: a cwd under a registered remote anchor opens a remote
|
|
374
|
+
* shell on that machine, chdir'd to the anchor's remote path (+ subpath);
|
|
375
|
+
* anything else opens a local shell at that cwd. This is the default
|
|
376
|
+
* "click Shell" entry — no local/remote chooser.
|
|
377
|
+
*/
|
|
378
|
+
async openShellAt(cwd, opts) {
|
|
379
|
+
try {
|
|
380
|
+
const anchor = typeof cwd === 'string' && cwd !== '' ? findByCwd(cwd) : undefined
|
|
381
|
+
if (anchor !== undefined) {
|
|
382
|
+
const machine = (anchor.machineId !== undefined && anchor.machineId !== null ? machineById(anchor.machineId) : undefined)
|
|
383
|
+
?? { host: anchor.host, port: anchor.port, user: anchor.user }
|
|
384
|
+
const remoteCwd = anchor.remoteSubpath === ''
|
|
385
|
+
? anchor.remotePath
|
|
386
|
+
: `${anchor.remotePath.replace(/\/+$/, '')}/${anchor.remoteSubpath}`
|
|
387
|
+
const session = await shells.openRemote(machine, { ...(opts ?? {}), cwd: remoteCwd })
|
|
388
|
+
return { ok: true, ...session, cwd: remoteCwd, label: machine.alias || machine.host || '远程' }
|
|
389
|
+
}
|
|
390
|
+
const session = await shells.openLocal({ ...(opts ?? {}), cwd })
|
|
391
|
+
return { ok: true, ...session, cwd: typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd(), label: '本机' }
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return { ok: false, error: messageOf(error) }
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
async shellWrite(id, data) {
|
|
398
|
+
try {
|
|
399
|
+
await shells.write(id, data)
|
|
400
|
+
return { ok: true }
|
|
401
|
+
} catch (error) {
|
|
402
|
+
return { ok: false, error: messageOf(error) }
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
shellRead(id) {
|
|
407
|
+
try {
|
|
408
|
+
return { ok: true, ...shells.read(id) }
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return { ok: false, error: messageOf(error) }
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async shellResize(id, rows, cols) {
|
|
415
|
+
try {
|
|
416
|
+
return { ok: true, ...(await shells.resize(id, rows, cols)) }
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return { ok: false, error: messageOf(error) }
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
async shellClose(id) {
|
|
423
|
+
try {
|
|
424
|
+
return { ok: true, ...(await shells.close(id)) }
|
|
425
|
+
} catch (error) {
|
|
426
|
+
return { ok: false, error: messageOf(error) }
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
shellList() {
|
|
431
|
+
return { ok: true, sessions: shells.list() }
|
|
432
|
+
},
|
|
309
433
|
}
|
|
310
434
|
}
|
|
311
435
|
|
|
@@ -334,7 +458,7 @@ export function apply(ctx) {
|
|
|
334
458
|
ctx.provide('fs', new RoutingFileSystem(remote))
|
|
335
459
|
ctx.provide('shell', new SshShellExecutor(remote))
|
|
336
460
|
|
|
337
|
-
const service = remoteWorkspacesService()
|
|
461
|
+
const service = remoteWorkspacesService(remote)
|
|
338
462
|
service.typertRemote = Object.freeze({ service, serviceKey: NAMESPACE, namespace: NAMESPACE })
|
|
339
463
|
ctx.provide(NAMESPACE, service)
|
|
340
464
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-directory listing for the workspace-add flow's "本地文件夹" tab when
|
|
3
|
+
* the page is NOT served from the host's own loopback (remote access): the OS
|
|
4
|
+
* chooser would open on an unattended desktop, so the plugin browses the host
|
|
5
|
+
* filesystem in-app instead. Pure Node stdlib (the plugin cannot import
|
|
6
|
+
* harness packages); mirrors the `listRemoteDir` contract so the client reuses
|
|
7
|
+
* the same entry rendering.
|
|
8
|
+
*
|
|
9
|
+
* Security posture: read-only one-level directory listing, driven by the user's
|
|
10
|
+
* own GUI action. Never writes, never executes, and never enters the agent
|
|
11
|
+
* toolchain's sandboxPolicy/approval line (same trust surface as the harness
|
|
12
|
+
* `directory-picker-browse` backend).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { access, readdir, stat } from 'node:fs/promises'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join, posix, win32 } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wire value for the virtual Windows drive-selection level: listing it returns
|
|
21
|
+
* the available drive roots. It is not a filesystem path, so it is fenced out
|
|
22
|
+
* of every real-path branch (never fully qualified). The client sends it only
|
|
23
|
+
* when a drive root's "上一级" is pressed on a win32 host (the client keeps an
|
|
24
|
+
* identical copy of this constant).
|
|
25
|
+
*/
|
|
26
|
+
export const LOCAL_DRIVES = '::drives::'
|
|
27
|
+
|
|
28
|
+
/** The host account's home directory (listing default + `~` expansion). */
|
|
29
|
+
export function localHome() {
|
|
30
|
+
return homedir()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Available Windows drive roots (`C:\`, `D:\`, …) for the virtual drive level.
|
|
35
|
+
* Empty on non-win32 platforms. Probes `A:`–`Z:` with fs access.
|
|
36
|
+
* @param platform - replaces `process.platform` for deterministic tests.
|
|
37
|
+
*/
|
|
38
|
+
export async function localDriveRoots(platform = process.platform) {
|
|
39
|
+
if (platform !== 'win32') return []
|
|
40
|
+
const found = []
|
|
41
|
+
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
42
|
+
const root = `${letter}:\\`
|
|
43
|
+
try {
|
|
44
|
+
await access(root)
|
|
45
|
+
found.push(root)
|
|
46
|
+
} catch { /* absent drive */ }
|
|
47
|
+
}
|
|
48
|
+
return found
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when the path names one fixed filesystem location regardless of process
|
|
53
|
+
* state: POSIX-absolute on POSIX; on Windows only drive-qualified (`C:\…`) or
|
|
54
|
+
* complete UNC (`\\server\share…`) forms. Rooted drive-less forms and
|
|
55
|
+
* incomplete UNC prefixes pass `isAbsolute` yet still resolve against the
|
|
56
|
+
* process's current drive — mirroring the harness browse backend's fence so a
|
|
57
|
+
* wire value never rebases under the host cwd.
|
|
58
|
+
* @param path - candidate path.
|
|
59
|
+
* @param platform - replaces `process.platform` for deterministic tests.
|
|
60
|
+
*/
|
|
61
|
+
export function localFullyQualified(path, platform = process.platform) {
|
|
62
|
+
if (typeof path !== 'string') return false
|
|
63
|
+
if (platform === 'win32') {
|
|
64
|
+
return win32.isAbsolute(path)
|
|
65
|
+
&& /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
|
|
66
|
+
}
|
|
67
|
+
return posix.isAbsolute(path)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Not-absolute / non-`~` input: never rebase under the host cwd. */
|
|
71
|
+
function notAbsolute(raw) {
|
|
72
|
+
const error = new Error(`不是完整路径(需要绝对路径或以 ~ 开头):${String(raw)}`)
|
|
73
|
+
error.code = 'DSH_NOT_ABSOLUTE'
|
|
74
|
+
return error
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve + normalize one fully qualified target on the platform. */
|
|
78
|
+
function normalizeTarget(input, platform) {
|
|
79
|
+
return platform === 'win32' ? win32.resolve(input) : posix.resolve(input)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Platform-consistent segment join (node's default join is host-flavored). */
|
|
83
|
+
function platformJoin(a, b, platform) {
|
|
84
|
+
return platform === 'win32' ? win32.join(a, b) : posix.join(a, b)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Turn the RPC's raw `path` argument into a concrete absolute listing target.
|
|
89
|
+
* `''`/undefined/`~`/`~/…` expand to the home directory; anything else must be
|
|
90
|
+
* fully qualified (client-browsed paths are, and `..` segments are resolved by
|
|
91
|
+
* the platform resolver, so the client's "上一级" can send `<path>/..`).
|
|
92
|
+
* @param raw - the wire value (may be undefined).
|
|
93
|
+
* @param home - home directory to expand against.
|
|
94
|
+
* @param platform - replaces `process.platform` for deterministic tests.
|
|
95
|
+
* @returns the absolute listing target.
|
|
96
|
+
* @throws {Error} with `code === 'DSH_NOT_ABSOLUTE'` for non-`~` relative input.
|
|
97
|
+
*/
|
|
98
|
+
export function expandLocalTarget(raw, home, platform = process.platform) {
|
|
99
|
+
const input = typeof raw === 'string' ? raw.trim() : ''
|
|
100
|
+
if (input === '' || input === '~') return normalizeTarget(home, platform)
|
|
101
|
+
if (input.startsWith('~/') || input.startsWith('~\\')) {
|
|
102
|
+
return normalizeTarget(platformJoin(home, input.slice(2), platform), platform)
|
|
103
|
+
}
|
|
104
|
+
if (!localFullyQualified(input, platform)) throw notAbsolute(raw)
|
|
105
|
+
return normalizeTarget(input, platform)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Classify one dirent: directories (symlinked dirs probed) are enterable rows. */
|
|
109
|
+
async function entryRow(target, dirent) {
|
|
110
|
+
const isDirectory = dirent.isDirectory()
|
|
111
|
+
if (!isDirectory && dirent.isSymbolicLink()) {
|
|
112
|
+
try {
|
|
113
|
+
return { name: dirent.name, dir: (await stat(join(target, dirent.name))).isDirectory() }
|
|
114
|
+
} catch {
|
|
115
|
+
// Broken/cyclic link: keep a plain (non-enterable) row.
|
|
116
|
+
return { name: dirent.name, dir: false }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { name: dirent.name, dir: isDirectory }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* List one directory level. Directories and files both return (files render
|
|
124
|
+
* muted and non-clickable, mirroring the remote tab); `.`/`..` are dropped;
|
|
125
|
+
* rows are name-sorted. The complete level is bounded at `maxEntries` rows with
|
|
126
|
+
* `truncated` flagging a cut tail.
|
|
127
|
+
* @param target - absolute listing target (already expanded).
|
|
128
|
+
* @param opts - `maxEntries` bound (default 1000, like the harness browse
|
|
129
|
+
* backend); `platform` for tests.
|
|
130
|
+
* @returns `{ path, entries: [{name, dir}], truncated }`.
|
|
131
|
+
* @throws filesystem errors (`ENOENT`/`ENOTDIR`/`EACCES`…) unchanged.
|
|
132
|
+
*/
|
|
133
|
+
export async function localLevelListing(target, opts = {}) {
|
|
134
|
+
const maxEntries = opts.maxEntries === undefined ? 1000 : opts.maxEntries
|
|
135
|
+
const entries = (await readdir(target, { withFileTypes: true }))
|
|
136
|
+
.filter((d) => d.name !== '.' && d.name !== '..')
|
|
137
|
+
const rows = []
|
|
138
|
+
for (const dirent of entries) {
|
|
139
|
+
if (rows.length === maxEntries) break
|
|
140
|
+
// eslint-disable-next-line no-await-in-loop -- per-entry symlink probes are sequential like the remote backend.
|
|
141
|
+
rows.push(await entryRow(target, dirent))
|
|
142
|
+
}
|
|
143
|
+
rows.sort((a, b) => a.name.localeCompare(b.name))
|
|
144
|
+
return {
|
|
145
|
+
path: target,
|
|
146
|
+
entries: rows,
|
|
147
|
+
truncated: entries.length > maxEntries,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Operator-facing text for a failed local listing.
|
|
153
|
+
* @param error - thrown value (filesystem error or DSH_NOT_ABSOLUTE).
|
|
154
|
+
* @param target - resolved target (for messages naming the directory).
|
|
155
|
+
*/
|
|
156
|
+
export function localListError(error, target) {
|
|
157
|
+
const code = error && error.code
|
|
158
|
+
if (code === 'DSH_NOT_ABSOLUTE') {
|
|
159
|
+
return error.message || '路径无效'
|
|
160
|
+
}
|
|
161
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
162
|
+
return `目录不存在:${target}`
|
|
163
|
+
}
|
|
164
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
165
|
+
return `无权限读取:${target}`
|
|
166
|
+
}
|
|
167
|
+
return `无法读取目录:${target}(${error instanceof Error ? error.message : String(error)})`
|
|
168
|
+
}
|
package/src/registry.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
-
import { join
|
|
12
|
+
import { join } from 'node:path'
|
|
13
13
|
import { remoteWorkspacesRoot } from './anchor.js'
|
|
14
14
|
|
|
15
15
|
function anchorsPath() {
|
|
@@ -64,27 +64,36 @@ export function unregisterAnchor(anchorPath) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Resolve a session cwd
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* Resolve a session cwd — the anchor path, any descendant, or the Files tree's
|
|
68
|
+
* `/`-joined child spelling of either — to its remote origin. Returns
|
|
69
|
+
* `{ anchorPath, remotePath, remoteSubpath, host, port, user, machineId }` or
|
|
70
|
+
* `undefined` when the cwd is not under any registered anchor.
|
|
71
|
+
*
|
|
72
|
+
* Matching is done on separator-normalized forms: anchor keys are stored with
|
|
73
|
+
* the native `sep` (`\` on Windows), while harness session cwds and the right
|
|
74
|
+
* Sidebar Files tree spell paths with `/` (and children are `root + '/' + name`
|
|
75
|
+
* even when the root is `\`-spelled). `remoteSubpath` is always `/`-joined.
|
|
70
76
|
*/
|
|
71
77
|
export function findByCwd(cwd) {
|
|
72
78
|
if (typeof cwd !== 'string' || cwd === '') return undefined
|
|
73
79
|
const anchors = loadAnchors()
|
|
80
|
+
const flat = (p) => p.replace(/\\/g, '/')
|
|
81
|
+
const query = flat(cwd)
|
|
74
82
|
let best
|
|
75
83
|
let bestLen = -1
|
|
76
84
|
for (const [anchorPath, rec] of Object.entries(anchors)) {
|
|
77
|
-
const base = anchorPath
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
const base = flat(anchorPath)
|
|
86
|
+
const prefix = base.endsWith('/') ? base : `${base}/`
|
|
87
|
+
if (query === base || query.startsWith(prefix)) {
|
|
88
|
+
if (base.length > bestLen) {
|
|
89
|
+
bestLen = base.length
|
|
90
|
+
best = { anchorPath, base, rec }
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
if (best === undefined) return undefined
|
|
86
|
-
const rel =
|
|
87
|
-
return { ...best,
|
|
95
|
+
const rel = query === best.base ? '' : query.slice(best.base.length + 1)
|
|
96
|
+
return { ...best.rec, anchorPath: best.anchorPath, remoteSubpath: rel }
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
export default { loadAnchors, registerAnchor, unregisterAnchor, updateAnchorOs, findByCwd, remoteWorkspacesRoot }
|
package/src/routing-fs.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Routing filesystem: the plugin's `ctx.fs` provider.
|
|
3
3
|
*
|
|
4
|
-
* Routes by the session cwd.
|
|
5
|
-
* - an `ssh://[user@]host[:port]/path` cwd (URI form),
|
|
4
|
+
* Routes by the session cwd OR by the path itself. Remote triggers:
|
|
5
|
+
* - an `ssh://[user@]host[:port]/path` cwd (URI form),
|
|
6
6
|
* - a LOCAL anchor directory registered in the remote-workspace registry
|
|
7
|
-
* (`anchors.json`), whose real content lives on the remote host
|
|
7
|
+
* (`anchors.json`), whose real content lives on the remote host, and
|
|
8
|
+
* - any absolute path that IS a registered anchor or sits under one (the
|
|
9
|
+
* anchor path is the remote world's local alias — the Files tree lists,
|
|
10
|
+
* expands and opens entirely in this spelling, so routing must answer it
|
|
11
|
+
* with or without an anchor cwd).
|
|
8
12
|
* Everything else goes to the local backend (with the workspace-write fence).
|
|
9
13
|
*
|
|
10
14
|
* The world identity is ENCODED into the target key (`ssh://host/path` for
|
|
@@ -75,6 +79,41 @@ export class RoutingFileSystem {
|
|
|
75
79
|
return `ssh://${user ? `${user}@` : ''}${host}${port ? `:${port}` : ''}${subKey}`
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Anchor alias of an absolute LOCAL path: a registered anchor dir — or any
|
|
84
|
+
* path under one — is the remote world's local spelling, so the registry
|
|
85
|
+
* lookup that maps a session cwd maps the path itself. Returns the remote
|
|
86
|
+
* origin (`{ host, user, port, remotePath }`) when `path` is an anchor dir or
|
|
87
|
+
* a descendant of one, else null. This is what lets the right Sidebar Files
|
|
88
|
+
* tree (which roots at the anchor path and joins children with `/`) reach the
|
|
89
|
+
* remote: those strings ARE remote aliases.
|
|
90
|
+
*/
|
|
91
|
+
aliasOfPath(path) {
|
|
92
|
+
if (typeof path !== 'string' || path === '') return null
|
|
93
|
+
const hit = findByCwd(path)
|
|
94
|
+
if (hit === undefined) return null
|
|
95
|
+
return {
|
|
96
|
+
host: hit.host,
|
|
97
|
+
user: hit.user,
|
|
98
|
+
port: hit.port,
|
|
99
|
+
remotePath: hit.remoteSubpath === '' ? hit.remotePath : posix.join(hit.remotePath, hit.remoteSubpath),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Remote routing params for a call, from its cwd first and its path second. */
|
|
104
|
+
routeRemote(path, cwd) {
|
|
105
|
+
const byCwd = this.remoteCwd(cwd)
|
|
106
|
+
if (byCwd !== null) return byCwd
|
|
107
|
+
const byPath = this.aliasOfPath(path)
|
|
108
|
+
return byPath === null ? null : { ...byPath, remoteCwd: byPath.remotePath }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The path argument translated to the remote world when it is itself an alias. */
|
|
112
|
+
remotePathArg(path) {
|
|
113
|
+
const alias = this.aliasOfPath(path)
|
|
114
|
+
return alias === null ? path : alias.remotePath
|
|
115
|
+
}
|
|
116
|
+
|
|
78
117
|
/** Decode a target key into { backend, target } using the encoded world prefix. */
|
|
79
118
|
splitTarget(target) {
|
|
80
119
|
const key = String(target.targetKey)
|
|
@@ -92,10 +131,17 @@ export class RoutingFileSystem {
|
|
|
92
131
|
|
|
93
132
|
async resolve(path, opts) {
|
|
94
133
|
const cwd = opts && opts.cwd
|
|
95
|
-
|
|
134
|
+
// Remote by cwd (session anchor), or by the path itself when no remote cwd
|
|
135
|
+
// is given — the Files endpoint resolves the workspace root WITHOUT a cwd,
|
|
136
|
+
// and the root is the anchor path, so the path must trigger routing alone.
|
|
137
|
+
const remote = this.routeRemote(path, cwd)
|
|
96
138
|
if (remote !== null) {
|
|
97
139
|
const backend = this.remoteBackend(remote.host, remote.user, remote.port)
|
|
98
|
-
|
|
140
|
+
// An anchor-absolute path (the Files vocabulary) is translated to its
|
|
141
|
+
// remote spelling here; a relative path under an anchor cwd passes
|
|
142
|
+
// through unchanged so the backend's cwd resolution applies as before.
|
|
143
|
+
const arg = this.remotePathArg(path)
|
|
144
|
+
const sub = await backend.resolve(arg, { cwd: remote.remoteCwd })
|
|
99
145
|
return {
|
|
100
146
|
targetKey: this.encodeTarget(remote.host, remote.user, remote.port, sub.targetKey),
|
|
101
147
|
displayPath: sub.displayPath,
|
|
@@ -135,10 +181,11 @@ export class RoutingFileSystem {
|
|
|
135
181
|
|
|
136
182
|
lstat(path, opts, signal) {
|
|
137
183
|
const cwd = opts && opts.cwd
|
|
138
|
-
const remote = this.
|
|
184
|
+
const remote = this.routeRemote(path, cwd)
|
|
139
185
|
if (remote !== null) {
|
|
140
186
|
const backend = this.remoteBackend(remote.host, remote.user, remote.port)
|
|
141
|
-
|
|
187
|
+
const arg = this.remotePathArg(path)
|
|
188
|
+
return backend.lstat(arg, { cwd: remote.remoteCwd })
|
|
142
189
|
}
|
|
143
190
|
return this.local.lstat(path, opts)
|
|
144
191
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-process registry of interactive UI shell sessions (local + remote).
|
|
5
|
+
*
|
|
6
|
+
* Every `openLocal` allocates an INDEPENDENT PTY process through the harness's
|
|
7
|
+
* `ctx.subprocess.spawnTerminal` seam; every `openRemote` opens an INDEPENDENT
|
|
8
|
+
* ssh2 shell channel through `SshClient.openShell`. Sessions never share a
|
|
9
|
+
* process/connection, cwd, stdin/stdout, or buffer (the §3.1 isolation model).
|
|
10
|
+
* Lifecycle is process-local (matches ctx.terminals semantics): no persistence,
|
|
11
|
+
* no cross-process recovery.
|
|
12
|
+
*
|
|
13
|
+
* A session keeps TWO buffers over the same stream:
|
|
14
|
+
* - `unread` — drained by `read` (incremental polling);
|
|
15
|
+
* - `history` — the full (capped) output, returned on ATTACH so a re-mounted
|
|
16
|
+
* tab replays its scrollback instead of coming back blank.
|
|
17
|
+
*
|
|
18
|
+
* Attach: an `open*` carrying a stable `key` REUSES a still-live session under
|
|
19
|
+
* that key instead of spawning a new one — this is what keeps a Shell tab's
|
|
20
|
+
* terminal alive across the client unmounting it on a DSH-session switch.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MAX_UNREAD = 256 * 1024
|
|
24
|
+
const MAX_HISTORY = 512 * 1024
|
|
25
|
+
|
|
26
|
+
export function createShellSessions({ getSubprocess, openRemote: openRemoteChannel, sweepMs = 10 * 60 * 1000 }) {
|
|
27
|
+
const sessions = new Map()
|
|
28
|
+
|
|
29
|
+
// Reap sessions no client has read from or written to within `sweepMs`: a
|
|
30
|
+
// shell whose tab was lost to a refresh (or an archived session) has nobody
|
|
31
|
+
// left to close it. An on-screen tab polls continuously, so only abandoned
|
|
32
|
+
// shells — or ones hidden longer than the threshold — are collected.
|
|
33
|
+
function sweep(now = Date.now()) {
|
|
34
|
+
for (const [id, session] of [...sessions]) {
|
|
35
|
+
if (!session.ended && now - session.lastActivityAt > sweepMs) {
|
|
36
|
+
sessions.delete(id)
|
|
37
|
+
void session.handle.terminate()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const sweepTimer = setInterval(sweep, 60 * 1000)
|
|
42
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref()
|
|
43
|
+
|
|
44
|
+
function liveSession(key) {
|
|
45
|
+
if (typeof key !== 'string' || key === '') return undefined
|
|
46
|
+
const existing = sessions.get(key)
|
|
47
|
+
return existing !== undefined && !existing.ended ? existing : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pushCapped(bag, cap, buf) {
|
|
51
|
+
bag.chunks.push(buf)
|
|
52
|
+
bag.bytes += buf.length
|
|
53
|
+
while (bag.bytes > cap && bag.chunks.length > 1) {
|
|
54
|
+
bag.bytes -= bag.chunks.shift().length
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function register(handle, meta, key) {
|
|
59
|
+
const id = typeof key === 'string' && key !== '' ? key : randomUUID()
|
|
60
|
+
const session = {
|
|
61
|
+
id,
|
|
62
|
+
handle,
|
|
63
|
+
meta,
|
|
64
|
+
unread: { chunks: [], bytes: 0 },
|
|
65
|
+
history: { chunks: [], bytes: 0 },
|
|
66
|
+
ended: false,
|
|
67
|
+
lastActivityAt: Date.now(),
|
|
68
|
+
}
|
|
69
|
+
handle.output.on('data', (chunk) => {
|
|
70
|
+
const buf = Buffer.from(chunk)
|
|
71
|
+
pushCapped(session.unread, MAX_UNREAD, buf)
|
|
72
|
+
pushCapped(session.history, MAX_HISTORY, buf)
|
|
73
|
+
})
|
|
74
|
+
handle.output.on('end', () => { session.ended = true })
|
|
75
|
+
handle.output.on('close', () => { session.ended = true })
|
|
76
|
+
sessions.set(id, session)
|
|
77
|
+
return session
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function historyText(session) {
|
|
81
|
+
return Buffer.concat(session.history.chunks).toString('utf8')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function openLocal(opts = {}) {
|
|
85
|
+
const key = typeof opts.key === 'string' && opts.key !== '' ? opts.key : undefined
|
|
86
|
+
const existing = liveSession(key)
|
|
87
|
+
if (existing !== undefined) {
|
|
88
|
+
const history = historyText(existing)
|
|
89
|
+
// The attach hands the client the full scrollback, so the unread tail is
|
|
90
|
+
// now covered — start incremental reads fresh to avoid double replay.
|
|
91
|
+
existing.unread.chunks.length = 0
|
|
92
|
+
existing.unread.bytes = 0
|
|
93
|
+
return { id: existing.id, pid: existing.handle.pid, kind: 'local', attached: true, history }
|
|
94
|
+
}
|
|
95
|
+
const subprocess = getSubprocess()
|
|
96
|
+
if (subprocess === undefined || typeof subprocess.spawnTerminal !== 'function') {
|
|
97
|
+
throw new Error('subprocess service unavailable (no spawnTerminal)')
|
|
98
|
+
}
|
|
99
|
+
const win = process.platform === 'win32'
|
|
100
|
+
const argv = win ? ['powershell.exe', '-NoLogo'] : ['bash', '-i']
|
|
101
|
+
const cwd = typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : process.cwd()
|
|
102
|
+
const rows = Number.isInteger(opts.rows) && opts.rows > 0 ? opts.rows : 24
|
|
103
|
+
const cols = Number.isInteger(opts.cols) && opts.cols > 0 ? opts.cols : 80
|
|
104
|
+
const handle = await subprocess.spawnTerminal({ argv, cwd, rows, cols, graceMs: 3000 })
|
|
105
|
+
const session = register(handle, { kind: 'local', label: win ? 'PowerShell' : 'bash' }, key)
|
|
106
|
+
return { id: session.id, pid: handle.pid, kind: 'local', attached: false }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function openRemote(machine, opts = {}) {
|
|
110
|
+
const key = typeof opts.key === 'string' && opts.key !== '' ? opts.key : undefined
|
|
111
|
+
const existing = liveSession(key)
|
|
112
|
+
if (existing !== undefined) {
|
|
113
|
+
const history = historyText(existing)
|
|
114
|
+
existing.unread.chunks.length = 0
|
|
115
|
+
existing.unread.bytes = 0
|
|
116
|
+
return { id: existing.id, pid: null, kind: 'remote', attached: true, history }
|
|
117
|
+
}
|
|
118
|
+
if (typeof openRemoteChannel !== 'function') throw new Error('remote shell unavailable (no openShell)')
|
|
119
|
+
const rows = Number.isInteger(opts.rows) && opts.rows > 0 ? opts.rows : 24
|
|
120
|
+
const cols = Number.isInteger(opts.cols) && opts.cols > 0 ? opts.cols : 80
|
|
121
|
+
const cwd = typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : undefined
|
|
122
|
+
const handle = await openRemoteChannel(machine ?? {}, { rows, cols, ...(cwd ? { cwd } : {}) })
|
|
123
|
+
const session = register(handle, {
|
|
124
|
+
kind: 'remote',
|
|
125
|
+
label: (machine && (machine.alias || machine.host)) || 'remote',
|
|
126
|
+
}, key)
|
|
127
|
+
return { id: session.id, pid: null, kind: 'remote', attached: false }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function requireSession(id) {
|
|
131
|
+
const session = sessions.get(id)
|
|
132
|
+
if (session === undefined) throw new Error(`shell session not found: ${id}`)
|
|
133
|
+
return session
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function write(id, data) {
|
|
137
|
+
const session = requireSession(id)
|
|
138
|
+
session.lastActivityAt = Date.now()
|
|
139
|
+
if (!session.ended && typeof data === 'string' && data !== '') await session.handle.write(data)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function read(id) {
|
|
143
|
+
const session = requireSession(id)
|
|
144
|
+
session.lastActivityAt = Date.now()
|
|
145
|
+
const text = Buffer.concat(session.unread.chunks).toString('utf8')
|
|
146
|
+
session.unread.chunks.length = 0
|
|
147
|
+
session.unread.bytes = 0
|
|
148
|
+
return { text, eof: session.ended }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resize(id, rows, cols) {
|
|
152
|
+
const session = requireSession(id)
|
|
153
|
+
if (session.ended) return { resized: false }
|
|
154
|
+
if (typeof session.handle.resize === 'function') {
|
|
155
|
+
session.handle.resize(rows, cols)
|
|
156
|
+
return { resized: true }
|
|
157
|
+
}
|
|
158
|
+
return { resized: false }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function close(id) {
|
|
162
|
+
const session = sessions.get(id)
|
|
163
|
+
if (session === undefined) return { closed: false }
|
|
164
|
+
sessions.delete(id)
|
|
165
|
+
await session.handle.terminate()
|
|
166
|
+
return { closed: true }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function list() {
|
|
170
|
+
return [...sessions.values()].map((session) => ({
|
|
171
|
+
id: session.id,
|
|
172
|
+
pid: session.handle.pid ?? null,
|
|
173
|
+
kind: session.meta.kind,
|
|
174
|
+
label: session.meta.label,
|
|
175
|
+
ended: session.ended,
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { openLocal, openRemote, write, read, resize, close, list, sweep }
|
|
180
|
+
}
|