@tiphareth/dsh-hardssh 0.1.2 → 0.2.3
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 +30 -0
- package/README.md +77 -50
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +333 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +42 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +59 -26
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +171 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* @module @tiphareth/dsh-hardssh/providers/ssh
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { posix } from 'node:path'
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import { WORKSPACE_PROVIDER_API_VERSION } from '../../base/model.ts'
|
|
17
20
|
import type {
|
|
18
21
|
WorkspaceCapabilityMap,
|
|
19
22
|
WorkspaceConnection,
|
|
@@ -25,231 +28,231 @@ import type {
|
|
|
25
28
|
import type {
|
|
26
29
|
WorkspaceFileSystem,
|
|
27
30
|
WorkspaceProcessRuntime,
|
|
28
|
-
WorkspaceSearchService,
|
|
29
|
-
WorkspaceStat,
|
|
30
|
-
WorkspaceTerminalService,
|
|
31
|
-
WorkspaceDirEntry,
|
|
32
31
|
WorkspaceSearchHit,
|
|
32
|
+
WorkspaceSearchService,
|
|
33
33
|
} from '../../base/capability.ts'
|
|
34
34
|
import type { SshEngine } from '../../ssh/engine.ts'
|
|
35
|
+
import type { WorkspaceState } from '../../protocol.ts'
|
|
36
|
+
import { decodeCanonicalPath, SshFileSystem } from '../../remote/remote-fs.ts'
|
|
37
|
+
import { SshSubprocessRuntime } from '../../remote/remote-subprocess.ts'
|
|
35
38
|
import { RemoteSearchService } from '../../remote-search.ts'
|
|
36
39
|
|
|
37
|
-
/** The ssh provider manifest. */
|
|
40
|
+
/** The ssh provider manifest (provider API v2, no separate terminal capability — terminals live on `workspace.process`). */
|
|
38
41
|
export const sshProviderManifest: WorkspaceProviderManifest = {
|
|
39
42
|
id: 'ssh',
|
|
40
43
|
version: '0.1.0',
|
|
41
|
-
apiVersion:
|
|
44
|
+
apiVersion: WORKSPACE_PROVIDER_API_VERSION,
|
|
42
45
|
displayName: 'SSH remote workspace',
|
|
43
|
-
capabilities: ['workspace.fs', 'workspace.process', 'workspace.
|
|
46
|
+
capabilities: ['workspace.fs', 'workspace.process', 'workspace.search'],
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** One open SSH workspace connection. */
|
|
47
50
|
export class SshWorkspaceConnection implements WorkspaceConnection {
|
|
48
51
|
readonly providerId = 'ssh'
|
|
49
52
|
|
|
53
|
+
private state: 'connecting' | 'ready' | 'degraded' | 'closed' = 'ready'
|
|
54
|
+
private fsInstance: unknown
|
|
55
|
+
private processInstance: unknown
|
|
56
|
+
private searchInstance: unknown
|
|
57
|
+
/** One workspace is one fixed remote execution world for every capability instance. */
|
|
58
|
+
private readonly stateOf: () => WorkspaceState
|
|
59
|
+
|
|
50
60
|
constructor(
|
|
51
61
|
readonly workspaceId: string,
|
|
52
62
|
private readonly engine: SshEngine,
|
|
53
63
|
private readonly alias: string,
|
|
54
64
|
private readonly remoteRoot: string,
|
|
55
|
-
|
|
65
|
+
private readonly cordisContext: Context,
|
|
66
|
+
) {
|
|
67
|
+
// Assigned in the constructor body so the closure captures the parameter
|
|
68
|
+
// properties after they are initialized.
|
|
69
|
+
this.stateOf = () => ({ mode: 'remote', alias: this.alias, remoteRoot: this.remoteRoot })
|
|
70
|
+
}
|
|
56
71
|
|
|
57
72
|
get<K extends keyof WorkspaceCapabilityMap>(capability: K): WorkspaceCapabilityMap[K] | undefined {
|
|
73
|
+
if (this.state === 'closed') return undefined
|
|
58
74
|
switch (capability) {
|
|
59
75
|
case 'workspace.fs': return this.fs() as WorkspaceCapabilityMap[K]
|
|
60
76
|
case 'workspace.process': return this.process() as WorkspaceCapabilityMap[K]
|
|
61
|
-
case 'workspace.terminal': return this.terminal() as WorkspaceCapabilityMap[K]
|
|
62
77
|
case 'workspace.search': return this.search() as WorkspaceCapabilityMap[K]
|
|
63
78
|
default: return undefined
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
private fs(): WorkspaceFileSystem {
|
|
68
|
-
|
|
83
|
+
if (this.fsInstance === undefined) {
|
|
84
|
+
// The capability is the production DSH FileSystem that DSH fs consumers
|
|
85
|
+
// (read/write/edit, glob/grep) already speak: remote/remote-fs.ts
|
|
86
|
+
// SshFileSystem (atomic writes, versions, streams). The workspace root is
|
|
87
|
+
// passed as the confinement boundary, so the capability can never escape
|
|
88
|
+
// `location.root` — lexically or canonically.
|
|
89
|
+
this.fsInstance = new SshFileSystem(this.cordisContext.isolate('fs'), this.engine, this.stateOf, this.remoteRoot)
|
|
90
|
+
}
|
|
91
|
+
return this.fsInstance as WorkspaceFileSystem
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
private process(): WorkspaceProcessRuntime {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
if (this.processInstance === undefined) {
|
|
96
|
+
// The capability is the production DSH SubprocessRuntime:
|
|
97
|
+
// remote/remote-subprocess.ts SshSubprocessRuntime owns the full
|
|
98
|
+
// structured argv/env/stdio/terminal protocol plus handle teardown.
|
|
99
|
+
this.processInstance = new SshSubprocessRuntime(this.cordisContext.isolate('subprocess'), this.engine, this.stateOf)
|
|
100
|
+
}
|
|
101
|
+
return this.processInstance as WorkspaceProcessRuntime
|
|
77
102
|
}
|
|
78
103
|
|
|
79
|
-
private search():
|
|
80
|
-
|
|
104
|
+
private search(): SshWorkspaceSearch {
|
|
105
|
+
if (this.searchInstance === undefined) {
|
|
106
|
+
// SshWorkspaceSearch is stateless apart from engine/alias/root, so one
|
|
107
|
+
// cached wrapper serves every get() (RemoteSearchService is shared too).
|
|
108
|
+
this.searchInstance = new SshWorkspaceSearch(this.engine, this.alias, this.remoteRoot)
|
|
109
|
+
}
|
|
110
|
+
return this.searchInstance as SshWorkspaceSearch
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
status(): 'connecting' | 'ready' | 'degraded' | 'closed' {
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
114
|
+
// Derived from this connection's own state instead of a constant: the
|
|
115
|
+
// object only exists after open() resolved, so 'ready' is truthful here and
|
|
116
|
+
// close() moves it to 'closed'. 'connecting' is therefore unobservable for
|
|
117
|
+
// this provider, and 'degraded' is never faked — the engine establishes and
|
|
118
|
+
// re-establishes connections lazily per operation, so this connection has
|
|
119
|
+
// no observable health signal to report.
|
|
120
|
+
return this.state
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
async close(): Promise<void> {
|
|
91
|
-
|
|
92
|
-
|
|
124
|
+
if (this.state === 'closed') return
|
|
125
|
+
this.state = 'closed'
|
|
126
|
+
const processInstance = this.processInstance
|
|
127
|
+
if (processInstance instanceof SshSubprocessRuntime) {
|
|
128
|
+
// Releases only this workspace's own live processes/PTYs/streams;
|
|
129
|
+
// SshEngine.dispose() is deliberately NOT called because the shared
|
|
130
|
+
// connection pool belongs to the engine (plan §5.5/§6.3).
|
|
131
|
+
await processInstance.close()
|
|
132
|
+
}
|
|
133
|
+
this.fsInstance = undefined
|
|
134
|
+
this.processInstance = undefined
|
|
135
|
+
this.searchInstance = undefined
|
|
93
136
|
}
|
|
94
137
|
}
|
|
95
138
|
|
|
96
139
|
/** POSIX-join a remote root with a relative path, confining to the root. */
|
|
97
140
|
export function joinRemoteRoot(root: string, path: string): string {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return root === '/' ? path : path
|
|
103
|
-
}
|
|
104
|
-
const base = root === '/' ? '' : root
|
|
105
|
-
return `${base}/${path}`.replace(/\/{2,}/g, '/')
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Wrap engine FS calls as the generic WorkspaceFileSystem. */
|
|
109
|
-
export class SshWorkspaceFileSystem implements WorkspaceFileSystem {
|
|
110
|
-
constructor(
|
|
111
|
-
private readonly engine: SshEngine,
|
|
112
|
-
private readonly alias: string,
|
|
113
|
-
private readonly root: string,
|
|
114
|
-
) {}
|
|
115
|
-
|
|
116
|
-
private full(path: string): string {
|
|
117
|
-
return joinRemoteRoot(this.root, path)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async stat(path: string, signal?: AbortSignal): Promise<WorkspaceStat | undefined> {
|
|
121
|
-
const result = await this.engine.stat(this.alias, this.full(path))
|
|
122
|
-
return { type: result.type, size: result.size, mtimeMs: result.mtimeMs, mode: result.mode }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async list(path: string, signal?: AbortSignal): Promise<WorkspaceDirEntry[]> {
|
|
126
|
-
return this.engine.ls(this.alias, this.full(path))
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async readFile(path: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
130
|
-
const { content } = await this.engine.readFile(this.alias, this.full(path))
|
|
131
|
-
return content
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void> {
|
|
135
|
-
await this.engine.writeFile(this.alias, this.full(path), Buffer.from(data))
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async mkdir(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
|
|
139
|
-
await this.engine.mkdir(this.alias, this.full(path))
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async rm(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
|
|
143
|
-
await this.engine.rm(this.alias, this.full(path), options?.recursive ?? false)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async rename(from: string, to: string, signal?: AbortSignal): Promise<void> {
|
|
147
|
-
await this.engine.rename(this.alias, this.full(from), this.full(to))
|
|
141
|
+
const normalizedRoot = posix.resolve('/', root)
|
|
142
|
+
const candidate = path.startsWith('/') ? posix.resolve('/', path) : posix.resolve(normalizedRoot, path)
|
|
143
|
+
if (normalizedRoot !== '/' && candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}/`)) {
|
|
144
|
+
throw new Error(`workspace.ssh-outside-root: '${path}' is outside '${normalizedRoot}'`)
|
|
148
145
|
}
|
|
146
|
+
return candidate
|
|
149
147
|
}
|
|
150
148
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
stderr: result.stderr,
|
|
166
|
-
exitCode: result.exitCode ?? 1,
|
|
167
|
-
timedOut: result.timedOut,
|
|
168
|
-
durationMs: result.durationMs,
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private full(path: string): string {
|
|
173
|
-
return joinRemoteRoot(this.root, path)
|
|
149
|
+
async function canonicalRemotePath(
|
|
150
|
+
engine: SshEngine,
|
|
151
|
+
alias: string,
|
|
152
|
+
root: string,
|
|
153
|
+
path: string,
|
|
154
|
+
signal?: AbortSignal,
|
|
155
|
+
): Promise<string> {
|
|
156
|
+
signal?.throwIfAborted()
|
|
157
|
+
const candidate = joinRemoteRoot(root, path)
|
|
158
|
+
const canonical = async (value: string): Promise<string> => {
|
|
159
|
+
const result = await engine.exec(alias, `set -o pipefail; realpath -mz -- ${quote(value)} | base64 -w0`, 10_000)
|
|
160
|
+
signal?.throwIfAborted()
|
|
161
|
+
if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for '${value}'`)
|
|
162
|
+
return decodeCanonicalPath(result.stdout.trim())
|
|
174
163
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
export class SshWorkspaceTerminal implements WorkspaceTerminalService {
|
|
179
|
-
constructor(
|
|
180
|
-
private readonly engine: SshEngine,
|
|
181
|
-
private readonly alias: string,
|
|
182
|
-
private readonly root: string,
|
|
183
|
-
) {}
|
|
184
|
-
|
|
185
|
-
async openTerminal(cols: number, rows: number): Promise<import('../../base/capability.ts').WorkspaceTerminalHandle> {
|
|
186
|
-
const session = await this.engine.openShell(this.alias, { cols, rows })
|
|
187
|
-
let exited = false
|
|
188
|
-
const exitHandlers = new Set<(event: { exitCode: number }) => void>()
|
|
189
|
-
const onDataHandlers = new Set<(data: string) => void>()
|
|
190
|
-
const enc = new TextDecoder()
|
|
191
|
-
session.onData = (data: Buffer) => {
|
|
192
|
-
for (const handler of onDataHandlers) handler(enc.decode(data))
|
|
193
|
-
}
|
|
194
|
-
session.onExit = (code: number | null) => {
|
|
195
|
-
exited = true
|
|
196
|
-
for (const handler of exitHandlers) handler({ exitCode: code ?? -1 })
|
|
197
|
-
}
|
|
198
|
-
return {
|
|
199
|
-
shell: 'ssh',
|
|
200
|
-
write(data: string) { if (!exited) session.send(data) },
|
|
201
|
-
resize(c: number, r: number) { session.resize(c, r) },
|
|
202
|
-
kill() {
|
|
203
|
-
try { session.signal('KILL') } catch { /* closed */ }
|
|
204
|
-
try { session.close() } catch { /* closed */ }
|
|
205
|
-
},
|
|
206
|
-
onData(handler) {
|
|
207
|
-
onDataHandlers.add(handler)
|
|
208
|
-
return { dispose: () => { onDataHandlers.delete(handler) } }
|
|
209
|
-
},
|
|
210
|
-
onExit(handler) {
|
|
211
|
-
exitHandlers.add(handler)
|
|
212
|
-
return { dispose: () => { exitHandlers.delete(handler) } }
|
|
213
|
-
},
|
|
214
|
-
}
|
|
164
|
+
const [canonicalRoot, canonicalCandidate] = await Promise.all([canonical(joinRemoteRoot(root, '.')), canonical(candidate)])
|
|
165
|
+
if (canonicalRoot !== '/' && canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}/`)) {
|
|
166
|
+
throw new Error(`workspace.ssh-outside-root: '${path}' resolves outside '${canonicalRoot}'`)
|
|
215
167
|
}
|
|
168
|
+
return canonicalCandidate
|
|
216
169
|
}
|
|
217
170
|
|
|
218
|
-
/** Wrap the
|
|
171
|
+
/** Wrap the shared RemoteSearchService as the generic WorkspaceSearchService. */
|
|
219
172
|
export class SshWorkspaceSearch implements WorkspaceSearchService {
|
|
220
173
|
private readonly service: RemoteSearchService
|
|
221
174
|
|
|
222
175
|
constructor(
|
|
223
176
|
private readonly engine: SshEngine,
|
|
224
177
|
private readonly alias: string,
|
|
225
|
-
private readonly
|
|
178
|
+
private readonly remoteRoot: string,
|
|
226
179
|
) {
|
|
227
180
|
this.service = new RemoteSearchService(engine)
|
|
228
181
|
}
|
|
229
182
|
|
|
183
|
+
/** Workspace-relative POSIX search base → absolute remote root. */
|
|
184
|
+
private searchBase(workspaceRelative: string | undefined, signal?: AbortSignal): Promise<string> {
|
|
185
|
+
return canonicalRemotePath(this.engine, this.alias, this.remoteRoot, workspaceRelative ?? '.', signal)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Absolute path → path relative to `base`, or undefined when outside it. */
|
|
189
|
+
private relativeTo(absPath: string, base: string): string | undefined {
|
|
190
|
+
if (base === '/') return absPath.replace(/^\/+/, '')
|
|
191
|
+
if (absPath === base) return ''
|
|
192
|
+
const prefix = base.endsWith('/') ? base : `${base}/`
|
|
193
|
+
if (!absPath.startsWith(prefix)) return undefined
|
|
194
|
+
return absPath.slice(prefix.length)
|
|
195
|
+
}
|
|
196
|
+
|
|
230
197
|
async glob(pattern: string, options?: { root?: string; maxDepth?: number; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }> {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
198
|
+
options?.signal?.throwIfAborted()
|
|
199
|
+
const [base, workspaceRoot] = await Promise.all([
|
|
200
|
+
this.searchBase(options?.root, options?.signal),
|
|
201
|
+
this.searchBase('.', options?.signal),
|
|
202
|
+
])
|
|
203
|
+
const result = await this.service.glob({ alias: this.alias, root: base }, pattern, options?.signal)
|
|
204
|
+
options?.signal?.throwIfAborted()
|
|
205
|
+
const hits: WorkspaceSearchHit[] = []
|
|
206
|
+
for (const absPath of result.hits) {
|
|
207
|
+
// RemoteSearchService.glob caps depth/bytes itself but takes no
|
|
208
|
+
// root/maxDepth/signal options, so the wrapper enforces them here: only
|
|
209
|
+
// hits under the requested search base survive, deeper-than-maxDepth
|
|
210
|
+
// results are clipped, and the abort signal is checked around the call.
|
|
211
|
+
const baseRelative = this.relativeTo(absPath, base)
|
|
212
|
+
if (baseRelative === undefined) continue
|
|
213
|
+
if (options?.maxDepth !== undefined && this.depthOf(baseRelative) > options.maxDepth) continue
|
|
214
|
+
const rel = this.relativeTo(absPath, workspaceRoot)
|
|
215
|
+
if (rel === undefined) continue
|
|
216
|
+
hits.push({ path: absPath, rel, isDir: false })
|
|
235
217
|
}
|
|
218
|
+
return { hits, truncated: result.truncated }
|
|
236
219
|
}
|
|
237
220
|
|
|
238
221
|
async grep(fixedPhrase: string, options?: { root?: string; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }> {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
222
|
+
options?.signal?.throwIfAborted()
|
|
223
|
+
const [base, workspaceRoot] = await Promise.all([
|
|
224
|
+
this.searchBase(options?.root, options?.signal),
|
|
225
|
+
this.searchBase('.', options?.signal),
|
|
226
|
+
])
|
|
227
|
+
const result = await this.service.grepFixed({ alias: this.alias, root: base }, fixedPhrase, options?.signal)
|
|
228
|
+
options?.signal?.throwIfAborted()
|
|
229
|
+
const hits: WorkspaceSearchHit[] = []
|
|
230
|
+
for (const line of result.lines) {
|
|
231
|
+
// grepFixed returns `absolute-path:line:content` records; the first
|
|
232
|
+
// colon splits the path from the line/content remainder. The whole raw
|
|
233
|
+
// record is preserved as `match` so capability consumers (e.g. the
|
|
234
|
+
// remote_search agent tool) can render matched snippets.
|
|
235
|
+
const colon = line.indexOf(':')
|
|
236
|
+
const absPath = colon >= 0 ? line.slice(0, colon) : line
|
|
237
|
+
if (this.relativeTo(absPath, base) === undefined) continue
|
|
238
|
+
const rel = this.relativeTo(absPath, workspaceRoot)
|
|
239
|
+
if (rel === undefined) continue
|
|
240
|
+
hits.push({ path: absPath, rel, isDir: false, match: line })
|
|
247
241
|
}
|
|
242
|
+
return { hits, truncated: result.truncated }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Depth of a base-relative path (root-level entries are depth 1, find -maxdepth style). */
|
|
246
|
+
private depthOf(baseRelative: string): number {
|
|
247
|
+
if (baseRelative === '') return 0
|
|
248
|
+
return baseRelative.split('/').length
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
/** The ssh provider factory.
|
|
252
|
-
|
|
252
|
+
/** The ssh provider factory. `context` is mandatory: the provider serves only
|
|
253
|
+
* the real DSH FileSystem/SubprocessRuntime capabilities, which need a Cordis
|
|
254
|
+
* scope to isolate each workspace's resources. */
|
|
255
|
+
export function createSshWorkspaceProvider(engine: SshEngine, context: Context): WorkspaceProvider {
|
|
253
256
|
return {
|
|
254
257
|
manifest: sshProviderManifest,
|
|
255
258
|
validate(record: WorkspaceRecord): void {
|
|
@@ -259,9 +262,9 @@ export function createSshWorkspaceProvider(engine: SshEngine): WorkspaceProvider
|
|
|
259
262
|
}
|
|
260
263
|
if (record.location.root === '') throw new Error('ssh workspace record is missing location.root')
|
|
261
264
|
},
|
|
262
|
-
async open(record: WorkspaceRecord,
|
|
265
|
+
async open(record: WorkspaceRecord, _context: WorkspaceOpenContext): Promise<WorkspaceConnection> {
|
|
263
266
|
const alias = record.provider.connectionRef!.id
|
|
264
|
-
return new SshWorkspaceConnection(record.id, engine, alias, record.location.root)
|
|
267
|
+
return new SshWorkspaceConnection(record.id, engine, alias, record.location.root, context)
|
|
265
268
|
},
|
|
266
269
|
}
|
|
267
270
|
}
|
|
@@ -271,4 +274,4 @@ function quote(arg: string): string {
|
|
|
271
274
|
if (arg === '') return "''"
|
|
272
275
|
if (/^[a-zA-Z0-9_\-./:=@]+$/.test(arg)) return arg
|
|
273
276
|
return `'${arg.replace(/'/g, `'\\''`)}'`
|
|
274
|
-
}
|
|
277
|
+
}
|
|
@@ -104,8 +104,28 @@ export function scrubRemoteEnvironment(environment: ReadonlyMap<string, string>)
|
|
|
104
104
|
return scrubbed
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Login-critical variables emitted FIRST, before the (potentially multi-KB)
|
|
109
|
+
* rest of a cluster environment. Both launchers hand the whole environment to
|
|
110
|
+
* `env -i` as one command line typed into a remote shell; if that line is ever
|
|
111
|
+
* truncated or mangled on the way (PTY input limits, flow control, shell
|
|
112
|
+
* line-buffer quirks), a shell with no HOME resolves every `$HOME/...` path to
|
|
113
|
+
* `/`, which makes site startup scripts run `ssh-keygen` into `/.ssh` and
|
|
114
|
+
* otherwise misbehave. Ordering these first makes the session survive it.
|
|
115
|
+
*/
|
|
116
|
+
const CRITICAL_ENV_ORDER = ['HOME', 'USER', 'LOGNAME', 'SHELL', 'PATH', 'PWD', 'TERM', 'LANG', 'TZ'] as const
|
|
117
|
+
|
|
118
|
+
function priorityOf(name: string): number {
|
|
119
|
+
const index = CRITICAL_ENV_ORDER.indexOf(name as typeof CRITICAL_ENV_ORDER[number])
|
|
120
|
+
if (index >= 0) return index
|
|
121
|
+
// Locale/terminal hints stay near the front too, but after the hard basics.
|
|
122
|
+
return /^(LC_|LANGUAGE$)/.test(name) ? CRITICAL_ENV_ORDER.length : CRITICAL_ENV_ORDER.length + 1
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
/**
|
|
108
126
|
* Overlay explicit entries and serialize one validated environment for `env -i`.
|
|
127
|
+
* Login-critical names are emitted first (see CRITICAL_ENV_ORDER); everything
|
|
128
|
+
* else keeps the remote's own order.
|
|
109
129
|
*/
|
|
110
130
|
export function serializeEnvironment(
|
|
111
131
|
scrubbed: ReadonlyMap<string, string>,
|
|
@@ -119,5 +139,6 @@ export function serializeEnvironment(
|
|
|
119
139
|
if (value === undefined) environment.delete(name)
|
|
120
140
|
else environment.set(name, value)
|
|
121
141
|
}
|
|
122
|
-
|
|
142
|
+
const ordered = [...environment].sort(([a], [b]) => priorityOf(a) - priorityOf(b))
|
|
143
|
+
return ordered.map(([name, value]) => quoteShellArg(`${name}=${value}`)).join(' ')
|
|
123
144
|
}
|