@tiphareth/dsh-hardssh 0.1.2
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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /api/dsh-hardssh route family: SSH-bound workspace CRUD, remote
|
|
3
|
+
* directory browsing for the picker, plus the workspace file ops (tree /
|
|
4
|
+
* file / search) served through the gated WorkspaceFileService — every file
|
|
5
|
+
* op is bound to an EXACT SSH workspace anchor; there is no implicit local
|
|
6
|
+
* fallback. Every route carries the same loopback-only trust fence as
|
|
7
|
+
* /api/dsh-ssh — these endpoints can read and write files on remote servers,
|
|
8
|
+
* so LAN-exposed dsh web deployments must not serve them.
|
|
9
|
+
*/
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
11
|
+
import { posix } from 'node:path'
|
|
12
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
13
|
+
import type { HostStore } from './ssh/store.ts'
|
|
14
|
+
import { NeedsPasswordError, type SshEngine } from './ssh/engine.ts'
|
|
15
|
+
import { HostKeyMismatchError, HostKeyUnknownError } from './ssh/known-hosts.ts'
|
|
16
|
+
import { isLoopbackRequest, queryParam, readJsonBody as readJsonBodyShared, writeJson } from './host-http.ts'
|
|
17
|
+
import type { SshWorkspaceLedger } from './ledger.ts'
|
|
18
|
+
import {
|
|
19
|
+
BackendError,
|
|
20
|
+
backendErrorStatus,
|
|
21
|
+
normalizeRel,
|
|
22
|
+
sortWorkspaceEntries,
|
|
23
|
+
type WorkspaceFileService,
|
|
24
|
+
} from './backend.ts'
|
|
25
|
+
import { WORKSPACE_API } from './protocol.ts'
|
|
26
|
+
|
|
27
|
+
/** Cap on JSON request bodies (file writes carry content). */
|
|
28
|
+
const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
/** A request-body failure with a distinct HTTP status (413 vs 400). */
|
|
31
|
+
class RequestBodyError extends Error {
|
|
32
|
+
constructor(
|
|
33
|
+
public readonly status: 400 | 413,
|
|
34
|
+
message: string,
|
|
35
|
+
) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'RequestBodyError'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read a JSON request body (throws RequestBodyError when too large/malformed). */
|
|
42
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
43
|
+
const result = await readJsonBodyShared(req, MAX_JSON_BODY_BYTES)
|
|
44
|
+
if (result.ok) return result.body
|
|
45
|
+
if (result.reason === 'too-large') {
|
|
46
|
+
throw new RequestBodyError(413, `request body exceeds ${MAX_JSON_BODY_BYTES} bytes`)
|
|
47
|
+
}
|
|
48
|
+
throw new RequestBodyError(400, result.reason === 'malformed' ? 'malformed JSON body' : 'JSON body must be an object')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Map any thrown error to a stable JSON error response. Interactive gates
|
|
52
|
+
* (session password, host-key TOFU) keep their machine codes so the client
|
|
53
|
+
* can prompt and retry instead of showing a raw message. */
|
|
54
|
+
function writeRouteError(res: ServerResponse, error: unknown, ioStatus: 500 | 502 = 502): void {
|
|
55
|
+
if (error instanceof RequestBodyError) {
|
|
56
|
+
writeJson(res, error.status, { error: error.message, code: error.status === 413 ? 'too-large' : 'invalid' })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof BackendError) {
|
|
60
|
+
writeJson(res, backendErrorStatus(error, ioStatus), { error: error.message, code: error.code })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof NeedsPasswordError) {
|
|
64
|
+
// NOT 200 here: readJson on a 2xx body would treat the structured error
|
|
65
|
+
// as a successful listing. 500 makes the client throw HttpApiError(code).
|
|
66
|
+
writeJson(res, 500, { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (error instanceof HostKeyUnknownError) {
|
|
70
|
+
writeJson(res, 500, { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (error instanceof HostKeyMismatchError) {
|
|
74
|
+
writeJson(res, 500, { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
writeJson(res, ioStatus, { error: error instanceof Error ? error.message : String(error), code: 'io' })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Required query parameter (missing/empty -> invalid BackendError). */
|
|
81
|
+
function requiredQuery(url: URL, name: string): string {
|
|
82
|
+
const value = queryParam(url, name)
|
|
83
|
+
if (value === undefined || value === '') {
|
|
84
|
+
throw new BackendError('invalid', `${name} query parameter is required`)
|
|
85
|
+
}
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Route family dependencies. */
|
|
90
|
+
export interface WorkspaceRoutesDeps {
|
|
91
|
+
/** Read-only host surface (list only; the SSH routes own the write path). */
|
|
92
|
+
hosts: import('./core.ts').HostStoreView
|
|
93
|
+
engine: SshEngine
|
|
94
|
+
ledger: SshWorkspaceLedger
|
|
95
|
+
/** Gated workspace file service (tree / file / search). */
|
|
96
|
+
files: WorkspaceFileService
|
|
97
|
+
/** Register the anchor dir as a HOST workspace (sidebar visibility). */
|
|
98
|
+
registerHostWorkspace?: (anchorPath: string, title: string) => Promise<void>
|
|
99
|
+
/** Drop the host workspace registration for an anchor dir. */
|
|
100
|
+
unregisterHostWorkspace?: (anchorPath: string) => Promise<void>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build every /api/dsh-hardssh route.
|
|
105
|
+
* @param deps - host store (alias listing), ssh engine, workspace ledger, file service.
|
|
106
|
+
* @returns the routes to register.
|
|
107
|
+
*/
|
|
108
|
+
export function makeRoutes(deps: WorkspaceRoutesDeps): WebRoute[] {
|
|
109
|
+
const { hosts, engine, ledger, files } = deps
|
|
110
|
+
|
|
111
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
112
|
+
if (!isLoopbackRequest(req)) {
|
|
113
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
if (req.method !== method) {
|
|
117
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** List the available SSH hosts (for the picker). */
|
|
124
|
+
const hostsRoute: WebRoute = {
|
|
125
|
+
kind: 'exact',
|
|
126
|
+
path: WORKSPACE_API.sshWorkspaces + '/hosts',
|
|
127
|
+
handler: async (req, res) => {
|
|
128
|
+
if (!guard(req, res, 'GET')) return
|
|
129
|
+
writeJson(res, 200, { hosts: hosts.list().map((host) => ({
|
|
130
|
+
alias: host.alias,
|
|
131
|
+
host: host.host,
|
|
132
|
+
port: host.port,
|
|
133
|
+
user: host.user,
|
|
134
|
+
auth: host.auth,
|
|
135
|
+
description: host.description,
|
|
136
|
+
})) })
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** List (GET) / create (POST) SSH-bound workspaces. Body for POST:
|
|
141
|
+
* { title, alias, remoteRoot }. */
|
|
142
|
+
const wsRoute: WebRoute = {
|
|
143
|
+
kind: 'exact',
|
|
144
|
+
path: WORKSPACE_API.sshWorkspaces,
|
|
145
|
+
handler: async (req, res) => {
|
|
146
|
+
if (!isLoopbackRequest(req)) {
|
|
147
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (req.method === 'GET') {
|
|
151
|
+
writeJson(res, 200, { workspaces: await ledger.list() })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (req.method === 'POST') {
|
|
155
|
+
let body: Record<string, unknown>
|
|
156
|
+
try {
|
|
157
|
+
body = await readJsonBody(req)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
writeRouteError(res, error, 500)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const title = typeof body.title === 'string' ? body.title.trim() : ''
|
|
163
|
+
const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
|
|
164
|
+
const remoteRoot = typeof body.remoteRoot === 'string' ? body.remoteRoot.trim() : ''
|
|
165
|
+
if (alias === '' || remoteRoot === '') {
|
|
166
|
+
writeJson(res, 400, { error: 'title, alias and remoteRoot are required' })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (hosts.find(alias) === undefined) {
|
|
170
|
+
writeJson(res, 404, { error: `alias '${alias}' not found — configure it in the SSH dialog first` })
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (!remoteRoot.startsWith('/')) {
|
|
174
|
+
writeJson(res, 400, { error: `remoteRoot must be an absolute remote path (got '${remoteRoot}')` })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const record = await ledger.create({ title, alias, remoteRoot })
|
|
179
|
+
// Make the anchor a REAL host workspace so it appears in the
|
|
180
|
+
// sidebar and can host sessions (the seams route it remote by its
|
|
181
|
+
// anchor path). Registration failure does not roll back the ledger
|
|
182
|
+
// record — the workspace still binds for tool routing.
|
|
183
|
+
try {
|
|
184
|
+
if (deps.registerHostWorkspace !== undefined) {
|
|
185
|
+
await deps.registerHostWorkspace(record.anchorPath, record.title)
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.warn(`[dsh-hardssh] host workspace registration failed for ${record.title}: ${error instanceof Error ? error.message : String(error)}`)
|
|
189
|
+
}
|
|
190
|
+
writeJson(res, 200, { workspace: record })
|
|
191
|
+
} catch (error) {
|
|
192
|
+
writeRouteError(res, error, 500)
|
|
193
|
+
}
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Rename / delete one SSH-bound workspace. */
|
|
201
|
+
const itemRoute: WebRoute = {
|
|
202
|
+
kind: 'exact',
|
|
203
|
+
path: WORKSPACE_API.sshWorkspaces + '/item',
|
|
204
|
+
handler: async (req, res) => {
|
|
205
|
+
if (!isLoopbackRequest(req)) {
|
|
206
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
210
|
+
const id = queryParam(url, 'id')
|
|
211
|
+
if (id === undefined || id === '') {
|
|
212
|
+
writeJson(res, 400, { error: 'id query parameter is required' })
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (req.method === 'PATCH') {
|
|
216
|
+
let body: Record<string, unknown>
|
|
217
|
+
try {
|
|
218
|
+
body = await readJsonBody(req)
|
|
219
|
+
} catch (error) {
|
|
220
|
+
writeRouteError(res, error, 500)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const title = typeof body.title === 'string' ? body.title.trim() : ''
|
|
224
|
+
if (title === '') {
|
|
225
|
+
writeJson(res, 400, { error: 'title is required' })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const record = await ledger.rename(id, title)
|
|
229
|
+
if (record === undefined) {
|
|
230
|
+
writeJson(res, 404, { error: `workspace '${id}' not found` })
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
writeJson(res, 200, { workspace: record })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if (req.method === 'DELETE') {
|
|
237
|
+
const record = await ledger.get(id)
|
|
238
|
+
const removed = await ledger.remove(id)
|
|
239
|
+
if (removed && record !== undefined && deps.unregisterHostWorkspace !== undefined) {
|
|
240
|
+
try {
|
|
241
|
+
await deps.unregisterHostWorkspace(record.anchorPath)
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.warn(`[dsh-hardssh] host workspace unregistration failed for ${record.title}: ${error instanceof Error ? error.message : String(error)}`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
writeJson(res, removed ? 200 : 404, removed ? { ok: true } : { error: `workspace '${id}' not found` })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Browse a remote directory tree (SFTP readdir via the engine) for the
|
|
254
|
+
* workspace picker. Query: alias + path (default home when omitted). */
|
|
255
|
+
const dirRoute: WebRoute = {
|
|
256
|
+
kind: 'exact',
|
|
257
|
+
path: WORKSPACE_API.sshWorkspaceDir,
|
|
258
|
+
handler: async (req, res) => {
|
|
259
|
+
if (!guard(req, res, 'GET')) return
|
|
260
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
261
|
+
const alias = queryParam(url, 'alias')
|
|
262
|
+
let requestedPath = queryParam(url, 'path')
|
|
263
|
+
if (alias === undefined || alias === '') {
|
|
264
|
+
writeJson(res, 400, { error: 'alias query parameter is required' })
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (hosts.find(alias) === undefined) {
|
|
268
|
+
writeJson(res, 404, { error: `alias '${alias}' not found` })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
if (requestedPath === undefined || requestedPath === '') {
|
|
273
|
+
const home = await engine.exec(alias, 'printf %s "$HOME"', 10_000)
|
|
274
|
+
if (!home.success || home.stdout.trim() === '') {
|
|
275
|
+
writeJson(res, 502, { error: `could not resolve remote home: ${home.stderr.trim() || 'empty $HOME'}` })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
requestedPath = home.stdout.trim()
|
|
279
|
+
}
|
|
280
|
+
if (requestedPath.includes('\0') || requestedPath.includes('\\') || !requestedPath.startsWith('/')) {
|
|
281
|
+
writeJson(res, 400, { error: `path must be an absolute POSIX path (got '${requestedPath}')` })
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
const abs = posix.normalize(requestedPath)
|
|
285
|
+
const entries = sortWorkspaceEntries(
|
|
286
|
+
(await engine.ls(alias, abs)).map((entry) => ({ name: entry.name, type: entry.type, size: entry.size, mtimeMs: entry.mtimeMs })),
|
|
287
|
+
)
|
|
288
|
+
writeJson(res, 200, { path: abs, entries })
|
|
289
|
+
} catch (error) {
|
|
290
|
+
writeRouteError(res, error, 502)
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** List a directory below an exact SSH workspace anchor. */
|
|
296
|
+
const treeRoute: WebRoute = {
|
|
297
|
+
kind: 'exact',
|
|
298
|
+
path: WORKSPACE_API.tree,
|
|
299
|
+
handler: async (req, res) => {
|
|
300
|
+
if (!guard(req, res, 'GET')) return
|
|
301
|
+
try {
|
|
302
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
303
|
+
const root = requiredQuery(url, 'root')
|
|
304
|
+
const rel = queryParam(url, 'path') ?? ''
|
|
305
|
+
// Explicit resolution keeps authorization visible at the route seam.
|
|
306
|
+
await files.resolveContext(root)
|
|
307
|
+
const listing = await files.list(root, normalizeRel(rel))
|
|
308
|
+
writeJson(res, 200, { listing })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
writeRouteError(res, error, 502)
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Read (GET) or write (PUT) a file below an exact SSH workspace anchor. */
|
|
316
|
+
const fileRoute: WebRoute = {
|
|
317
|
+
kind: 'exact',
|
|
318
|
+
path: WORKSPACE_API.file,
|
|
319
|
+
handler: async (req, res) => {
|
|
320
|
+
if (!isLoopbackRequest(req)) {
|
|
321
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only', code: 'forbidden' })
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (req.method === 'GET') {
|
|
326
|
+
try {
|
|
327
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
328
|
+
const root = requiredQuery(url, 'root')
|
|
329
|
+
const rel = requiredQuery(url, 'path')
|
|
330
|
+
await files.resolveContext(root)
|
|
331
|
+
const file = await files.read(root, normalizeRel(rel))
|
|
332
|
+
writeJson(res, 200, { file })
|
|
333
|
+
} catch (error) {
|
|
334
|
+
writeRouteError(res, error, 502)
|
|
335
|
+
}
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (req.method === 'PUT') {
|
|
340
|
+
try {
|
|
341
|
+
const body = await readJsonBody(req)
|
|
342
|
+
const root = typeof body.root === 'string' ? body.root : ''
|
|
343
|
+
const rel = typeof body.path === 'string' ? body.path : ''
|
|
344
|
+
const content = body.content
|
|
345
|
+
if (root === '' || rel === '') {
|
|
346
|
+
throw new BackendError('invalid', 'root and path are required')
|
|
347
|
+
}
|
|
348
|
+
if (typeof content !== 'string') {
|
|
349
|
+
throw new BackendError('invalid', 'content must be a string')
|
|
350
|
+
}
|
|
351
|
+
let expectedMtime: number | undefined
|
|
352
|
+
if (body.expectedMtime !== undefined) {
|
|
353
|
+
if (
|
|
354
|
+
typeof body.expectedMtime !== 'number'
|
|
355
|
+
|| !Number.isFinite(body.expectedMtime)
|
|
356
|
+
|| body.expectedMtime < 0
|
|
357
|
+
) {
|
|
358
|
+
throw new BackendError('invalid', 'expectedMtime must be a finite non-negative number')
|
|
359
|
+
}
|
|
360
|
+
expectedMtime = body.expectedMtime
|
|
361
|
+
}
|
|
362
|
+
await files.resolveContext(root)
|
|
363
|
+
const result = await files.write(root, normalizeRel(rel), content, expectedMtime)
|
|
364
|
+
writeJson(res, 200, { result })
|
|
365
|
+
} catch (error) {
|
|
366
|
+
writeRouteError(res, error, 502)
|
|
367
|
+
}
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}`, code: 'invalid' })
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Filename search below an exact SSH workspace anchor. */
|
|
376
|
+
const searchRoute: WebRoute = {
|
|
377
|
+
kind: 'exact',
|
|
378
|
+
path: WORKSPACE_API.search,
|
|
379
|
+
handler: async (req, res) => {
|
|
380
|
+
if (!guard(req, res, 'GET')) return
|
|
381
|
+
try {
|
|
382
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
383
|
+
const root = requiredQuery(url, 'root')
|
|
384
|
+
const query = queryParam(url, 'query') ?? ''
|
|
385
|
+
await files.resolveContext(root)
|
|
386
|
+
const search = await files.search(root, query)
|
|
387
|
+
writeJson(res, 200, { search })
|
|
388
|
+
} catch (error) {
|
|
389
|
+
writeRouteError(res, error, 502)
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return [hostsRoute, wsRoute, itemRoute, dirRoute, treeRoute, fileRoute, searchRoute]
|
|
395
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime bridge: exposes the generic workspace base (ledger, provider
|
|
3
|
+
* registry, router, plugin host) as a cordis service under the canonical
|
|
4
|
+
* `workspaceCore` name, while keeping the legacy `hardsshCore` /
|
|
5
|
+
* `sshWorkspaceCore` aliases pointing at the same instances so existing
|
|
6
|
+
* consumers keep working.
|
|
7
|
+
*
|
|
8
|
+
* This module is the DSH boundary: it imports the base (provider-agnostic)
|
|
9
|
+
* and the legacy SSH modules, but the base itself never imports this
|
|
10
|
+
* module — direction of dependency stays base ← runtime.
|
|
11
|
+
*
|
|
12
|
+
* @module @tiphareth/dsh-hardssh/runtime/workspace-core
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { WorkspaceProviderRegistry, type WorkspaceRegistry } from '../base/registry.ts'
|
|
19
|
+
import type { WorkspaceRecord } from '../base/model.ts'
|
|
20
|
+
import { WorkspaceLedger } from '../base/ledger.ts'
|
|
21
|
+
import { registerBuiltinProviders } from '../providers/index.ts'
|
|
22
|
+
import type { SshEngine } from '../ssh/engine.ts'
|
|
23
|
+
import type { HostStoreView } from '../core.ts'
|
|
24
|
+
import type { SshWorkspaceLedger } from '../ledger.ts'
|
|
25
|
+
import type { SshWorkspaceRecord } from '../protocol.ts'
|
|
26
|
+
import { LedgerWorkspaceRouter } from '../base/ledger-router.ts'
|
|
27
|
+
import type { WorkspaceProvider } from '../base/model.ts'
|
|
28
|
+
|
|
29
|
+
/** The canonical workspace-core service shape. */
|
|
30
|
+
export interface WorkspaceCore {
|
|
31
|
+
/** Generic ledger (WorkspaceRecord CRUD + anchor index). */
|
|
32
|
+
ledger: WorkspaceLedger
|
|
33
|
+
/** Provider registry (local/ssh/builtin + third-party). */
|
|
34
|
+
providers: WorkspaceProviderRegistry
|
|
35
|
+
/** Ledger+provider router for the switch facades. */
|
|
36
|
+
router: LedgerWorkspaceRouter
|
|
37
|
+
/** Registry surface exposed to plugins. */
|
|
38
|
+
registry: WorkspaceRegistry
|
|
39
|
+
/** Map a legacy SSH record into a generic WorkspaceRecord. */
|
|
40
|
+
fromSshRecord(record: SshWorkspaceRecord): WorkspaceRecord
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A WorkspaceRegistry backed by the generic ledger + provider registry.
|
|
45
|
+
*/
|
|
46
|
+
class LedgerBasedWorkspaceRegistry implements WorkspaceRegistry {
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly ledger: WorkspaceLedger,
|
|
49
|
+
private readonly providerRegistry: WorkspaceProviderRegistry,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
async listWorkspaces(): Promise<WorkspaceRecord[]> {
|
|
53
|
+
return this.ledger.list()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
subscribe(listener: (workspace: WorkspaceRecord) => void): () => void {
|
|
57
|
+
return this.ledger.subscribe((change) => listener(change.record))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
register(provider: WorkspaceProvider): void {
|
|
61
|
+
this.providerRegistry.register(provider)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
unregister(providerId: string): void {
|
|
65
|
+
this.providerRegistry.unregister(providerId)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
provider(id: string): WorkspaceProvider | undefined {
|
|
69
|
+
return this.providerRegistry.get(id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
providers(): WorkspaceProvider[] {
|
|
73
|
+
return this.providerRegistry.list()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Convert a legacy SSH-bound workspace record into the generic model. */
|
|
78
|
+
export function sshRecordToWorkspaceRecord(record: SshWorkspaceRecord): WorkspaceRecord {
|
|
79
|
+
return {
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
id: record.id,
|
|
82
|
+
title: record.title,
|
|
83
|
+
provider: {
|
|
84
|
+
id: 'ssh',
|
|
85
|
+
connectionRef: { id: record.alias, alias: record.alias },
|
|
86
|
+
},
|
|
87
|
+
location: {
|
|
88
|
+
kind: 'posix',
|
|
89
|
+
root: record.remoteRoot,
|
|
90
|
+
},
|
|
91
|
+
anchor: {
|
|
92
|
+
path: record.anchorPath,
|
|
93
|
+
mode: 'managed',
|
|
94
|
+
},
|
|
95
|
+
createdAt: record.createdAt,
|
|
96
|
+
updatedAt: record.createdAt,
|
|
97
|
+
extensions: {},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Persistence path for the generic ledger (~/.dsh/workspaces/index.v1.json). */
|
|
102
|
+
export function genericLedgerPath(): string {
|
|
103
|
+
return join(homedir(), '.dsh', 'workspaces', 'index.v1.json')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Anchor root for managed generic workspaces (~/.dsh/workspaces/anchors). */
|
|
107
|
+
export function genericAnchorRoot(): string {
|
|
108
|
+
return join(homedir(), '.dsh', 'workspaces', 'anchors')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build and provide the workspace core on the cordis context: registers the
|
|
113
|
+
* builtin providers, instantiates the generic ledger (persisting under
|
|
114
|
+
* ~/.dsh/workspaces/index.v1.json), constructs the ledger router, and
|
|
115
|
+
* provides `workspaceCore`.
|
|
116
|
+
*/
|
|
117
|
+
export function mountWorkspaceCore(ctx: Context, deps: { engine?: SshEngine; hosts?: HostStoreView }): WorkspaceCore {
|
|
118
|
+
const providers = new WorkspaceProviderRegistry()
|
|
119
|
+
const ledger = new WorkspaceLedger(genericLedgerPath(), genericAnchorRoot())
|
|
120
|
+
|
|
121
|
+
// Register builtin providers against the shared registry.
|
|
122
|
+
registerBuiltinProviders(providers, deps)
|
|
123
|
+
|
|
124
|
+
const registry: WorkspaceRegistry = new LedgerBasedWorkspaceRegistry(ledger, providers)
|
|
125
|
+
const router = new LedgerWorkspaceRouter(ledger, registry, {
|
|
126
|
+
open: async (record) => {
|
|
127
|
+
const provider = providers.get(record.provider.id)
|
|
128
|
+
if (provider === undefined) return undefined
|
|
129
|
+
const connection = await provider.open(record)
|
|
130
|
+
return connection
|
|
131
|
+
},
|
|
132
|
+
onRemoved: () => { /* connection cleanup handled by closeAll */ },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const core: WorkspaceCore = {
|
|
136
|
+
ledger,
|
|
137
|
+
providers,
|
|
138
|
+
router,
|
|
139
|
+
registry,
|
|
140
|
+
fromSshRecord: sshRecordToWorkspaceRecord,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ctx.provide('workspaceCore', core)
|
|
144
|
+
return core
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The legacy SSH ledger held by the hardssh core (type bridge). */
|
|
148
|
+
export type { SshWorkspaceLedger }
|
|
149
|
+
|
|
150
|
+
declare module '@deepseek-ai/cordis' {
|
|
151
|
+
interface Context {
|
|
152
|
+
workspaceCore: WorkspaceCore
|
|
153
|
+
}
|
|
154
|
+
}
|