@dsh-enhanced/assistant-isolation 0.1.65 → 0.1.67
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/README.md +287 -9
- package/lib/audit-archive.d.ts +1 -1
- package/lib/audit-archive.d.ts.map +1 -1
- package/lib/audit-archive.js +31 -13
- package/lib/audit-archive.js.map +1 -1
- package/lib/cli.d.ts +7 -3
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +104 -23
- package/lib/cli.js.map +1 -1
- package/lib/config.d.ts +15 -2
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +49 -9
- package/lib/config.js.map +1 -1
- package/lib/diagnostics.js +2 -2
- package/lib/diagnostics.js.map +1 -1
- package/lib/index.d.ts +17 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -1
- package/lib/index.js.map +1 -1
- package/lib/ledger.d.ts +19 -2
- package/lib/ledger.d.ts.map +1 -1
- package/lib/ledger.js +371 -44
- package/lib/ledger.js.map +1 -1
- package/lib/linux-cgroups.d.ts +75 -0
- package/lib/linux-cgroups.d.ts.map +1 -0
- package/lib/linux-cgroups.js +517 -0
- package/lib/linux-cgroups.js.map +1 -0
- package/lib/linux-execution-contract.d.ts +123 -0
- package/lib/linux-execution-contract.d.ts.map +1 -0
- package/lib/linux-execution-contract.js +279 -0
- package/lib/linux-execution-contract.js.map +1 -0
- package/lib/linux-execution.d.ts +123 -0
- package/lib/linux-execution.d.ts.map +1 -0
- package/lib/linux-execution.js +1264 -0
- package/lib/linux-execution.js.map +1 -0
- package/lib/linux-job-recovery.d.ts +5 -0
- package/lib/linux-job-recovery.d.ts.map +1 -0
- package/lib/linux-job-recovery.js +63 -0
- package/lib/linux-job-recovery.js.map +1 -0
- package/lib/linux-namespace.d.ts +80 -0
- package/lib/linux-namespace.d.ts.map +1 -0
- package/lib/linux-namespace.js +471 -0
- package/lib/linux-namespace.js.map +1 -0
- package/lib/linux-repository-admission.d.ts +26 -0
- package/lib/linux-repository-admission.d.ts.map +1 -0
- package/lib/linux-repository-admission.js +64 -0
- package/lib/linux-repository-admission.js.map +1 -0
- package/lib/linux-repository-runner.d.ts +54 -0
- package/lib/linux-repository-runner.d.ts.map +1 -0
- package/lib/linux-repository-runner.js +614 -0
- package/lib/linux-repository-runner.js.map +1 -0
- package/lib/linux-runner-state-migration.d.ts +94 -0
- package/lib/linux-runner-state-migration.d.ts.map +1 -0
- package/lib/linux-runner-state-migration.js +463 -0
- package/lib/linux-runner-state-migration.js.map +1 -0
- package/lib/linux-runner-state.d.ts +53 -0
- package/lib/linux-runner-state.d.ts.map +1 -0
- package/lib/linux-runner-state.js +332 -0
- package/lib/linux-runner-state.js.map +1 -0
- package/lib/linux-runtime-source.d.ts +28 -0
- package/lib/linux-runtime-source.d.ts.map +1 -0
- package/lib/linux-runtime-source.js +237 -0
- package/lib/linux-runtime-source.js.map +1 -0
- package/lib/linux-runtime.d.ts +37 -0
- package/lib/linux-runtime.d.ts.map +1 -0
- package/lib/linux-runtime.js +192 -0
- package/lib/linux-runtime.js.map +1 -0
- package/lib/linux-system-snapshot.d.ts +47 -0
- package/lib/linux-system-snapshot.d.ts.map +1 -0
- package/lib/linux-system-snapshot.js +704 -0
- package/lib/linux-system-snapshot.js.map +1 -0
- package/lib/linux-user-namespace.d.ts +79 -0
- package/lib/linux-user-namespace.d.ts.map +1 -0
- package/lib/linux-user-namespace.js +234 -0
- package/lib/linux-user-namespace.js.map +1 -0
- package/lib/linux-verifier-runner.d.ts +27 -0
- package/lib/linux-verifier-runner.d.ts.map +1 -0
- package/lib/linux-verifier-runner.js +346 -0
- package/lib/linux-verifier-runner.js.map +1 -0
- package/lib/native-cgroup-roots.d.ts +20 -0
- package/lib/native-cgroup-roots.d.ts.map +1 -0
- package/lib/native-cgroup-roots.js +121 -0
- package/lib/native-cgroup-roots.js.map +1 -0
- package/lib/native-resources.d.ts +44 -0
- package/lib/native-resources.d.ts.map +1 -0
- package/lib/native-resources.js +354 -0
- package/lib/native-resources.js.map +1 -0
- package/lib/probe.d.ts +10 -0
- package/lib/probe.d.ts.map +1 -1
- package/lib/probe.js +52 -2
- package/lib/probe.js.map +1 -1
- package/lib/service.d.ts +1 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +122 -9
- package/lib/service.js.map +1 -1
- package/lib/storage.d.ts.map +1 -1
- package/lib/storage.js +36 -6
- package/lib/storage.js.map +1 -1
- package/lib/tools.js +1 -1
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +5 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/verifier-runner.d.ts +4 -2
- package/lib/verifier-runner.d.ts.map +1 -1
- package/lib/verifier-runner.js +22 -1
- package/lib/verifier-runner.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +15 -10
- package/runtime/linux-candidate-entry.py +82 -0
- package/runtime/linux-namespace-worker.mjs +561 -0
- package/runtime/linux-seccomp-policy.LICENSE +202 -0
- package/runtime/linux-seccomp-policy.json +886 -0
- package/runtime/linux-seccomp.py +106 -0
- package/runtime/linux-userns-launcher.py +149 -0
- package/runtime/linux-userns-proof.py +299 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
3
|
+
import { chmodSync, chownSync, closeSync, constants, cpSync, fchmodSync, fstatSync, readSync, writeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, statfsSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { Socket } from 'node:net'
|
|
5
|
+
import { isAbsolute, join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const TMPFS_MAGIC = 0x01021994
|
|
9
|
+
const CONTROL = /[\p{Cc}]/u
|
|
10
|
+
const MAX_PROTOCOL = 1_400_000
|
|
11
|
+
const FILTER_COMPILER = fileURLToPath(new URL('./linux-seccomp.py', import.meta.url))
|
|
12
|
+
const CANDIDATE_ENTRY = fileURLToPath(new URL('./linux-candidate-entry.py', import.meta.url))
|
|
13
|
+
const say = value => process.stdout.write(`${JSON.stringify(value)}\n`)
|
|
14
|
+
const fail = message => { throw new Error(`linux namespace worker: ${message}`) }
|
|
15
|
+
let buffer = ''
|
|
16
|
+
let ended = false
|
|
17
|
+
let gate
|
|
18
|
+
let gateClosed = false
|
|
19
|
+
let finished = false
|
|
20
|
+
let revoked = false
|
|
21
|
+
let watchdog
|
|
22
|
+
let archiveInput
|
|
23
|
+
let binaryOutput
|
|
24
|
+
function revoke() {
|
|
25
|
+
if (finished || revoked) return
|
|
26
|
+
revoked = true
|
|
27
|
+
archiveInput?.destroy()
|
|
28
|
+
binaryOutput?.destroy()
|
|
29
|
+
if (gate?.pid && !gateClosed) {
|
|
30
|
+
gate.stdio[3]?.destroy()
|
|
31
|
+
gate.stdio[6]?.destroy()
|
|
32
|
+
try { process.kill(-gate.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') throw error }
|
|
33
|
+
gate.kill('SIGKILL')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function closeControl() {
|
|
37
|
+
ended = true
|
|
38
|
+
revoke()
|
|
39
|
+
while (waiters.length) waiters.shift()(undefined)
|
|
40
|
+
}
|
|
41
|
+
const lines = []
|
|
42
|
+
const waiters = []
|
|
43
|
+
process.stdin.setEncoding('utf8')
|
|
44
|
+
process.stdin.on('data', chunk => {
|
|
45
|
+
buffer += chunk
|
|
46
|
+
if (buffer.length > MAX_PROTOCOL) { fail('protocol exceeds bound') }
|
|
47
|
+
for (;;) {
|
|
48
|
+
const end = buffer.indexOf('\n')
|
|
49
|
+
if (end < 0) break
|
|
50
|
+
const line = buffer.slice(0, end)
|
|
51
|
+
buffer = buffer.slice(end + 1)
|
|
52
|
+
const waiter = waiters.shift()
|
|
53
|
+
if (waiter) waiter(line)
|
|
54
|
+
else lines.push(line)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
process.stdin.on('end', closeControl)
|
|
58
|
+
process.stdin.on('error', closeControl)
|
|
59
|
+
process.stdout.on('error', () => { closeControl(); process.stdin.destroy() })
|
|
60
|
+
function line() {
|
|
61
|
+
if (lines.length) return Promise.resolve(lines.shift())
|
|
62
|
+
if (ended) return Promise.resolve(undefined)
|
|
63
|
+
return new Promise(resolve => waiters.push(resolve))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ownedDirectory(path) {
|
|
67
|
+
if (realpathSync(path) !== path) fail('directory changed')
|
|
68
|
+
const info = lstatSync(path)
|
|
69
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o077) !== 0) fail('unsafe directory')
|
|
70
|
+
}
|
|
71
|
+
function tmpfs(path, bytes, inodes, noexec) {
|
|
72
|
+
mkdirSync(path, { mode: 0o700 })
|
|
73
|
+
ownedDirectory(path)
|
|
74
|
+
const opts = `size=${bytes},nr_inodes=${inodes},mode=0700,nosuid,nodev${noexec ? ',noexec' : ''}`
|
|
75
|
+
const mounted = spawnSync('/usr/bin/mount', ['-t', 'tmpfs', '-o', opts, 'tmpfs', path], {
|
|
76
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }, encoding: 'utf8', timeout: 5_000,
|
|
77
|
+
})
|
|
78
|
+
if (mounted.status !== 0 || mounted.error) fail('bounded tmpfs mount failed')
|
|
79
|
+
const observed = statfsSync(path)
|
|
80
|
+
if (observed.type !== TMPFS_MAGIC || observed.bsize * observed.blocks !== bytes || observed.files !== inodes) {
|
|
81
|
+
fail('tmpfs capacity differs')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function mountPoints(root) {
|
|
85
|
+
const decoded = value => value.replace(/\\([0-7]{3})/gu, (_, code) => String.fromCharCode(Number.parseInt(code, 8)))
|
|
86
|
+
return readFileSync('/proc/self/mountinfo', 'utf8').trimEnd().split('\n').map(line => {
|
|
87
|
+
const fields = line.split(' ')
|
|
88
|
+
return { path: decoded(fields[4]), options: fields[5].split(',') }
|
|
89
|
+
}).filter(entry => entry.path === root || entry.path.startsWith(root + '/'))
|
|
90
|
+
}
|
|
91
|
+
function pinnedReadOnly(source, target) {
|
|
92
|
+
mkdirSync(target, { mode: 0o700 })
|
|
93
|
+
ownedDirectory(target)
|
|
94
|
+
const bind = spawnSync('/usr/bin/mount', ['--rbind', source, target], {
|
|
95
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }, encoding: 'utf8', timeout: 5_000,
|
|
96
|
+
})
|
|
97
|
+
if (bind.status !== 0 || bind.error) fail('read-only runtime bind failed')
|
|
98
|
+
const points = mountPoints(target).sort((a, b) => b.path.length - a.path.length)
|
|
99
|
+
if (points.length < 1 || !points.some(point => point.path === target)) fail('runtime mount missing')
|
|
100
|
+
for (const point of points) {
|
|
101
|
+
const readonly = spawnSync('/usr/bin/mount', ['-o', 'remount,bind,ro', point.path], {
|
|
102
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }, encoding: 'utf8', timeout: 5_000,
|
|
103
|
+
})
|
|
104
|
+
if (readonly.status !== 0 || readonly.error) fail('runtime mount could not be locked read-only')
|
|
105
|
+
}
|
|
106
|
+
if (mountPoints(target).some(point => !point.options.includes('ro'))) fail('runtime mount is writable')
|
|
107
|
+
}
|
|
108
|
+
function compiledFilter(jobPath, profile) {
|
|
109
|
+
const output = spawnSync('/usr/bin/python3', ['-I', '-S', FILTER_COMPILER, profile], {
|
|
110
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }, timeout: 10_000, maxBuffer: 65_536,
|
|
111
|
+
})
|
|
112
|
+
const bytes = output.stdout
|
|
113
|
+
if (output.status !== 0 || output.error || !Buffer.isBuffer(bytes)
|
|
114
|
+
|| bytes.length < 8 || bytes.length > 65_536 || bytes.length % 8 !== 0) fail('fixed seccomp filter unavailable')
|
|
115
|
+
const path = join(jobPath, 'seccomp.bpf')
|
|
116
|
+
writeFileSync(path, bytes, { flag: 'wx', mode: 0o600 })
|
|
117
|
+
const info = lstatSync(path)
|
|
118
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.uid !== process.getuid()
|
|
119
|
+
|| (info.mode & 0o077) !== 0 || info.size !== bytes.length) fail('fixed seccomp filter changed')
|
|
120
|
+
return openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
121
|
+
}
|
|
122
|
+
function pathParts(path) {
|
|
123
|
+
if (typeof path !== 'string' || path.length < 1 || path.length > 4096 || isAbsolute(path)
|
|
124
|
+
|| path.includes('\\') || CONTROL.test(path)
|
|
125
|
+
|| path.split('/').some(part => part.length === 0 || part === '.' || part === '..')) fail('unsafe path')
|
|
126
|
+
return path.split('/')
|
|
127
|
+
}
|
|
128
|
+
function stage(workspace, file) {
|
|
129
|
+
const parts = pathParts(file.path)
|
|
130
|
+
let parent = workspace
|
|
131
|
+
for (const part of parts.slice(0, -1)) {
|
|
132
|
+
parent = join(parent, part)
|
|
133
|
+
try { mkdirSync(parent, { mode: 0o700 }) } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
134
|
+
ownedDirectory(parent)
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(join(parent, parts.at(-1)), file.content, { flag: 'wx', mode: 0o600 })
|
|
137
|
+
}
|
|
138
|
+
function processStartTicks(pid) {
|
|
139
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
140
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(' ')
|
|
141
|
+
const value = Number(fields[19])
|
|
142
|
+
if (!Number.isSafeInteger(value) || value < 1) fail('candidate process identity unavailable')
|
|
143
|
+
return value
|
|
144
|
+
}
|
|
145
|
+
function boundArtifact(workspace, path, byteLimit) {
|
|
146
|
+
const parts = pathParts(path)
|
|
147
|
+
let parent = workspace
|
|
148
|
+
for (const part of parts.slice(0, -1)) {
|
|
149
|
+
parent = join(parent, part)
|
|
150
|
+
const info = lstatSync(parent)
|
|
151
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.nlink < 2) fail('artifact parent is unsafe')
|
|
152
|
+
}
|
|
153
|
+
const target = join(parent, parts.at(-1))
|
|
154
|
+
const info = lstatSync(target)
|
|
155
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > byteLimit) fail('artifact is not a bounded regular file')
|
|
156
|
+
const bytes = readFileSync(target)
|
|
157
|
+
if (bytes.length !== info.size || bytes.includes(0)) fail('artifact changed or is binary')
|
|
158
|
+
const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
159
|
+
if (!content.isWellFormed() || !Buffer.from(content).equals(bytes)) fail('artifact is not canonical UTF-8')
|
|
160
|
+
const again = lstatSync(target)
|
|
161
|
+
if (again.dev !== info.dev || again.ino !== info.ino || again.size !== info.size || again.mtimeMs !== info.mtimeMs) {
|
|
162
|
+
fail('artifact changed during export')
|
|
163
|
+
}
|
|
164
|
+
return { path, content }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function receiveArchive(config, target, assertLive) {
|
|
168
|
+
const fd = openSync(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, config.schemaVersion === 3 ? 0o444 : 0o400)
|
|
169
|
+
const hash = createHash('sha256')
|
|
170
|
+
let count = 0
|
|
171
|
+
archiveInput = new Socket({ fd: 4, readable: true, writable: false })
|
|
172
|
+
try {
|
|
173
|
+
for await (const bytes of archiveInput) {
|
|
174
|
+
assertLive()
|
|
175
|
+
count += bytes.length
|
|
176
|
+
if (count > config.archive.size) fail('archive input exceeds declared length')
|
|
177
|
+
hash.update(bytes)
|
|
178
|
+
let offset = 0
|
|
179
|
+
while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset)
|
|
180
|
+
}
|
|
181
|
+
assertLive()
|
|
182
|
+
if (count !== config.archive.size || hash.digest('hex') !== config.archive.sha256
|
|
183
|
+
|| fstatSync(fd).size !== count) fail('archive input length or digest mismatch')
|
|
184
|
+
// Creation modes are filtered by the Host umask. Only the checked archive
|
|
185
|
+
// is shared with the subordinate candidate, after its bytes are complete.
|
|
186
|
+
fchmodSync(fd, config.schemaVersion === 3 ? 0o444 : 0o400)
|
|
187
|
+
} finally { archiveInput.destroy(); closeSync(fd) }
|
|
188
|
+
}
|
|
189
|
+
// cpSync preserves file modes but filters newly created directories through
|
|
190
|
+
// the Host umask. Snapshot directories are validated as 0755 before copying.
|
|
191
|
+
function restoreIdentityDirectoryModes(path) {
|
|
192
|
+
chmodSync(path, 0o755)
|
|
193
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
194
|
+
if (entry.isDirectory()) restoreIdentityDirectoryModes(join(path, entry.name))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function writeIdentityFile(path, content) {
|
|
198
|
+
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o400)
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(fd, content)
|
|
201
|
+
fchmodSync(fd, 0o444)
|
|
202
|
+
} finally { closeSync(fd) }
|
|
203
|
+
}
|
|
204
|
+
function sameFile(before, after) {
|
|
205
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size
|
|
206
|
+
&& before.mtimeNs === after.mtimeNs && before.ctimeNs === after.ctimeNs
|
|
207
|
+
&& after.isFile() && after.nlink === 1n
|
|
208
|
+
}
|
|
209
|
+
async function exportBinary(workspace, path, byteLimit, assertLive) {
|
|
210
|
+
const parts = pathParts(path)
|
|
211
|
+
let parent = workspace
|
|
212
|
+
const parents = []
|
|
213
|
+
for (const part of parts.slice(0, -1)) {
|
|
214
|
+
parent = join(parent, part)
|
|
215
|
+
const info = lstatSync(parent, { bigint: true })
|
|
216
|
+
if (!info.isDirectory() || info.isSymbolicLink()) fail('artifact parent is unsafe')
|
|
217
|
+
parents.push({ path: parent, info })
|
|
218
|
+
}
|
|
219
|
+
const target = join(parent, parts.at(-1))
|
|
220
|
+
const info = lstatSync(target, { bigint: true })
|
|
221
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > BigInt(byteLimit)) fail('artifact is not a bounded regular file')
|
|
222
|
+
const fd = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
223
|
+
const verify = () => {
|
|
224
|
+
assertLive()
|
|
225
|
+
if (!sameFile(info, fstatSync(fd, { bigint: true })) || !sameFile(info, lstatSync(target, { bigint: true }))) fail('artifact metadata changed')
|
|
226
|
+
for (const parent of parents) {
|
|
227
|
+
const again = lstatSync(parent.path, { bigint: true })
|
|
228
|
+
if (!again.isDirectory() || again.isSymbolicLink() || again.dev !== parent.info.dev || again.ino !== parent.info.ino
|
|
229
|
+
|| again.mtimeNs !== parent.info.mtimeNs || again.ctimeNs !== parent.info.ctimeNs) fail('artifact parent changed')
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const hash = createHash('sha256')
|
|
233
|
+
const bytes = Buffer.allocUnsafe(65536)
|
|
234
|
+
let count = 0
|
|
235
|
+
try {
|
|
236
|
+
verify()
|
|
237
|
+
for (;;) {
|
|
238
|
+
verify()
|
|
239
|
+
const read = readSync(fd, bytes, 0, Math.min(bytes.length, byteLimit - count + 1), null)
|
|
240
|
+
if (read === 0) break
|
|
241
|
+
count += read
|
|
242
|
+
if (count > byteLimit || count > Number(info.size)) fail('artifact exceeds bound')
|
|
243
|
+
hash.update(bytes.subarray(0, read))
|
|
244
|
+
await new Promise((resolve, reject) => binaryOutput.write(bytes.subarray(0, read), error => error ? reject(error) : resolve()))
|
|
245
|
+
verify()
|
|
246
|
+
}
|
|
247
|
+
verify()
|
|
248
|
+
if (count !== Number(info.size)) fail('artifact length changed')
|
|
249
|
+
return { path, size: count, sha256: hash.digest('hex') }
|
|
250
|
+
} finally { closeSync(fd) }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function bwrapArgs(config, workspace, tmp, shm, pinned) {
|
|
254
|
+
if (config.schemaVersion === 3) {
|
|
255
|
+
const args = ['--unshare-pid', '--unshare-net', '--unshare-ipc', '--unshare-uts', '--hostname', 'dsh-sandbox', '--unshare-cgroup-try',
|
|
256
|
+
'--cap-drop', 'ALL', '--cap-add', 'CAP_SETUID', '--cap-add', 'CAP_SETGID', '--cap-add', 'CAP_SETPCAP',
|
|
257
|
+
'--die-with-parent', '--new-session', '--clearenv', '--ro-bind', join(pinned, 'usr'), '/usr',
|
|
258
|
+
'--symlink', 'usr/bin', '/bin', '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib64', '/lib64',
|
|
259
|
+
'--proc', '/proc', '--dev', '/dev', '--bind', shm, '/dev/shm', '--remount-ro', '/dev',
|
|
260
|
+
'--ro-bind', join(config.jobPath, 'identity'), '/etc', '--dir', '/run',
|
|
261
|
+
'--bind', workspace, '/workspace', '--bind', tmp, '/tmp',
|
|
262
|
+
'--dir', '/input', '--ro-bind', join(config.jobPath, 'input', 'source.tar'), '/input/source.tar',
|
|
263
|
+
'--ro-bind', CANDIDATE_ENTRY, '/dsh-candidate-entry.py']
|
|
264
|
+
if (config.readOnlyRoots.length) args.push('--dir', '/runtime')
|
|
265
|
+
for (let i = 0; i < config.readOnlyRoots.length; i++) args.push('--ro-bind', join(pinned, 'runtime', String(i)), `/runtime/${i}`)
|
|
266
|
+
// Bwrap has already reduced its setup caps when it performs chdir. Only
|
|
267
|
+
// the fully dropped entry may enter the subordinate-owned 0700 workspace.
|
|
268
|
+
args.push('--remount-ro', '/', '--chdir', '/', '--setenv', 'HOME', '/workspace',
|
|
269
|
+
'--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/usr/bin:/bin', '--setenv', 'LANG', 'C', '--setenv', 'LC_ALL', 'C',
|
|
270
|
+
'--info-fd', '5', '--seccomp', '4', '--', '/usr/bin/python3', '-I', '-S', '/dsh-candidate-entry.py',
|
|
271
|
+
String(config.deadline), JSON.stringify(config.argv))
|
|
272
|
+
return args
|
|
273
|
+
}
|
|
274
|
+
const args = ['--unshare-all', '--unshare-user', '--uid', '65534', '--gid', '65534',
|
|
275
|
+
'--cap-drop', 'ALL', '--die-with-parent', '--new-session', '--clearenv',
|
|
276
|
+
'--ro-bind', join(pinned, 'usr'), '/usr', '--ro-bind', join(pinned, 'lib'), '/lib',
|
|
277
|
+
...(existsSync('/lib64') ? ['--ro-bind', join(pinned, 'lib64'), '/lib64'] : []),
|
|
278
|
+
'--symlink', 'usr/bin', '/bin', '--proc', '/proc', '--dev', '/dev',
|
|
279
|
+
'--bind', shm, '/dev/shm', '--remount-ro', '/dev',
|
|
280
|
+
'--ro-bind', join(config.jobPath, 'identity'), '/etc',
|
|
281
|
+
'--dir', '/workspace', '--bind', workspace, '/workspace',
|
|
282
|
+
'--dir', '/tmp', '--bind', tmp, '/tmp']
|
|
283
|
+
if (config.schemaVersion === 2) args.push('--dir', '/input', '--ro-bind', join(config.jobPath, 'input', 'source.tar'), '/input/source.tar')
|
|
284
|
+
if (config.readOnlyRoots.length > 0) args.push('--dir', '/runtime')
|
|
285
|
+
for (let i = 0; i < config.readOnlyRoots.length; i++) {
|
|
286
|
+
const root = config.readOnlyRoots[i]
|
|
287
|
+
if (realpathSync(root) !== root || !lstatSync(root).isDirectory()) fail('runtime root changed')
|
|
288
|
+
args.push('--ro-bind', join(pinned, 'runtime', String(i)), `/runtime/${i}`)
|
|
289
|
+
}
|
|
290
|
+
args.push('--remount-ro', '/', '--chdir', '/workspace', '--setenv', 'HOME', '/workspace',
|
|
291
|
+
'--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/usr/bin:/bin', '--setenv', 'LANG', 'C',
|
|
292
|
+
'--setenv', 'LC_ALL', 'C')
|
|
293
|
+
if (config.profile === 'ordinary') args.push('--disable-userns')
|
|
294
|
+
// Bwrap consumes fd 4 to install the fixed BPF policy. The candidate shell
|
|
295
|
+
// closes that descriptor before replacing itself with the requested program.
|
|
296
|
+
args.push('--seccomp', '4', '--', '/bin/sh', '-c', 'exec 4<&-; exec "$@"', 'dsh-seccomp-entry', ...config.argv)
|
|
297
|
+
return args
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function dualMapping(config) {
|
|
301
|
+
const mapping = config.userNamespace
|
|
302
|
+
if (!mapping || mapping.schemaVersion !== 1 || process.getuid() !== 0 || process.getgid() !== 0
|
|
303
|
+
|| !Number.isSafeInteger(mapping.ownerUid) || mapping.ownerUid < 1
|
|
304
|
+
|| !Number.isSafeInteger(mapping.ownerGid) || mapping.ownerGid < 1
|
|
305
|
+
|| !Number.isSafeInteger(mapping.candidateUid) || mapping.candidateUid < 1 || mapping.candidateUid === mapping.ownerUid
|
|
306
|
+
|| !Number.isSafeInteger(mapping.candidateGid) || mapping.candidateGid < 1 || mapping.candidateGid === mapping.ownerGid) fail('invalid keeper dual mapping')
|
|
307
|
+
const expected = (owner, candidate) => [[0, owner, 1], [65534, candidate, 1]]
|
|
308
|
+
const observed = path => readFileSync(path, 'ascii').trim().split('\n').map(row => row.trim().split(/\s+/u).map(Number))
|
|
309
|
+
if (JSON.stringify(observed('/proc/self/uid_map')) !== JSON.stringify(expected(mapping.ownerUid, mapping.candidateUid))
|
|
310
|
+
|| JSON.stringify(observed('/proc/self/gid_map')) !== JSON.stringify(expected(mapping.ownerGid, mapping.candidateGid))
|
|
311
|
+
// Node getgroups() adds the effective GID even when supplementary groups
|
|
312
|
+
// are empty; the kernel status field is the supplementary inventory.
|
|
313
|
+
|| !/^Groups:\s*$/mu.test(readFileSync('/proc/self/status', 'ascii'))) fail('keeper mapping differs')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function entryReady(infoPipe, entryPipe, assertLive) {
|
|
317
|
+
// Neither trusted pipe accepts a namespace-local PID as a Host PID.
|
|
318
|
+
const bounded = (stream, parse, limit) => new Promise((resolve, reject) => {
|
|
319
|
+
let text = '', settled = false
|
|
320
|
+
const finish = (error, value) => {
|
|
321
|
+
if (settled) return
|
|
322
|
+
settled = true
|
|
323
|
+
stream.off('data', data); stream.off('end', end); stream.off('error', end); stream.off('close', end)
|
|
324
|
+
if (error) { closeControl(); reject(error) }
|
|
325
|
+
else resolve(value)
|
|
326
|
+
}
|
|
327
|
+
const end = () => finish(new Error('trusted candidate gate closed'))
|
|
328
|
+
const data = chunk => {
|
|
329
|
+
text += chunk.toString('ascii')
|
|
330
|
+
if (text.length > limit) { finish(new Error('trusted candidate gate exceeded bound')); return }
|
|
331
|
+
try {
|
|
332
|
+
const value = parse(text)
|
|
333
|
+
if (value !== undefined) finish(undefined, value)
|
|
334
|
+
} catch (error) { finish(error) }
|
|
335
|
+
}
|
|
336
|
+
stream.on('data', data); stream.once('end', end); stream.once('error', end); stream.once('close', end)
|
|
337
|
+
})
|
|
338
|
+
const info = bounded(infoPipe, text => {
|
|
339
|
+
if (!text.trimEnd().endsWith('}')) return undefined
|
|
340
|
+
const value = JSON.parse(text)
|
|
341
|
+
if (!Number.isSafeInteger(value['child-pid']) || value['child-pid'] < 1) fail('invalid bwrap parent identity')
|
|
342
|
+
return value
|
|
343
|
+
}, 4096)
|
|
344
|
+
const ready = bounded(entryPipe, text => {
|
|
345
|
+
if (!text.endsWith('\n')) return undefined
|
|
346
|
+
if (text !== 'READY\n') fail('unexpected candidate privilege message')
|
|
347
|
+
return true
|
|
348
|
+
}, 16)
|
|
349
|
+
// Consume both pending streams on revoke, closure, and protocol failure.
|
|
350
|
+
const outcomes = await Promise.allSettled([info, ready])
|
|
351
|
+
for (const outcome of outcomes) if (outcome.status === 'rejected') throw outcome.reason
|
|
352
|
+
assertLive()
|
|
353
|
+
const parent = outcomes[0].value['child-pid']
|
|
354
|
+
const children = readFileSync(`/proc/${parent}/task/${parent}/children`, 'ascii').trim().split(/\s+/u)
|
|
355
|
+
if (children.length !== 1 || !/^[1-9][0-9]*$/u.test(children[0])) fail('bwrap entry is not its sole child')
|
|
356
|
+
const pid = Number(children[0]), startTicks = processStartTicks(pid)
|
|
357
|
+
const fields = Object.fromEntries(readFileSync(`/proc/${pid}/status`, 'ascii').split('\n').filter(row => row.includes(':')).map(row => {
|
|
358
|
+
const index = row.indexOf(':'); return [row.slice(0, index), row.slice(index + 1).trim()]
|
|
359
|
+
}))
|
|
360
|
+
if (fields.Uid?.split(/\s+/u).join() !== '65534,65534,65534,65534'
|
|
361
|
+
|| fields.Gid?.split(/\s+/u).join() !== '65534,65534,65534,65534' || fields.Groups !== ''
|
|
362
|
+
|| ['CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'].some(key => !/^0+$/u.test(fields[key] ?? ''))
|
|
363
|
+
|| fields.NoNewPrivs !== '1' || fields.Seccomp !== '2'
|
|
364
|
+
|| readlinkSync(`/proc/${pid}/ns/user`) !== readlinkSync('/proc/self/ns/user')
|
|
365
|
+
|| processStartTicks(pid) !== startTicks) fail('candidate privilege identity differs')
|
|
366
|
+
return { pid, startTicks }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function main() {
|
|
370
|
+
const payload = await line()
|
|
371
|
+
if (payload === undefined) fail('request missing')
|
|
372
|
+
const config = JSON.parse(payload)
|
|
373
|
+
if (!config || ![1, 2, 3].includes(config.schemaVersion) || !['ordinary', 'repository'].includes(config.profile)
|
|
374
|
+
|| !Number.isSafeInteger(config.workspaceBytes) || !Number.isSafeInteger(config.workspaceInodes)
|
|
375
|
+
|| !Number.isSafeInteger(config.tmpBytes) || !Number.isSafeInteger(config.tmpInodes)
|
|
376
|
+
|| !Number.isSafeInteger(config.deadline) || config.deadline <= Date.now() || config.deadline > Date.now() + 1_800_000
|
|
377
|
+
|| !Number.isSafeInteger(config.maxArtifactBytes) || config.maxArtifactBytes < 1 || config.maxArtifactBytes > (config.schemaVersion >= 2 ? 33_554_432 : 1_048_576)
|
|
378
|
+
|| !Number.isSafeInteger(config.maxOutputBytes) || !Array.isArray(config.argv)
|
|
379
|
+
|| !Array.isArray(config.files) || !Array.isArray(config.artifacts) || !Array.isArray(config.readOnlyRoots)) fail('invalid request')
|
|
380
|
+
const repository = config.schemaVersion >= 2
|
|
381
|
+
const dual = config.schemaVersion === 3
|
|
382
|
+
if (repository && (config.profile !== 'repository' || !config.archive || !Number.isSafeInteger(config.archive.size)
|
|
383
|
+
|| config.archive.size < 1 || config.archive.size > 67_108_864 || !/^[0-9a-f]{64}$/u.test(config.archive.sha256)
|
|
384
|
+
|| config.files.length !== 0)) fail('invalid repository request')
|
|
385
|
+
if (config.workspaceBytes < 4096 || config.workspaceBytes > (repository ? 8 : 4) * 1024 ** 3 || config.workspaceBytes % 4096 !== 0
|
|
386
|
+
|| config.tmpBytes < 4096 || config.tmpBytes > (repository ? 4 : 2) * 1024 ** 3 || config.tmpBytes % 4096 !== 0
|
|
387
|
+
|| config.workspaceInodes < 16 || config.workspaceInodes > 1_000_000 || config.tmpInodes < 16 || config.tmpInodes > 1_000_000
|
|
388
|
+
|| config.maxOutputBytes < 1 || config.maxOutputBytes > 1_048_576 || config.artifacts.length > 128) fail('invalid resource bounds')
|
|
389
|
+
const names = new Set()
|
|
390
|
+
for (const file of config.files) {
|
|
391
|
+
pathParts(file.path)
|
|
392
|
+
if (names.has(file.path) || typeof file.content !== 'string' || !file.content.isWellFormed()) fail('invalid input file')
|
|
393
|
+
names.add(file.path)
|
|
394
|
+
}
|
|
395
|
+
if ([...names].some(name => [...names].some(other => other !== name && other.startsWith(`${name}/`)))
|
|
396
|
+
|| config.files.reduce((bytes, file) => bytes + Buffer.byteLength(file.content), 0) > 1_048_576) fail('input files exceed bounds or collide')
|
|
397
|
+
for (const path of config.artifacts) pathParts(path)
|
|
398
|
+
if (new Set(config.artifacts).size !== config.artifacts.length) fail('duplicate artifacts')
|
|
399
|
+
// This deadline belongs to the keeper, independently of the Host process.
|
|
400
|
+
// Closing the Host control pipe also revokes the candidate's execution.
|
|
401
|
+
watchdog = setTimeout(() => { closeControl(); process.stdin.destroy() }, Math.max(1, config.deadline - Date.now()))
|
|
402
|
+
const assertLive = () => { if (revoked || ended || Date.now() >= config.deadline) fail('execution authority ended') }
|
|
403
|
+
assertLive()
|
|
404
|
+
if (dual) {
|
|
405
|
+
dualMapping(config)
|
|
406
|
+
const { verifyLinuxSystemSnapshot } = await import('../lib/linux-system-snapshot.js')
|
|
407
|
+
await verifyLinuxSystemSnapshot(config.systemSnapshot)
|
|
408
|
+
assertLive()
|
|
409
|
+
say({ type: 'NAMESPACE_READY', pid: process.pid, startTicks: processStartTicks(process.pid) })
|
|
410
|
+
if (await line() !== 'NAMESPACE_ACK') fail('keeper namespace binding missing')
|
|
411
|
+
assertLive()
|
|
412
|
+
}
|
|
413
|
+
ownedDirectory(config.jobPath)
|
|
414
|
+
for (const source of ['/usr', '/lib', '/lib64']) {
|
|
415
|
+
if (!existsSync(source)) continue
|
|
416
|
+
const system = realpathSync(source)
|
|
417
|
+
if (config.jobPath === system || config.jobPath.startsWith(system + '/')
|
|
418
|
+
|| system.startsWith(config.jobPath + '/')) fail('private state overlaps exposed system files')
|
|
419
|
+
}
|
|
420
|
+
const workspace = join(config.jobPath, 'workspace'), tmp = join(config.jobPath, 'tmp'), shm = join(config.jobPath, 'shm')
|
|
421
|
+
tmpfs(workspace, config.workspaceBytes, config.workspaceInodes, false)
|
|
422
|
+
tmpfs(tmp, config.tmpBytes, config.tmpInodes, !repository)
|
|
423
|
+
tmpfs(shm, 1_048_576, 4096, true)
|
|
424
|
+
if (dual) {
|
|
425
|
+
chownSync(workspace, 65534, 65534)
|
|
426
|
+
chownSync(shm, 65534, 65534)
|
|
427
|
+
chmodSync(tmp, 0o1777)
|
|
428
|
+
}
|
|
429
|
+
if (repository) {
|
|
430
|
+
const input = join(config.jobPath, 'input')
|
|
431
|
+
tmpfs(input, 67_112_960, 16, true)
|
|
432
|
+
if (dual) chmodSync(input, 0o755)
|
|
433
|
+
await receiveArchive(config, join(input, 'source.tar'), assertLive)
|
|
434
|
+
binaryOutput = new Socket({ fd: 5, readable: false, writable: true })
|
|
435
|
+
binaryOutput.on('error', closeControl)
|
|
436
|
+
}
|
|
437
|
+
const identityDirectory = join(config.jobPath, 'identity')
|
|
438
|
+
if (dual) {
|
|
439
|
+
tmpfs(identityDirectory, 8 * 1024 ** 2, 16384, true)
|
|
440
|
+
cpSync(join(config.systemSnapshot.root.path, 'etc'), identityDirectory, {
|
|
441
|
+
recursive: true, dereference: false, verbatimSymlinks: true,
|
|
442
|
+
})
|
|
443
|
+
restoreIdentityDirectoryModes(identityDirectory)
|
|
444
|
+
for (const name of ['passwd', 'group', 'nsswitch.conf', 'hosts', 'hostname']) {
|
|
445
|
+
const target = join(identityDirectory, name)
|
|
446
|
+
try { unlinkSync(target) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
447
|
+
}
|
|
448
|
+
} else mkdirSync(identityDirectory, { mode: 0o755 })
|
|
449
|
+
writeIdentityFile(join(identityDirectory, 'passwd'), 'nobody:x:65534:65534:Sandbox:/workspace:/bin/sh\n')
|
|
450
|
+
writeIdentityFile(join(identityDirectory, 'group'), 'nobody:x:65534:\n')
|
|
451
|
+
writeIdentityFile(join(identityDirectory, 'nsswitch.conf'), 'passwd: files\ngroup: files\nhosts: files\n')
|
|
452
|
+
writeIdentityFile(join(identityDirectory, 'hosts'), '127.0.0.1 localhost\n::1 localhost\n')
|
|
453
|
+
if (config.schemaVersion === 3) writeIdentityFile(join(identityDirectory, 'hostname'), 'dsh-sandbox\n')
|
|
454
|
+
const pinned = join(config.jobPath, 'immutable')
|
|
455
|
+
mkdirSync(pinned, { mode: 0o700 })
|
|
456
|
+
if (dual) {
|
|
457
|
+
pinnedReadOnly(join(config.systemSnapshot.root.path, 'usr'), join(pinned, 'usr'))
|
|
458
|
+
const entry = lstatSync(CANDIDATE_ENTRY)
|
|
459
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== 0 || entry.nlink !== 1 || entry.mode & 0o022) fail('unsafe trusted entry')
|
|
460
|
+
} else {
|
|
461
|
+
pinnedReadOnly('/usr', join(pinned, 'usr'))
|
|
462
|
+
pinnedReadOnly('/lib', join(pinned, 'lib'))
|
|
463
|
+
if (existsSync('/lib64')) pinnedReadOnly('/lib64', join(pinned, 'lib64'))
|
|
464
|
+
}
|
|
465
|
+
if (config.readOnlyRoots.length > 0) mkdirSync(join(pinned, 'runtime'), { mode: 0o700 })
|
|
466
|
+
for (let i = 0; i < config.readOnlyRoots.length; i++) {
|
|
467
|
+
const root = config.readOnlyRoots[i]
|
|
468
|
+
if (realpathSync(root) !== root || !lstatSync(root).isDirectory()) fail('runtime root changed')
|
|
469
|
+
pinnedReadOnly(root, join(pinned, 'runtime', String(i)))
|
|
470
|
+
}
|
|
471
|
+
for (const file of config.files) stage(workspace, file)
|
|
472
|
+
const args = bwrapArgs(config, workspace, tmp, shm, pinned)
|
|
473
|
+
const filter = compiledFilter(config.jobPath, config.profile)
|
|
474
|
+
// This trusted fixed shell waits on a private fd, then execs bwrap in the
|
|
475
|
+
// same host PID. The Host can move that exact PID into a cgroup first.
|
|
476
|
+
assertLive()
|
|
477
|
+
try {
|
|
478
|
+
gate = spawn('/bin/sh', ['-c', 'IFS= read -r gate <&3 || exit 125; [ "$gate" = START ] || exit 125; exec 3<&-; exec "$@"',
|
|
479
|
+
'dsh-native-gate', dual ? join(pinned, 'usr/bin/bwrap') : '/usr/bin/bwrap', ...args], {
|
|
480
|
+
stdio: dual ? ['ignore', 'pipe', 'pipe', 'pipe', filter, 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe', 'pipe', filter], shell: false, detached: true,
|
|
481
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' },
|
|
482
|
+
})
|
|
483
|
+
} finally { closeSync(filter) }
|
|
484
|
+
const gateExit = new Promise(resolve => {
|
|
485
|
+
gate.once('error', error => { closeControl(); resolve({ code: null, signal: null, error: error.message }) })
|
|
486
|
+
gate.once('close', (code, signal) => { gateClosed = true; resolve({ code, signal }) })
|
|
487
|
+
})
|
|
488
|
+
gate.stdio[3]?.on('error', closeControl)
|
|
489
|
+
gate.stdio[6]?.on('error', closeControl)
|
|
490
|
+
if (!gate.pid) fail('candidate gate did not start')
|
|
491
|
+
assertLive()
|
|
492
|
+
const identity = { pid: gate.pid, startTicks: processStartTicks(gate.pid) }
|
|
493
|
+
say({ type: 'READY', ...identity })
|
|
494
|
+
const authorization = await line()
|
|
495
|
+
if (authorization !== 'START' || gateClosed || revoked || ended || Date.now() >= config.deadline) {
|
|
496
|
+
gate.stdio[3].end()
|
|
497
|
+
gate.kill('SIGKILL')
|
|
498
|
+
fail('candidate start not authorized')
|
|
499
|
+
}
|
|
500
|
+
let stdout = '', stderr = '', outputBytes = 0, exceeded = false
|
|
501
|
+
const chunksOut = [], chunksErr = []
|
|
502
|
+
const append = (which, chunk) => {
|
|
503
|
+
outputBytes += chunk.length
|
|
504
|
+
if (outputBytes > config.maxOutputBytes) {
|
|
505
|
+
exceeded = true
|
|
506
|
+
try { process.kill(-gate.pid, 'SIGKILL') } catch {}
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
if (which === 'out') chunksOut.push(chunk)
|
|
510
|
+
else chunksErr.push(chunk)
|
|
511
|
+
}
|
|
512
|
+
gate.stdout.on('data', chunk => append('out', chunk))
|
|
513
|
+
gate.stderr.on('data', chunk => append('err', chunk))
|
|
514
|
+
const entry = dual ? entryReady(gate.stdio[5], gate.stdio[6], assertLive) : undefined
|
|
515
|
+
gate.stdio[3].end('START\n')
|
|
516
|
+
if (entry) {
|
|
517
|
+
const candidate = await entry.catch(error => { throw new Error(`${error.message}; entry: ${Buffer.concat(chunksErr).toString('utf8').slice(-2048)}`) })
|
|
518
|
+
say({ type: 'CANDIDATE_READY', ...candidate })
|
|
519
|
+
if (await line() !== 'CANDIDATE_START') fail('candidate identity binding missing')
|
|
520
|
+
assertLive()
|
|
521
|
+
if (processStartTicks(candidate.pid) !== candidate.startTicks) fail('candidate identity changed at gate')
|
|
522
|
+
gate.stdio[6].end('START\n')
|
|
523
|
+
}
|
|
524
|
+
const exit = await gateExit
|
|
525
|
+
const decode = chunks => {
|
|
526
|
+
try { return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)) }
|
|
527
|
+
catch { return '' }
|
|
528
|
+
}
|
|
529
|
+
stdout = decode(chunksOut); stderr = decode(chunksErr)
|
|
530
|
+
say({ type: 'EXIT', ...identity, ...exit, exceeded })
|
|
531
|
+
if (await line() !== 'EXPORT') fail('external quiescence proof missing')
|
|
532
|
+
assertLive()
|
|
533
|
+
const artifacts = []
|
|
534
|
+
let artifactBytes = 0
|
|
535
|
+
if (!repository || !exceeded && exit.code === 0) {
|
|
536
|
+
for (const path of config.artifacts) {
|
|
537
|
+
assertLive()
|
|
538
|
+
const file = repository
|
|
539
|
+
? await exportBinary(workspace, path, config.maxArtifactBytes - artifactBytes, assertLive)
|
|
540
|
+
: boundArtifact(workspace, path, config.maxArtifactBytes)
|
|
541
|
+
artifactBytes += repository ? file.size : Buffer.byteLength(file.content)
|
|
542
|
+
if (artifactBytes > config.maxArtifactBytes) fail('artifact output exceeds bound')
|
|
543
|
+
artifacts.push(file)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (repository) await new Promise((resolve, reject) => {
|
|
547
|
+
binaryOutput.once('error', reject)
|
|
548
|
+
binaryOutput.end(resolve)
|
|
549
|
+
})
|
|
550
|
+
assertLive()
|
|
551
|
+
finished = true
|
|
552
|
+
say({ type: 'RESULT', status: !exceeded && exit.code === 0 ? 'succeeded' : 'failed',
|
|
553
|
+
exitCode: exit.code, stdout: exceeded ? '' : stdout, stderr: exceeded ? '' : stderr,
|
|
554
|
+
artifacts, reason: exceeded ? 'candidate output exceeded bound' : exit.error })
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
main().catch(error => {
|
|
558
|
+
revoke()
|
|
559
|
+
try { say({ type: 'ERROR', reason: error instanceof Error ? error.message : 'worker failed' }) } catch {}
|
|
560
|
+
process.exitCode = 1
|
|
561
|
+
}).finally(() => { clearTimeout(watchdog); process.stdin.destroy(); archiveInput?.destroy(); binaryOutput?.destroy() })
|