dsh-remote-workspaces 0.1.0

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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * SshShellExecutor — the plugin's `ctx.shell` provider (structural, not a
3
+ * harness `ShellExecutor` subclass, so the bundle resolves with no harness
4
+ * import and Cordis does not dual-package).
5
+ *
6
+ * Routes by the resolved workdir: `ssh://[user@]host[:port]/path` workdirs
7
+ * execute on the remote over ssh2 `exec`; ordinary local workdirs execute
8
+ * locally through `ctx.subprocess` + `ctx.sandbox` (the same confinement the
9
+ * harness's `bash-sandbox`/`pwsh-sandbox` apply).
10
+ */
11
+
12
+ import { SshClient, shellQuote } from './transport.js'
13
+ import { isRemoteCwd, parseSshUri } from './ssh-uri.js'
14
+ import { findByCwd } from './registry.js'
15
+ import { lstatSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+ const ENV_OVERRIDES = { NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' }
19
+ const DEFAULT_TIMEOUT_MS = 120_000
20
+ const MAX_TIMEOUT_MS = 600_000
21
+ const DEFAULT_STDOUT_MAX_BYTES = 64_000
22
+ const DEFAULT_STDERR_MAX_BYTES = 64_000
23
+ const SPILL_MAX_BYTES = 64 * 1024 * 1024
24
+ const GRACE_MS = 3_000
25
+
26
+ function clamp(value, fallback, max) {
27
+ const v = value === undefined ? fallback : value
28
+ if (!Number.isFinite(v) || v <= 0) return fallback
29
+ return Math.min(v, max)
30
+ }
31
+
32
+ /** Fused timeout + cancellation deadline (mirrors the harness's `deadline`). */
33
+ function makeDeadline(signal, timeoutMs) {
34
+ const ac = new AbortController()
35
+ let timedOut = false
36
+ const timer = setTimeout(() => { timedOut = true; ac.abort() }, timeoutMs)
37
+ const onAbort = () => { if (!timedOut) ac.abort(signal.reason) }
38
+ if (signal !== undefined) {
39
+ if (signal.aborted) onAbort()
40
+ else signal.addEventListener('abort', onAbort, { once: true })
41
+ }
42
+ return {
43
+ signal: ac.signal,
44
+ timedOut: () => timedOut,
45
+ dispose() {
46
+ clearTimeout(timer)
47
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
48
+ },
49
+ }
50
+ }
51
+
52
+ function matchesSignature(exitCode, stderr, signatures) {
53
+ if (exitCode === null || exitCode === 0) return false
54
+ const lowered = String(stderr).toLowerCase()
55
+ return signatures.some((s) => lowered.includes(String(s).toLowerCase()))
56
+ }
57
+
58
+ function messageOf(error) {
59
+ return error instanceof Error ? error.message : String(error)
60
+ }
61
+
62
+ /**
63
+ * Resolve the pwsh executable (mirrors the harness's `resolvePwshPath`): the
64
+ * Windows ACL runner needs a full path (a bare `pwsh` fails CreateProcessAsUser
65
+ * with Win32 error 2), so probe PowerShell 7, PATH entries, then PowerShell 5.1.
66
+ */
67
+ function resolvePwshPath() {
68
+ const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files'
69
+ const systemRoot = process.env.SystemRoot ?? 'C:\\Windows'
70
+ const candidates = [join(programFiles, 'PowerShell', '7', 'pwsh.exe')]
71
+ for (const entry of (process.env.PATH ?? '').split(';')) {
72
+ const trimmed = entry.trim().replace(/^"|"$/g, '')
73
+ if (trimmed.length > 0) candidates.push(join(trimmed, 'pwsh.exe'))
74
+ }
75
+ candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
76
+ for (const candidate of candidates) {
77
+ try {
78
+ const st = lstatSync(candidate)
79
+ if (st.isFile() || st.isSymbolicLink()) return candidate
80
+ } catch {}
81
+ }
82
+ return 'pwsh'
83
+ }
84
+
85
+ /** Parse a resolved remote workdir (`ssh://…`) into connection parts. */
86
+ function parseRemoteWorkdir(workdir) {
87
+ const parsed = parseSshUri(workdir)
88
+ if (parsed === null) return null
89
+ return { host: parsed.host, user: parsed.user, port: parsed.port, path: parsed.path }
90
+ }
91
+
92
+ export class SshShellExecutor {
93
+ constructor({ clientForRemote, getPolicy, getSandbox, getSubprocess }) {
94
+ this.clientForRemote = clientForRemote
95
+ this.getPolicy = getPolicy
96
+ this.getSandbox = getSandbox
97
+ this.getSubprocess = getSubprocess
98
+ }
99
+
100
+ /** Local half confines under workspace-write; the tool wires per-session policy. */
101
+ get sandboxMode() {
102
+ return 'workspace-write'
103
+ }
104
+
105
+ /**
106
+ * Translate the workdir into the execution world: a registered anchor (or
107
+ * `ssh://` URI) becomes an `ssh://host/remotepath` URI; local paths pass
108
+ * through. The URI prefix is the run/start routing marker.
109
+ */
110
+ translateWorkdir(workdir) {
111
+ if (typeof workdir !== 'string' || workdir === '') return workdir
112
+ if (isRemoteCwd(workdir)) return workdir
113
+ const hit = findByCwd(workdir)
114
+ if (hit === undefined) return workdir
115
+ const path = hit.remoteSubpath === '' ? hit.remotePath : `${hit.remotePath.replace(/\/+$/, '')}/${hit.remoteSubpath}`
116
+ return `ssh://${hit.user ? `${hit.user}@` : ''}${hit.host}${hit.port ? `:${hit.port}` : ''}${path}`
117
+ }
118
+
119
+ resolve(request) {
120
+ const timeoutMs = clamp(request.timeoutMs, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
121
+ const stdoutMaxBytes = request.stdoutMaxBytes ?? DEFAULT_STDOUT_MAX_BYTES
122
+ return {
123
+ command: request.command,
124
+ workdir: this.translateWorkdir(request.workdir ?? process.cwd()),
125
+ timeoutMs,
126
+ stdoutMaxBytes,
127
+ ...(request.signal ? { signal: request.signal } : {}),
128
+ ...(request.stdin !== undefined ? { stdin: request.stdin } : {}),
129
+ ...(request.env !== undefined ? { env: request.env } : {}),
130
+ ...(request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}),
131
+ sandboxPolicy: request.sandboxPolicy,
132
+ }
133
+ }
134
+
135
+ async run(spec) {
136
+ if (isRemoteCwd(spec.workdir)) return this.remoteRun(spec)
137
+ return this.localRun(spec)
138
+ }
139
+
140
+ start(spec) {
141
+ if (isRemoteCwd(spec.workdir)) return this.remoteStart(spec)
142
+ return this.localStart(spec)
143
+ }
144
+
145
+ // -------------------------------------------------------------------------
146
+ // Remote (ssh2 exec)
147
+ // -------------------------------------------------------------------------
148
+ clientFor(spec) {
149
+ const parsed = parseRemoteWorkdir(spec.workdir)
150
+ if (parsed === null) throw new Error(`cannot parse remote workdir "${spec.workdir}"`)
151
+ return {
152
+ client: this.clientForRemote(parsed.host, parsed.user, parsed.port),
153
+ path: parsed.path,
154
+ }
155
+ }
156
+
157
+ /**
158
+ * A remote command refused because the policy cannot be enforced on the
159
+ * remote host: `read-only` and `workspace-write` would both be bypassed by
160
+ * arbitrary remote code (there is no directory-level sandbox on the remote),
161
+ * so under either mode the command does not run. The result reports the
162
+ * shared sandbox denial (the tool layer turns it into the `[sandbox: …]`
163
+ * marker + escalation hint), and the command can still run after the user
164
+ * approves a `sandbox_permissions: danger-full-access` escalation.
165
+ */
166
+ deniedRemoteResult(spec, mode) {
167
+ return {
168
+ exitCode: 1, signal: null, timedOut: false, aborted: false,
169
+ timeoutMs: spec.timeoutMs,
170
+ stdout: { text: '', truncated: false },
171
+ stderr: { text: '', truncated: false },
172
+ sandbox: { mode, denied: true },
173
+ }
174
+ }
175
+
176
+ async remoteRun(spec) {
177
+ const policy = this.policy(spec)
178
+ if (policy !== undefined && policy.mode !== 'danger-full-access') {
179
+ return this.deniedRemoteResult(spec, policy.mode)
180
+ }
181
+ const { client, path } = this.clientFor(spec)
182
+ const result = await client.execShell(spec.command, {
183
+ cwd: path,
184
+ timeoutMs: spec.timeoutMs,
185
+ stdoutMaxBytes: spec.stdoutMaxBytes,
186
+ stderrMaxBytes: DEFAULT_STDERR_MAX_BYTES,
187
+ ...(spec.stdin !== undefined ? { stdin: spec.stdin } : {}),
188
+ ...(spec.signal !== undefined ? { signal: spec.signal } : {}),
189
+ })
190
+ if (!result.ok) {
191
+ return {
192
+ exitCode: null, signal: null, timedOut: false, aborted: spec.signal?.aborted === true,
193
+ timeoutMs: spec.timeoutMs,
194
+ stdout: { text: '', truncated: false },
195
+ stderr: { text: result.error ?? '', truncated: false },
196
+ }
197
+ }
198
+ return {
199
+ exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, aborted: result.aborted,
200
+ timeoutMs: spec.timeoutMs, stdout: result.stdout, stderr: result.stderr,
201
+ sandbox: { mode: 'danger-full-access', denied: false },
202
+ }
203
+ }
204
+
205
+ remoteStart(spec) {
206
+ const policy = this.policy(spec)
207
+ if (policy !== undefined && policy.mode !== 'danger-full-access') {
208
+ return {
209
+ status: 'completed',
210
+ exitCode: 1,
211
+ signal: null,
212
+ sandbox: { mode: policy.mode, denied: true },
213
+ readOutput() { return { delta: '', lossy: false } },
214
+ kill() { return false },
215
+ done: Promise.resolve(),
216
+ }
217
+ }
218
+ const parsed = parseRemoteWorkdir(spec.workdir)
219
+ const client = this.clientForRemote(parsed.host, parsed.user, parsed.port)
220
+ const path = parsed.path
221
+ const log = `${path.replace(/\/+$/, '')}/.dsh-bg-${Date.now()}-${Math.floor(Math.random() * 1e6)}.log`
222
+ const launchScript = `cd ${shellQuote(path)} || exit 1\nnohup sh -c ${shellQuote(spec.command)} > ${shellQuote(log)} 2>&1 &\necho $!`
223
+
224
+ let pid = null
225
+ let offset = 0
226
+ let buffer = ''
227
+ let spawnError
228
+ let pollTimer
229
+
230
+ const proc = {
231
+ status: 'running',
232
+ exitCode: null,
233
+ signal: null,
234
+ sandbox: { mode: 'danger-full-access', denied: false },
235
+ readOutput() {
236
+ const delta = buffer.slice(offset)
237
+ offset = buffer.length
238
+ return { delta, lossy: false }
239
+ },
240
+ kill() {
241
+ if (proc.status !== 'running') return false
242
+ proc.status = 'killed'
243
+ if (pid !== null) void client.run(`kill ${pid} 2>/dev/null || true`)
244
+ return true
245
+ },
246
+ done: (async () => {
247
+ const launched = await client.run(launchScript)
248
+ if (!launched.ok) {
249
+ spawnError = new Error((launched.stderr ?? '').trim() || launched.error || 'background spawn failed')
250
+ proc.status = 'killed'
251
+ return
252
+ }
253
+ const parsedPid = Number((launched.stdout ?? '').trim())
254
+ if (!Number.isFinite(parsedPid) || parsedPid <= 0) {
255
+ spawnError = new Error('background spawn failed: no pid')
256
+ proc.status = 'killed'
257
+ return
258
+ }
259
+ pid = parsedPid
260
+ // Poll the log into the buffer (readOutput stays synchronous).
261
+ const poll = async () => {
262
+ if (proc.status !== 'running') return
263
+ const tail = await client.run(`tail -c +${offset + 1} ${shellQuote(log)} 2>/dev/null || true`)
264
+ if (tail.ok && tail.stdout) { buffer += tail.stdout; }
265
+ pollTimer = setTimeout(poll, 500)
266
+ }
267
+ pollTimer = setTimeout(poll, 500)
268
+ // Poll pid liveness until it exits.
269
+ for (;;) {
270
+ const alive = await client.run(`kill -0 ${pid} 2>/dev/null && echo yes || echo no`)
271
+ if (alive.stdout?.trim() === 'no' || proc.status !== 'running') break
272
+ await new Promise((r) => setTimeout(r, 500))
273
+ }
274
+ if (proc.status === 'running') proc.status = 'completed'
275
+ clearTimeout(pollTimer)
276
+ })().catch((error) => {
277
+ spawnError = error
278
+ proc.status = 'killed'
279
+ }),
280
+ }
281
+ return proc
282
+ }
283
+
284
+ // -------------------------------------------------------------------------
285
+ // Local (ctx.subprocess + ctx.sandbox confine)
286
+ // -------------------------------------------------------------------------
287
+ argv(spec) {
288
+ return process.platform === 'win32'
289
+ ? [resolvePwshPath(), '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', spec.command]
290
+ : ['bash', '-c', spec.command]
291
+ }
292
+
293
+ confine(argv, policy) {
294
+ if (policy === undefined || policy.mode === 'danger-full-access') {
295
+ return { argv, enforcement: undefined, denialSignatures: [] }
296
+ }
297
+ const sandbox = this.getSandbox()
298
+ if (!sandbox) throw new Error('sandbox backend unavailable: refusing to run unconfined')
299
+ return sandbox.confine(argv, {
300
+ mode: policy.mode,
301
+ workspaceRoot: policy.workspaceRoot,
302
+ ...(policy.sessionId !== undefined ? { sessionId: policy.sessionId } : {}),
303
+ })
304
+ }
305
+
306
+ policy(spec) {
307
+ return spec.sandboxPolicy ?? this.getPolicy()?.resolve?.()
308
+ }
309
+
310
+ spawnSpec(spec, argv, stdoutMaxBytes, signal) {
311
+ const collect = (maxBytes) => ({ maxBytes, spill: { maxBytes: SPILL_MAX_BYTES } })
312
+ return {
313
+ argv,
314
+ cwd: spec.workdir,
315
+ stdio: {
316
+ stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
317
+ stdout: collect(stdoutMaxBytes),
318
+ stderr: collect(DEFAULT_STDERR_MAX_BYTES),
319
+ },
320
+ graceMs: GRACE_MS,
321
+ signal,
322
+ env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
323
+ }
324
+ }
325
+
326
+ async localRun(spec) {
327
+ const policy = this.policy(spec)
328
+ const confined = this.confine(this.argv(spec), policy)
329
+ const subprocess = this.getSubprocess()
330
+ if (!subprocess) throw new Error('subprocess service unavailable')
331
+ const d = makeDeadline(spec.signal, spec.timeoutMs)
332
+ let handle
333
+ try {
334
+ handle = subprocess.spawn(this.spawnSpec(spec, confined.argv, spec.stdoutMaxBytes, d.signal))
335
+ } catch (error) {
336
+ d.dispose()
337
+ throw new Error(`sandbox runner failed to start: ${messageOf(error)}`)
338
+ }
339
+ const outcome = await handle.done
340
+ const timedOut = d.timedOut()
341
+ const aborted = d.signal.aborted && !timedOut
342
+ d.dispose()
343
+ const { stdout, stderr } = handle.collected
344
+ return {
345
+ ...outcome,
346
+ timedOut,
347
+ aborted,
348
+ timeoutMs: spec.timeoutMs,
349
+ stdout: stdout ? this.finalOutput(stdout) : { text: '', truncated: false },
350
+ stderr: stderr ? this.finalOutput(stderr) : { text: '', truncated: false },
351
+ ...(policy !== undefined && policy.mode !== 'danger-full-access' ? {
352
+ sandbox: {
353
+ mode: policy.mode,
354
+ denied: matchesSignature(outcome.exitCode, stderr?.readFrom(0).text ?? '', confined.denialSignatures),
355
+ enforcement: confined.enforcement,
356
+ },
357
+ } : {}),
358
+ }
359
+ }
360
+
361
+ finalOutput(reader) {
362
+ const read = reader.readFrom(0)
363
+ return { text: read.text, truncated: read.lossy, ...(read.spillPath !== undefined ? { spillPath: read.spillPath } : {}) }
364
+ }
365
+
366
+ localStart(spec) {
367
+ const policy = this.policy(spec)
368
+ const confined = this.confine(this.argv(spec), policy)
369
+ const subprocess = this.getSubprocess()
370
+ if (!subprocess) throw new Error('subprocess service unavailable')
371
+ const running = subprocess.spawn(this.spawnSpec(spec, confined.argv, DEFAULT_STDOUT_MAX_BYTES, spec.signal))
372
+ const { stdout, stderr } = running.collected
373
+ let spawnFailureNote
374
+ const consumeSpawnFailure = () => { const n = spawnFailureNote ?? ''; spawnFailureNote = undefined; return n }
375
+ let outOffset = 0
376
+ let errOffset = 0
377
+ const proc = {
378
+ status: 'running',
379
+ exitCode: null,
380
+ signal: null,
381
+ done: running.done.then((outcome) => {
382
+ if (proc.status === 'running') {
383
+ proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
384
+ }
385
+ proc.exitCode = outcome.exitCode
386
+ proc.signal = outcome.signal
387
+ if (policy !== undefined && policy.mode !== 'danger-full-access') {
388
+ proc.sandbox = {
389
+ mode: policy.mode,
390
+ denied: matchesSignature(outcome.exitCode, stderr?.readFrom(0).text ?? '', confined.denialSignatures),
391
+ enforcement: confined.enforcement,
392
+ }
393
+ }
394
+ }, (error) => {
395
+ proc.status = 'killed'
396
+ spawnFailureNote = `spawn failed: ${messageOf(error)}`
397
+ }),
398
+ readOutput() {
399
+ const out = stdout ? stdout.readFrom(outOffset) : { text: '', nextOffset: 0, lossy: false }
400
+ const err = stderr ? stderr.readFrom(errOffset) : { text: '', nextOffset: 0, lossy: false }
401
+ outOffset = out.nextOffset
402
+ errOffset = err.nextOffset
403
+ const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
404
+ const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
405
+ return {
406
+ delta: out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : ''),
407
+ lossy: out.lossy || err.lossy,
408
+ ...(out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}),
409
+ ...(err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {}),
410
+ }
411
+ },
412
+ kill() {
413
+ if (proc.status !== 'running') return false
414
+ proc.status = 'killed'
415
+ running.terminate()
416
+ return true
417
+ },
418
+ }
419
+ return proc
420
+ }
421
+ }
422
+
423
+ export default SshShellExecutor
@@ -0,0 +1,52 @@
1
+ import { homedir } from 'node:os'
2
+
3
+ /**
4
+ * Expand a leading `~` against the home directory (OpenSSH `~` expansion).
5
+ */
6
+ export function expandTilde(value, home = homedir()) {
7
+ if (value === '~') return home
8
+ if (value.startsWith('~/') || value.startsWith('~\\')) return home + value.slice(1)
9
+ return value
10
+ }
11
+
12
+ /**
13
+ * Parse an OpenSSH client config file into host blocks.
14
+ *
15
+ * Handles the subset SSH remote workspaces needs: `Host` (first alias),
16
+ * `HostName`, `User`, `Port`, `IdentityFile`, plus a passthrough options list
17
+ * for everything else. Wildcard/pattern hosts and `Include` are intentionally
18
+ * out of scope for the P0 prototype.
19
+ *
20
+ * @param {string} text - raw `~/.ssh/config` content.
21
+ * @param {string} [home] - home directory used for `~` expansion.
22
+ * @returns {Array<{alias:string, host:string|null, user:string|null, port:number|null, identityFile:string|null, options:Array<{key:string,value:string}>}>}
23
+ */
24
+ export function parseSshConfig(text, home = homedir()) {
25
+ const hosts = []
26
+ let current = null
27
+ const flush = () => {
28
+ if (current !== null && current.alias) hosts.push(current)
29
+ current = null
30
+ }
31
+ for (const raw of text.split(/\r?\n/)) {
32
+ const line = raw.trim()
33
+ if (line === '' || line.startsWith('#')) continue
34
+ const match = /^(\S+)\s+(.*)$/.exec(line)
35
+ if (match === null) continue
36
+ const key = match[1].toLowerCase()
37
+ const value = match[2].trim()
38
+ if (key === 'host') {
39
+ flush()
40
+ const alias = value.split(/\s+/)[0]
41
+ current = { alias, host: null, user: null, port: null, identityFile: null, options: [] }
42
+ } else if (current !== null) {
43
+ if (key === 'hostname') current.host = value
44
+ else if (key === 'user') current.user = value
45
+ else if (key === 'port') current.port = Number.parseInt(value, 10)
46
+ else if (key === 'identityfile') current.identityFile = expandTilde(value, home)
47
+ else current.options.push({ key, value })
48
+ }
49
+ }
50
+ flush()
51
+ return hosts
52
+ }
package/src/ssh-uri.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Parse an `ssh://[user@]host[:port]/abs/path` session cwd into its parts.
3
+ * Returns null when the value is not a remote workspace URI.
4
+ */
5
+ export function parseSshUri(uri) {
6
+ const match = /^ssh:\/\/(?:([^@/]+)@)?([^/:]+)(?::(\d+))?(\/.*)$/.exec(String(uri))
7
+ if (match === null) return null
8
+ return {
9
+ user: match[1] || undefined,
10
+ host: match[2],
11
+ port: match[3] ? Number(match[3]) : undefined,
12
+ path: match[4],
13
+ }
14
+ }
15
+
16
+ /** True when a session cwd denotes a remote workspace. */
17
+ export function isRemoteCwd(cwd) {
18
+ return typeof cwd === 'string' && cwd.startsWith('ssh://')
19
+ }