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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +25 -0
- package/package.json +44 -0
- package/src/anchor.js +67 -0
- package/src/client.js +756 -0
- package/src/containment.js +71 -0
- package/src/errors.js +17 -0
- package/src/fs-sftp.js +231 -0
- package/src/index.js +327 -0
- package/src/local-backend.js +224 -0
- package/src/machine-store.js +238 -0
- package/src/registry.js +76 -0
- package/src/routing-fs.js +230 -0
- package/src/search.js +286 -0
- package/src/shell-exec.js +423 -0
- package/src/ssh-config.js +52 -0
- package/src/ssh-uri.js +19 -0
- package/src/transport.js +401 -0
|
@@ -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
|
+
}
|