child-process.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. /**
  2. * The `node:child_process` face over the in-worker shell, and the ladder above
  3. * it: the REAL local subprocess service, running unmodified against this
  4. * module instead of a host kernel. The bash tool walks this same ladder in the
  5. * browser.
  6. *
  7. * A Node test host has no DOM `Worker`, so the commands here run through the
  8. * inline strategy; the worker strategy and its frames are proven in
  9. * `../shell/shell-process.spec.ts`.
  10. *
  11. * `process.kill` is redirected to the worker's process table for the same
  12. * reason the worker does it: the subprocess service polls process-group
  13. * liveness through it, and on a test host those pids belong to real processes.
  14. */
  15. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  16. import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
  17. import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
  18. import { spawn, spawnSync } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts'
  19. import {
  20. LAUNCHER_FAILURE_EXIT, grantArgs, launcherPath, probe,
  21. } from '@deepseek-ai/node-addon-landlock-run'
  22. import { processAlive, signalProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/process-table.ts'
  23. import { hostFileSystem } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/fs-access.ts'
  24. import {
  25. LANDLOCK_EXECUTABLE, landlockFileSystem, parseLandlockArguments,
  26. } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/landlock.ts'
  27. import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
  28. vi.mock('node:child_process', async () =>
  29. await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts'))
  30. const WORKSPACE = '/dsh/workspace'
  31. const HOME = '/dsh/home'
  32. const TMP = '/dsh/tmp'
  33. let vfs: MemoryVfs
  34. beforeEach(() => {
  35. vfs = new MemoryVfs()
  36. setActiveVfs(vfs)
  37. vfs.mkdirSync(WORKSPACE, { recursive: true })
  38. vfs.mkdirSync(HOME, { recursive: true })
  39. vfs.mkdirSync(TMP, { recursive: true })
  40. vi.spyOn(process, 'kill').mockImplementation((pid: number, signal?: string | number): true => {
  41. if (signal === 0) {
  42. if (processAlive(pid)) return true
  43. const error = new Error('kill ESRCH') as NodeJS.ErrnoException
  44. error.code = 'ESRCH'
  45. throw error
  46. }
  47. signalProcess(pid, (signal ?? 'SIGTERM') as NodeJS.Signals)
  48. return true
  49. })
  50. })
  51. afterEach(() => {
  52. vi.restoreAllMocks()
  53. })
  54. /** Collect one child's stdout, stderr, and settlement. */
  55. async function collect(child: ReturnType<typeof spawn>): Promise<{ stdout: string; stderr: string; code: number | null }> {
  56. let stdout = ''
  57. let stderr = ''
  58. child.stdout?.on('data', (chunk: unknown) => { stdout += String(chunk) })
  59. child.stderr?.on('data', (chunk: unknown) => { stderr += String(chunk) })
  60. const code = await new Promise<number | null>((settle, fail) => {
  61. child.on('close', (value: unknown) => { settle(value as number | null) })
  62. child.on('error', fail)
  63. })
  64. return { stdout, stderr, code }
  65. }
  66. it('runs a bash command line and reports its output through the pipes', async () => {
  67. const child = spawn('bash', ['-c', 'echo hi; echo oops >&2'], { cwd: WORKSPACE })
  68. expect(child.pid).toBeGreaterThan(1)
  69. expect(await collect(child)).toEqual({ stdout: 'hi\n', stderr: 'oops\n', code: 0 })
  70. })
  71. it('runs an explicit argv without re-parsing it as a command line', async () => {
  72. vfs.writeFileSync(`${WORKSPACE}/spaced name.txt`, 'kept\n')
  73. const child = spawn('cat', ['spaced name.txt'], { cwd: WORKSPACE })
  74. expect((await collect(child)).stdout).toBe('kept\n')
  75. })
  76. it('fails a program the command table does not hold the way a missing binary does', async () => {
  77. const child = spawn('nowhere-binary', [], { cwd: WORKSPACE })
  78. // A caller that configures the pipes first (the browser launcher does) must
  79. // reach the ENOENT, not a TypeError on the configuration line.
  80. child.stdout?.setEncoding()
  81. child.stderr?.setEncoding()
  82. const error = await new Promise<NodeJS.ErrnoException>((settle) => {
  83. child.on('error', (value: unknown) => { settle(value as NodeJS.ErrnoException) })
  84. })
  85. expect(error.code).toBe('ENOENT')
  86. expect(error.syscall).toBe('spawn nowhere-binary')
  87. })
  88. it('refuses a command name that is not a string, as Node does', () => {
  89. expect(() => spawn(undefined as unknown as string)).toThrow(/must be a non-empty string/)
  90. })
  91. it('reports that a synchronous run cannot happen, without throwing at the probe', () => {
  92. expect(spawnSync('bwrap').error?.code).toBe('ENOENT')
  93. expect(spawnSync('echo').error?.message).toContain('commands run asynchronously')
  94. expect(spawnSync(launcherPath(), ['--probe'])).toMatchObject({
  95. status: 0,
  96. stdout: Buffer.from('landlock: fully enforced\n'),
  97. })
  98. expect(spawnSync(launcherPath(), ['--ro', '/', '--', 'echo', 'x']).error?.message)
  99. .toContain('commands run asynchronously')
  100. const failedProbe = spawnSync(launcherPath(), ['--probe', '--'])
  101. expect(failedProbe.status).toBe(LAUNCHER_FAILURE_EXIT)
  102. expect(Buffer.isBuffer(failedProbe.stderr)).toBe(true)
  103. })
  104. it('keeps the native Landlock package API and CLI failure contract', async () => {
  105. expect(probe()).toBe('full')
  106. expect(probe('/not-the-worker-launcher')).toBe('unusable')
  107. expect(probe('/another-package-layout/bin/landlock-run')).toBe('full')
  108. expect(launcherPath(() => '/ignored/package.json')).toBe('/ignored/bin/landlock-run')
  109. expect(LAUNCHER_FAILURE_EXIT).toBe(125)
  110. expect(await collect(spawn(launcherPath(), ['--probe']))).toEqual({
  111. stdout: 'landlock: fully enforced\n', stderr: '', code: 0,
  112. })
  113. const malformed = spawn(launcherPath(), ['--rw'], { cwd: WORKSPACE })
  114. expect(await collect(malformed)).toEqual({
  115. stdout: '',
  116. stderr: 'landlock-run: usage error: --rw requires a path\n',
  117. code: 125,
  118. })
  119. const missingGrant = spawn(launcherPath(), ['--rw', '/dsh/missing', '--', 'touch', `${WORKSPACE}/never`], { cwd: WORKSPACE })
  120. expect(await collect(missingGrant)).toEqual({
  121. stdout: '',
  122. stderr: 'landlock-run: cannot open rule path: /dsh/missing: No such file or directory\n',
  123. code: 125,
  124. })
  125. expect(vfs.existsSync(`${WORKSPACE}/never`)).toBe(false)
  126. const missingCommand = spawn(launcherPath(), ['--ro', '/', '--', 'not-a-program'], { cwd: WORKSPACE })
  127. expect(await collect(missingCommand)).toEqual({
  128. stdout: '',
  129. stderr: 'landlock-run: exec failed: No such file or directory\n',
  130. code: 125,
  131. })
  132. })
  133. it('enforces every ShellFileSystem operation and virtual device edge', async () => {
  134. vfs.writeFileSync(`${HOME}/private.txt`, 'private\n')
  135. const invocation = parseLandlockArguments([
  136. ...grantArgs({ readOnly: ['/dev'], readWrite: [WORKSPACE, '/dev/null'] }), '--', 'true',
  137. ])
  138. if (invocation.kind !== 'run') throw new Error('expected a confined run invocation')
  139. const guarded = await landlockFileSystem(hostFileSystem(), invocation, WORKSPACE)
  140. expect(await guarded.stat('/dev/null')).toEqual({ directory: false, size: 0, mtimeMs: 0 })
  141. expect(await guarded.stat('/dev')).toEqual({ directory: true, size: 0, mtimeMs: 0 })
  142. expect(await guarded.list('/dev')).toEqual([{ name: 'null', directory: false }])
  143. await expect(guarded.list('/dev/null')).rejects.toMatchObject({ code: 'ENOTDIR' })
  144. expect(await guarded.readText('/dev/null')).toBe('')
  145. await guarded.writeText('/dev/null', 'discarded')
  146. await expect(guarded.mkdir('/dev/null', false)).rejects.toMatchObject({ code: 'EEXIST' })
  147. await expect(guarded.remove('/dev/null', { recursive: false, force: false })).rejects.toMatchObject({ code: 'EACCES' })
  148. await expect(guarded.rename('/dev/null', `${WORKSPACE}/null`)).rejects.toMatchObject({ code: 'EACCES' })
  149. await expect(guarded.stat('/dev/null/child')).rejects.toMatchObject({ code: 'ENOTDIR' })
  150. await expect(guarded.writeText('/dev/null/child', 'not written')).rejects.toMatchObject({ code: 'ENOTDIR' })
  151. await expect(guarded.mkdir('/dev/null/child', true)).rejects.toMatchObject({ code: 'ENOTDIR' })
  152. expect(vfs.existsSync('/dev')).toBe(false)
  153. await expect(guarded.readText(`${HOME}/private.txt`)).rejects.toMatchObject({ code: 'EACCES' })
  154. await guarded.mkdir('created', false)
  155. await guarded.writeText('created/file', 'one')
  156. await guarded.writeText('created/file', ' two', true)
  157. expect(await guarded.readText(`${WORKSPACE}/created/file`)).toBe('one two')
  158. expect(await guarded.list(`${WORKSPACE}/created`)).toEqual([{ name: 'file', directory: false }])
  159. await guarded.rename('created/file', 'created/moved')
  160. await expect(guarded.rename('created/moved', '/dev/null')).rejects.toMatchObject({ code: 'EACCES' })
  161. await guarded.remove('created', { recursive: true, force: false })
  162. expect(vfs.existsSync(`${WORKSPACE}/created`)).toBe(false)
  163. })
  164. it('turns an unexpected virtual-launcher preparation failure into exit 125', async () => {
  165. const base = hostFileSystem()
  166. const result = await LANDLOCK_EXECUTABLE.prepare(
  167. ['--ro', '/', '--', 'true'],
  168. {
  169. cwd: WORKSPACE,
  170. filesystem: { ...base, stat: () => Promise.reject(new Error('storage unavailable')) },
  171. },
  172. )
  173. expect(result).toEqual({
  174. kind: 'exit', exitCode: 125, stdout: '', stderr: 'landlock-run: Error: storage unavailable\n',
  175. })
  176. })
  177. it.each([
  178. { args: [], message: 'missing `-- <argv>...` command' },
  179. { args: ['--unknown', '--', 'true'], message: 'unknown argument: --unknown' },
  180. { args: ['--probe', '--'], message: '--probe takes no other arguments' },
  181. { args: ['--'], message: 'missing `-- <argv>...` command' },
  182. { args: ['--rw', '', '--', 'true'], message: 'cannot open rule path' },
  183. ])('rejects malformed Landlock argv before execution: $message', async ({ args, message }) => {
  184. const child = spawn(launcherPath(), args, { cwd: WORKSPACE })
  185. const result = await collect(child)
  186. expect(result.code).toBe(LAUNCHER_FAILURE_EXIT)
  187. expect(result.stderr).toContain(message)
  188. })
  189. it('enforces read-only and workspace-write grants over the VFS', async () => {
  190. vfs.writeFileSync(`${HOME}/readable.txt`, 'visible\n')
  191. const readOnly = spawn(launcherPath(), [
  192. ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }),
  193. '--', 'bash', '-c', `cat ${HOME}/readable.txt; echo discarded > /dev/null; echo denied > ${WORKSPACE}/denied.txt`,
  194. ], { cwd: WORKSPACE })
  195. const strict = await collect(readOnly)
  196. expect(strict.code).toBe(1)
  197. expect(strict.stdout).toBe('visible\n')
  198. expect(strict.stderr.toLowerCase()).toContain('permission denied')
  199. expect(vfs.existsSync(`${WORKSPACE}/denied.txt`)).toBe(false)
  200. const workspaceWrite = spawn(launcherPath(), [
  201. ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null', '/tmp', WORKSPACE] }),
  202. '--', 'bash', '-c', `echo workspace > ${WORKSPACE}/allowed.txt; echo temporary > /tmp/temp.txt; cat /tmp/temp.txt`,
  203. ], { cwd: WORKSPACE })
  204. expect(await collect(workspaceWrite)).toEqual({ stdout: 'temporary\n', stderr: '', code: 0 })
  205. expect(vfs.readFileSync(`${WORKSPACE}/allowed.txt`, 'utf8')).toBe('workspace\n')
  206. expect(vfs.readFileSync(`${TMP}/temp.txt`, 'utf8')).toBe('temporary\n')
  207. expect(vfs.existsSync('/dev/null')).toBe(false)
  208. })
  209. it('normalizes relative grants and denies sibling-prefix escapes and unreadable paths', async () => {
  210. vfs.mkdirSync(`${WORKSPACE}/nested`)
  211. vfs.mkdirSync(`${WORKSPACE}-other`)
  212. vfs.writeFileSync(`${HOME}/private.txt`, 'private\n')
  213. const child = spawn(launcherPath(), [
  214. ...grantArgs({ readOnly: [WORKSPACE], readWrite: ['.'] }),
  215. '--', 'bash', '-c', `echo kept > nested/relative.txt; echo escaped > ${WORKSPACE}-other/escape.txt; cat ${HOME}/private.txt`,
  216. ], { cwd: WORKSPACE })
  217. const result = await collect(child)
  218. expect(result.code).toBe(1)
  219. expect(result.stderr.toLowerCase()).toContain('permission denied')
  220. expect(vfs.readFileSync(`${WORKSPACE}/nested/relative.txt`, 'utf8')).toBe('kept\n')
  221. expect(vfs.existsSync(`${WORKSPACE}-other/escape.txt`)).toBe(false)
  222. expect(result.stdout).not.toContain('private')
  223. })
  224. it('treats trailing-slash grants as the same subtree', async () => {
  225. const invocation = parseLandlockArguments(['--rw', '/tmp/', '--', 'true'])
  226. if (invocation.kind !== 'run') throw new Error('expected a confined run invocation')
  227. const guarded = await landlockFileSystem(hostFileSystem(), invocation, WORKSPACE)
  228. await guarded.writeText('/tmp/nested.txt', 'allowed')
  229. expect(vfs.readFileSync(`${TMP}/nested.txt`, 'utf8')).toBe('allowed')
  230. })
  231. it('presents the virtual device directory without storing it in the VFS', async () => {
  232. const child = spawn(launcherPath(), [
  233. ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }),
  234. '--', 'bash', '-c', 'ls /dev; cat /dev/null',
  235. ], { cwd: WORKSPACE })
  236. expect(await collect(child)).toEqual({ stdout: 'null\n', stderr: '', code: 0 })
  237. expect(vfs.existsSync('/dev')).toBe(false)
  238. })
  239. it('requires both rename paths to be writable', async () => {
  240. vfs.writeFileSync(`${WORKSPACE}/source.txt`, 'kept\n')
  241. const child = spawn(launcherPath(), [
  242. ...grantArgs({ readOnly: ['/'], readWrite: [WORKSPACE] }),
  243. '--', 'mv', `${WORKSPACE}/source.txt`, `${HOME}/moved.txt`,
  244. ], { cwd: WORKSPACE })
  245. const result = await collect(child)
  246. expect(result.code).toBe(1)
  247. expect(result.stderr.toLowerCase()).toContain('permission denied')
  248. expect(vfs.readFileSync(`${WORKSPACE}/source.txt`, 'utf8')).toBe('kept\n')
  249. expect(vfs.existsSync(`${HOME}/moved.txt`)).toBe(false)
  250. })
  251. it('keeps concurrent Landlock grants process-local', async () => {
  252. const strict = spawn(launcherPath(), [
  253. ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }),
  254. '--', 'bash', '-c', `sleep 0.02; echo denied > ${WORKSPACE}/strict.txt`,
  255. ], { cwd: WORKSPACE })
  256. const writable = spawn(launcherPath(), [
  257. ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null', WORKSPACE] }),
  258. '--', 'bash', '-c', `echo allowed > ${WORKSPACE}/writable.txt`,
  259. ], { cwd: WORKSPACE })
  260. const [strictResult, writableResult] = await Promise.all([collect(strict), collect(writable)])
  261. expect(strictResult.code).toBe(1)
  262. expect(strictResult.stderr.toLowerCase()).toContain('permission denied')
  263. expect(writableResult).toEqual({ stdout: '', stderr: '', code: 0 })
  264. expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false)
  265. expect(vfs.readFileSync(`${WORKSPACE}/writable.txt`, 'utf8')).toBe('allowed\n')
  266. })
  267. it('carries a command through the real local subprocess service', async () => {
  268. const handle = spawnSubprocess({
  269. argv: ['bash', '-c', 'echo written > note.txt && cat note.txt'],
  270. cwd: WORKSPACE,
  271. stdio: {
  272. stdin: 'ignore',
  273. stdout: { maxBytes: 64_000 },
  274. stderr: { maxBytes: 64_000 },
  275. },
  276. graceMs: 3_000,
  277. env: {},
  278. })
  279. const outcome = await handle.done
  280. expect(outcome).toEqual({ exitCode: 0, signal: null })
  281. expect(handle.collected.stdout?.readFrom(0).text).toBe('written\n')
  282. expect(vfs.readFileSync(`${WORKSPACE}/note.txt`, 'utf8')).toBe('written\n')
  283. })
  284. it('writes the caller-supplied standard input into the command', async () => {
  285. const handle = spawnSubprocess({
  286. argv: ['bash', '-c', 'grep -c ""'],
  287. cwd: WORKSPACE,
  288. stdio: {
  289. stdin: { data: 'one\ntwo\nthree\n' },
  290. stdout: { maxBytes: 64_000 },
  291. stderr: { maxBytes: 64_000 },
  292. },
  293. graceMs: 3_000,
  294. env: {},
  295. })
  296. await handle.done
  297. expect(handle.collected.stdout?.readFrom(0).text).toBe('3\n')
  298. })
  299. it('kills a running command through the service and reports the signal', async () => {
  300. const handle = spawnSubprocess({
  301. argv: ['bash', '-c', 'sleep 30; echo never'],
  302. cwd: WORKSPACE,
  303. stdio: {
  304. stdin: 'ignore',
  305. stdout: { maxBytes: 64_000 },
  306. stderr: { maxBytes: 64_000 },
  307. },
  308. graceMs: 3_000,
  309. env: {},
  310. })
  311. const started = performance.now()
  312. handle.terminate()
  313. const outcome = await handle.done
  314. expect(outcome.signal).toBe('SIGTERM')
  315. expect(outcome.exitCode).toBeNull()
  316. expect(handle.collected.stdout?.readFrom(0).text).toBe('')
  317. // The command settles on the signal, not on the interval it was waiting out:
  318. // a `sleep` that ignored the abort would hold this handle open for 30s.
  319. expect(performance.now() - started).toBeLessThan(5_000)
  320. })