dsh-remote-workspaces 0.2.0 → 0.3.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/package.json +10 -3
- package/src/client.js +540 -23
- package/src/index.js +136 -3
- package/src/local-browse.js +168 -0
- package/src/registry.js +20 -11
- package/src/routing-fs.js +54 -7
- package/src/shell-exec.js +13 -1
- package/src/shell-sessions.js +180 -0
- package/src/transport.js +124 -7
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). */
|
|
@@ -131,8 +142,17 @@ function kickOsBackfill(cwd) {
|
|
|
131
142
|
const profile = await client.profile()
|
|
132
143
|
if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
|
|
133
144
|
updateAnchorOs(hit.anchorPath, { family: profile.family, os: profile.os, shell: profile.shell })
|
|
145
|
+
return
|
|
134
146
|
}
|
|
135
|
-
|
|
147
|
+
// Unknown / undetectable: leave the dedupe queue so the NEXT prompt
|
|
148
|
+
// render re-kicks the probe (failed probes are not cached, so a later
|
|
149
|
+
// attempt really does re-probe instead of reusing a stale unknown).
|
|
150
|
+
OS_BACKFILL_QUEUED.delete(hit.anchorPath)
|
|
151
|
+
console.warn(`[dsh-remote-workspaces] os backfill probe failed for ${hit.host} (family unknown); will retry on next render`)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
OS_BACKFILL_QUEUED.delete(hit.anchorPath)
|
|
154
|
+
console.warn(`[dsh-remote-workspaces] os backfill probe errored for ${hit.host}: ${messageOf(error)}`)
|
|
155
|
+
}
|
|
136
156
|
})()
|
|
137
157
|
}
|
|
138
158
|
|
|
@@ -174,7 +194,11 @@ async function resolveRemotePath(client, raw, sharedSftp) {
|
|
|
174
194
|
* Host owner of the `remoteWorkspaces` Remote namespace. Every method returns
|
|
175
195
|
* only lossless-JSON data and never echoes stored secrets back to the browser.
|
|
176
196
|
*/
|
|
177
|
-
function remoteWorkspacesService() {
|
|
197
|
+
function remoteWorkspacesService(remote) {
|
|
198
|
+
const shells = createShellSessions({
|
|
199
|
+
getSubprocess: () => remote.getSubprocess(),
|
|
200
|
+
openRemote: (machine, opts) => sshClientFor(machine).openShell(opts),
|
|
201
|
+
})
|
|
178
202
|
return {
|
|
179
203
|
listMachines() {
|
|
180
204
|
return { ok: true, machines: loadMachines().map(sanitizeMachine) }
|
|
@@ -252,6 +276,28 @@ function remoteWorkspacesService() {
|
|
|
252
276
|
}
|
|
253
277
|
},
|
|
254
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
|
+
|
|
255
301
|
/**
|
|
256
302
|
* Open a remote directory as a workspace: create an EMPTY local anchor
|
|
257
303
|
* directory (the harness's workspace identity — `fs.realpath` must resolve
|
|
@@ -297,6 +343,93 @@ function remoteWorkspacesService() {
|
|
|
297
343
|
sftp.end()
|
|
298
344
|
}
|
|
299
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
|
+
},
|
|
300
433
|
}
|
|
301
434
|
}
|
|
302
435
|
|
|
@@ -325,7 +458,7 @@ export function apply(ctx) {
|
|
|
325
458
|
ctx.provide('fs', new RoutingFileSystem(remote))
|
|
326
459
|
ctx.provide('shell', new SshShellExecutor(remote))
|
|
327
460
|
|
|
328
|
-
const service = remoteWorkspacesService()
|
|
461
|
+
const service = remoteWorkspacesService(remote)
|
|
329
462
|
service.typertRemote = Object.freeze({ service, serviceKey: NAMESPACE, namespace: NAMESPACE })
|
|
330
463
|
ctx.provide(NAMESPACE, service)
|
|
331
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
|
}
|
package/src/shell-exec.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* harness's `bash-sandbox`/`pwsh-sandbox` apply).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { SshClient, shellQuote } from './transport.js'
|
|
12
|
+
import { SshClient, shellQuote, PROBE_UNKNOWN_MSG } from './transport.js'
|
|
13
13
|
import { isRemoteCwd, parseSshUri } from './ssh-uri.js'
|
|
14
14
|
import { findByCwd } from './registry.js'
|
|
15
15
|
import { lstatSync } from 'node:fs'
|
|
@@ -247,6 +247,13 @@ export class SshShellExecutor {
|
|
|
247
247
|
},
|
|
248
248
|
done: (async () => {
|
|
249
249
|
const profile = await client.profile()
|
|
250
|
+
if (profile.family === 'unknown') {
|
|
251
|
+
// Never run the POSIX nohup launcher against an undetected host (a
|
|
252
|
+
// Windows/cmd host would die on the `cd '…'` line with Win32 123).
|
|
253
|
+
spawnError = new Error(PROBE_UNKNOWN_MSG)
|
|
254
|
+
proc.status = 'killed'
|
|
255
|
+
return
|
|
256
|
+
}
|
|
250
257
|
if (profile.family === 'windows') {
|
|
251
258
|
// No nohup-style detach exists on Windows remotes, and closing the
|
|
252
259
|
// channel alone does NOT reap the remote tree (verified) - so the
|
|
@@ -282,6 +289,11 @@ export class SshShellExecutor {
|
|
|
282
289
|
const outcome = await ctl.exit
|
|
283
290
|
merge()
|
|
284
291
|
if (proc.status === 'running') proc.status = 'completed'
|
|
292
|
+
if (outcome.error !== undefined) {
|
|
293
|
+
spawnError = new Error(outcome.error)
|
|
294
|
+
proc.status = 'killed'
|
|
295
|
+
return
|
|
296
|
+
}
|
|
285
297
|
proc.exitCode = typeof outcome.exitCode === 'number' ? outcome.exitCode : null
|
|
286
298
|
return
|
|
287
299
|
}
|