@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local provider
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
2
|
+
* Local workspace provider. Inside DSH it exposes the official FileSystem and
|
|
3
|
+
* SubprocessRuntime capability shapes, both confined to the record root. Pure
|
|
4
|
+
* base consumers without Cordis keep the lightweight path-style fallbacks.
|
|
9
5
|
*
|
|
10
6
|
* @module @tiphareth/dsh-hardssh/providers/local
|
|
11
7
|
*/
|
|
12
8
|
|
|
13
|
-
import {
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
10
|
+
import type { FileSystem, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
|
11
|
+
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
|
12
|
+
import type {
|
|
13
|
+
SubprocessHandle,
|
|
14
|
+
SubprocessSpawnSpec,
|
|
15
|
+
SubprocessTerminalHandle,
|
|
16
|
+
SubprocessTerminalSpawnSpec,
|
|
17
|
+
} from '@deepseek-ai/dsh-subprocess'
|
|
18
|
+
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
|
19
|
+
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
20
|
+
import { exec as execChild } from 'node:child_process'
|
|
21
|
+
import { constants as fsConstants, existsSync, realpathSync } from 'node:fs'
|
|
14
22
|
import { access, lstat as fsLstat, mkdir, readFile, readdir, rename, rm, stat as fsStat, writeFile } from 'node:fs/promises'
|
|
15
|
-
import { basename,
|
|
23
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
|
|
24
|
+
import { WORKSPACE_PROVIDER_API_VERSION } from '../../base/model.ts'
|
|
16
25
|
import type {
|
|
17
26
|
WorkspaceCapabilityMap,
|
|
18
27
|
WorkspaceConnection,
|
|
@@ -24,156 +33,319 @@ import type {
|
|
|
24
33
|
import type {
|
|
25
34
|
WorkspaceDirEntry,
|
|
26
35
|
WorkspaceFileSystem,
|
|
36
|
+
WorkspaceProcessRuntime,
|
|
27
37
|
WorkspaceStat,
|
|
28
38
|
} from '../../base/capability.ts'
|
|
29
39
|
|
|
30
|
-
/** The local provider
|
|
40
|
+
/** The local provider implements the same official v2 fs/process keys as SSH. */
|
|
31
41
|
export const localProviderManifest: WorkspaceProviderManifest = {
|
|
32
42
|
id: 'local',
|
|
33
43
|
version: '0.1.0',
|
|
34
|
-
apiVersion:
|
|
44
|
+
apiVersion: WORKSPACE_PROVIDER_API_VERSION,
|
|
35
45
|
displayName: 'Local disk workspace',
|
|
36
|
-
capabilities: ['workspace.fs'],
|
|
46
|
+
capabilities: ['workspace.fs', 'workspace.process'],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Canonicalize a workspace root. resolvePath() alone cannot collapse existing
|
|
51
|
+
* symlinks, so realpathSync is attempted before the lexical fallback.
|
|
52
|
+
*/
|
|
53
|
+
function canonicalRoot(root: string): string {
|
|
54
|
+
const absolute = resolvePath(root)
|
|
55
|
+
if (existsSync(absolute)) return realpathSync.native(absolute)
|
|
56
|
+
const suffix: string[] = [basename(absolute)]
|
|
57
|
+
let cursor = dirname(absolute)
|
|
58
|
+
while (!existsSync(cursor)) {
|
|
59
|
+
const parent = dirname(cursor)
|
|
60
|
+
if (parent === cursor) return absolute
|
|
61
|
+
suffix.unshift(basename(cursor))
|
|
62
|
+
cursor = parent
|
|
63
|
+
}
|
|
64
|
+
return resolvePath(realpathSync.native(cursor), ...suffix)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Root containment predicate. path.startsWith() cannot distinguish siblings
|
|
69
|
+
* such as /work/a and /work/ab, so relative() owns the boundary check.
|
|
70
|
+
*/
|
|
71
|
+
function isInsideRoot(root: string, candidate: string): boolean {
|
|
72
|
+
const rel = relative(root, candidate)
|
|
73
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reject process/fs paths outside a record root. LocalFileSystem.resolve() and
|
|
78
|
+
* LocalSubprocessRuntime.spawn() deliberately treat cwd as a default rather
|
|
79
|
+
* than a containment boundary, so neither existing method can enforce this.
|
|
80
|
+
*/
|
|
81
|
+
function assertInsideRoot(root: string, candidate: string): void {
|
|
82
|
+
const canonical = canonicalRoot(candidate)
|
|
83
|
+
if (!isInsideRoot(root, canonical)) {
|
|
84
|
+
throw new Error(`workspace.local-outside-root: '${candidate}' is outside '${root}'`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Official DSH local filesystem with the record root promoted to a hard jail. */
|
|
89
|
+
export class RootedLocalFileSystem extends LocalFileSystem {
|
|
90
|
+
readonly workspaceRoot: string
|
|
91
|
+
|
|
92
|
+
constructor(ctx: Context, root: string) {
|
|
93
|
+
const canonical = canonicalRoot(root)
|
|
94
|
+
super(ctx, { cwd: canonical, diffBasisMaxBytes: 10 * 1024 * 1024 })
|
|
95
|
+
this.workspaceRoot = canonical
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** LocalFileSystem.resolve() has no root jail, so this override adds one. */
|
|
99
|
+
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
|
|
100
|
+
if (opts?.cwd !== undefined) assertInsideRoot(this.workspaceRoot, opts.cwd)
|
|
101
|
+
const target = await super.resolve(path, { ...opts, cwd: opts?.cwd ?? this.workspaceRoot })
|
|
102
|
+
assertInsideRoot(this.workspaceRoot, this.processPath(target))
|
|
103
|
+
return target
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** LocalFileSystem.lstat() bypasses resolve(), so it needs the same explicit jail. */
|
|
107
|
+
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
|
|
108
|
+
await this.resolve(path, { cwd: opts?.cwd, signal })
|
|
109
|
+
return super.lstat(path, { cwd: opts?.cwd ?? this.workspaceRoot }, signal)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** LocalFileSystem maps every host path; a rooted capability must hide outsiders. */
|
|
113
|
+
override processPathFromHostPath(hostPath: string): string | undefined {
|
|
114
|
+
const mapped = super.processPathFromHostPath(hostPath)
|
|
115
|
+
if (mapped === undefined) return undefined
|
|
116
|
+
return isInsideRoot(this.workspaceRoot, canonicalRoot(mapped)) ? mapped : undefined
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Per-connection root guard over one shared official local subprocess runtime.
|
|
122
|
+
* LocalSubprocessRuntime cannot bind a record root and cannot tear down only
|
|
123
|
+
* one workspace's handles, so this facade adds both missing lifecycle facts.
|
|
124
|
+
*/
|
|
125
|
+
export class RootedLocalSubprocessRuntime {
|
|
126
|
+
private readonly processes = new Set<SubprocessHandle>()
|
|
127
|
+
private readonly terminals = new Set<SubprocessTerminalHandle>()
|
|
128
|
+
|
|
129
|
+
constructor(
|
|
130
|
+
private readonly runtime: LocalSubprocessRuntime,
|
|
131
|
+
readonly workspaceRoot: string,
|
|
132
|
+
) {}
|
|
133
|
+
|
|
134
|
+
/** The shared runtime already owns executable lookup; no root path is involved. */
|
|
135
|
+
resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string> {
|
|
136
|
+
return this.runtime.resolveExecutable(command, env, signal)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** LocalSubprocessRuntime.spawn() accepts any cwd, so this method fences it. */
|
|
140
|
+
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
|
141
|
+
assertInsideRoot(this.workspaceRoot, spec.cwd)
|
|
142
|
+
const handle = this.runtime.spawn(spec)
|
|
143
|
+
this.processes.add(handle)
|
|
144
|
+
// finally() would create a second rejected promise on spawn failure; both
|
|
145
|
+
// branches remove ownership without manufacturing an unhandled rejection.
|
|
146
|
+
void handle.done.then(
|
|
147
|
+
() => this.processes.delete(handle),
|
|
148
|
+
() => this.processes.delete(handle),
|
|
149
|
+
)
|
|
150
|
+
return handle
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** LocalSubprocessRuntime.spawnTerminal() accepts any cwd, so this method fences it. */
|
|
154
|
+
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
|
|
155
|
+
assertInsideRoot(this.workspaceRoot, spec.cwd)
|
|
156
|
+
const handle = await this.runtime.spawnTerminal(spec)
|
|
157
|
+
this.terminals.add(handle)
|
|
158
|
+
// Same rejection-safe ownership cleanup as spawn(): the caller still owns
|
|
159
|
+
// the authoritative done rejection.
|
|
160
|
+
void handle.done.then(
|
|
161
|
+
() => this.terminals.delete(handle),
|
|
162
|
+
() => this.terminals.delete(handle),
|
|
163
|
+
)
|
|
164
|
+
return handle
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The shared runtime cannot close one workspace, so terminate only owned handles. */
|
|
168
|
+
async close(): Promise<void> {
|
|
169
|
+
const processes = [...this.processes]
|
|
170
|
+
const terminals = [...this.terminals]
|
|
171
|
+
for (const handle of processes) handle.terminate()
|
|
172
|
+
await Promise.allSettled([
|
|
173
|
+
...processes.map(handle => handle.waitForExit()),
|
|
174
|
+
...terminals.map(handle => handle.terminate()),
|
|
175
|
+
])
|
|
176
|
+
this.processes.clear()
|
|
177
|
+
this.terminals.clear()
|
|
178
|
+
}
|
|
37
179
|
}
|
|
38
180
|
|
|
39
|
-
/** One open local workspace connection. */
|
|
181
|
+
/** One open local workspace connection with identity-cached capabilities. */
|
|
40
182
|
export class LocalWorkspaceConnection implements WorkspaceConnection {
|
|
41
183
|
readonly providerId = 'local'
|
|
184
|
+
private state: 'connecting' | 'ready' | 'degraded' | 'closed' = 'ready'
|
|
185
|
+
private fsInstance: FileSystem | WorkspaceFileSystem | undefined
|
|
186
|
+
private processInstance: RootedLocalSubprocessRuntime | WorkspaceProcessRuntime | undefined
|
|
42
187
|
|
|
43
188
|
constructor(
|
|
44
189
|
readonly workspaceId: string,
|
|
45
190
|
private readonly root: string,
|
|
191
|
+
private readonly cordisContext?: Context,
|
|
192
|
+
private readonly officialProcess?: LocalSubprocessRuntime,
|
|
46
193
|
) {}
|
|
47
194
|
|
|
195
|
+
/** A static manifest cannot return instances, so get() lazily caches each capability. */
|
|
48
196
|
get<K extends keyof WorkspaceCapabilityMap>(capability: K): WorkspaceCapabilityMap[K] | undefined {
|
|
49
|
-
if (
|
|
197
|
+
if (this.state === 'closed') return undefined
|
|
198
|
+
if (capability === 'workspace.fs') return this.fs() as WorkspaceCapabilityMap[K]
|
|
199
|
+
if (capability === 'workspace.process') return this.process() as WorkspaceCapabilityMap[K]
|
|
50
200
|
return undefined
|
|
51
201
|
}
|
|
52
202
|
|
|
203
|
+
/** The old path-style wrapper is not a DSH FileSystem, so Cordis uses the official rooted backend. */
|
|
204
|
+
private fs(): FileSystem | WorkspaceFileSystem {
|
|
205
|
+
if (this.fsInstance === undefined) {
|
|
206
|
+
this.fsInstance = this.cordisContext === undefined
|
|
207
|
+
? new LocalWorkspaceFileSystem(this.root)
|
|
208
|
+
: new RootedLocalFileSystem(this.cordisContext.isolate('fs'), this.root)
|
|
209
|
+
}
|
|
210
|
+
return this.fsInstance
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The fs capability cannot spawn processes, so process has its own official rooted facade. */
|
|
214
|
+
private process(): RootedLocalSubprocessRuntime | WorkspaceProcessRuntime {
|
|
215
|
+
if (this.processInstance === undefined) {
|
|
216
|
+
this.processInstance = this.officialProcess === undefined
|
|
217
|
+
? new LocalWorkspaceProcess(this.root)
|
|
218
|
+
: new RootedLocalSubprocessRuntime(this.officialProcess, canonicalRoot(this.root))
|
|
219
|
+
}
|
|
220
|
+
return this.processInstance
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Same honesty rule as the SSH connection: only states this object owns. */
|
|
53
224
|
status(): 'connecting' | 'ready' | 'degraded' | 'closed' {
|
|
54
|
-
return
|
|
225
|
+
return this.state
|
|
55
226
|
}
|
|
56
227
|
|
|
228
|
+
/** Dropping references alone cannot stop live local processes, so close the rooted facade first. */
|
|
57
229
|
async close(): Promise<void> {
|
|
58
|
-
|
|
230
|
+
if (this.state === 'closed') return
|
|
231
|
+
this.state = 'closed'
|
|
232
|
+
if (this.processInstance instanceof RootedLocalSubprocessRuntime) await this.processInstance.close()
|
|
233
|
+
this.fsInstance = undefined
|
|
234
|
+
this.processInstance = undefined
|
|
59
235
|
}
|
|
60
236
|
}
|
|
61
237
|
|
|
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
|
-
*/
|
|
238
|
+
/** Standalone path-style filesystem fallback for consumers without Cordis. */
|
|
69
239
|
export class LocalWorkspaceFileSystem implements WorkspaceFileSystem {
|
|
70
240
|
constructor(private readonly root: string) {}
|
|
71
241
|
|
|
72
|
-
/**
|
|
242
|
+
/** Node fs accepts arbitrary paths, so every fallback operation resolves through this jail. */
|
|
73
243
|
private abs(path: string): string {
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
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
|
-
}
|
|
244
|
+
const rel = path.startsWith('\\') || path.startsWith('/') ? path.slice(1) : path
|
|
245
|
+
const root = canonicalRoot(this.root)
|
|
246
|
+
const absolute = canonicalRoot(resolvePath(root, rel))
|
|
247
|
+
if (!isInsideRoot(root, absolute)) throw new Error(`path escapes workspace root: '${path}'`)
|
|
83
248
|
return absolute
|
|
84
249
|
}
|
|
85
250
|
|
|
86
251
|
async stat(path: string, signal?: AbortSignal): Promise<WorkspaceStat | undefined> {
|
|
87
|
-
|
|
252
|
+
signal?.throwIfAborted()
|
|
88
253
|
try {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
254
|
+
const result = toStat(await fsStat(this.abs(path)))
|
|
255
|
+
signal?.throwIfAborted()
|
|
256
|
+
return result
|
|
257
|
+
} catch (error) {
|
|
258
|
+
signal?.throwIfAborted()
|
|
259
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
260
|
+
throw error
|
|
93
261
|
}
|
|
94
262
|
}
|
|
95
263
|
|
|
96
264
|
async list(path: string, signal?: AbortSignal): Promise<WorkspaceDirEntry[]> {
|
|
265
|
+
signal?.throwIfAborted()
|
|
97
266
|
const absolute = this.abs(path)
|
|
98
|
-
const names = await readdir(absolute)
|
|
99
267
|
const entries: WorkspaceDirEntry[] = []
|
|
100
|
-
for (const name of
|
|
101
|
-
|
|
268
|
+
for (const name of await readdir(absolute)) {
|
|
269
|
+
signal?.throwIfAborted()
|
|
102
270
|
try {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
size: info.size,
|
|
108
|
-
mtimeMs: info.mtimeMs,
|
|
109
|
-
})
|
|
110
|
-
} catch {
|
|
111
|
-
// Unreadable entry: skip rather than fail the whole listing.
|
|
112
|
-
}
|
|
271
|
+
const candidate = this.abs(join(path, name))
|
|
272
|
+
const info = await fsLstat(candidate)
|
|
273
|
+
entries.push({ name, type: info.isDirectory() ? 'dir' : info.isFile() ? 'file' : 'other', size: info.size, mtimeMs: info.mtimeMs })
|
|
274
|
+
} catch { /* Skip a vanished or outside-root symlink entry. */ }
|
|
113
275
|
}
|
|
276
|
+
signal?.throwIfAborted()
|
|
114
277
|
return entries
|
|
115
278
|
}
|
|
116
279
|
|
|
117
|
-
async readFile(path: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void> {
|
|
122
|
-
await writeFile(this.abs(path), data)
|
|
123
|
-
}
|
|
124
|
-
|
|
280
|
+
async readFile(path: string, signal?: AbortSignal): Promise<Uint8Array> { return readFile(this.abs(path), { signal }) }
|
|
281
|
+
async writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void> { await writeFile(this.abs(path), data, { signal }) }
|
|
125
282
|
async mkdir(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
|
|
126
|
-
await mkdir(this.abs(path), { recursive: options?.recursive ?? false })
|
|
283
|
+
options?.signal?.throwIfAborted(); await mkdir(this.abs(path), { recursive: options?.recursive ?? false }); options?.signal?.throwIfAborted()
|
|
127
284
|
}
|
|
128
|
-
|
|
129
285
|
async rm(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void> {
|
|
130
|
-
await rm(this.abs(path), { recursive: options?.recursive ?? false, force: false })
|
|
286
|
+
options?.signal?.throwIfAborted(); await rm(this.abs(path), { recursive: options?.recursive ?? false, force: false }); options?.signal?.throwIfAborted()
|
|
131
287
|
}
|
|
132
|
-
|
|
133
288
|
async rename(from: string, to: string, signal?: AbortSignal): Promise<void> {
|
|
134
|
-
await rename(this.abs(from), this.abs(to))
|
|
289
|
+
signal?.throwIfAborted(); await rename(this.abs(from), this.abs(to)); signal?.throwIfAborted()
|
|
135
290
|
}
|
|
136
291
|
|
|
137
|
-
/**
|
|
292
|
+
/** WorkspaceProvider.open() cannot infer root availability from stat(undefined), so expose this probe. */
|
|
138
293
|
async accessible(): Promise<boolean> {
|
|
139
|
-
try {
|
|
140
|
-
await access(this.root, fsConstants.R_OK)
|
|
141
|
-
return true
|
|
142
|
-
} catch {
|
|
143
|
-
return false
|
|
144
|
-
}
|
|
294
|
+
try { await access(this.root, fsConstants.R_OK); return true } catch { return false }
|
|
145
295
|
}
|
|
146
296
|
}
|
|
147
297
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
298
|
+
/** Standalone process fallback for base-only consumers without a Cordis runtime. */
|
|
299
|
+
export class LocalWorkspaceProcess implements WorkspaceProcessRuntime {
|
|
300
|
+
private readonly root: string
|
|
301
|
+
|
|
302
|
+
constructor(root: string) { this.root = canonicalRoot(root) }
|
|
303
|
+
|
|
304
|
+
/** LocalSubprocessRuntime needs Cordis, so standalone exec uses node:child_process with the same root jail. */
|
|
305
|
+
async exec(command: string, options?: { timeoutMs?: number; cwd?: string; signal?: AbortSignal }): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut?: boolean; durationMs?: number }> {
|
|
306
|
+
const cwd = resolvePath(this.root, options?.cwd ?? '')
|
|
307
|
+
assertInsideRoot(this.root, cwd)
|
|
308
|
+
const started = Date.now()
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
const child = execChild(command, { cwd, timeout: options?.timeoutMs, signal: options?.signal }, (error, stdout, stderr) => {
|
|
311
|
+
const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1
|
|
312
|
+
resolve({ stdout, stderr, exitCode: code, timedOut: error?.killed === true, durationMs: Date.now() - started })
|
|
313
|
+
})
|
|
314
|
+
child.once('error', reject)
|
|
315
|
+
})
|
|
155
316
|
}
|
|
156
317
|
}
|
|
157
318
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
319
|
+
/** fs.Stats has provider-specific methods, so normalize only stable metadata. */
|
|
320
|
+
function toStat(info: { isDirectory(): boolean; isFile(): boolean; size: number; mtimeMs: number; mode: number }): WorkspaceStat {
|
|
321
|
+
return { type: info.isDirectory() ? 'dir' : info.isFile() ? 'file' : 'other', size: info.size, mtimeMs: info.mtimeMs, mode: info.mode }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The local provider factory; Context is optional only for standalone base consumers/tests. */
|
|
325
|
+
export function createLocalWorkspaceProvider(context?: Context): WorkspaceProvider {
|
|
326
|
+
let sharedProcess: LocalSubprocessRuntime | undefined
|
|
327
|
+
/** Creating one runtime per connection leaks Cordis services; this factory-owned instance is shared safely. */
|
|
328
|
+
const processRuntime = (): LocalSubprocessRuntime | undefined => {
|
|
329
|
+
if (context === undefined) return undefined
|
|
330
|
+
sharedProcess ??= new LocalSubprocessRuntime(context.isolate('subprocess'))
|
|
331
|
+
return sharedProcess
|
|
332
|
+
}
|
|
333
|
+
|
|
160
334
|
return {
|
|
161
335
|
manifest: localProviderManifest,
|
|
162
336
|
validate(record: WorkspaceRecord): void {
|
|
163
337
|
if (record.provider.id !== 'local') throw new Error('not a local workspace record')
|
|
164
338
|
if (record.location.root === '') throw new Error('local workspace record is missing location.root')
|
|
165
339
|
},
|
|
166
|
-
async open(record: WorkspaceRecord,
|
|
340
|
+
async open(record: WorkspaceRecord, openContext?: WorkspaceOpenContext): Promise<WorkspaceConnection> {
|
|
167
341
|
const fs = new LocalWorkspaceFileSystem(record.location.root)
|
|
168
|
-
if (!(await fs.accessible()))
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
return new LocalWorkspaceConnection(record.id, record.location.root)
|
|
342
|
+
if (!(await fs.accessible())) openContext?.logger?.warn?.('local workspace root is not accessible:', record.location.root)
|
|
343
|
+
return new LocalWorkspaceConnection(record.id, record.location.root, context, processRuntime())
|
|
172
344
|
},
|
|
173
345
|
}
|
|
174
346
|
}
|
|
175
347
|
|
|
176
|
-
/**
|
|
348
|
+
/** basename() alone returns empty for filesystem roots, so retain the root as fallback. */
|
|
177
349
|
export function localDefaultTitle(root: string): string {
|
|
178
350
|
return basename(root) || root
|
|
179
|
-
}
|
|
351
|
+
}
|