@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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -1,18 +1,92 @@
1
1
  /**
2
- * Browser-side HTTP helpers for the dsh-hardssh route clients.
3
- * Plain fetch / same-origin bundled inline into the client bundle.
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
- /** Error carrying the route's JSON error message and stable code/status. */
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
- public readonly code?: string,
11
- public readonly status?: number,
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 an HttpApiError (code/status preserved). */
102
+ /** Parse a JSON response or throw {@link HttpApiError} (status + body kept). */
29
103
  export async function readJson<T>(response: Response): Promise<T> {
30
- let body: unknown
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
- body = await response.json()
129
+ return await response.json()
33
130
  } catch {
34
- throw new HttpApiError(`HTTP ${response.status}: invalid JSON response`, undefined, response.status)
131
+ return undefined
35
132
  }
36
- if (!response.ok) {
37
- const record = typeof body === 'object' && body !== null
38
- ? body as { error?: unknown; code?: unknown }
39
- : undefined
40
- const message = typeof record?.error === 'string' ? record.error : `HTTP ${response.status}`
41
- const code = typeof record?.code === 'string' ? record.code : undefined
42
- throw new HttpApiError(message, code, response.status)
43
- }
44
- return body as T
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 workspace core: the host store, the SSH engine, and the
3
- * workspace ledger, provided as `ctx.hardsshCore` by the main plugin row so
4
- * the two switch rows (fs / subprocess) resolve one instance each.
5
- */
6
-
7
- import type { SshEngine } from './ssh/engine.ts'
8
- import type { SshHostEntry, SshHostSummary } from './ssh/protocol.ts'
9
- import type { SshWorkspaceLedger } from './ledger.ts'
10
- import type { RemoteWorkspaceRunner } from './remote-runner.ts'
11
- import type { WorkspaceSeamState } from './seam-state.ts'
12
-
13
- /** Read-only host-store surface exposed on the core (the write paths live in
14
- * the SSH routes, which get the full store). Avoids coupling the core type
15
- * to either the plaintext HostStore or the vault-backed SecureHostStore. */
16
- export interface HostStoreView {
17
- readonly path: string
18
- list(): SshHostEntry[]
19
- find(alias: string): SshHostEntry | undefined
20
- summarize(entry: SshHostEntry): SshHostSummary
21
- }
22
-
23
- /** One process-wide core shared by every dsh-hardssh row. */
24
- export interface HardsshCore {
25
- hosts: HostStoreView
26
- engine: SshEngine
27
- /** The SSH-bound workspace ledger (anchor dir -> remote dir). */
28
- ledger: SshWorkspaceLedger
29
- /** The shared seam state: ledger-derived routing snapshot + per-record
30
- * fs/subprocess instances, consumed by the fs/subprocess switch rows. */
31
- seams: WorkspaceSeamState
32
- /** Remote-workspace runner: resolve local anchor paths to remote channels
33
- * (git / files / commands) so other plugins (dsh-workbench-tiphareth) can
34
- * operate SSH-bound workspaces transparently. Optional in older builds. */
35
- resolveRemote?: RemoteWorkspaceRunner['resolveRemote']
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,171 @@
1
- /**
2
- * The `ctx.fs` switch row: provides the workspace-routing filesystem facade in
3
- * the host scope. The local backend (the deployment's sandboxed filesystem) is
4
- * mounted in an isolated child scope so its own `ctx.fs` provide never
5
- * collides. Routing and per-record remote backends are owned by the SHARED
6
- * seam state (hardsshCore.seams): the row only binds its per-record backend
7
- * factory and reads the state on every call. Every SSH-bound workspace record
8
- * gets its OWN remote backend instance bound to that record's alias + remote
9
- * root; the facade routes each call by the session cwd an anchor path of an
10
- * SSH workspace routes remote, everything else stays local. The facade
11
- * auto-provides `fs` here because the `fs-sandbox` row is disabled by the
12
- * profile patch.
13
- *
14
- * The seam state re-applies the ledger snapshot synchronously on every
15
- * commit, so creating or removing an SSH workspace takes effect without a
16
- * plugin restart and the fs/subprocess seams can never disagree. Before the
17
- * initial ledger load finishes, routing degrades to local with a one-time
18
- * warning for anchor-root paths.
19
- *
20
- * @module dsh-hardssh/fs
21
- */
22
-
23
- import type { Context } from '@deepseek-ai/cordis'
24
- import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
25
- import type { HardsshCore } from './core.ts'
26
- import { isPathUnderAnchor } from './ledger.ts'
27
- import type { WorkspaceState } from './protocol.ts'
28
- import { SshFileSystem } from './remote/remote-fs.ts'
29
- import { WFS_NAMESPACE_MARKER, SwitchFileSystem } from './switch/switch-fs.ts'
30
-
31
- declare module '@deepseek-ai/cordis' {
32
- interface Context {
33
- hardsshCore: HardsshCore
34
- }
35
- }
36
-
37
- /** Stable cordis plugin name. */
38
- export const name = 'hardssh-fs'
39
-
40
- /** Services required: the shared workspace core (mode store + engine) and the
41
- * sandbox policy the local backend (`SandboxedFileSystem`) consumes. */
42
- export const inject = ['hardsshCore', 'sandboxPolicy']
43
-
44
- /** A remote backend fixed to one SSH workspace's alias + remote root. */
45
- function fixedRemoteState(alias: string, remoteRoot: string): () => WorkspaceState {
46
- return () => ({ mode: 'remote' as const, alias, remoteRoot })
47
- }
48
-
49
- /** Mount the switching filesystem facade. */
50
- export function apply(ctx: Context): void {
51
- const core = ctx.hardsshCore
52
-
53
- // The local backend lives in an isolated scope: its `fs` provide shadows
54
- // only below this scope, so consumers keep resolving our facade.
55
- const localCtx = ctx.isolate('fs')
56
- const localFs = new SandboxedFileSystem(localCtx, {
57
- cwd: process.env.DSH_CWD ?? process.cwd(),
58
- diffBasisMaxBytes: 10 * 1024 * 1024,
59
- })
60
-
61
- // Bind the per-record remote backend builder into the shared seam state;
62
- // instances are built lazily on first route and reused across refreshes.
63
- core.seams.bindFs((record) => ({
64
- backend: new SshFileSystem(ctx.isolate('fs'), core.engine, fixedRemoteState(record.alias, record.remoteRoot)),
65
- namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
66
- anchorPath: record.anchorPath,
67
- remoteRoot: record.remoteRoot,
68
- }))
69
-
70
- const anchorRoot = core.ledger.anchorsRoot()
71
- let warnedUnready = false
72
-
73
- // The SwitchFileSystem constructor registers the `fs` provide on ctx.
74
- new SwitchFileSystem(ctx, {
75
- local: localFs,
76
- worldFor: (cwd) => {
77
- if (!core.seams.isReady() && !warnedUnready && cwd !== undefined && isPathUnderAnchor(anchorRoot, cwd)) {
78
- warnedUnready = true
79
- console.warn('[dsh-hardssh] fs routing is not ready yet (workspace ledger still loading) — operating on the local anchor until the snapshot is applied')
80
- }
81
- return core.seams.worldForFs(cwd) ?? { backend: localFs, namespace: '' }
82
- },
83
- worldForNamespace: (namespace) => core.seams.worldForFsNamespace(namespace),
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 { FsError } from '@deepseek-ai/dsh-fs'
20
+ import type { FileSystem } from '@deepseek-ai/dsh-fs'
21
+ import { homedir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import { anchorRoot, isPathUnderAnchor } from './ledger.ts'
24
+ import { vaultDirectory, legacyVaultPath } from './ssh/vault.ts'
25
+ import { WFS_NAMESPACE_MARKER, SwitchFileSystem, type WorkspaceWorld } from './switch/switch-fs.ts'
26
+ import type { WorkspaceCore } from './runtime/workspace-core.ts'
27
+ import type { WorkspaceRecord } from './base/model.ts'
28
+
29
+ /** Stable cordis plugin name. */
30
+ export const name = 'hardssh-fs'
31
+
32
+ /** The generic workspace core plus the policy used by the local fallback. */
33
+ export const inject = ['sandboxPolicy', 'workspaceCore']
34
+
35
+ /** The record behind a router connection (sync snapshot lookup). */
36
+ function genericRecordFor(core: WorkspaceCore, id: string): WorkspaceRecord | undefined {
37
+ return core.ledger.snapshotSync().records.find(record => record.id === id)
38
+ }
39
+
40
+ /** Resolve the fs world owning a cwd, or undefined when it is definitely local. */
41
+ export function genericFsWorldFor(
42
+ core: WorkspaceCore,
43
+ cwd: string | undefined,
44
+ reservedAnchorRoots: readonly string[],
45
+ ): WorkspaceWorld | undefined {
46
+ if (!core.isReady()) {
47
+ if (cwd !== undefined && reservedAnchorRoots.some(root => isPathUnderAnchor(root, cwd))) {
48
+ throw new Error('fs-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
49
+ }
50
+ return undefined
51
+ }
52
+ const connection = core.router.fromAnchor(cwd)
53
+ if (connection === undefined) return undefined
54
+ const record = genericRecordFor(core, connection.workspaceId)
55
+ if (record === undefined) throw new Error(`fs-workspace: routed workspace '${connection.workspaceId}' is missing from the ledger`)
56
+ if (record.anchor === undefined) throw new Error(`fs-workspace: workspace '${record.id}' has no anchor`)
57
+ const backend = connection.get('workspace.fs') as FileSystem | undefined
58
+ if (backend === undefined) throw new Error(`fs-workspace: workspace '${record.id}' provides no workspace.fs capability`)
59
+ return {
60
+ backend,
61
+ namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
62
+ anchorPath: record.anchor.path,
63
+ remoteRoot: record.location.root,
64
+ }
65
+ }
66
+
67
+ /** Resolve one namespaced target key, failing closed for stale namespaces. */
68
+ export function genericFsWorldForNamespace(core: WorkspaceCore, namespace: string): WorkspaceWorld | undefined {
69
+ if (!core.isReady()) return undefined
70
+ const route = core.router.codec.decode(namespace)
71
+ if (route === undefined) return undefined
72
+ const resolution = core.router.resolveRoute(route)
73
+ if (resolution === undefined) return undefined
74
+ const record = genericRecordFor(core, resolution.connection.workspaceId)
75
+ if (record === undefined) throw new Error(`fs-workspace: routed workspace '${resolution.connection.workspaceId}' is missing from the ledger`)
76
+ if (record.anchor === undefined) throw new Error(`fs-workspace: workspace '${record.id}' has no anchor`)
77
+ const backend = resolution.connection.get('workspace.fs') as FileSystem | undefined
78
+ if (backend === undefined) throw new Error(`fs-workspace: workspace '${record.id}' provides no workspace.fs capability`)
79
+ return {
80
+ backend,
81
+ namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
82
+ anchorPath: record.anchor.path,
83
+ remoteRoot: record.location.root,
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Refuse a path that sits inside the managed anchor root but is owned by no
89
+ * registered workspace — as a NOT-FOUND answer, not as a fatal error.
90
+ *
91
+ * The path stays unreachable either way (it is never handed to the local
92
+ * backend, so a bound session cannot read or write client files through the
93
+ * anchor window). What changes is how callers SEE the refusal: `FS_NOT_FOUND`
94
+ * is the DSH contract for "this path does not exist", and the harness relies on
95
+ * it when walking UP from the session cwd looking for a project root —
96
+ * `dsh-agent-instructions` probes `<dir>/.git` per ancestor and treats ONLY
97
+ * `FS_NOT_FOUND` as "keep walking" (any other error aborts the whole run),
98
+ * `dsh-skill-filesystem` does the same. With a bare Error the very first step
99
+ * above the anchor (`<anchorRoot>/.git`) killed the run with
100
+ * "fs-ssh: … is inside the workspace anchor root but no registered workspace
101
+ * owns it (fail closed)". The message is kept verbatim so the refusal is still
102
+ * self-explanatory in logs.
103
+ */
104
+ export function refuseUnownedAnchorPath(path: string): never {
105
+ throw new FsError(
106
+ `fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`,
107
+ 'FS_NOT_FOUND',
108
+ )
109
+ }
110
+
111
+ /**
112
+ * The shipped `worldForAnchorPath` deps hook: the workspace owning an absolute
113
+ * anchor path (this session's or a SIBLING's), else the anchor-window refusal.
114
+ * Exported so the seam can be exercised without re-implementing the policy.
115
+ */
116
+ export function anchorWorldFor(core: WorkspaceCore, anchorRootDir: string, path: string): WorkspaceWorld | undefined {
117
+ const world = genericFsWorldFor(core, path, [anchorRootDir])
118
+ if (world !== undefined) return world
119
+ if (isPathUnderAnchor(anchorRootDir, path)) refuseUnownedAnchorPath(path)
120
+ return undefined
121
+ }
122
+
123
+ /** Mount the generic switching filesystem facade. */
124
+ export function apply(ctx: Context): void {
125
+ const localCtx = ctx.isolate('fs')
126
+ const localFs = new SandboxedFileSystem(localCtx, {
127
+ cwd: process.env.DSH_CWD ?? process.cwd(),
128
+ diffBasisMaxBytes: 10 * 1024 * 1024,
129
+ })
130
+ const ws = ctx.workspaceCore
131
+ const anchorRootDir = anchorRoot()
132
+ let warnedUnready = false
133
+
134
+ new SwitchFileSystem(ctx, {
135
+ local: localFs,
136
+ localRoots: [join(homedir(), '.dsh'), join(homedir(), '.agents')],
137
+ // The anchor root is a window of placeholder dirs that must never be
138
+ // treated as local infrastructure even though it lives under ~/.dsh.
139
+ localRootExclusions: [anchorRootDir],
140
+ // The credential vault is REFUSED outright on every dispatch path (not only
141
+ // resolve/lstat): a bound session's agent must not be able to read — and
142
+ // offline-attack — the ciphertext through the routed filesystem. The
143
+ // pre-relocation path is denied too, because a failed move deliberately
144
+ // leaves the original file in place.
145
+ deniedRoots: [vaultDirectory(), legacyVaultPath()],
146
+ worldForAnchorPath: (path) => anchorWorldFor(ws, anchorRootDir, path),
147
+ worldFor: (cwd) => {
148
+ if (!ws.isReady()) {
149
+ if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
150
+ if (!warnedUnready) {
151
+ warnedUnready = true
152
+ console.warn('[dsh-hardssh] fs routing is not ready yet (workspace core still initializing or failed) — refusing access beneath the workspace anchor root')
153
+ }
154
+ throw new Error('fs-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
155
+ }
156
+ return { backend: localFs, namespace: '' }
157
+ }
158
+ const world = genericFsWorldFor(ws, cwd, [anchorRootDir])
159
+ if (world !== undefined) return world
160
+ if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
161
+ // Deliberately NOT the FS_NOT_FOUND refusal above: `cwd` is the session's
162
+ // own identity, not a path being probed. An unowned cwd means the
163
+ // workspace was deleted out from under a live session, which must stay
164
+ // loud instead of degrading into "not found" on every relative path.
165
+ throw new Error(`fs-ssh: '${cwd}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`)
166
+ }
167
+ return { backend: localFs, namespace: '' }
168
+ },
169
+ worldForNamespace: (namespace) => genericFsWorldForNamespace(ws, namespace),
170
+ })
171
+ }