@dsh-enhanced/assistant-isolation 0.1.66 → 0.1.68

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.
Files changed (63) hide show
  1. package/README.md +39 -68
  2. package/lib/cli.d.ts +2 -4
  3. package/lib/cli.d.ts.map +1 -1
  4. package/lib/cli.js +51 -77
  5. package/lib/cli.js.map +1 -1
  6. package/lib/config.d.ts +4 -7
  7. package/lib/config.d.ts.map +1 -1
  8. package/lib/config.js +26 -40
  9. package/lib/config.js.map +1 -1
  10. package/lib/index.d.ts +3 -3
  11. package/lib/index.d.ts.map +1 -1
  12. package/lib/index.js +2 -2
  13. package/lib/index.js.map +1 -1
  14. package/lib/ledger.d.ts +2 -13
  15. package/lib/ledger.d.ts.map +1 -1
  16. package/lib/ledger.js +7 -85
  17. package/lib/ledger.js.map +1 -1
  18. package/lib/probe.d.ts +0 -3
  19. package/lib/probe.d.ts.map +1 -1
  20. package/lib/probe.js +1 -41
  21. package/lib/probe.js.map +1 -1
  22. package/lib/service.d.ts +1 -1
  23. package/lib/service.d.ts.map +1 -1
  24. package/lib/service.js +10 -90
  25. package/lib/service.js.map +1 -1
  26. package/lib/types.d.ts +4 -31
  27. package/lib/types.d.ts.map +1 -1
  28. package/lib/verifier-runner.d.ts +8 -16
  29. package/lib/verifier-runner.d.ts.map +1 -1
  30. package/lib/verifier-runner.js +9 -207
  31. package/lib/verifier-runner.js.map +1 -1
  32. package/lib/version.d.ts +1 -1
  33. package/lib/version.js +1 -1
  34. package/package.json +8 -5
  35. package/lib/cleanup-receipt.d.ts +0 -28
  36. package/lib/cleanup-receipt.d.ts.map +0 -1
  37. package/lib/cleanup-receipt.js +0 -137
  38. package/lib/cleanup-receipt.js.map +0 -1
  39. package/lib/daemon-binding.d.ts +0 -13
  40. package/lib/daemon-binding.d.ts.map +0 -1
  41. package/lib/daemon-binding.js +0 -120
  42. package/lib/daemon-binding.js.map +0 -1
  43. package/lib/docker-socket.d.ts +0 -10
  44. package/lib/docker-socket.d.ts.map +0 -1
  45. package/lib/docker-socket.js +0 -43
  46. package/lib/docker-socket.js.map +0 -1
  47. package/lib/reconcile.d.ts +0 -5
  48. package/lib/reconcile.d.ts.map +0 -1
  49. package/lib/reconcile.js +0 -31
  50. package/lib/reconcile.js.map +0 -1
  51. package/lib/runner.d.ts +0 -12
  52. package/lib/runner.d.ts.map +0 -1
  53. package/lib/runner.js +0 -227
  54. package/lib/runner.js.map +0 -1
  55. package/lib/runtime-witness.d.ts +0 -32
  56. package/lib/runtime-witness.d.ts.map +0 -1
  57. package/lib/runtime-witness.js +0 -225
  58. package/lib/runtime-witness.js.map +0 -1
  59. package/lib/state-socket-binding.d.ts +0 -4
  60. package/lib/state-socket-binding.d.ts.map +0 -1
  61. package/lib/state-socket-binding.js +0 -64
  62. package/lib/state-socket-binding.js.map +0 -1
  63. package/runtime/supervisor.mjs +0 -300
@@ -1,300 +0,0 @@
1
- import { spawn } from 'node:child_process'
2
- import { chmod, mkdtemp, rm } from 'node:fs/promises'
3
- import { lstatSync } from 'node:fs'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
-
7
- const OPERATION_TIMEOUT_MS = 10_000
8
- const NAME = /^dsh-isolation-[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/
9
- const IMAGE = /^sha256:[0-9a-f]{64}$/
10
- let config
11
- let workerOwned = false
12
- let keeperOwned = false
13
- let volumeOwned = false
14
- let started = false
15
- let finalizing = false
16
- let complete = false
17
- let deadlineTimer
18
- let stdout = ''
19
- let stderr = ''
20
- let dockerDirectory
21
- let provisioning = Promise.resolve()
22
- let cancelReason
23
- let creationAmbiguous = false
24
- let mutationAmbiguous = false
25
- let requestsSettled = true
26
- const activeCommands = new Set()
27
- const workerName = () => config.containerName
28
- const keeperName = () => `${workerName()}-keeper`
29
- const volumeName = () => `${workerName()}-workspace`
30
-
31
- function environment() {
32
- return { PATH: process.env.PATH || '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }
33
- }
34
- function send(message) {
35
- if (process.connected) { try { process.send(message) } catch {} }
36
- }
37
- function decode(buffer) {
38
- try { return new TextDecoder('utf-8', { fatal: true }).decode(buffer) }
39
- catch { return undefined }
40
- }
41
- function succeeded(result) {
42
- return result.code === 0 && !result.timeout && !result.overflow
43
- }
44
- function socketUnchanged() {
45
- try {
46
- const info = lstatSync(config.socket.path)
47
- return info.isSocket() && info.dev === config.socket.dev && info.ino === config.socket.ino
48
- } catch { return false }
49
- }
50
- function docker(args, { outputLimit = 65_536, timeoutMs = OPERATION_TIMEOUT_MS } = {}) {
51
- return new Promise(resolve => {
52
- let child
53
- let out = Buffer.alloc(0)
54
- let err = Buffer.alloc(0)
55
- let overflow = false
56
- let timeout = false
57
- let settled = false
58
- let timer
59
- const finish = result => {
60
- if (settled) return
61
- settled = true
62
- if (timer !== undefined) clearTimeout(timer)
63
- const mutating = ['create', 'start', 'cp', 'exec'].includes(args[0]) || (args[0] === 'volume' && args[1] === 'create')
64
- if (mutating && (timeout || overflow || result.code === null)) mutationAmbiguous = true
65
- if (mutating && (timeout || overflow || result.code !== 0 || result.signal || result.error)) requestsSettled = false
66
- const decoded = decode(out)
67
- resolve({ ...result, stdout: decoded ?? '', stderr: decode(err) ?? '', stdoutBytes: out.length,
68
- stdoutUtf8: decoded !== undefined, overflow, timeout })
69
- }
70
- if (!socketUnchanged()) { mutationAmbiguous = true; finish({ code: null, error: 'docker-socket-changed' }); return }
71
- try {
72
- child = spawn(config.dockerPath, ['--config', dockerDirectory, '-H', `unix://${config.socket.path}`, ...args], {
73
- shell: false, env: environment(), stdio: ['ignore', 'pipe', 'pipe'],
74
- })
75
- } catch (error) { finish({ code: null, error: String(error) }); return }
76
- activeCommands.add(child)
77
- child.once('close', () => activeCommands.delete(child))
78
- const collect = (target, chunk) => {
79
- const kept = chunk.subarray(0, Math.max(0, outputLimit - out.length - err.length))
80
- if (target === 'out') out = Buffer.concat([out, kept])
81
- else err = Buffer.concat([err, kept])
82
- if (kept.length !== chunk.length && !overflow) { overflow = true; child.kill('SIGTERM') }
83
- }
84
- child.stdout.on('data', chunk => collect('out', chunk))
85
- child.stderr.on('data', chunk => collect('err', chunk))
86
- child.once('error', error => finish({ code: null, error: error.message }))
87
- // close, not exit: collect the final pipe bytes before accepting an artifact.
88
- child.once('close', (code, signal) => finish({ code, signal }))
89
- timer = setTimeout(() => { timeout = true; child.kill('SIGKILL') }, timeoutMs)
90
- timer.unref()
91
- })
92
- }
93
- async function absent(type, name) {
94
- const result = await docker(['inspect', '--type', type, name])
95
- return !result.timeout && !result.overflow && result.code !== 0
96
- && /no such (object|container|volume)/i.test(result.stderr)
97
- }
98
- async function erase(type, name) {
99
- for (let attempt = 0; attempt < 3; attempt++) {
100
- const result = await docker(type === 'volume' ? ['volume', 'rm', name] : ['rm', '-f', name])
101
- if (succeeded(result) || await absent(type, name)) return true
102
- if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 100))
103
- }
104
- return false
105
- }
106
- async function removeWorker() {
107
- if (!workerOwned) return true
108
- await docker(['kill', workerName()])
109
- return erase('container', workerName())
110
- }
111
- async function removeStorage() {
112
- const keeperRemoved = !keeperOwned || await erase('container', keeperName())
113
- const volumeRemoved = !volumeOwned || await erase('volume', volumeName())
114
- return keeperRemoved && volumeRemoved
115
- }
116
- function artifactPath(path) {
117
- return typeof path === 'string' && path.length > 0 && path.length <= 4096 && !path.startsWith('/')
118
- && !/[\p{Cc}\\]/u.test(path) && path.split('/').every(part => part && part !== '.' && part !== '..')
119
- }
120
- function exportInterruption() {
121
- if (cancelReason !== undefined) return cancelReason
122
- return Date.now() >= config.deadline ? 'deadline-expired' : undefined
123
- }
124
- function exportTimeout() {
125
- return Math.max(1, Math.min(OPERATION_TIMEOUT_MS, config.deadline - Date.now()))
126
- }
127
- async function stat(path, regular) {
128
- if (exportInterruption()) return undefined
129
- const result = await docker(['exec', keeperName(), '/bin/busybox', 'stat', '-c', '%f:%h:%s', '--', path], { timeoutMs: exportTimeout() })
130
- if (!succeeded(result) || exportInterruption() || !/^[0-9a-fA-F]+:\d+:\d+\n?$/.test(result.stdout)) return undefined
131
- const fields = result.stdout.trim().split(':')
132
- const mode = Number.parseInt(fields[0], 16)
133
- const links = Number(fields[1])
134
- const size = Number(fields[2])
135
- if (![mode, links, size].every(Number.isSafeInteger)) return undefined
136
- // BusyBox stat without -L examines the link itself. Each parent is examined
137
- // separately after worker removal, so no worker can race path resolution.
138
- if (regular ? (mode & 0o170000) !== 0o100000 || links !== 1 : (mode & 0o170000) !== 0o40000) return undefined
139
- return { size }
140
- }
141
- async function exportArtifacts() {
142
- const files = []
143
- let total = 0
144
- for (const path of config.artifacts) {
145
- if (exportInterruption() || !artifactPath(path)) return undefined
146
- let parent = '/workspace'
147
- if (!await stat(parent, false)) return undefined
148
- const parts = path.split('/')
149
- for (const part of parts.slice(0, -1)) {
150
- parent += `/${part}`
151
- if (!await stat(parent, false)) return undefined
152
- }
153
- const target = `/workspace/${path}`
154
- const entry = await stat(target, true)
155
- if (!entry || entry.size > config.limits.maxArtifactBytes - total || exportInterruption()) return undefined
156
- const result = await docker(['exec', keeperName(), '/bin/busybox', 'cat', '--', target], {
157
- outputLimit: entry.size, timeoutMs: exportTimeout(),
158
- })
159
- if (!succeeded(result) || !result.stdoutUtf8 || result.stdoutBytes !== entry.size || exportInterruption()) return undefined
160
- total += entry.size
161
- files.push({ path, content: result.stdout })
162
- }
163
- return files
164
- }
165
- async function finish(status, reason, exitCode) {
166
- if (finalizing) return
167
- finalizing = true
168
- if (deadlineTimer !== undefined) clearTimeout(deadlineTimer)
169
- // Cleanup cannot overtake a create/copy request still owned by this process.
170
- await provisioning
171
- const workerRemoved = await removeWorker()
172
- let artifacts = []
173
- if (status === 'succeeded' && workerRemoved && keeperOwned) {
174
- const exported = await exportArtifacts()
175
- const interrupted = exportInterruption()
176
- if (interrupted !== undefined) {
177
- status = interrupted === 'deadline-expired' ? 'timed-out' : 'cancelled'
178
- reason = interrupted
179
- } else if (exported === undefined) {
180
- status = 'failed'
181
- reason = 'artifact-export-rejected'
182
- } else artifacts = exported
183
- }
184
- const storageRemoved = await removeStorage()
185
- // A final receipt must not outrun an attached start or other CLI still alive.
186
- // No new create can follow provisioning; begin() stops when finalizing is set.
187
- const outstanding = [...activeCommands]
188
- await Promise.all(outstanding.map(child => new Promise(resolve => child.once('close', resolve))))
189
- // A timed-out CLI can leave an in-flight daemon create request. A momentary
190
- // absence is not a release receipt for such a request; retain its reservation.
191
- const quiescent = workerRemoved && storageRemoved && !mutationAmbiguous
192
- if (creationAmbiguous) reason = 'docker-creation-unconfirmed'
193
- else if (mutationAmbiguous) reason = 'docker-mutation-unconfirmed'
194
- if (!quiescent || status !== 'succeeded') artifacts = []
195
- if (dockerDirectory !== undefined) await rm(dockerDirectory, { recursive: true, force: true }).catch(() => undefined)
196
- complete = true
197
- send({ type: 'result', settlementProtocol: 'all-cli-closed/v1', requestsSettled: requestsSettled && !mutationAmbiguous, result: { status: quiescent ? status : 'unknown', quiescent,
198
- ...(exitCode === undefined ? {} : { exitCode }), stdout, stderr, artifacts,
199
- ...(reason === undefined ? {} : { reason }) } })
200
- if (process.connected) process.disconnect()
201
- }
202
- async function begin() {
203
- const attached = await docker(['start', '--attach', workerName()], {
204
- outputLimit: config.limits.maxOutputBytes, timeoutMs: Math.max(1, config.deadline - Date.now()),
205
- })
206
- stdout = attached.stdout
207
- stderr = attached.stderr
208
- if (finalizing) return
209
- if (attached.overflow) { await finish('failed', 'output-limit-exceeded'); return }
210
- if (attached.timeout) { await finish('unknown', 'docker-start-timeout'); return }
211
- const inspected = await docker(['inspect', '--type', 'container', '--format', '{{json .State}}', workerName()])
212
- let state
213
- try { state = JSON.parse(inspected.stdout) } catch {}
214
- if (!succeeded(inspected) || typeof state?.Running !== 'boolean' || state.Running || !Number.isSafeInteger(state.ExitCode)) {
215
- await finish('unknown', 'container-state-unconfirmed')
216
- return
217
- }
218
- await finish(state.ExitCode === 0 ? 'succeeded' : 'failed', state.ExitCode === 0 ? undefined : 'container-exited-nonzero', state.ExitCode)
219
- }
220
- function valid(input) {
221
- return input && NAME.test(input.containerName) && IMAGE.test(input.image)
222
- && typeof input.dockerPath === 'string' && input.dockerPath.startsWith('/')
223
- && input.socket && typeof input.socket.path === 'string' && input.socket.path.startsWith('/')
224
- && Number.isSafeInteger(input.socket.dev) && Number.isSafeInteger(input.socket.ino)
225
- && typeof input.workspacePath === 'string' && input.workspacePath.startsWith('/') && !/[\p{Cc},]/u.test(input.workspacePath)
226
- && typeof input.command === 'string' && Number.isSafeInteger(input.deadline)
227
- && input.limits && Object.values(input.limits).every(value => typeof value === 'number' && Number.isFinite(value) && value > 0)
228
- && Array.isArray(input.artifacts) && input.artifacts.length <= input.limits.maxFiles && input.artifacts.every(artifactPath)
229
- }
230
- function sandbox(keeper = false) {
231
- const memory = keeper ? '32m' : `${Math.floor(config.limits.memoryMiB)}m`
232
- const flags = ['--network', 'none', '--read-only', '--log-driver', 'none', '--cap-drop', 'ALL',
233
- '--security-opt', 'no-new-privileges', '--user', `${process.getuid()}:${process.getgid()}`,
234
- '--pids-limit', keeper ? '16' : String(Math.floor(config.limits.pidsLimit)), '--memory', memory, '--memory-swap', memory,
235
- '--shm-size', '1m', '--cpus', String(config.limits.cpus)]
236
- if (!keeper) {
237
- const tmpMiB = Math.max(1, Math.min(64, Math.floor(config.limits.memoryMiB / 4)))
238
- flags.push('--tmpfs', `/tmp:rw,nosuid,nodev,size=${tmpMiB}m,nr_inodes=${Math.floor(config.limits.workspaceInodes)}`)
239
- }
240
- return flags
241
- }
242
- function creationResult(result) {
243
- if (result.timeout || result.overflow || result.code === null) creationAmbiguous = true
244
- return !finalizing && succeeded(result)
245
- }
246
- async function provision() {
247
- const inspected = await docker(['image', 'inspect', '--format', '{{json .Config.Volumes}}', config.image])
248
- if (finalizing || !succeeded(inspected) || !['null', '{}'].includes(inspected.stdout.trim())) return false
249
- // Mark each unique resource owned before sending its create request, including
250
- // the timeout / lost-ack window. No resource name comes from the model.
251
- volumeOwned = true
252
- const options = `o=size=${Math.floor(config.limits.workspaceMiB)}m,nr_inodes=${Math.floor(config.limits.workspaceInodes)},uid=${process.getuid()},gid=${process.getgid()},mode=0700,nosuid,nodev`
253
- if (!creationResult(await docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', '--opt', options, volumeName()]))) return false
254
- keeperOwned = true
255
- if (!creationResult(await docker(['create', '--pull', 'never', '--name', keeperName(), ...sandbox(true),
256
- '--mount', `type=volume,src=${volumeName()},dst=/workspace,volume-nocopy`, '--entrypoint', '/bin/busybox', config.image, 'sleep', '3600']))) return false
257
- if (!succeeded(await docker(['start', keeperName()])) || finalizing) return false
258
- if (!succeeded(await docker(['cp', '-a', `${config.workspacePath}/.`, `${keeperName()}:/workspace`])) || finalizing) return false
259
- workerOwned = true
260
- return creationResult(await docker(['create', '--pull', 'never', '--name', workerName(), ...sandbox(),
261
- '--mount', `type=volume,src=${volumeName()},dst=/workspace,volume-nocopy`, '--workdir', '/workspace',
262
- '--entrypoint', '/bin/sh', config.image, '-c', config.command]))
263
- }
264
- async function configure(input) {
265
- if (config !== undefined || !valid(input)) { send({ type: 'error', reason: 'invalid-supervisor-config' }); return }
266
- if (!['linux', 'darwin'].includes(process.platform) || typeof process.getuid !== 'function' || typeof process.getgid !== 'function' || process.getuid() === 0) {
267
- send({ type: 'error', reason: 'non-root-unix-host-required' })
268
- return
269
- }
270
- config = input
271
- if (!socketUnchanged()) { send({ type: 'error', reason: 'docker-socket-changed' }); return }
272
- try {
273
- dockerDirectory = await mkdtemp(join(tmpdir(), 'dsh-isolation-docker-'))
274
- await chmod(dockerDirectory, 0o700)
275
- } catch { await finish('failed', 'docker-config-directory-unavailable'); return }
276
- if (finalizing) { await rm(dockerDirectory, { recursive: true, force: true }).catch(() => undefined); return }
277
- if (config.deadline <= Date.now()) { await finish('timed-out', 'deadline-expired-before-create'); return }
278
- deadlineTimer = setTimeout(() => { void finish('timed-out', 'deadline-expired') }, config.deadline - Date.now())
279
- deadlineTimer.unref()
280
- let markSettled
281
- provisioning = new Promise(resolve => { markSettled = resolve })
282
- let prepared = false
283
- try { prepared = await provision() } finally { markSettled() }
284
- if (finalizing) return
285
- if (!prepared) { await finish('failed', 'docker-create-failed'); return }
286
- if (!process.connected) { await finish('unknown', 'parent-disconnected-before-authorization'); return }
287
- send({ type: 'ready' })
288
- }
289
- process.on('message', message => {
290
- if (!message || typeof message !== 'object') return
291
- if (message.type === 'configure') void configure(message.config)
292
- else if (message.type === 'start' && config !== undefined && !finalizing && !started) { started = true; void begin() }
293
- else if (message.type === 'cancel' && config !== undefined) {
294
- cancelReason = typeof message.reason === 'string' ? message.reason : 'cancelled'
295
- void finish(cancelReason === 'deadline-expired' ? 'timed-out' : 'cancelled', cancelReason)
296
- }
297
- })
298
- process.on('disconnect', () => {
299
- if (config !== undefined && !complete) { cancelReason = 'parent-ipc-disconnected'; void finish('unknown', cancelReason) }
300
- })