@tiphareth/dsh-hardssh 0.1.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|