@namzu/sandbox 1.1.0 → 2.0.1
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/CHANGELOG.md +277 -0
- package/README.md +205 -105
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +2 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +1 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +61 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +1 -0
- package/dist/backends/aci-standby-pool/index.d.ts +2 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +36 -1
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/__tests__/hardening.test.d.ts +2 -0
- package/dist/backends/docker/__tests__/hardening.test.d.ts.map +1 -0
- package/dist/backends/docker/__tests__/hardening.test.js +32 -0
- package/dist/backends/docker/__tests__/hardening.test.js.map +1 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +1 -1
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +1 -1
- package/dist/backends/docker/index.d.ts +47 -3
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +144 -5
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +16 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +37 -0
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/backend.test.js +11 -3
- package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +10 -2
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +2 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.js +67 -0
- package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +21 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +30 -0
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +1 -0
- package/dist/backends/firecracker/__tests__/protocol.test.js +7 -17
- package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -1
- package/dist/backends/firecracker/__tests__/transport.test.js +11 -3
- package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts +20 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +70 -15
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/egress/__tests__/allowlist.test.d.ts +2 -0
- package/dist/egress/__tests__/allowlist.test.d.ts.map +1 -0
- package/dist/egress/__tests__/allowlist.test.js +85 -0
- package/dist/egress/__tests__/allowlist.test.js.map +1 -0
- package/dist/egress/__tests__/proxy.test.d.ts +2 -0
- package/dist/egress/__tests__/proxy.test.d.ts.map +1 -0
- package/dist/egress/__tests__/proxy.test.js +177 -0
- package/dist/egress/__tests__/proxy.test.js.map +1 -0
- package/dist/egress/allowlist.d.ts +40 -0
- package/dist/egress/allowlist.d.ts.map +1 -0
- package/dist/egress/allowlist.js +81 -0
- package/dist/egress/allowlist.js.map +1 -0
- package/dist/egress/index.d.ts +4 -0
- package/dist/egress/index.d.ts.map +1 -0
- package/dist/egress/index.js +3 -0
- package/dist/egress/index.js.map +1 -0
- package/dist/egress/proxy.d.ts +90 -0
- package/dist/egress/proxy.d.ts.map +1 -0
- package/dist/egress/proxy.js +194 -0
- package/dist/egress/proxy.js.map +1 -0
- package/dist/index.d.ts +101 -190
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -80
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +18 -39
- package/dist/index.test.js.map +1 -1
- package/package.json +5 -4
- package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +69 -0
- package/src/backends/aci-standby-pool/index.ts +42 -1
- package/src/backends/docker/__tests__/hardening.test.ts +43 -0
- package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +1 -1
- package/src/backends/docker/index.ts +210 -12
- package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +48 -0
- package/src/backends/firecracker/__tests__/backend.test.ts +76 -65
- package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +10 -2
- package/src/backends/firecracker/__tests__/egress-policy.test.ts +91 -0
- package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +31 -0
- package/src/backends/firecracker/__tests__/protocol.test.ts +8 -23
- package/src/backends/firecracker/__tests__/transport.test.ts +11 -3
- package/src/backends/firecracker/index.ts +76 -13
- package/src/egress/__tests__/allowlist.test.ts +103 -0
- package/src/egress/__tests__/proxy.test.ts +212 -0
- package/src/egress/allowlist.ts +82 -0
- package/src/egress/index.ts +7 -0
- package/src/egress/proxy.ts +294 -0
- package/src/index.test.ts +19 -41
- package/src/index.ts +170 -259
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createServer, request as httpRequest } from 'node:http'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import { request as httpsRequest } from 'node:https'
|
|
4
|
+
import { connect as netConnect } from 'node:net'
|
|
5
|
+
import type { Duplex } from 'node:stream'
|
|
6
|
+
|
|
7
|
+
import { isHostAllowed, splitAuthority } from './allowlist.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The network boundary a sandbox's egress policy is enforced at.
|
|
11
|
+
*
|
|
12
|
+
* Until this existed, an egress policy could be *declared* and only two of
|
|
13
|
+
* its four shapes could be honoured anywhere: the container backend refuses
|
|
14
|
+
* a host allowlist outright because it has no proxy to filter through, and
|
|
15
|
+
* only the microVM backend forwards one. `deny-all` and `allow-all` are the
|
|
16
|
+
* whole spectrum a container-tier sandbox could express — all or nothing.
|
|
17
|
+
*
|
|
18
|
+
* It also settles where credentials live. Any token the agent needs in
|
|
19
|
+
* order to reach an allowed host used to have to be inside the sandbox, in
|
|
20
|
+
* the environment, readable by the untrusted code it is meant to be
|
|
21
|
+
* isolated from — via `/proc/self/environ`, or via a prompt injection that
|
|
22
|
+
* exfiltrates it over the very egress the policy permits. Here the token
|
|
23
|
+
* never enters the sandbox: a placeholder does, and the real value is
|
|
24
|
+
* stamped on at this boundary.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A credential the proxy stamps on, and the host it may be sent to. */
|
|
28
|
+
export interface BrokeredCredential {
|
|
29
|
+
/**
|
|
30
|
+
* Host this credential is for. Same matching rules as the allowlist,
|
|
31
|
+
* so `.example.com` covers subdomains.
|
|
32
|
+
*
|
|
33
|
+
* Scoped per host on purpose: a credential attached to every request
|
|
34
|
+
* is a credential handed to whichever host the agent was talked into
|
|
35
|
+
* contacting.
|
|
36
|
+
*/
|
|
37
|
+
readonly host: string
|
|
38
|
+
/** Header to set, e.g. `authorization`. */
|
|
39
|
+
readonly header: string
|
|
40
|
+
/** The real value. Never leaves this process. */
|
|
41
|
+
readonly value: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EgressProxyOptions {
|
|
45
|
+
/** Resolved at request time, so a rotating allowlist is honoured. */
|
|
46
|
+
readonly allowedHosts: () => Promise<readonly string[]>
|
|
47
|
+
readonly credentials?: readonly BrokeredCredential[]
|
|
48
|
+
/**
|
|
49
|
+
* Rewrite a plain-HTTP request to HTTPS upstream.
|
|
50
|
+
*
|
|
51
|
+
* This is what makes brokering work at all. A credential cannot be
|
|
52
|
+
* injected into a CONNECT tunnel — the bytes are already encrypted by
|
|
53
|
+
* the time they reach the proxy, and reading them would mean
|
|
54
|
+
* terminating TLS with a CA the sandbox trusts, which is a far larger
|
|
55
|
+
* and far more dangerous thing to build. So the sandbox speaks plain
|
|
56
|
+
* HTTP to the proxy, and the proxy speaks HTTPS to the world.
|
|
57
|
+
*
|
|
58
|
+
* Default `true`. Turn it off only for a genuinely plaintext upstream.
|
|
59
|
+
*/
|
|
60
|
+
readonly upgradeToHttps?: boolean
|
|
61
|
+
readonly onDenied?: (host: string, reason: string) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RunningEgressProxy {
|
|
65
|
+
readonly port: number
|
|
66
|
+
/** `http://127.0.0.1:<port>` — what a sandbox sets `HTTP_PROXY` to. */
|
|
67
|
+
readonly url: string
|
|
68
|
+
/** Swap the allowlist on a live proxy. See `setNetworkPolicy`. */
|
|
69
|
+
setAllowedHosts(resolve: () => Promise<readonly string[]>): void
|
|
70
|
+
close(): Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DENIED_STATUS = 403
|
|
74
|
+
|
|
75
|
+
export class EgressProxy {
|
|
76
|
+
private resolveAllowed: () => Promise<readonly string[]>
|
|
77
|
+
private readonly credentials: readonly BrokeredCredential[]
|
|
78
|
+
private readonly upgradeToHttps: boolean
|
|
79
|
+
private readonly onDenied: ((host: string, reason: string) => void) | undefined
|
|
80
|
+
/** See the loop guard in `listen`. */
|
|
81
|
+
private selfPort: number | undefined
|
|
82
|
+
|
|
83
|
+
constructor(options: EgressProxyOptions) {
|
|
84
|
+
this.resolveAllowed = options.allowedHosts
|
|
85
|
+
this.credentials = options.credentials ?? []
|
|
86
|
+
this.upgradeToHttps = options.upgradeToHttps ?? true
|
|
87
|
+
this.onDenied = options.onDenied
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async listen(port = 0): Promise<RunningEgressProxy> {
|
|
91
|
+
const server = createServer((req, res) => {
|
|
92
|
+
void this.handleRequest(req, res)
|
|
93
|
+
})
|
|
94
|
+
server.on('connect', (req, socket, head) => {
|
|
95
|
+
void this.handleConnect(req, socket, head)
|
|
96
|
+
})
|
|
97
|
+
await new Promise<void>((resolve, reject) => {
|
|
98
|
+
server.once('error', reject)
|
|
99
|
+
// Loopback only. A proxy that holds real credentials and binds
|
|
100
|
+
// every interface is reachable by anything on the network, which
|
|
101
|
+
// is the opposite of what it exists for.
|
|
102
|
+
server.listen(port, '127.0.0.1', () => {
|
|
103
|
+
server.off('error', reject)
|
|
104
|
+
resolve()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const address = server.address()
|
|
109
|
+
const boundPort = typeof address === 'object' && address !== null ? address.port : port
|
|
110
|
+
// Refuse to forward to ourselves. A client that sends its `Host` as
|
|
111
|
+
// the proxy's own address — which is what a library that rewrites
|
|
112
|
+
// `Host` on a proxied request produces — would otherwise make the
|
|
113
|
+
// proxy call itself forever, holding a socket per hop until the
|
|
114
|
+
// process runs out. Found by a test that hung rather than failed,
|
|
115
|
+
// which is the shape this failure takes in production too.
|
|
116
|
+
this.selfPort = boundPort
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
port: boundPort,
|
|
120
|
+
url: `http://127.0.0.1:${boundPort}`,
|
|
121
|
+
setAllowedHosts: (resolve) => {
|
|
122
|
+
this.resolveAllowed = resolve
|
|
123
|
+
},
|
|
124
|
+
close: () =>
|
|
125
|
+
new Promise<void>((resolve) => {
|
|
126
|
+
server.close(() => resolve())
|
|
127
|
+
}),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async allowed(host: string): Promise<boolean> {
|
|
132
|
+
try {
|
|
133
|
+
return isHostAllowed(host, await this.resolveAllowed())
|
|
134
|
+
} catch {
|
|
135
|
+
// A resolver that throws is a policy that could not be read.
|
|
136
|
+
// Denying is the only safe reading: an allowlist that fails open
|
|
137
|
+
// is not an allowlist.
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private deny(host: string, reason: string): void {
|
|
143
|
+
this.onDenied?.(host, reason)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Plain HTTP. The only path where a credential can be stamped on. */
|
|
147
|
+
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
148
|
+
const target = parseTarget(req)
|
|
149
|
+
if (!target) {
|
|
150
|
+
res.writeHead(400, { 'content-type': 'text/plain' })
|
|
151
|
+
res.end('Malformed proxy request: no absolute URL and no Host header.\n')
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (this.isSelf(target.host, target.port)) {
|
|
156
|
+
res.writeHead(400, { 'content-type': 'text/plain' })
|
|
157
|
+
res.end(
|
|
158
|
+
'Refusing to proxy a request addressed to the proxy itself; forwarding it would loop until the process ran out of sockets. Send an absolute URL in the request line, which is what a proxied client does.\n',
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!(await this.allowed(target.host))) {
|
|
164
|
+
this.deny(target.host, 'not on the egress allowlist')
|
|
165
|
+
res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' })
|
|
166
|
+
// Named, not a generic refusal: an agent that cannot tell "this
|
|
167
|
+
// host is not permitted" from "the network is down" will retry
|
|
168
|
+
// forever, and a human debugging it has nothing to go on.
|
|
169
|
+
res.end(`Egress denied: ${target.host} is not on this sandbox's allowlist.\n`)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const headers = { ...req.headers }
|
|
174
|
+
// The proxy re-issues the request, so hop-by-hop headers about the
|
|
175
|
+
// hop that just ended must not be forwarded.
|
|
176
|
+
for (const hop of ['proxy-authorization', 'proxy-connection', 'connection', 'keep-alive']) {
|
|
177
|
+
delete headers[hop]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const credential = this.credentialFor(target.host)
|
|
181
|
+
if (credential) {
|
|
182
|
+
headers[credential.header.toLowerCase()] = credential.value
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const secure = this.upgradeToHttps || target.protocol === 'https:'
|
|
186
|
+
const send = secure ? httpsRequest : httpRequest
|
|
187
|
+
const upstream = send(
|
|
188
|
+
{
|
|
189
|
+
protocol: secure ? 'https:' : 'http:',
|
|
190
|
+
host: target.host,
|
|
191
|
+
port: target.port ?? (secure ? 443 : 80),
|
|
192
|
+
method: req.method,
|
|
193
|
+
path: target.path,
|
|
194
|
+
headers,
|
|
195
|
+
},
|
|
196
|
+
(response) => {
|
|
197
|
+
res.writeHead(response.statusCode ?? 502, response.headers)
|
|
198
|
+
response.pipe(res)
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
upstream.on('error', (err) => {
|
|
203
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
|
|
204
|
+
res.end(`Upstream request failed: ${err.message}\n`)
|
|
205
|
+
})
|
|
206
|
+
req.pipe(upstream)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* HTTPS, tunnelled.
|
|
211
|
+
*
|
|
212
|
+
* The allowlist is enforceable here because the CONNECT target names
|
|
213
|
+
* the host in clear text. Credential brokering is NOT: the tunnel is
|
|
214
|
+
* opaque, and injecting into it would mean terminating TLS with a CA
|
|
215
|
+
* the sandbox trusts — which would let this process read every byte the
|
|
216
|
+
* agent sends anywhere, a strictly larger risk than the one being
|
|
217
|
+
* mitigated. A workload that needs brokering speaks plain HTTP to the
|
|
218
|
+
* proxy and lets it upgrade upstream.
|
|
219
|
+
*/
|
|
220
|
+
private async handleConnect(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
|
|
221
|
+
const { host, port } = splitAuthority(req.url ?? '')
|
|
222
|
+
|
|
223
|
+
if (!(await this.allowed(host))) {
|
|
224
|
+
this.deny(host, 'not on the egress allowlist')
|
|
225
|
+
socket.write(
|
|
226
|
+
`HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\nEgress denied: ${host} is not on this sandbox's allowlist.\n`,
|
|
227
|
+
)
|
|
228
|
+
socket.end()
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const upstream = netConnect(port ?? 443, host, () => {
|
|
233
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
234
|
+
if (head.length > 0) upstream.write(head)
|
|
235
|
+
upstream.pipe(socket)
|
|
236
|
+
socket.pipe(upstream)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
upstream.on('error', () => {
|
|
240
|
+
socket.end()
|
|
241
|
+
})
|
|
242
|
+
socket.on('error', () => {
|
|
243
|
+
upstream.destroy()
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Whether a target names this proxy. See the loop guard in `listen`. */
|
|
248
|
+
private isSelf(host: string, port?: number): boolean {
|
|
249
|
+
if (port === undefined || port !== this.selfPort) return false
|
|
250
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private credentialFor(host: string): BrokeredCredential | undefined {
|
|
254
|
+
return this.credentials.find((c) => isHostAllowed(host, [c.host]))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface ProxyTarget {
|
|
259
|
+
readonly host: string
|
|
260
|
+
readonly port?: number
|
|
261
|
+
readonly path: string
|
|
262
|
+
readonly protocol: string
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Read the target from a proxied request.
|
|
267
|
+
*
|
|
268
|
+
* A proxy receives an absolute URL in the request line; a client that
|
|
269
|
+
* forgot it still sends `Host`, and honouring that is what makes a plain
|
|
270
|
+
* `fetch` through `HTTP_PROXY` work.
|
|
271
|
+
*/
|
|
272
|
+
function parseTarget(req: IncomingMessage): ProxyTarget | null {
|
|
273
|
+
const raw = req.url ?? ''
|
|
274
|
+
|
|
275
|
+
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
276
|
+
const url = new URL(raw)
|
|
277
|
+
return {
|
|
278
|
+
host: url.hostname,
|
|
279
|
+
...(url.port ? { port: Number(url.port) } : {}),
|
|
280
|
+
path: `${url.pathname}${url.search}`,
|
|
281
|
+
protocol: url.protocol,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const hostHeader = req.headers.host
|
|
286
|
+
if (!hostHeader) return null
|
|
287
|
+
const { host, port } = splitAuthority(hostHeader)
|
|
288
|
+
return {
|
|
289
|
+
host,
|
|
290
|
+
...(port !== undefined ? { port } : {}),
|
|
291
|
+
path: raw || '/',
|
|
292
|
+
protocol: 'http:',
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -77,50 +77,28 @@ describe('createSandboxProvider', () => {
|
|
|
77
77
|
expect(provider.id).toContain('docker')
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
-
it('
|
|
81
|
-
expect(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
createSandboxProvider({
|
|
89
|
-
backend: { tier: 'microvm', service: 'fly-machines', apiToken: 't', app: 'a', image: 'i' },
|
|
90
|
-
})
|
|
91
|
-
} catch (err) {
|
|
92
|
-
expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
|
|
93
|
-
expect((err as SandboxBackendNotImplementedError).backend).toBe('microvm:fly-machines')
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('throws for process tier until P3.4 lands, naming the engine in the label', () => {
|
|
98
|
-
try {
|
|
99
|
-
createSandboxProvider({ backend: { tier: 'process', engine: 'bubblewrap' } })
|
|
100
|
-
} catch (err) {
|
|
101
|
-
expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
|
|
102
|
-
expect((err as SandboxBackendNotImplementedError).backend).toBe('process:bubblewrap')
|
|
103
|
-
}
|
|
104
|
-
})
|
|
80
|
+
it('builds the userspace-kernel runtime rather than refusing it', () => {
|
|
81
|
+
// This case was pinned by a test shaped `try { … } catch (e) { expect(e) }`
|
|
82
|
+
// with no failure branch, so once the runtime landed the test kept
|
|
83
|
+
// passing while asserting nothing at all.
|
|
84
|
+
const provider = createSandboxProvider({
|
|
85
|
+
backend: { tier: 'container', runtime: 'runsc', image: 'i' },
|
|
86
|
+
layout: validLayout(),
|
|
87
|
+
})
|
|
105
88
|
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
createSandboxProvider({
|
|
109
|
-
backend: { tier: 'container', runtime: 'runsc', image: 'i' },
|
|
110
|
-
layout: validLayout(),
|
|
111
|
-
})
|
|
112
|
-
} catch (err) {
|
|
113
|
-
expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
|
|
114
|
-
expect((err as SandboxBackendNotImplementedError).backend).toBe('container:runsc')
|
|
115
|
-
}
|
|
89
|
+
expect(provider.name).toContain('container:runsc')
|
|
116
90
|
})
|
|
117
91
|
|
|
118
|
-
it('
|
|
92
|
+
it('refuses a tier it does not implement, by name', () => {
|
|
93
|
+
// Reachable only from untyped callers now that every shape in the
|
|
94
|
+
// union has an implementation — which is the point of keeping it: a
|
|
95
|
+
// JS host that invents a tier gets a named refusal, not a provider
|
|
96
|
+
// that silently confines nothing.
|
|
119
97
|
try {
|
|
120
|
-
createSandboxProvider({ backend: { tier: 'passthrough' } })
|
|
98
|
+
createSandboxProvider({ backend: { tier: 'passthrough' } } as never)
|
|
99
|
+
expect.unreachable('a tier with no backend must not construct')
|
|
121
100
|
} catch (err) {
|
|
122
101
|
expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
|
|
123
|
-
expect((err as SandboxBackendNotImplementedError).backend).toBe('passthrough')
|
|
124
102
|
}
|
|
125
103
|
})
|
|
126
104
|
|
|
@@ -437,7 +415,7 @@ describe('serializeSandboxError — transport-safe error envelope', () => {
|
|
|
437
415
|
expect(serializeSandboxError(true).message).toBe('true')
|
|
438
416
|
})
|
|
439
417
|
|
|
440
|
-
//
|
|
418
|
+
// the envelope must defend against values that
|
|
441
419
|
// JSON.stringify either drops silently or that structuredClone
|
|
442
420
|
// throws on. Each of these cases asserts a specific envelope shape
|
|
443
421
|
// AND that the result survives both transport channels.
|
|
@@ -484,7 +462,7 @@ describe('serializeSandboxError — transport-safe error envelope', () => {
|
|
|
484
462
|
}
|
|
485
463
|
})
|
|
486
464
|
|
|
487
|
-
//
|
|
465
|
+
// cycle guard. The previous round-4 implementation
|
|
488
466
|
// recursed unconditionally on `cause`, so a cycle would either
|
|
489
467
|
// stack-overflow (synchronous) or trip JSON.stringify's circular
|
|
490
468
|
// detection (async via the receiver). The WeakSet path replaces
|
|
@@ -561,7 +539,7 @@ describe('serializeSandboxError — transport-safe error envelope', () => {
|
|
|
561
539
|
})
|
|
562
540
|
|
|
563
541
|
describe('public exports — runtime import paths', () => {
|
|
564
|
-
//
|
|
542
|
+
// Vandal-side prompt template generators must
|
|
565
543
|
// be able to import the default-path constants from the sandbox
|
|
566
544
|
// package via the SDK's root barrel. `@namzu/sdk` exposes only
|
|
567
545
|
// `"."` in its package.json `exports`; subpath imports like
|