@tiphareth/dsh-hardssh 0.1.2 → 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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +58 -25
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -1,18 +1,27 @@
1
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.
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 { constants as fsConstants } from 'node:fs'
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, join, parse as parsePath, resolve as resolvePath } from 'node:path'
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 manifest. */
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: 1,
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 (capability === 'workspace.fs') return new LocalWorkspaceFileSystem(this.root) as unknown as WorkspaceCapabilityMap[K]
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 'ready'
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
- // Nothing to release (no pool, no leases).
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
- /** Resolve a workspace-relative path into an absolute path inside root. */
242
+ /** Node fs accepts arbitrary paths, so every fallback operation resolves through this jail. */
73
243
  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
- }
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
- const absolute = this.abs(path)
252
+ signal?.throwIfAborted()
88
253
  try {
89
- const info = await fsStat(absolute)
90
- return toStat(info)
91
- } catch {
92
- return undefined
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 names) {
101
- const child = join(absolute, name)
268
+ for (const name of await readdir(absolute)) {
269
+ signal?.throwIfAborted()
102
270
  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
- }
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
- 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
-
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
- /** Convenience: check the root exists and is readable (for open()). */
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
- /** 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,
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
- /** The local provider factory. */
159
- export function createLocalWorkspaceProvider(): WorkspaceProvider {
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, context?: WorkspaceOpenContext): Promise<WorkspaceConnection> {
340
+ async open(record: WorkspaceRecord, openContext?: WorkspaceOpenContext): Promise<WorkspaceConnection> {
167
341
  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)
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
- /** Helper: title mostly used by record factories. */
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
+ }