@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.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Wire contract between the host half (routes.ts) and the browser half
3
+ * (client/api.ts). Pure types only — imported by both halves, bundled into
4
+ * each, no runtime identity to share.
5
+ */
6
+
7
+ /** The two workspace modes of the plugin. */
8
+ export type WorkspaceMode = 'local' | 'remote'
9
+
10
+ /** The plugin's mode state (host-owned singleton). */
11
+ export interface WorkspaceState {
12
+ /** Current mode: local = this machine, remote = an SSH host. */
13
+ mode: WorkspaceMode
14
+ /** Active SSH host alias (kept while in local mode so the toggle can return). */
15
+ alias?: string
16
+ /** Resolved absolute remote root (the workspace gate prefix). */
17
+ remoteRoot?: string
18
+ /** Human label of the remote root ('~' when the default home was resolved). */
19
+ remoteRootLabel?: string
20
+ }
21
+
22
+ /** One directory entry (both backends normalize to this shape). */
23
+ export interface WorkspaceEntry {
24
+ name: string
25
+ type: 'dir' | 'file' | 'other'
26
+ size: number
27
+ mtimeMs: number
28
+ }
29
+
30
+ /** Directory listing response. */
31
+ export interface DirListing {
32
+ /** The listed absolute path. */
33
+ path: string
34
+ entries: WorkspaceEntry[]
35
+ }
36
+
37
+ /** File read response (text only; binary/oversize is rejected with an error). */
38
+ export interface FileRead {
39
+ path: string
40
+ content: string
41
+ size: number
42
+ mtime: number
43
+ }
44
+
45
+ /** File write response (new mtime for the UI's conflict tracking). */
46
+ export interface FileWriteResult {
47
+ mtime: number
48
+ }
49
+
50
+ /** One filename-search hit. */
51
+ export interface SearchHit {
52
+ /** Absolute path. */
53
+ path: string
54
+ /** Path relative to the search root. */
55
+ rel: string
56
+ isDir: boolean
57
+ }
58
+
59
+ /** Filename-search response. */
60
+ export interface SearchView {
61
+ query: string
62
+ hits: SearchHit[]
63
+ truncated: boolean
64
+ }
65
+
66
+ /** JSON error body used by every route. */
67
+ export interface ApiErrorBody {
68
+ error: string
69
+ }
70
+
71
+ /** Route paths the client calls (shared literals). */
72
+ export const WORKSPACE_API_BASE = '/api/dsh-hardssh' as const
73
+
74
+ export const WORKSPACE_API = {
75
+ state: WORKSPACE_API_BASE + '/state',
76
+ tree: WORKSPACE_API_BASE + '/tree',
77
+ file: WORKSPACE_API_BASE + '/file',
78
+ search: WORKSPACE_API_BASE + '/search',
79
+ sshWorkspaces: WORKSPACE_API_BASE + '/ws',
80
+ sshWorkspaceDir: WORKSPACE_API_BASE + '/ws/dir',
81
+ } as const
82
+
83
+ /**
84
+ * One SSH-bound workspace: a local anchor directory (the host-visible
85
+ * workspace path, which sessions use as their cwd) bound to a directory on
86
+ * an SSH host. The fs/subprocess seams route by the anchor path: a session
87
+ * whose cwd is this anchor operates on the REMOTE directory; everything else
88
+ * stays local.
89
+ */
90
+ export interface SshWorkspaceRecord {
91
+ /** Stable id (uuid). */
92
+ id: string
93
+ /** Display title (sidebar). */
94
+ title: string
95
+ /** SSH host alias (dsh-ssh host store). */
96
+ alias: string
97
+ /** Resolved absolute remote root (the remote directory to operate in). */
98
+ remoteRoot: string
99
+ /** Local anchor directory (host workspace path / session cwd). */
100
+ anchorPath: string
101
+ /** When the record was created. */
102
+ createdAt: string
103
+ }
104
+
105
+ /** The ledger of SSH-bound workspaces (persisted by the host). */
106
+ export type SshWorkspaceLedger = SshWorkspaceRecord[]
107
+
108
+ /** One remote directory entry for the picker tree. */
109
+ export interface RemoteDirEntry {
110
+ name: string
111
+ type: 'dir' | 'file' | 'other'
112
+ size: number
113
+ mtimeMs: number
114
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Registry of every built-in provider. The DSH runtime (and any consumer)
3
+ * uses this to mount local + ssh providers; third-party providers register
4
+ * the same way through the base plugin API.
5
+ *
6
+ * @module @tiphareth/dsh-hardssh/providers
7
+ */
8
+
9
+ import type { WorkspaceProvider } from '../base/model.ts'
10
+ import type { WorkspaceProviderRegistry } from '../base/registry.ts'
11
+ import type { SshEngine } from '../ssh/engine.ts'
12
+ import type { HostStoreView } from '../core.ts'
13
+ import { createSshWorkspaceProvider } from './ssh/provider.ts'
14
+ import { createLocalWorkspaceProvider } from './local/provider.ts'
15
+
16
+ /** Register both built-in providers. Returns per-provider disposers. */
17
+ export function registerBuiltinProviders(
18
+ registry: WorkspaceProviderRegistry,
19
+ deps: { engine?: SshEngine; hosts?: HostStoreView } = {},
20
+ ): Array<() => void> {
21
+ const disposers: Array<() => void> = []
22
+ // Local first: always available, no external state.
23
+ disposers.push(registry.register(createLocalWorkspaceProvider()))
24
+ // SSH requires the engine; when absent (headless / partial load) it is
25
+ // simply not registered — the base keeps working for local workspaces.
26
+ if (deps.engine !== undefined) {
27
+ disposers.push(registry.register(createSshWorkspaceProvider(deps.engine)))
28
+ }
29
+ return disposers
30
+ }
31
+
32
+ /** The canonical provider ids. */
33
+ export const BUILTIN_PROVIDER_IDS = ['local', 'ssh'] as const
34
+ export type BuiltinProviderId = (typeof BUILTIN_PROVIDER_IDS)[number]
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Local provider — the workspace provider for local-disk workspaces. Direct
3
+ * Node fs implementation of `WorkspaceFileSystem`, so the local provider is
4
+ * usable OUTSIDE the dsh harness too (a consumer only needs `base`).
5
+ *
6
+ * The DSH runtime later re-wraps this over `dsh-fs-sandbox` / `dsh-fs-local`
7
+ * for sandbox semantics when running inside the harness; the base contract
8
+ * stays identical.
9
+ *
10
+ * @module @tiphareth/dsh-hardssh/providers/local
11
+ */
12
+
13
+ import { constants as fsConstants } from 'node:fs'
14
+ import { access, lstat as fsLstat, mkdir, readFile, readdir, rename, rm, stat as fsStat, writeFile } from 'node:fs/promises'
15
+ import { basename, join, parse as parsePath, resolve as resolvePath } from 'node:path'
16
+ import type {
17
+ WorkspaceCapabilityMap,
18
+ WorkspaceConnection,
19
+ WorkspaceOpenContext,
20
+ WorkspaceProvider,
21
+ WorkspaceProviderManifest,
22
+ WorkspaceRecord,
23
+ } from '../../base/model.ts'
24
+ import type {
25
+ WorkspaceDirEntry,
26
+ WorkspaceFileSystem,
27
+ WorkspaceStat,
28
+ } from '../../base/capability.ts'
29
+
30
+ /** The local provider manifest. */
31
+ export const localProviderManifest: WorkspaceProviderManifest = {
32
+ id: 'local',
33
+ version: '0.1.0',
34
+ apiVersion: 1,
35
+ displayName: 'Local disk workspace',
36
+ capabilities: ['workspace.fs'],
37
+ }
38
+
39
+ /** One open local workspace connection. */
40
+ export class LocalWorkspaceConnection implements WorkspaceConnection {
41
+ readonly providerId = 'local'
42
+
43
+ constructor(
44
+ readonly workspaceId: string,
45
+ private readonly root: string,
46
+ ) {}
47
+
48
+ get<K extends keyof WorkspaceCapabilityMap>(capability: K): WorkspaceCapabilityMap[K] | undefined {
49
+ if (capability === 'workspace.fs') return new LocalWorkspaceFileSystem(this.root) as unknown as WorkspaceCapabilityMap[K]
50
+ return undefined
51
+ }
52
+
53
+ status(): 'connecting' | 'ready' | 'degraded' | 'closed' {
54
+ return 'ready'
55
+ }
56
+
57
+ async close(): Promise<void> {
58
+ // Nothing to release (no pool, no leases).
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Node-fs implementation of `WorkspaceFileSystem` confined to a root.
64
+ * Workspace-relative paths (`/src/index.ts`, `src/index.ts`) are resolved
65
+ * against the root; `..` escapes are rejected; symlinks are not followed
66
+ * out of the root by the stat path (defense in depth — the contract says
67
+ * the provider enforces confinement).
68
+ */
69
+ export class LocalWorkspaceFileSystem implements WorkspaceFileSystem {
70
+ constructor(private readonly root: string) {}
71
+
72
+ /** Resolve a workspace-relative path into an absolute path inside root. */
73
+ private abs(path: string): string {
74
+ if (path.startsWith('\\') || path.startsWith('/')) path = path.slice(1)
75
+ const absolute = resolvePath(this.root, path)
76
+ const normalized = resolvePath(this.root)
77
+ if (absolute !== normalized && !absolute.startsWith(normalized + (parsePath(normalized).root === normalized ? '' : '/')) && !absolute.startsWith(normalized + '\\')) {
78
+ // Allow exact root and descendants only.
79
+ if (!absolute.startsWith(normalized + '/') && !absolute.startsWith(normalized + '\\')) {
80
+ throw new Error(`path escapes workspace root: '${path}'`)
81
+ }
82
+ }
83
+ return absolute
84
+ }
85
+
86
+ async stat(path: string, signal?: AbortSignal): Promise<WorkspaceStat | undefined> {
87
+ const absolute = this.abs(path)
88
+ try {
89
+ const info = await fsStat(absolute)
90
+ return toStat(info)
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ async list(path: string, signal?: AbortSignal): Promise<WorkspaceDirEntry[]> {
97
+ const absolute = this.abs(path)
98
+ const names = await readdir(absolute)
99
+ const entries: WorkspaceDirEntry[] = []
100
+ for (const name of names) {
101
+ const child = join(absolute, name)
102
+ try {
103
+ const info = await fsLstat(child)
104
+ entries.push({
105
+ name,
106
+ type: info.isDirectory() ? 'dir' : info.isFile() ? 'file' : 'other',
107
+ size: info.size,
108
+ mtimeMs: info.mtimeMs,
109
+ })
110
+ } catch {
111
+ // Unreadable entry: skip rather than fail the whole listing.
112
+ }
113
+ }
114
+ return entries
115
+ }
116
+
117
+ async readFile(path: string, signal?: AbortSignal): Promise<Uint8Array> {
118
+ return readFile(this.abs(path))
119
+ }
120
+
121
+ async writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void> {
122
+ await writeFile(this.abs(path), data)
123
+ }
124
+
125
+ async mkdir(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
126
+ await mkdir(this.abs(path), { recursive: options?.recursive ?? false })
127
+ }
128
+
129
+ async rm(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
130
+ await rm(this.abs(path), { recursive: options?.recursive ?? false, force: false })
131
+ }
132
+
133
+ async rename(from: string, to: string, signal?: AbortSignal): Promise<void> {
134
+ await rename(this.abs(from), this.abs(to))
135
+ }
136
+
137
+ /** Convenience: check the root exists and is readable (for open()). */
138
+ async accessible(): Promise<boolean> {
139
+ try {
140
+ await access(this.root, fsConstants.R_OK)
141
+ return true
142
+ } catch {
143
+ return false
144
+ }
145
+ }
146
+ }
147
+
148
+ /** Convert fs.StatFs into the uniform WorkspaceStat. */
149
+ function toStat(info: { isDirectory(): boolean; isFile(): boolean; size: number; mtimeMs: number; mode: number }): WorkspaceStat {
150
+ return {
151
+ type: info.isDirectory() ? 'dir' : info.isFile() ? 'file' : 'other',
152
+ size: info.size,
153
+ mtimeMs: info.mtimeMs,
154
+ mode: info.mode,
155
+ }
156
+ }
157
+
158
+ /** The local provider factory. */
159
+ export function createLocalWorkspaceProvider(): WorkspaceProvider {
160
+ return {
161
+ manifest: localProviderManifest,
162
+ validate(record: WorkspaceRecord): void {
163
+ if (record.provider.id !== 'local') throw new Error('not a local workspace record')
164
+ if (record.location.root === '') throw new Error('local workspace record is missing location.root')
165
+ },
166
+ async open(record: WorkspaceRecord, context?: WorkspaceOpenContext): Promise<WorkspaceConnection> {
167
+ const fs = new LocalWorkspaceFileSystem(record.location.root)
168
+ if (!(await fs.accessible())) {
169
+ context?.logger?.warn?.('local workspace root is not accessible:', record.location.root)
170
+ }
171
+ return new LocalWorkspaceConnection(record.id, record.location.root)
172
+ },
173
+ }
174
+ }
175
+
176
+ /** Helper: title mostly used by record factories. */
177
+ export function localDefaultTitle(root: string): string {
178
+ return basename(root) || root
179
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * SSH provider — wraps the SSH engine/host store as a generic
3
+ * `WorkspaceProvider`, so the workspace base can route SSH-bound workspaces
4
+ * without knowing anything about ssh2. This is the concrete "provider-ssh"
5
+ * layer: everything SSH lives here (or in the legacy ./ssh, ./remote
6
+ * modules it adapts), and the base + runtime never import it directly by
7
+ * type.
8
+ *
9
+ * A record with `provider.id === 'ssh'` maps:
10
+ * - `provider.connectionRef.id` → HostStore alias
11
+ * - `location.root` → the remote root directory
12
+ * - `anchor.path` → the local anchor (session-visible)
13
+ *
14
+ * @module @tiphareth/dsh-hardssh/providers/ssh
15
+ */
16
+
17
+ import type {
18
+ WorkspaceCapabilityMap,
19
+ WorkspaceConnection,
20
+ WorkspaceOpenContext,
21
+ WorkspaceProvider,
22
+ WorkspaceProviderManifest,
23
+ WorkspaceRecord,
24
+ } from '../../base/model.ts'
25
+ import type {
26
+ WorkspaceFileSystem,
27
+ WorkspaceProcessRuntime,
28
+ WorkspaceSearchService,
29
+ WorkspaceStat,
30
+ WorkspaceTerminalService,
31
+ WorkspaceDirEntry,
32
+ WorkspaceSearchHit,
33
+ } from '../../base/capability.ts'
34
+ import type { SshEngine } from '../../ssh/engine.ts'
35
+ import { RemoteSearchService } from '../../remote-search.ts'
36
+
37
+ /** The ssh provider manifest. */
38
+ export const sshProviderManifest: WorkspaceProviderManifest = {
39
+ id: 'ssh',
40
+ version: '0.1.0',
41
+ apiVersion: 1,
42
+ displayName: 'SSH remote workspace',
43
+ capabilities: ['workspace.fs', 'workspace.process', 'workspace.terminal', 'workspace.search'],
44
+ }
45
+
46
+ /** One open SSH workspace connection. */
47
+ export class SshWorkspaceConnection implements WorkspaceConnection {
48
+ readonly providerId = 'ssh'
49
+
50
+ constructor(
51
+ readonly workspaceId: string,
52
+ private readonly engine: SshEngine,
53
+ private readonly alias: string,
54
+ private readonly remoteRoot: string,
55
+ ) {}
56
+
57
+ get<K extends keyof WorkspaceCapabilityMap>(capability: K): WorkspaceCapabilityMap[K] | undefined {
58
+ switch (capability) {
59
+ case 'workspace.fs': return this.fs() as WorkspaceCapabilityMap[K]
60
+ case 'workspace.process': return this.process() as WorkspaceCapabilityMap[K]
61
+ case 'workspace.terminal': return this.terminal() as WorkspaceCapabilityMap[K]
62
+ case 'workspace.search': return this.search() as WorkspaceCapabilityMap[K]
63
+ default: return undefined
64
+ }
65
+ }
66
+
67
+ private fs(): WorkspaceFileSystem {
68
+ return new SshWorkspaceFileSystem(this.engine, this.alias, this.remoteRoot)
69
+ }
70
+
71
+ private process(): WorkspaceProcessRuntime {
72
+ return new SshWorkspaceProcess(this.engine, this.alias, this.remoteRoot)
73
+ }
74
+
75
+ private terminal(): WorkspaceTerminalService {
76
+ return new SshWorkspaceTerminal(this.engine, this.alias, this.remoteRoot)
77
+ }
78
+
79
+ private search(): WorkspaceSearchService {
80
+ return new SshWorkspaceSearch(this.engine, this.alias, this.remoteRoot)
81
+ }
82
+
83
+ status(): 'connecting' | 'ready' | 'degraded' | 'closed' {
84
+ // The engine owns the connection pool; a resolved workspace whose host
85
+ // is configured is treated as ready (connectivity is verified lazily by
86
+ // the exec/search calls, which report degraded results).
87
+ return 'ready'
88
+ }
89
+
90
+ async close(): Promise<void> {
91
+ // The engine owns the shared connection pool; per-workspace close is a
92
+ // no-op here (pool invalidation is host-store driven).
93
+ }
94
+ }
95
+
96
+ /** POSIX-join a remote root with a relative path, confining to the root. */
97
+ export function joinRemoteRoot(root: string, path: string): string {
98
+ if (path.startsWith('/')) {
99
+ // Absolute path must still be under the root? SSH workspaces allow
100
+ // arbitrary absolute remote paths (the root is the model's own habit,
101
+ // not a sandbox). Keep absolute paths verbatim for the SSH provider.
102
+ return root === '/' ? path : path
103
+ }
104
+ const base = root === '/' ? '' : root
105
+ return `${base}/${path}`.replace(/\/{2,}/g, '/')
106
+ }
107
+
108
+ /** Wrap engine FS calls as the generic WorkspaceFileSystem. */
109
+ export class SshWorkspaceFileSystem implements WorkspaceFileSystem {
110
+ constructor(
111
+ private readonly engine: SshEngine,
112
+ private readonly alias: string,
113
+ private readonly root: string,
114
+ ) {}
115
+
116
+ private full(path: string): string {
117
+ return joinRemoteRoot(this.root, path)
118
+ }
119
+
120
+ async stat(path: string, signal?: AbortSignal): Promise<WorkspaceStat | undefined> {
121
+ const result = await this.engine.stat(this.alias, this.full(path))
122
+ return { type: result.type, size: result.size, mtimeMs: result.mtimeMs, mode: result.mode }
123
+ }
124
+
125
+ async list(path: string, signal?: AbortSignal): Promise<WorkspaceDirEntry[]> {
126
+ return this.engine.ls(this.alias, this.full(path))
127
+ }
128
+
129
+ async readFile(path: string, signal?: AbortSignal): Promise<Uint8Array> {
130
+ const { content } = await this.engine.readFile(this.alias, this.full(path))
131
+ return content
132
+ }
133
+
134
+ async writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void> {
135
+ await this.engine.writeFile(this.alias, this.full(path), Buffer.from(data))
136
+ }
137
+
138
+ async mkdir(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
139
+ await this.engine.mkdir(this.alias, this.full(path))
140
+ }
141
+
142
+ async rm(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
143
+ await this.engine.rm(this.alias, this.full(path), options?.recursive ?? false)
144
+ }
145
+
146
+ async rename(from: string, to: string, signal?: AbortSignal): Promise<void> {
147
+ await this.engine.rename(this.alias, this.full(from), this.full(to))
148
+ }
149
+ }
150
+
151
+ /** Wrap engine exec as the generic WorkspaceProcessRuntime. */
152
+ export class SshWorkspaceProcess implements WorkspaceProcessRuntime {
153
+ constructor(
154
+ private readonly engine: SshEngine,
155
+ private readonly alias: string,
156
+ private readonly root: string,
157
+ ) {}
158
+
159
+ async exec(command: string, options?: { timeoutMs?: number; cwd?: string; signal?: AbortSignal }): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut?: boolean; durationMs?: number }> {
160
+ const cwd = options?.cwd !== undefined ? this.full(options.cwd) : this.root
161
+ // cd into the resolved cwd (relative paths resolved against the root).
162
+ const result = await this.engine.exec(this.alias, `cd ${quote(cwd)} && ${command}`, options?.timeoutMs)
163
+ return {
164
+ stdout: result.stdout,
165
+ stderr: result.stderr,
166
+ exitCode: result.exitCode ?? 1,
167
+ timedOut: result.timedOut,
168
+ durationMs: result.durationMs,
169
+ }
170
+ }
171
+
172
+ private full(path: string): string {
173
+ return joinRemoteRoot(this.root, path)
174
+ }
175
+ }
176
+
177
+ /** Wrap engine PTY as the generic WorkspaceTerminalService. */
178
+ export class SshWorkspaceTerminal implements WorkspaceTerminalService {
179
+ constructor(
180
+ private readonly engine: SshEngine,
181
+ private readonly alias: string,
182
+ private readonly root: string,
183
+ ) {}
184
+
185
+ async openTerminal(cols: number, rows: number): Promise<import('../../base/capability.ts').WorkspaceTerminalHandle> {
186
+ const session = await this.engine.openShell(this.alias, { cols, rows })
187
+ let exited = false
188
+ const exitHandlers = new Set<(event: { exitCode: number }) => void>()
189
+ const onDataHandlers = new Set<(data: string) => void>()
190
+ const enc = new TextDecoder()
191
+ session.onData = (data: Buffer) => {
192
+ for (const handler of onDataHandlers) handler(enc.decode(data))
193
+ }
194
+ session.onExit = (code: number | null) => {
195
+ exited = true
196
+ for (const handler of exitHandlers) handler({ exitCode: code ?? -1 })
197
+ }
198
+ return {
199
+ shell: 'ssh',
200
+ write(data: string) { if (!exited) session.send(data) },
201
+ resize(c: number, r: number) { session.resize(c, r) },
202
+ kill() {
203
+ try { session.signal('KILL') } catch { /* closed */ }
204
+ try { session.close() } catch { /* closed */ }
205
+ },
206
+ onData(handler) {
207
+ onDataHandlers.add(handler)
208
+ return { dispose: () => { onDataHandlers.delete(handler) } }
209
+ },
210
+ onExit(handler) {
211
+ exitHandlers.add(handler)
212
+ return { dispose: () => { exitHandlers.delete(handler) } }
213
+ },
214
+ }
215
+ }
216
+ }
217
+
218
+ /** Wrap the remote search service as the generic WorkspaceSearchService. */
219
+ export class SshWorkspaceSearch implements WorkspaceSearchService {
220
+ private readonly service: RemoteSearchService
221
+
222
+ constructor(
223
+ private readonly engine: SshEngine,
224
+ private readonly alias: string,
225
+ private readonly root: string,
226
+ ) {
227
+ this.service = new RemoteSearchService(engine)
228
+ }
229
+
230
+ async glob(pattern: string, options?: { root?: string; maxDepth?: number; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }> {
231
+ const result = await this.service.glob({ alias: this.alias, root: this.root }, pattern)
232
+ return {
233
+ hits: result.hits.map(path => ({ path, rel: path, isDir: false })),
234
+ truncated: result.truncated,
235
+ }
236
+ }
237
+
238
+ async grep(fixedPhrase: string, options?: { root?: string; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }> {
239
+ const result = await this.service.grepFixed({ alias: this.alias, root: this.root }, fixedPhrase)
240
+ return {
241
+ hits: result.lines.map(line => {
242
+ const colon = line.indexOf(':')
243
+ const path = colon >= 0 ? line.slice(0, colon) : line
244
+ return { path, rel: path, isDir: false }
245
+ }),
246
+ truncated: result.truncated,
247
+ }
248
+ }
249
+ }
250
+
251
+ /** The ssh provider factory. */
252
+ export function createSshWorkspaceProvider(engine: SshEngine): WorkspaceProvider {
253
+ return {
254
+ manifest: sshProviderManifest,
255
+ validate(record: WorkspaceRecord): void {
256
+ if (record.provider.id !== 'ssh') throw new Error('not an ssh workspace record')
257
+ if (record.provider.connectionRef === undefined || record.provider.connectionRef.id === '') {
258
+ throw new Error('ssh workspace record is missing provider.connectionRef.id (host alias)')
259
+ }
260
+ if (record.location.root === '') throw new Error('ssh workspace record is missing location.root')
261
+ },
262
+ async open(record: WorkspaceRecord, context?: WorkspaceOpenContext): Promise<WorkspaceConnection> {
263
+ const alias = record.provider.connectionRef!.id
264
+ return new SshWorkspaceConnection(record.id, engine, alias, record.location.root)
265
+ },
266
+ }
267
+ }
268
+
269
+ /** POSIX single-quote a shell argument. */
270
+ function quote(arg: string): string {
271
+ if (arg === '') return "''"
272
+ if (/^[a-zA-Z0-9_\-./:=@]+$/.test(arg)) return arg
273
+ return `'${arg.replace(/'/g, `'\\''`)}'`
274
+ }