|
|
@@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
import { dirname, join } from 'node:path'
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
-import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
|
|
|
-import { killGroup, OutputCollector, runBash } from '../src/run.ts'
|
|
|
-import type { RunningBash } from '../src/run.ts'
|
|
|
+import type { DshEnvironment } from '@deepseek-ai/dsh-process'
|
|
|
+import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts'
|
|
|
+import type { ProcessHandle } from '@deepseek-ai/dsh-process'
|
|
|
|
|
|
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
|
|
failNextClose: { value: false },
|
|
|
@@ -31,11 +31,11 @@ vi.mock('node:fs', async (importOriginal) => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
-const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
|
|
|
+const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-'))
|
|
|
|
|
|
-function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
|
|
|
+function spec(command: string, overrides: Partial<Parameters<typeof spawnProcess>[0]> = {}) {
|
|
|
return {
|
|
|
- command,
|
|
|
+ argv: ['bash', '-c', command],
|
|
|
cwd: process.cwd(),
|
|
|
stdoutMaxBytes: 64_000,
|
|
|
stderrMaxBytes: 64_000,
|
|
|
@@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
|
|
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
|
|
}
|
|
|
|
|
|
-async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
|
|
|
+async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
|
|
|
const deadline = Date.now() + timeoutMs
|
|
|
while (Date.now() < deadline) {
|
|
|
if (running.stdout.readFrom(0).text.includes(expected)) return
|
|
|
@@ -82,9 +82,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number>
|
|
|
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
|
|
|
}
|
|
|
|
|
|
-describe('runBash', () => {
|
|
|
+describe('spawnProcess', () => {
|
|
|
it('captures stdout on success', async () => {
|
|
|
- const result = await runBash(spec('echo hello')).done
|
|
|
+ const result = await spawnProcess(spec('echo hello')).done
|
|
|
expect(result.exitCode).toBe(0)
|
|
|
expect(result.signal).toBeNull()
|
|
|
expect(result.stdout.text).toBe('hello\n')
|
|
|
@@ -93,41 +93,43 @@ describe('runBash', () => {
|
|
|
})
|
|
|
|
|
|
it('captures stderr separately', async () => {
|
|
|
- const result = await runBash(spec('echo oops >&2')).done
|
|
|
+ const result = await spawnProcess(spec('echo oops >&2')).done
|
|
|
expect(result.exitCode).toBe(0)
|
|
|
expect(result.stdout.text).toBe('')
|
|
|
expect(result.stderr.text).toBe('oops\n')
|
|
|
})
|
|
|
|
|
|
it('captures both streams', async () => {
|
|
|
- const result = await runBash(spec('echo out; echo err >&2')).done
|
|
|
+ const result = await spawnProcess(spec('echo out; echo err >&2')).done
|
|
|
expect(result.stdout.text).toBe('out\n')
|
|
|
expect(result.stderr.text).toBe('err\n')
|
|
|
})
|
|
|
|
|
|
it('reports non-zero exit codes', async () => {
|
|
|
- const result = await runBash(spec('exit 42')).done
|
|
|
+ const result = await spawnProcess(spec('exit 42')).done
|
|
|
expect(result.exitCode).toBe(42)
|
|
|
expect(result.signal).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('applies model-friendly env overrides', async () => {
|
|
|
- const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
|
|
|
- expect(result.stdout.text).toBe('1/dumb/cat\n')
|
|
|
+ it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => {
|
|
|
+ const result = await spawnProcess(spec('echo "${TERM:-unset}"', {
|
|
|
+ env: { TERM: 'callers-choice' },
|
|
|
+ })).done
|
|
|
+ expect(result.stdout.text).toBe('callers-choice\n')
|
|
|
})
|
|
|
|
|
|
it('runs in the requested cwd', async () => {
|
|
|
- const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
|
|
|
+ const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done
|
|
|
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
|
|
|
})
|
|
|
|
|
|
it('kills the process group with SIGTERM when the signal fires', async () => {
|
|
|
- // runBash owns no timer: it kills on abort. The executor drives the timeout
|
|
|
+ // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout
|
|
|
// by firing this signal via a deadline (see executor.spec.ts); here we
|
|
|
// assert the kill itself lands as SIGTERM.
|
|
|
const controller = new AbortController()
|
|
|
const start = Date.now()
|
|
|
- const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
|
|
+ const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
|
|
|
setTimeout(() => { controller.abort('deadline') }, 100)
|
|
|
const result = await running.done
|
|
|
expect(Date.now() - start).toBeLessThan(5_000)
|
|
|
@@ -136,7 +138,7 @@ describe('runBash', () => {
|
|
|
})
|
|
|
|
|
|
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
|
|
- const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
|
|
+ const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
|
|
await waitForStdout(running, 'ready\n')
|
|
|
running.kill()
|
|
|
const result = await running.done
|
|
|
@@ -147,7 +149,7 @@ describe('runBash', () => {
|
|
|
// The subshell writes the sleep's pid then waits on it; killing the
|
|
|
// group must take the sleep down with bash.
|
|
|
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
|
|
|
- const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
|
|
+ const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
|
|
const grandchild = await waitForPidFile(pidFile)
|
|
|
expect(grandchild).toBeGreaterThan(0)
|
|
|
|
|
|
@@ -159,7 +161,7 @@ describe('runBash', () => {
|
|
|
|
|
|
it('aborts via AbortSignal mid-run', async () => {
|
|
|
const controller = new AbortController()
|
|
|
- const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
|
|
+ const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
|
|
|
setTimeout(() => { controller.abort('user cancelled') }, 50)
|
|
|
const result = await running.done
|
|
|
expect(result.signal).toBe('SIGTERM')
|
|
|
@@ -168,17 +170,17 @@ describe('runBash', () => {
|
|
|
it('throws when the signal is already aborted before spawn', () => {
|
|
|
const controller = new AbortController()
|
|
|
controller.abort('too late')
|
|
|
- expect(() => runBash(spec('echo hi', { signal: controller.signal })))
|
|
|
+ expect(() => spawnProcess(spec('echo hi', { signal: controller.signal })))
|
|
|
.toThrow(/aborted before spawn: too late/)
|
|
|
})
|
|
|
|
|
|
it('rejects with a spawn error for a nonexistent cwd', async () => {
|
|
|
- await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
|
|
|
+ await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
|
|
|
.rejects.toThrow(/ENOENT/)
|
|
|
})
|
|
|
|
|
|
it('kill() is idempotent (second call does not restart escalation)', async () => {
|
|
|
- const running = runBash(spec('sleep 60'))
|
|
|
+ const running = spawnProcess(spec('sleep 60'))
|
|
|
running.kill()
|
|
|
running.kill()
|
|
|
const result = await running.done
|
|
|
@@ -188,7 +190,7 @@ describe('runBash', () => {
|
|
|
it('bounds inherited-pipe draining after the shell exits', async () => {
|
|
|
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
|
|
const started = Date.now()
|
|
|
- const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
|
|
+ const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
|
|
const descendant = await waitForPidFile(pidFile)
|
|
|
try {
|
|
|
const result = await running.done
|
|
|
@@ -204,7 +206,7 @@ describe('runBash', () => {
|
|
|
|
|
|
describe('stdin and extra env (set by in-process plugins)', () => {
|
|
|
it('writes stdin to the command and closes it', async () => {
|
|
|
- const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
|
|
|
+ const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done
|
|
|
expect(result.exitCode).toBe(0)
|
|
|
expect(result.stdout.text).toBe('hello from stdin\n')
|
|
|
})
|
|
|
@@ -212,7 +214,7 @@ describe('stdin and extra env (set by in-process plugins)', () => {
|
|
|
it('a command that reads stdin sees EOF when none is supplied', async () => {
|
|
|
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
|
|
|
// output (it does NOT block).
|
|
|
- const result = await runBash(spec('cat')).done
|
|
|
+ const result = await spawnProcess(spec('cat')).done
|
|
|
expect(result.exitCode).toBe(0)
|
|
|
expect(result.stdout.text).toBe('')
|
|
|
})
|
|
|
@@ -220,41 +222,40 @@ describe('stdin and extra env (set by in-process plugins)', () => {
|
|
|
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
|
|
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
|
|
|
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
|
|
|
- const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
|
|
|
+ const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done
|
|
|
expect(none.stdout.text).toBe('char\n')
|
|
|
- const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
|
|
|
+ const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
|
|
|
expect(piped.stdout.text).toBe('socket\n')
|
|
|
})
|
|
|
|
|
|
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
|
|
|
- const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
|
|
|
+ const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
|
|
|
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
|
|
|
})).done
|
|
|
expect(result.stdout.text).toBe('alpha/beta\n')
|
|
|
})
|
|
|
|
|
|
- it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
|
|
|
- // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
|
|
|
+ it('an explicit extra env entry overrides the credential scrub', async () => {
|
|
|
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
|
|
|
// entry is still honored — the scrub only drops AMBIENT process.env creds.
|
|
|
- const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
|
|
|
- env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
|
|
|
+ const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
|
|
|
+ env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
|
|
|
})).done
|
|
|
- expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
|
|
|
+ expect(result.stdout.text).toBe('explicit-wins\n')
|
|
|
})
|
|
|
|
|
|
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
|
|
|
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
|
|
|
// The handler swallows that write error and `done` reports the child's real exit.
|
|
|
const big = 'x'.repeat(1024 * 1024)
|
|
|
- const result = await runBash(spec('exit 7', { stdin: big })).done
|
|
|
+ const result = await spawnProcess(spec('exit 7', { stdin: big })).done
|
|
|
expect(result.exitCode).toBe(7)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('output truncation and spill', () => {
|
|
|
it('applies stdout and stderr caps independently', async () => {
|
|
|
- const result = await runBash(
|
|
|
+ const result = await spawnProcess(
|
|
|
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
|
|
|
stdoutMaxBytes: 500,
|
|
|
stderrMaxBytes: 100,
|
|
|
@@ -269,7 +270,7 @@ describe('output truncation and spill', () => {
|
|
|
|
|
|
it('keeps the tail and spills the full stream to disk', async () => {
|
|
|
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
|
|
|
- const result = await runBash(
|
|
|
+ const result = await spawnProcess(
|
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
|
{ spillDir },
|
|
|
).done
|
|
|
@@ -284,7 +285,7 @@ describe('output truncation and spill', () => {
|
|
|
})
|
|
|
|
|
|
it('does not truncate output exactly at the cap', async () => {
|
|
|
- const result = await runBash(
|
|
|
+ const result = await spawnProcess(
|
|
|
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
|
{ spillDir },
|
|
|
).done
|
|
|
@@ -295,7 +296,7 @@ describe('output truncation and spill', () => {
|
|
|
|
|
|
it('settles with the tail and no spill path when final spill close fails', async () => {
|
|
|
failNextClose.value = true
|
|
|
- const result = await runBash(
|
|
|
+ const result = await spawnProcess(
|
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
|
{ spillDir },
|
|
|
).done
|
|
|
@@ -402,12 +403,27 @@ describe('killGroup', () => {
|
|
|
})
|
|
|
|
|
|
it('swallows ESRCH for vanished groups', async () => {
|
|
|
- const running = runBash(spec('true'))
|
|
|
+ const running = spawnProcess(spec('true'))
|
|
|
await running.done
|
|
|
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+describe('argv validation', () => {
|
|
|
+ it('rejects an empty argv before spawning', () => {
|
|
|
+ expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects an empty program name before spawning', () => {
|
|
|
+ expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('spawns argv verbatim without shell interpretation', async () => {
|
|
|
+ const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done
|
|
|
+ expect(result.stdout.text).toBe('$HOME')
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
describe('abort edge cases', () => {
|
|
|
it('reports a fallback reason for reason-less pre-aborted signals', () => {
|
|
|
// Real AbortControllers always set a DOMException reason; signal-like
|
|
|
@@ -418,14 +434,14 @@ describe('abort edge cases', () => {
|
|
|
addEventListener() {},
|
|
|
removeEventListener() {},
|
|
|
} as unknown as AbortSignal
|
|
|
- expect(() => runBash(spec('echo hi', { signal: bare })))
|
|
|
+ expect(() => spawnProcess(spec('echo hi', { signal: bare })))
|
|
|
.toThrow(/aborted before spawn: aborted/)
|
|
|
})
|
|
|
|
|
|
it('reports the terminating signal of an externally self-killed command', async () => {
|
|
|
- // runBash reports the raw signal; whether it counts as timeout/cancel is the
|
|
|
+ // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the
|
|
|
// executor's classification (a self-kill is neither) — see executor.spec.ts.
|
|
|
- const result = await runBash(spec('kill -TERM $$')).done
|
|
|
+ const result = await spawnProcess(spec('kill -TERM $$')).done
|
|
|
expect(result.signal).toBe('SIGTERM')
|
|
|
})
|
|
|
})
|
|
|
@@ -436,7 +452,7 @@ describe('environment and spill-file hardening', () => {
|
|
|
process.env.DSH_TEST_TOKEN = 'also-secret'
|
|
|
process.env.DSH_TEST_PLAIN = 'visible'
|
|
|
try {
|
|
|
- const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
|
|
|
+ const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
|
|
|
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
|
|
|
} finally {
|
|
|
delete process.env.DSH_TEST_API_KEY
|
|
|
@@ -448,7 +464,7 @@ describe('environment and spill-file hardening', () => {
|
|
|
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
|
|
|
process.env.DSH_STALE = 'old-value'
|
|
|
try {
|
|
|
- const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
|
|
|
+ const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
|
|
|
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
|
|
|
})).done
|
|
|
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
|
|
|
@@ -458,33 +474,33 @@ describe('environment and spill-file hardening', () => {
|
|
|
})
|
|
|
|
|
|
it('rejects DSH variables on the ordinary env channel', () => {
|
|
|
- expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
|
|
|
+ expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
|
|
|
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
|
|
|
})
|
|
|
|
|
|
it('rejects ordinary variables on the managed env channel', () => {
|
|
|
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
|
|
|
- expect(() => runBash(spec('true', { dshEnv: invalid })))
|
|
|
- .toThrow(/managed bash env.*PATH.*use env/)
|
|
|
+ expect(() => spawnProcess(spec('true', { dshEnv: invalid })))
|
|
|
+ .toThrow(/managed child env.*PATH.*use env/)
|
|
|
})
|
|
|
|
|
|
it('creates spill files with owner-only permissions and random names', async () => {
|
|
|
- const result = await runBash(
|
|
|
+ const result = await spawnProcess(
|
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
|
{ spillDir },
|
|
|
).done
|
|
|
const path = result.stdout.spillPath!
|
|
|
- expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
|
|
+ expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
|
|
const mode = statSync(path).mode & 0o777
|
|
|
expect(mode).toBe(0o600)
|
|
|
})
|
|
|
|
|
|
it('defaults spills into a private per-process directory', async () => {
|
|
|
- const result = await runBash(
|
|
|
+ const result = await spawnProcess(
|
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
|
).done
|
|
|
const dir = dirname(result.stdout.spillPath!)
|
|
|
- expect(dir).toMatch(/dsh-bash-/)
|
|
|
+ expect(dir).toMatch(/dsh-proc-/)
|
|
|
const mode = statSync(dir).mode & 0o777
|
|
|
expect(mode).toBe(0o700)
|
|
|
})
|
|
|
@@ -502,7 +518,7 @@ describe('environment and spill-file hardening', () => {
|
|
|
|
|
|
it('honors AbortSignal on background-style runs (no timeout)', async () => {
|
|
|
const controller = new AbortController()
|
|
|
- const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
|
|
+ const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
|
|
|
setTimeout(() => { controller.abort() }, 50)
|
|
|
const result = await running.done
|
|
|
expect(result.signal).toBe('SIGTERM')
|