@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.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/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -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 +18 -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 +36 -11
- 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 -99
- 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 +137 -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 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/client-http.ts
CHANGED
|
@@ -1,18 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-side HTTP
|
|
3
|
-
*
|
|
2
|
+
* Browser-side HTTP transport for the dsh-hardssh route clients — the ONE
|
|
3
|
+
* fetch / JSON-error layer shared by the workspace client (./client/api.ts)
|
|
4
|
+
* and the SSH client (./client/ssh/api.ts). Plain fetch / same-origin —
|
|
5
|
+
* bundled inline into the client bundle.
|
|
6
|
+
*
|
|
7
|
+
* Business layers may translate the error (named subclasses/aliases), but they
|
|
8
|
+
* must not re-parse a response: every route error is decoded here, once, into
|
|
9
|
+
* {@link HttpApiError} with the HTTP status AND the parsed body.
|
|
4
10
|
*/
|
|
5
11
|
|
|
6
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Every route's JSON error body (superset of the SSH `ApiErrorBody`): the
|
|
14
|
+
* stable machine code plus the interactive-gate fields the UI reads.
|
|
15
|
+
*/
|
|
16
|
+
export interface HttpErrorBody {
|
|
17
|
+
/** Human-readable message (`error` is the wire field name). */
|
|
18
|
+
error?: string
|
|
19
|
+
/** Stable machine code (HOST_KEY_UNKNOWN / NEEDS_PASSWORD / VAULT_* / …). */
|
|
20
|
+
code?: string
|
|
21
|
+
/** Which secret a connection needs when code === 'NEEDS_PASSWORD'. */
|
|
22
|
+
secret?: 'password' | 'passphrase'
|
|
23
|
+
/** The fingerprint when a host key was refused. */
|
|
24
|
+
hostKeyFingerprint?: string
|
|
25
|
+
/** Mismatch detail (expected vs actual) when the host key changed. */
|
|
26
|
+
hostKeyMismatch?: { expected: string; actual: string }
|
|
27
|
+
/** Workspace titles referencing a host that cannot be deleted (HOST_IN_USE). */
|
|
28
|
+
workspaces?: Array<{ id: string; title: string }>
|
|
29
|
+
/** Vault lockout: attempts left before the next lockout window. */
|
|
30
|
+
remaining?: number
|
|
31
|
+
/** Vault lockout: milliseconds until the next attempt is allowed. */
|
|
32
|
+
retryAfterMs?: number
|
|
33
|
+
/** Forward-compatible: unlisted fields stay reachable through `body`. */
|
|
34
|
+
[key: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The single transport error: HTTP status plus the PARSED response body, so
|
|
39
|
+
* no caller re-parses a response and no field is flattened away. The SSH
|
|
40
|
+
* client re-exports it as `SshApiError`; the workspace client subclasses it as
|
|
41
|
+
* `WorkspaceApiError`. Both are this class at runtime, so the UI's
|
|
42
|
+
* `instanceof` / `.code` / `.hostKeyFingerprint` checks keep working.
|
|
43
|
+
*/
|
|
7
44
|
export class HttpApiError extends Error {
|
|
8
45
|
constructor(
|
|
9
46
|
message: string,
|
|
10
|
-
|
|
11
|
-
|
|
47
|
+
/** HTTP status, when the error came from a response. */
|
|
48
|
+
readonly status?: number,
|
|
49
|
+
/** Parsed JSON error body, when the route sent one. */
|
|
50
|
+
readonly body?: HttpErrorBody,
|
|
12
51
|
) {
|
|
13
52
|
super(message)
|
|
14
53
|
this.name = 'HttpApiError'
|
|
15
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Stable machine code from the route error body. */
|
|
57
|
+
get code(): string | undefined {
|
|
58
|
+
return typeof this.body?.code === 'string' ? this.body.code : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Which secret a connection needs when {@link code} === 'NEEDS_PASSWORD'. */
|
|
62
|
+
get secret(): 'password' | 'passphrase' | undefined {
|
|
63
|
+
const secret = this.body?.secret
|
|
64
|
+
return secret === 'password' || secret === 'passphrase' ? secret : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The fingerprint when a host key was refused. */
|
|
68
|
+
get hostKeyFingerprint(): string | undefined {
|
|
69
|
+
return typeof this.body?.hostKeyFingerprint === 'string' ? this.body.hostKeyFingerprint : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Mismatch detail (expected vs actual) when the host key changed. */
|
|
73
|
+
get hostKeyMismatch(): { expected: string; actual: string } | undefined {
|
|
74
|
+
const mismatch = this.body?.hostKeyMismatch
|
|
75
|
+
if (typeof mismatch !== 'object' || mismatch === null) return undefined
|
|
76
|
+
const { expected, actual } = mismatch as { expected?: unknown; actual?: unknown }
|
|
77
|
+
if (typeof expected !== 'string' || typeof actual !== 'string') return undefined
|
|
78
|
+
return { expected, actual }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Vault lockout: attempts left. */
|
|
82
|
+
get remaining(): number | undefined {
|
|
83
|
+
return typeof this.body?.remaining === 'number' ? this.body.remaining : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Vault lockout: milliseconds until the next attempt is allowed. */
|
|
87
|
+
get retryAfterMs(): number | undefined {
|
|
88
|
+
return typeof this.body?.retryAfterMs === 'number' ? this.body.retryAfterMs : undefined
|
|
89
|
+
}
|
|
16
90
|
}
|
|
17
91
|
|
|
18
92
|
/** Query-string helper (skips undefined and empty values). */
|
|
@@ -25,21 +99,47 @@ export function buildQuery(params: Record<string, string | number | undefined>):
|
|
|
25
99
|
return text === '' ? '' : '?' + text
|
|
26
100
|
}
|
|
27
101
|
|
|
28
|
-
/** Parse a JSON response or throw
|
|
102
|
+
/** Parse a JSON response or throw {@link HttpApiError} (status + body kept). */
|
|
29
103
|
export async function readJson<T>(response: Response): Promise<T> {
|
|
30
|
-
|
|
104
|
+
const body = await parseJson(response)
|
|
105
|
+
if (body === undefined) {
|
|
106
|
+
throw new HttpApiError(`HTTP ${response.status}: invalid JSON response`, response.status)
|
|
107
|
+
}
|
|
108
|
+
if (!response.ok) throw errorFor(response.status, body)
|
|
109
|
+
return body as T
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Throw {@link HttpApiError} for a response that does NOT carry a JSON
|
|
114
|
+
* success body (streamed upload/download): a JSON error body is still parsed
|
|
115
|
+
* and preserved, so the caller sees the route's code instead of raw text.
|
|
116
|
+
* @param response - the non-ok response (or a stream that could not start).
|
|
117
|
+
* @param fallback - message prefix used when the route sent no `error` field.
|
|
118
|
+
*/
|
|
119
|
+
export async function throwHttpError(response: Response, fallback: string): Promise<never> {
|
|
120
|
+
const body = await parseJson(response)
|
|
121
|
+
const object = bodyObject(body)
|
|
122
|
+
const message = typeof object?.error === 'string' ? object.error : `${fallback}: HTTP ${response.status}`
|
|
123
|
+
throw new HttpApiError(message, response.status, object)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse a response body as JSON; undefined when absent or not JSON. */
|
|
127
|
+
async function parseJson(response: Response): Promise<unknown> {
|
|
31
128
|
try {
|
|
32
|
-
|
|
129
|
+
return await response.json()
|
|
33
130
|
} catch {
|
|
34
|
-
|
|
131
|
+
return undefined
|
|
35
132
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The parsed body as an error-body record, or undefined for non-objects. */
|
|
136
|
+
function bodyObject(body: unknown): HttpErrorBody | undefined {
|
|
137
|
+
return typeof body === 'object' && body !== null ? body as HttpErrorBody : undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Build the transport error for a non-ok response. */
|
|
141
|
+
function errorFor(status: number, body: unknown): HttpApiError {
|
|
142
|
+
const object = bodyObject(body)
|
|
143
|
+
const message = typeof object?.error === 'string' ? object.error : `HTTP ${status}`
|
|
144
|
+
return new HttpApiError(message, status, object)
|
|
45
145
|
}
|
package/src/core.ts
CHANGED
|
@@ -1,47 +1,37 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shared
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import type {
|
|
9
|
-
import type {
|
|
10
|
-
import type {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* the
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @deprecated Use HardsshCore. Kept as a source-compatible migration alias.
|
|
40
|
-
*/
|
|
41
|
-
export type EasysshCore = HardsshCore
|
|
42
|
-
|
|
43
|
-
declare module '@deepseek-ai/cordis' {
|
|
44
|
-
interface Context {
|
|
45
|
-
hardsshCore: HardsshCore
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The shared SSH operations core provided as `ctx.hardsshCore` by the main
|
|
3
|
+
* plugin row. Workspace routing is owned exclusively by `ctx.workspaceCore`;
|
|
4
|
+
* this service retains only the host store and shared SSH engine needed by
|
|
5
|
+
* SSH-specific integrations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type { SshEngine } from './ssh/engine.ts'
|
|
10
|
+
import type { SshHostEntry, SshHostSummary } from './ssh/protocol.ts'
|
|
11
|
+
|
|
12
|
+
/** Read-only host-store surface exposed on the core (the write paths live in
|
|
13
|
+
* the SSH routes, which get the full store). Avoids coupling the core type
|
|
14
|
+
* to either the plaintext HostStore or the vault-backed SecureHostStore. */
|
|
15
|
+
export interface HostStoreView {
|
|
16
|
+
readonly path: string
|
|
17
|
+
list(): SshHostEntry[]
|
|
18
|
+
find(alias: string): SshHostEntry | undefined
|
|
19
|
+
summarize(entry: SshHostEntry): SshHostSummary
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One process-wide SSH operations core. Workspace consumers use WorkspaceCore. */
|
|
23
|
+
export interface HardsshCore {
|
|
24
|
+
hosts: HostStoreView
|
|
25
|
+
engine: SshEngine
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @deprecated Use HardsshCore. Kept as a source-compatible migration alias.
|
|
30
|
+
*/
|
|
31
|
+
export type EasysshCore = HardsshCore
|
|
32
|
+
|
|
33
|
+
declare module '@deepseek-ai/cordis' {
|
|
34
|
+
interface Context {
|
|
35
|
+
hardsshCore: HardsshCore
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/fs.ts
CHANGED
|
@@ -1,85 +1,137 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `ctx.fs` switch row: provides the
|
|
3
|
-
* the host scope. The local backend (the deployment's
|
|
4
|
-
* mounted in an isolated child scope so its own
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import
|
|
24
|
-
import {
|
|
25
|
-
import type {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
new
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The `ctx.fs` switch row: provides the generic WorkspaceCore-routing
|
|
3
|
+
* filesystem facade in the host scope. The local backend (the deployment's
|
|
4
|
+
* sandboxed filesystem) is mounted in an isolated child scope so its own
|
|
5
|
+
* `ctx.fs` provide never collides.
|
|
6
|
+
*
|
|
7
|
+
* Routing worlds always come from `WorkspaceCore`/router:
|
|
8
|
+
* `connection.get('workspace.fs')` returns the root-bound DSH `FileSystem`
|
|
9
|
+
* cached by the provider. A cwd inside a workspace anchor resolves through
|
|
10
|
+
* that connection; everything else stays local. Before the core is ready, or
|
|
11
|
+
* for an unowned path beneath the managed SSH anchor root, access fails closed
|
|
12
|
+
* rather than touching the client filesystem.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-hardssh/fs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
|
|
19
|
+
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { anchorRoot, isPathUnderAnchor } from './ledger.ts'
|
|
23
|
+
import { vaultDirectory, legacyVaultPath } from './ssh/vault.ts'
|
|
24
|
+
import { WFS_NAMESPACE_MARKER, SwitchFileSystem, type WorkspaceWorld } from './switch/switch-fs.ts'
|
|
25
|
+
import type { WorkspaceCore } from './runtime/workspace-core.ts'
|
|
26
|
+
import type { WorkspaceRecord } from './base/model.ts'
|
|
27
|
+
|
|
28
|
+
/** Stable cordis plugin name. */
|
|
29
|
+
export const name = 'hardssh-fs'
|
|
30
|
+
|
|
31
|
+
/** The generic workspace core plus the policy used by the local fallback. */
|
|
32
|
+
export const inject = ['sandboxPolicy', 'workspaceCore']
|
|
33
|
+
|
|
34
|
+
/** The record behind a router connection (sync snapshot lookup). */
|
|
35
|
+
function genericRecordFor(core: WorkspaceCore, id: string): WorkspaceRecord | undefined {
|
|
36
|
+
return core.ledger.snapshotSync().records.find(record => record.id === id)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve the fs world owning a cwd, or undefined when it is definitely local. */
|
|
40
|
+
export function genericFsWorldFor(
|
|
41
|
+
core: WorkspaceCore,
|
|
42
|
+
cwd: string | undefined,
|
|
43
|
+
reservedAnchorRoots: readonly string[],
|
|
44
|
+
): WorkspaceWorld | undefined {
|
|
45
|
+
if (!core.isReady()) {
|
|
46
|
+
if (cwd !== undefined && reservedAnchorRoots.some(root => isPathUnderAnchor(root, cwd))) {
|
|
47
|
+
throw new Error('fs-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
|
|
48
|
+
}
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
const connection = core.router.fromAnchor(cwd)
|
|
52
|
+
if (connection === undefined) return undefined
|
|
53
|
+
const record = genericRecordFor(core, connection.workspaceId)
|
|
54
|
+
if (record === undefined) throw new Error(`fs-workspace: routed workspace '${connection.workspaceId}' is missing from the ledger`)
|
|
55
|
+
if (record.anchor === undefined) throw new Error(`fs-workspace: workspace '${record.id}' has no anchor`)
|
|
56
|
+
const backend = connection.get('workspace.fs') as FileSystem | undefined
|
|
57
|
+
if (backend === undefined) throw new Error(`fs-workspace: workspace '${record.id}' provides no workspace.fs capability`)
|
|
58
|
+
return {
|
|
59
|
+
backend,
|
|
60
|
+
namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
|
|
61
|
+
anchorPath: record.anchor.path,
|
|
62
|
+
remoteRoot: record.location.root,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve one namespaced target key, failing closed for stale namespaces. */
|
|
67
|
+
export function genericFsWorldForNamespace(core: WorkspaceCore, namespace: string): WorkspaceWorld | undefined {
|
|
68
|
+
if (!core.isReady()) return undefined
|
|
69
|
+
const route = core.router.codec.decode(namespace)
|
|
70
|
+
if (route === undefined) return undefined
|
|
71
|
+
const resolution = core.router.resolveRoute(route)
|
|
72
|
+
if (resolution === undefined) return undefined
|
|
73
|
+
const record = genericRecordFor(core, resolution.connection.workspaceId)
|
|
74
|
+
if (record === undefined) throw new Error(`fs-workspace: routed workspace '${resolution.connection.workspaceId}' is missing from the ledger`)
|
|
75
|
+
if (record.anchor === undefined) throw new Error(`fs-workspace: workspace '${record.id}' has no anchor`)
|
|
76
|
+
const backend = resolution.connection.get('workspace.fs') as FileSystem | undefined
|
|
77
|
+
if (backend === undefined) throw new Error(`fs-workspace: workspace '${record.id}' provides no workspace.fs capability`)
|
|
78
|
+
return {
|
|
79
|
+
backend,
|
|
80
|
+
namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
|
|
81
|
+
anchorPath: record.anchor.path,
|
|
82
|
+
remoteRoot: record.location.root,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mount the generic switching filesystem facade. */
|
|
87
|
+
export function apply(ctx: Context): void {
|
|
88
|
+
const localCtx = ctx.isolate('fs')
|
|
89
|
+
const localFs = new SandboxedFileSystem(localCtx, {
|
|
90
|
+
cwd: process.env.DSH_CWD ?? process.cwd(),
|
|
91
|
+
diffBasisMaxBytes: 10 * 1024 * 1024,
|
|
92
|
+
})
|
|
93
|
+
const ws = ctx.workspaceCore
|
|
94
|
+
const anchorRootDir = anchorRoot()
|
|
95
|
+
let warnedUnready = false
|
|
96
|
+
|
|
97
|
+
new SwitchFileSystem(ctx, {
|
|
98
|
+
local: localFs,
|
|
99
|
+
localRoots: [join(homedir(), '.dsh'), join(homedir(), '.agents')],
|
|
100
|
+
// The anchor root is a window of placeholder dirs that must never be
|
|
101
|
+
// treated as local infrastructure even though it lives under ~/.dsh.
|
|
102
|
+
localRootExclusions: [anchorRootDir],
|
|
103
|
+
// The credential vault is REFUSED outright on every dispatch path (not only
|
|
104
|
+
// resolve/lstat): a bound session's agent must not be able to read — and
|
|
105
|
+
// offline-attack — the ciphertext through the routed filesystem. The
|
|
106
|
+
// pre-relocation path is denied too, because a failed move deliberately
|
|
107
|
+
// leaves the original file in place.
|
|
108
|
+
deniedRoots: [vaultDirectory(), legacyVaultPath()],
|
|
109
|
+
worldForAnchorPath: (path) => {
|
|
110
|
+
const world = genericFsWorldFor(ws, path, [anchorRootDir])
|
|
111
|
+
if (world !== undefined) return world
|
|
112
|
+
if (isPathUnderAnchor(anchorRootDir, path)) {
|
|
113
|
+
throw new Error(`fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`)
|
|
114
|
+
}
|
|
115
|
+
return undefined
|
|
116
|
+
},
|
|
117
|
+
worldFor: (cwd) => {
|
|
118
|
+
if (!ws.isReady()) {
|
|
119
|
+
if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
120
|
+
if (!warnedUnready) {
|
|
121
|
+
warnedUnready = true
|
|
122
|
+
console.warn('[dsh-hardssh] fs routing is not ready yet (workspace core still initializing or failed) — refusing access beneath the workspace anchor root')
|
|
123
|
+
}
|
|
124
|
+
throw new Error('fs-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
|
|
125
|
+
}
|
|
126
|
+
return { backend: localFs, namespace: '' }
|
|
127
|
+
}
|
|
128
|
+
const world = genericFsWorldFor(ws, cwd, [anchorRootDir])
|
|
129
|
+
if (world !== undefined) return world
|
|
130
|
+
if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
131
|
+
throw new Error(`fs-ssh: '${cwd}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`)
|
|
132
|
+
}
|
|
133
|
+
return { backend: localFs, namespace: '' }
|
|
134
|
+
},
|
|
135
|
+
worldForNamespace: (namespace) => genericFsWorldForNamespace(ws, namespace),
|
|
136
|
+
})
|
|
137
|
+
}
|