File size: 18,176 Bytes
d04f74a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import { mkdtemp, mkdir, readFile, readdir, rm, symlink } from 'node:fs/promises'
import { homedir } from 'node:os'
import { createServer, type Socket } from 'node:net'
import { delimiter, join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import Sandbox from '@deepseek-ai/dsh-sandbox-local'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { PtcBindingFunction, PtcBindingNamespace, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime'
import type { Config } from '../src/index.ts'
import { mountRuntime } from './setup.ts'

/** Probe the sandbox independently so Node-runtime launch failures cannot skip enforcement tests. */
const sandboxUsable = await (async () => {
  const probe = new Context()
  try {
    await probe.plugin(Sandbox, {})
    await probe.sandbox.confine([process.execPath, '--version'], { mode: 'read-only', workspaceRoot: process.cwd() })
    return true
  } catch (error: unknown) {
    if (error instanceof SandboxUnavailableError) return false
    throw error
  } finally { await probe.fiber.dispose() }
})()

async function setup(config: Config = {}, mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'danger-full-access') {
  const root = await mkdtemp(join(homedir(), '.dsh-node-runtime-test-'))
  const cwd = join(root, 'workspace')
  await mkdir(cwd)
  const ctx = new Context()
  onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
  const runtime = await mountRuntime(ctx, config, { mode, workspaceRoot: cwd })
  const run = (request: PtcRunRequest) => runtime.run(runtime.resolve(request))
  return { ctx, runtime, root, cwd, run }
}

function bindings(functions: Record<string, PtcBindingFunction>): PtcBindingNamespace[] {
  return [{ global: 'tools', functions, errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }]
}

describe('Node program process', () => {
  it('runs erasable TypeScript in the resolved directory with an empty environment', async () => {
    const { run, cwd } = await setup()
    const result = await run({ program: 'const n: number = 6; console.log("ready"); return { n: n * 7, cwd: process.cwd(), env: { ...process.env } };', bindings: [] })
    expect(result.error).toBeUndefined()
    expect(result.value).toEqual({ n: 42, cwd, env: {} })
    expect(result.logs).toEqual(['ready'])
    expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
  })

  it('keeps native startup paths available for nested Node creation with an empty model environment', async () => {
    const { run } = await setup()
    const result = await run({
      program: 'const {spawnSync}=await import("node:child_process"); const child=spawnSync(process.execPath,["-e","process.stdout.write(JSON.stringify(Object.keys(process.env)))"],{encoding:"utf8"}); return {env:Object.keys(process.env),status:child.status,error:child.error?.message ?? null,childKeys:JSON.parse(child.stdout || "[]")};',
      bindings: [],
    })
    expect(result.error).toBeUndefined()
    const value = result.value as { env: string[]; status: number | null; error: string | null; childKeys: string[] }
    expect(value.env).toEqual([])
    expect(value.status).toBe(0)
    expect(value.error).toBeNull()
    const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP']
    // CoreFoundation initializes this entry independently when a macOS child starts.
    if (process.platform === 'darwin') nativeKeys.push('__CF_USER_TEXT_ENCODING')
    expect(value.childKeys.filter(key => !nativeKeys.includes(key.toUpperCase()))).toEqual([])
  })

  it.skipIf(process.platform !== 'win32' || !sandboxUsable)('uses the common Windows grant lock and private native temp without exposing ambient values', async () => {
    const { run, root } = await setup({}, 'workspace-write')
    const temp = join(root, 'node-temp')
    const tmp = join(root, 'win32-temp')
    await mkdir(temp)
    await mkdir(tmp)
    onTestFinished(() => { vi.unstubAllEnvs() })
    vi.stubEnv('TEMP', temp)
    vi.stubEnv('TMP', tmp)
    vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
    const childCode = 'const fs=require("node:fs"); const path=require("node:path"); const temp=require("node:os").tmpdir(); const file=path.join(temp,"native-temp.txt"); fs.writeFileSync(file,"native-temp"); fs.writeFileSync("native-observation.json",JSON.stringify({file,temp,env:Object.keys(process.env)}));'
    const result = await run({
      program: `const {spawnSync}=await import("node:child_process"); const child=spawnSync(process.execPath,["-e",${JSON.stringify(childCode)}],{stdio:"inherit"}); if(child.status!==0) throw new Error(child.error?.message ?? "native child failed"); const native=JSON.parse((await import("node:fs")).readFileSync("native-observation.json","utf8")); return {env:Object.keys(process.env),native,observed:await tools.inspect({path:native.file})};`,
      bindings: bindings({ inspect: async (args) => {
        const path = (args as { path: string }).path
        expect(path.startsWith(`${temp}\\dsh-`)).toBe(true)
        return await readFile(path, 'utf8')
      } }),
    })
    expect(result.error).toBeUndefined()
    const value = result.value as { env: string[]; native: { env: string[]; temp: string }; observed: string }
    expect(value.env).toEqual([])
    expect(value.native.env).not.toContain('DSH_TEST_RUNTIME_SECRET')
    expect(value.native.temp.startsWith(`${temp}\\dsh-`)).toBe(true)
    expect(value.observed).toBe('native-temp')
    expect((await readdir(join(tmp, 'dsh-acl-locks'))).some(name => name.endsWith('.lock'))).toBe(true)
  })

  it('returns binding values and preserves typed binding rejection', async () => {
    const { run } = await setup()
    const result = await run({
      program: 'const value = await tools.echo({ n: 42 }); let rejected; try { await tools.fail({}) } catch (e) { rejected = [e instanceof ToolCallError,e.name,e.toolName,e.message]; } return {value,rejected};',
      bindings: bindings({ echo: async args => args as { n: number }, fail: async () => { throw new Error('denied') } }),
    })
    expect(result.error).toBeUndefined()
    expect(result.value).toEqual({ value: { n: 42 }, rejected: [true, 'ToolCallError', 'fail', 'denied'] })
  })

  it('retains raw native stdout and stderr separately from control frames', async () => {
    const { run } = await setup()
    const result = await run({ program: 'const fs = await import("node:fs"); fs.writeSync(1,"native-out你好"); fs.writeSync(2,"native-err🙂"); return 42;', bindings: [] })
    expect(result.error).toBeUndefined()
    expect(result.value).toBe(42)
    expect(result.logs.join('')).toContain('native-out你好')
    expect(result.logs.join('')).toContain('native-err🙂')
  })

  it.each(['throw new Error("broken")', 'enum E { A }'])('reports program failure for %s', async (program) => {
    const { run } = await setup()
    expect((await run({ program, bindings: [] })).error?.kind).toBe('exception')
  })

  it('rejects lossy program output', async () => {
    const { run } = await setup()
    expect((await run({ program: 'return { value: undefined }', bindings: [] })).error?.kind).toBe('invalid-output')
  })

  it('enforces the combined output cap', async () => {
    const { run } = await setup({ maxOutputBytes: 80 })
    const result = await run({ program: 'console.log("x".repeat(500)); return 42;', bindings: [] })
    expect(result.error?.kind).toBe('output-limit')
    const bytes = Buffer.byteLength(JSON.stringify(result.logs)) + Buffer.byteLength(JSON.stringify(result.error?.message))
    expect(bytes).toBeLessThanOrEqual(80)
  })

  it('uses the default deadline and caps explicit requests', async () => {
    const { runtime } = await setup()
    expect(runtime.timeout).toEqual({ defaultMs: 120_000, maxMs: 600_000 })
    expect(runtime.executionInstructions).toBe('Each call runs in a fresh Node process. Node APIs are available through await import(...). Relative paths use the supplied working directory; process.env starts empty. Direct filesystem access follows this execution\'s sandbox policy.')
    expect(runtime.resolve({ program: '', bindings: [] }).timeoutMs).toBe(120_000)
    expect(runtime.resolve({ program: '', bindings: [], timeoutMs: 900_000 }).timeoutMs).toBe(600_000)
    expect(runtime.resolve({ program: '', bindings: [], timeoutMs: null }).timeoutMs).toBeNull()
    for (const timeoutMs of [0, -1, NaN, Infinity]) expect(() => runtime.resolve({ program: '', bindings: [], timeoutMs })).toThrow()
    await expect(runtime.run({ program: '', bindings: [], cwd: process.cwd(), timeoutMs: 1000 })).rejects.toThrow('sandbox policy')
  })

  it('advertises the capped default when the deployment maximum is lower', async () => {
    const { runtime } = await setup({ timeoutMs: 2000, maxTimeoutMs: 1000 })
    expect(runtime.timeout).toEqual({ defaultMs: 1000, maxMs: 1000 })
    expect(runtime.timeout.defaultMs).toBe(runtime.resolve({ program: '', bindings: [] }).timeoutMs)
  })

  it.each(['for (;;) {}', 'await new Promise(() => {})'])('ends an unfinished program at its elapsed deadline: %s', async (program) => {
    const { run } = await setup({ timeoutMs: 600, graceMs: 50 })
    expect((await run({ program, bindings: [] })).error?.kind).toBe('timeout')
  })

  it('retains console output emitted immediately before a non-yielding program', async () => {
    const { run } = await setup({ timeoutMs: 1000, graceMs: 50 })
    const result = await run({ program: 'console.log("before hot loop"); for (;;) {}', bindings: [] })
    expect(result.error?.kind).toBe('timeout')
    expect(result.logs).toEqual(['before hot loop'])
  })

  it('does not pause the deadline while a binding is pending', async () => {
    const { run } = await setup({ timeoutMs: 1000, graceMs: 50 })
    const result = await run({ program: 'void tools.wait({}); for (;;) {}', bindings: bindings({ wait: () => new Promise(() => {}) }) })
    expect(result.error?.kind).toBe('timeout')
  })

  it.each([{}, { timeoutMs: null }] as const)('cancels a live program and closes its managed process with %j', async (timing) => {
    const { run } = await setup()
    const entered = Promise.withResolvers<undefined>()
    const controller = new AbortController()
    const active = run({ ...timing, program: 'void tools.enter({}); for (;;) {}', signal: controller.signal, bindings: bindings({ enter: async () => { entered.resolve(undefined); return null } }) })
    await entered.promise
    controller.abort('stop')
    expect((await active).error).toEqual({ kind: 'abort', message: 'stop' })
  })

  it('closes a still-running descendant before returning the program result', async () => {
    const { run } = await setup()
    const connected = Promise.withResolvers<undefined>()
    const disconnected = Promise.withResolvers<undefined>()
    let peer: Socket | undefined
    const server = createServer((socket) => {
      peer = socket
      // A terminated peer can reset its connection instead of sending FIN.
      socket.on('error', () => {})
      socket.once('close', () => { disconnected.resolve(undefined) })
      connected.resolve(undefined)
    })
    onTestFinished(async () => {
      peer?.destroy()
      if (server.listening) await new Promise<void>((resolve, reject) => {
        server.close((error) => { if (error) reject(error); else resolve() })
      })
    })
    await new Promise<void>((resolve, reject) => {
      server.once('error', reject)
      server.listen(0, '127.0.0.1', resolve)
    })
    const address = server.address()
    if (address === null || typeof address === 'string') throw new Error('expected bound TCP listener')
    const child = `require('node:net').connect(${address.port},'127.0.0.1')`
    const result = await run({
      program: `const {spawn}=await import("node:child_process"); spawn(process.execPath,["-e",${JSON.stringify(child)}],{stdio:"ignore"}); await tools.connected({}); return 42;`,
      bindings: bindings({ connected: async () => { await connected.promise; return null } }),
    })
    expect(result.error).toBeUndefined()
    expect(result.value).toBe(42)
    await disconnected.promise
  })

  it('applies the configured V8 old-generation ceiling to each fresh Node process', async () => {
    const limits: number[] = []
    for (const maxOldGenerationSizeMb of [32, 64]) {
      const { run } = await setup({ maxOldGenerationSizeMb })
      const result = await run({ program: 'return (await import("node:v8")).getHeapStatistics().heap_size_limit;', bindings: [] })
      expect(result.error).toBeUndefined()
      if (typeof result.value !== 'number') throw new Error('expected V8 heap limit')
      limits.push(result.value)
    }
    expect(Number(limits[1]) - Number(limits[0])).toBe(32 * 1024 * 1024)
  })

  it.each([{}, { timeoutMs: null }] as const)('disposes active programs and rejects later execution with %j', async (timing) => {
    const { ctx, run, runtime } = await setup()
    const spec = runtime.resolve({ program: '', bindings: [] })
    const entered = Promise.withResolvers<undefined>()
    const active = run({ ...timing, program: 'await tools.enter({}); await new Promise(() => {})', bindings: bindings({ enter: async () => { entered.resolve(undefined); return null } }) })
    await entered.promise
    await ctx.fiber.dispose()
    expect((await active).error?.kind).toBe('abort')
    await expect(runtime.run(spec)).rejects.toThrow('disposal')
    expect(() => runtime.resolve({ program: '', bindings: [] })).toThrow('disposal')
    expect(runtime.isolation).toBe('process')
  })

  it.each([
    'const fs = await import("node:fs"); const b=Buffer.alloc(4); b.writeUInt32BE(4294967295); fs.writeSync(7,b); await new Promise(()=>{});',
    'const fs = await import("node:fs"); const body=Buffer.from(JSON.stringify({type:"call",id:1,global:"tools",name:"undeclared",args:[]})); const h=Buffer.alloc(4); h.writeUInt32BE(body.length); fs.writeSync(7,Buffer.concat([h,body])); await new Promise(()=>{});',
  ])('refuses hostile program control traffic', async (program) => {
    const { run } = await setup()
    expect((await run({ program, bindings: [] })).error?.kind).toBe('protocol')
  })

  it.skipIf(!sandboxUsable)('applies read-only confinement to direct Node filesystem writes', async () => {
    const { run, cwd } = await setup({}, 'read-only')
    const path = join(cwd, 'denied.txt')
    const result = await run({ program: `await (await import('node:fs/promises')).writeFile(${JSON.stringify(path)}, 'denied')`, bindings: [] })
    expect(result.error?.kind).toBe('exception')
    expect(result.sandbox).toMatchObject({ mode: 'read-only', denied: true })
    await expect(readFile(path)).rejects.toMatchObject({ code: 'ENOENT' })
  })

  it.skipIf(process.platform === 'win32' || !sandboxUsable)('starts a confining launcher found only through the execution PATH', async () => {
    const { ctx, runtime, run, root } = await setup({}, 'read-only')
    const confine = ctx.sandbox.confine.bind(ctx.sandbox)
    const policy = runtime.resolve({ program: '', bindings: [] }).sandboxPolicy
    if (policy === undefined || policy.mode === 'danger-full-access') throw new Error('expected confined policy')
    const wrapped = await confine([process.execPath, '--version'], { ...policy, mode: policy.mode })
    const original = wrapped.argv[0]
    if (original === undefined) throw new Error('expected sandbox launcher')
    const executable = await ctx.subprocess.resolveExecutable(original)
    const alias = 'ptc-private-sandbox-launcher'
    await symlink(executable, join(root, alias))
    const previousPath = process.env.PATH
    const substitute = vi.spyOn(ctx.sandbox, 'confine').mockImplementation(async (argv, selected, signal) => {
      const result = await confine(argv, selected, signal)
      return { ...result, argv: [alias, ...result.argv.slice(1)] }
    })
    try {
      process.env.PATH = `${root}${delimiter}${previousPath ?? ''}`
      const result = await run({ program: 'return { env: Object.keys(process.env) };', bindings: [] })
      expect(result.error).toBeUndefined()
      expect(result.value).toEqual({ env: [] })
    } finally {
      substitute.mockRestore()
      if (previousPath === undefined) delete process.env.PATH
      else process.env.PATH = previousPath
    }
  })

  it.skipIf(!sandboxUsable)('permits workspace writes and denies a symlink to a sibling outside it', async () => {
    const { run, cwd, root } = await setup({}, 'workspace-write')
    const target = join(cwd, 'allowed.txt')
    expect((await run({ program: `await (await import('node:fs/promises')).writeFile(${JSON.stringify(target)}, 'allowed'); return true`, bindings: [] })).value).toBe(true)
    expect(await readFile(target, 'utf8')).toBe('allowed')
    const outside = join(root, 'outside')
    await mkdir(outside)
    await symlink(outside, join(cwd, 'escape'), process.platform === 'win32' ? 'junction' : 'dir')
    const result = await run({ program: `await (await import('node:fs/promises')).writeFile(${JSON.stringify(join(cwd, 'escape', 'denied.txt'))}, 'denied')`, bindings: [] })
    expect(result.error?.kind).toBe('exception')
    expect(result.sandbox).toMatchObject({ mode: 'workspace-write', denied: true })
    await expect(readFile(join(outside, 'denied.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
  })
})

it('preserves empty console entries and bounds native output overflow', async () => {
  const { run } = await setup({ maxOutputBytes: 100 })
  const lines = await run({ program: 'console.log("a"); console.log(""); console.log("b")', bindings: [] })
  expect(lines.logs).toEqual(['a', '', 'b'])
  const overflow = await run({ program: 'const fs=await import("node:fs"); fs.writeSync(1,"HEAD-"+"x".repeat(100000));', bindings: [] })
  expect(overflow.error?.kind).toBe('output-limit')
  expect(overflow.logs.join('')).toContain('HEAD-')
  const bytes = Buffer.byteLength(JSON.stringify(overflow.logs)) + Buffer.byteLength(JSON.stringify(overflow.error?.message))
  expect(bytes).toBeLessThanOrEqual(100)
})