@namzu/sandbox 2.0.2 → 3.0.0
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 +84 -0
- package/README.md +62 -1
- package/dist/backends/docker/index.d.ts +26 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +20 -7
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/egress/address.d.ts +75 -0
- package/dist/egress/address.d.ts.map +1 -0
- package/dist/egress/address.js +289 -0
- package/dist/egress/address.js.map +1 -0
- package/dist/egress/index.d.ts +2 -0
- package/dist/egress/index.d.ts.map +1 -1
- package/dist/egress/index.js +1 -0
- package/dist/egress/index.js.map +1 -1
- package/dist/egress/proxy.d.ts +41 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +83 -2
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +12 -2
- package/src/backends/docker/index.ts +45 -8
- package/src/egress/address.ts +366 -0
- package/src/egress/index.ts +7 -0
- package/src/egress/proxy.ts +106 -2
- package/src/index.ts +18 -0
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +0 -2
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +0 -1
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +0 -61
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +0 -1
- package/dist/backends/docker/__tests__/hardening.test.d.ts +0 -2
- package/dist/backends/docker/__tests__/hardening.test.d.ts.map +0 -1
- package/dist/backends/docker/__tests__/hardening.test.js +0 -32
- package/dist/backends/docker/__tests__/hardening.test.js.map +0 -1
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +0 -40
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +0 -1
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +0 -157
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +0 -16
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +0 -37
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/backend.test.d.ts +0 -13
- package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/backend.test.js +0 -361
- package/dist/backends/firecracker/__tests__/backend.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +0 -19
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +0 -209
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +0 -2
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/egress-policy.test.js +0 -67
- package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +0 -21
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +0 -30
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +0 -39
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +0 -149
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +0 -1
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts +0 -6
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/protocol.test.js +0 -67
- package/dist/backends/firecracker/__tests__/protocol.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/transport.test.d.ts +0 -20
- package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/transport.test.js +0 -457
- package/dist/backends/firecracker/__tests__/transport.test.js.map +0 -1
- package/dist/egress/__tests__/allowlist.test.d.ts +0 -2
- package/dist/egress/__tests__/allowlist.test.d.ts.map +0 -1
- package/dist/egress/__tests__/allowlist.test.js +0 -85
- package/dist/egress/__tests__/allowlist.test.js.map +0 -1
- package/dist/egress/__tests__/proxy.test.d.ts +0 -2
- package/dist/egress/__tests__/proxy.test.d.ts.map +0 -1
- package/dist/egress/__tests__/proxy.test.js +0 -177
- package/dist/egress/__tests__/proxy.test.js.map +0 -1
- package/dist/index.test.d.ts +0 -28
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -649
- package/dist/index.test.js.map +0 -1
- package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +0 -69
- package/src/backends/docker/__tests__/hardening.test.ts +0 -43
- package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +0 -169
- package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +0 -48
- package/src/backends/firecracker/__tests__/backend.test.ts +0 -429
- package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +0 -261
- package/src/backends/firecracker/__tests__/egress-policy.test.ts +0 -91
- package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +0 -31
- package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +0 -166
- package/src/backends/firecracker/__tests__/protocol.test.ts +0 -75
- package/src/backends/firecracker/__tests__/transport.test.ts +0 -534
- package/src/egress/__tests__/allowlist.test.ts +0 -103
- package/src/egress/__tests__/proxy.test.ts +0 -212
- package/src/index.test.ts +0 -709
package/src/egress/proxy.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { request as httpsRequest } from 'node:https'
|
|
|
4
4
|
import { connect as netConnect } from 'node:net'
|
|
5
5
|
import type { Duplex } from 'node:stream'
|
|
6
6
|
|
|
7
|
+
import { EgressAddressDenied, blockedLiteralReason, createScreeningLookup } from './address.js'
|
|
8
|
+
import type { ScreeningLookupOptions } from './address.js'
|
|
7
9
|
import { isHostAllowed, splitAuthority } from './allowlist.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -59,6 +61,18 @@ export interface EgressProxyOptions {
|
|
|
59
61
|
*/
|
|
60
62
|
readonly upgradeToHttps?: boolean
|
|
61
63
|
readonly onDenied?: (host: string, reason: string) => void
|
|
64
|
+
/**
|
|
65
|
+
* Allowlisted hosts permitted to resolve to an inward address anyway.
|
|
66
|
+
*
|
|
67
|
+
* Matched by the allowlist's own rules, so `.internal.example` covers
|
|
68
|
+
* subdomains. Per host on purpose: an operator who genuinely proxies to
|
|
69
|
+
* one service on a private network needs that one exempted, and a global
|
|
70
|
+
* switch to get it would hand every other allowlisted name the same
|
|
71
|
+
* reach — which is the hole this screen exists to close.
|
|
72
|
+
*/
|
|
73
|
+
readonly allowInwardFor?: readonly string[]
|
|
74
|
+
/** Injected in tests. Defaults to the platform resolver. */
|
|
75
|
+
readonly resolveAddresses?: ScreeningLookupOptions['resolve']
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
export interface RunningEgressProxy {
|
|
@@ -79,12 +93,28 @@ export class EgressProxy {
|
|
|
79
93
|
private readonly onDenied: ((host: string, reason: string) => void) | undefined
|
|
80
94
|
/** See the loop guard in `listen`. */
|
|
81
95
|
private selfPort: number | undefined
|
|
96
|
+
/**
|
|
97
|
+
* The resolver both paths connect through.
|
|
98
|
+
*
|
|
99
|
+
* Held once rather than built per request so there is exactly one place
|
|
100
|
+
* the address screen can be bypassed, and it is visible from here.
|
|
101
|
+
*/
|
|
102
|
+
private readonly lookup: ReturnType<typeof createScreeningLookup>
|
|
103
|
+
private readonly inwardAllowed: readonly string[]
|
|
82
104
|
|
|
83
105
|
constructor(options: EgressProxyOptions) {
|
|
84
106
|
this.resolveAllowed = options.allowedHosts
|
|
85
107
|
this.credentials = options.credentials ?? []
|
|
86
108
|
this.upgradeToHttps = options.upgradeToHttps ?? true
|
|
87
109
|
this.onDenied = options.onDenied
|
|
110
|
+
this.inwardAllowed = options.allowInwardFor ?? []
|
|
111
|
+
this.lookup = createScreeningLookup(
|
|
112
|
+
{
|
|
113
|
+
...(options.allowInwardFor ? { allowInwardFor: options.allowInwardFor } : {}),
|
|
114
|
+
...(options.resolveAddresses ? { resolve: options.resolveAddresses } : {}),
|
|
115
|
+
},
|
|
116
|
+
isHostAllowed,
|
|
117
|
+
)
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
async listen(port = 0): Promise<RunningEgressProxy> {
|
|
@@ -143,6 +173,18 @@ export class EgressProxy {
|
|
|
143
173
|
this.onDenied?.(host, reason)
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Why this target may not be dialled as written, or `null`.
|
|
178
|
+
*
|
|
179
|
+
* Covers the literal-address case only; a name is screened inside
|
|
180
|
+
* `this.lookup`, which the socket calls. Both are needed — see
|
|
181
|
+
* `blockedLiteralReason` for why one cannot cover the other.
|
|
182
|
+
*/
|
|
183
|
+
private literalDenial(host: string): string | null {
|
|
184
|
+
if (this.inwardAllowed.length > 0 && isHostAllowed(host, this.inwardAllowed)) return null
|
|
185
|
+
return blockedLiteralReason(host)
|
|
186
|
+
}
|
|
187
|
+
|
|
146
188
|
/** Plain HTTP. The only path where a credential can be stamped on. */
|
|
147
189
|
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
148
190
|
const target = parseTarget(req)
|
|
@@ -170,6 +212,20 @@ export class EgressProxy {
|
|
|
170
212
|
return
|
|
171
213
|
}
|
|
172
214
|
|
|
215
|
+
const literal = this.literalDenial(target.host)
|
|
216
|
+
if (literal) {
|
|
217
|
+
this.deny(target.host, `is a ${literal} address`)
|
|
218
|
+
res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' })
|
|
219
|
+
// Refused BEFORE the credential is looked up, let alone stamped.
|
|
220
|
+
// The ordering is the point: on this path the token goes on the
|
|
221
|
+
// headers a few lines below, so a check that ran after it would be
|
|
222
|
+
// deciding whether to send a request that already carried it.
|
|
223
|
+
res.end(
|
|
224
|
+
`Egress denied: ${target.host} is a ${literal} address, which is not reachable from a sandbox.\n`,
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
173
229
|
const headers = { ...req.headers }
|
|
174
230
|
// The proxy re-issues the request, so hop-by-hop headers about the
|
|
175
231
|
// hop that just ended must not be forwarded.
|
|
@@ -192,6 +248,11 @@ export class EgressProxy {
|
|
|
192
248
|
method: req.method,
|
|
193
249
|
path: target.path,
|
|
194
250
|
headers,
|
|
251
|
+
// `host` stays the NAME so SNI and certificate validation still
|
|
252
|
+
// check the name the allowlist approved; only the address the
|
|
253
|
+
// socket dials is screened. Swapping in the address here would
|
|
254
|
+
// break TLS verification, which is the wrong way to fix this.
|
|
255
|
+
lookup: this.lookup,
|
|
195
256
|
},
|
|
196
257
|
(response) => {
|
|
197
258
|
res.writeHead(response.statusCode ?? 502, response.headers)
|
|
@@ -200,6 +261,17 @@ export class EgressProxy {
|
|
|
200
261
|
)
|
|
201
262
|
|
|
202
263
|
upstream.on('error', (err) => {
|
|
264
|
+
// An address denial is a policy refusal, not a network fault, and
|
|
265
|
+
// telling them apart is the difference between an agent that stops
|
|
266
|
+
// and one that retries a forbidden host forever. The credential is
|
|
267
|
+
// already on `headers` at this point and has still not left the
|
|
268
|
+
// process: the socket never connected, so nothing was written.
|
|
269
|
+
if (err instanceof EgressAddressDenied) {
|
|
270
|
+
this.deny(err.host, `resolves to a ${err.reason} address`)
|
|
271
|
+
if (!res.headersSent) res.writeHead(DENIED_STATUS, { 'content-type': 'text/plain' })
|
|
272
|
+
res.end(`${err.message}\n`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
203
275
|
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
|
|
204
276
|
res.end(`Upstream request failed: ${err.message}\n`)
|
|
205
277
|
})
|
|
@@ -216,6 +288,18 @@ export class EgressProxy {
|
|
|
216
288
|
* agent sends anywhere, a strictly larger risk than the one being
|
|
217
289
|
* mitigated. A workload that needs brokering speaks plain HTTP to the
|
|
218
290
|
* proxy and lets it upgrade upstream.
|
|
291
|
+
*
|
|
292
|
+
* **And the allowlist here is a check on the name in the CONNECT line,
|
|
293
|
+
* which is the only thing about this tunnel that is ever in clear text.**
|
|
294
|
+
* The address screen makes it a check on where that name goes, which is a
|
|
295
|
+
* real bound — a permitted name can no longer be a route to the host's own
|
|
296
|
+
* network. It is not a check on what travels afterwards. Inside the tunnel
|
|
297
|
+
* the bytes are the caller's, and the name the caller puts in its own TLS
|
|
298
|
+
* handshake or `Host` header is not visible to this process, so against a
|
|
299
|
+
* determined caller running inside the sandbox this path bounds the
|
|
300
|
+
* DESTINATION and nothing else. Say so plainly rather than let a reader
|
|
301
|
+
* infer that a tunnel to an allowlisted host carries only allowlisted
|
|
302
|
+
* traffic.
|
|
219
303
|
*/
|
|
220
304
|
private async handleConnect(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
|
|
221
305
|
const { host, port } = splitAuthority(req.url ?? '')
|
|
@@ -229,14 +313,34 @@ export class EgressProxy {
|
|
|
229
313
|
return
|
|
230
314
|
}
|
|
231
315
|
|
|
232
|
-
const
|
|
316
|
+
const literal = this.literalDenial(host)
|
|
317
|
+
if (literal) {
|
|
318
|
+
this.deny(host, `is a ${literal} address`)
|
|
319
|
+
socket.write(
|
|
320
|
+
`HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\nEgress denied: ${host} is a ${literal} address, which is not reachable from a sandbox.\n`,
|
|
321
|
+
)
|
|
322
|
+
socket.end()
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Same screened resolver as the plain path. The tunnel carries no
|
|
327
|
+
// brokered credential, so the loss here is reach rather than a token —
|
|
328
|
+
// but an allowlisted name pointing inward still turns this proxy into
|
|
329
|
+
// a route to the host's own network, which is what a sandbox is for.
|
|
330
|
+
const upstream = netConnect({ port: port ?? 443, host, lookup: this.lookup }, () => {
|
|
233
331
|
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
234
332
|
if (head.length > 0) upstream.write(head)
|
|
235
333
|
upstream.pipe(socket)
|
|
236
334
|
socket.pipe(upstream)
|
|
237
335
|
})
|
|
238
336
|
|
|
239
|
-
upstream.on('error', () => {
|
|
337
|
+
upstream.on('error', (err) => {
|
|
338
|
+
if (err instanceof EgressAddressDenied) {
|
|
339
|
+
this.deny(err.host, `resolves to a ${err.reason} address`)
|
|
340
|
+
socket.write(
|
|
341
|
+
`HTTP/1.1 ${DENIED_STATUS} Forbidden\r\nContent-Type: text/plain\r\n\r\n${err.message}\n`,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
240
344
|
socket.end()
|
|
241
345
|
})
|
|
242
346
|
socket.on('error', () => {
|
package/src/index.ts
CHANGED
|
@@ -210,6 +210,22 @@ export interface ContainerBackendConfig {
|
|
|
210
210
|
* separately by `EgressPolicy`.
|
|
211
211
|
*/
|
|
212
212
|
readonly network?: 'none' | 'bridge' | string
|
|
213
|
+
/**
|
|
214
|
+
* Allowlisted hosts permitted to resolve to an inward address anyway.
|
|
215
|
+
*
|
|
216
|
+
* The egress boundary refuses a host that resolves to loopback, a
|
|
217
|
+
* private range, or the link-local block cloud metadata services answer
|
|
218
|
+
* on — whatever the allowlist says, because an allowlisted name whose
|
|
219
|
+
* DNS someone else controls is a permitted spelling rather than a
|
|
220
|
+
* permitted destination. A deployment that genuinely proxies to one
|
|
221
|
+
* service on a private network names that service here.
|
|
222
|
+
*
|
|
223
|
+
* Per host, matched by the allowlist's own rules so `.internal.example`
|
|
224
|
+
* covers subdomains. There is deliberately no switch that turns the
|
|
225
|
+
* screen off: one would hand every other allowlisted name the same
|
|
226
|
+
* reach, which is the hole the screen exists to close.
|
|
227
|
+
*/
|
|
228
|
+
readonly allowInwardFor?: readonly string[]
|
|
213
229
|
/**
|
|
214
230
|
* Optional `--label key=value` pairs applied to the spawned
|
|
215
231
|
* container. Hosts use this to make the container findable from
|
|
@@ -549,6 +565,7 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
|
549
565
|
? { hostReachability: backend.hostReachability }
|
|
550
566
|
: {}),
|
|
551
567
|
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
568
|
+
...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
|
|
552
569
|
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
553
570
|
})
|
|
554
571
|
}
|
|
@@ -599,6 +616,7 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
|
599
616
|
? { hostReachability: backend.hostReachability }
|
|
600
617
|
: {}),
|
|
601
618
|
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
619
|
+
...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
|
|
602
620
|
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
603
621
|
})
|
|
604
622
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"unenforceable-controls.test.d.ts","sourceRoot":"","sources":["../../../../src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { assertEnforceable } from '../index.js';
|
|
3
|
-
/**
|
|
4
|
-
* The claim API rejects every property override that is not a config map,
|
|
5
|
-
* so a memory cap, a process cap, environment variables and an egress
|
|
6
|
-
* policy have nowhere to ride through on this backend. They were accepted
|
|
7
|
-
* and dropped: a host that asked for `deny-all` and 512 MB got full
|
|
8
|
-
* outbound network and no cap, with no error and no warning — from the same
|
|
9
|
-
* call shape that IS enforced on the sibling container backend.
|
|
10
|
-
*
|
|
11
|
-
* Switching backends therefore removed the blast-radius controls silently,
|
|
12
|
-
* which is the worst way to lose them. namzu already holds the norm for
|
|
13
|
-
* this next door: a policy accepted and quietly ignored is worse than one
|
|
14
|
-
* that is refused.
|
|
15
|
-
*/
|
|
16
|
-
const opts = (extra = {}) => ({
|
|
17
|
-
workingDirectory: '/w',
|
|
18
|
-
...extra,
|
|
19
|
-
});
|
|
20
|
-
describe('controls this backend cannot apply', () => {
|
|
21
|
-
it.each([
|
|
22
|
-
['an egress policy', { egress: { kind: 'deny-all' } }],
|
|
23
|
-
['a memory limit', { memoryLimitMb: 512 }],
|
|
24
|
-
['a process limit', { maxProcesses: 32 }],
|
|
25
|
-
['environment variables', { env: { SECRET: 'x' } }],
|
|
26
|
-
])('refuses %s', (_name, extra) => {
|
|
27
|
-
expect(() => assertEnforceable(opts(extra))).toThrow(/cannot enforce per-sandbox/);
|
|
28
|
-
});
|
|
29
|
-
it('names every field it cannot honour, not just the first', () => {
|
|
30
|
-
try {
|
|
31
|
-
assertEnforceable(opts({ egress: { kind: 'deny-all' }, memoryLimitMb: 512, maxProcesses: 8 }));
|
|
32
|
-
expect.unreachable();
|
|
33
|
-
}
|
|
34
|
-
catch (err) {
|
|
35
|
-
const message = err.message;
|
|
36
|
-
// A host fixing one and hitting the next on the following run
|
|
37
|
-
// learns the shape one failure at a time.
|
|
38
|
-
expect(message).toContain('egress');
|
|
39
|
-
expect(message).toContain('memory');
|
|
40
|
-
expect(message).toContain('process');
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
it('says where the limits do belong', () => {
|
|
44
|
-
// Refusing without saying what to do instead is a dead end; these
|
|
45
|
-
// are a property of the pooled profile.
|
|
46
|
-
expect(() => assertEnforceable(opts({ memoryLimitMb: 512 }))).toThrow(/container group profile/);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
describe('what it does allow through', () => {
|
|
50
|
-
it('accepts a request that asks for nothing it cannot apply', () => {
|
|
51
|
-
expect(() => assertEnforceable(opts())).not.toThrow();
|
|
52
|
-
});
|
|
53
|
-
it('accepts a timeout, which is applied by the caller not the pool', () => {
|
|
54
|
-
expect(() => assertEnforceable(opts({ timeoutMs: 30_000 }))).not.toThrow();
|
|
55
|
-
});
|
|
56
|
-
it('treats an empty env bag as asking for nothing', () => {
|
|
57
|
-
// A host spreading an empty object should not be refused for it.
|
|
58
|
-
expect(() => assertEnforceable(opts({ env: {} }))).not.toThrow();
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
//# sourceMappingURL=unenforceable-controls.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"unenforceable-controls.test.js","sourceRoot":"","sources":["../../../../src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"hardening.test.d.ts","sourceRoot":"","sources":["../../../../src/backends/docker/__tests__/hardening.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { resolveNetwork } from '../index.js';
|
|
3
|
-
/**
|
|
4
|
-
* `EgressPolicy` was accepted by the type, threaded through the options,
|
|
5
|
-
* and silently ignored by this backend. That is worse than not supporting
|
|
6
|
-
* it: a host that set `deny-all` believed the container had no network,
|
|
7
|
-
* and it had whatever `config.network` said.
|
|
8
|
-
*/
|
|
9
|
-
describe('resolveNetwork', () => {
|
|
10
|
-
it('leaves the configured network alone when no policy is supplied', () => {
|
|
11
|
-
expect(resolveNetwork('bridge', undefined)).toBe('bridge');
|
|
12
|
-
});
|
|
13
|
-
it('enforces deny-all natively', () => {
|
|
14
|
-
// Docker can actually do this one, so there is no excuse for
|
|
15
|
-
// accepting the policy and ignoring it.
|
|
16
|
-
expect(resolveNetwork('bridge', { kind: 'deny-all' })).toBe('none');
|
|
17
|
-
});
|
|
18
|
-
it('allow-all keeps the configured network', () => {
|
|
19
|
-
expect(resolveNetwork('my-bridge', { kind: 'allow-all' })).toBe('my-bridge');
|
|
20
|
-
});
|
|
21
|
-
it('REFUSES a host allowlist rather than silently granting full access', () => {
|
|
22
|
-
// This backend has no proxy to filter through. Downgrading a
|
|
23
|
-
// restrictive policy to "allow everything" is the failure mode that
|
|
24
|
-
// makes a security control worse than useless.
|
|
25
|
-
expect(() => resolveNetwork('bridge', { kind: 'static', allowedHosts: ['example.com'] })).toThrow(/cannot enforce an egress policy/);
|
|
26
|
-
expect(() => resolveNetwork('bridge', { kind: 'resolver', resolve: async () => [] })).toThrow(/cannot enforce an egress policy/);
|
|
27
|
-
});
|
|
28
|
-
it('names what it can do, so the failure is actionable', () => {
|
|
29
|
-
expect(() => resolveNetwork('bridge', { kind: 'static', allowedHosts: [] })).toThrow(/deny-all/);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
//# sourceMappingURL=hardening.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"hardening.test.js","sourceRoot":"","sources":["../../../../src/backends/docker/__tests__/hardening.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AAE7C,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAE5C;;;;;GAKG;AAEH,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE;IAC/B,EAAE,CAAC,gEAAgE,EAAE,GAAG,EAAE;QACzE,MAAM,CAAC,cAAc,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAC3D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4BAA4B,EAAE,GAAG,EAAE;QACrC,6DAA6D;QAC7D,wCAAwC;QACxC,MAAM,CAAC,cAAc,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACpE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wCAAwC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,cAAc,CAAC,WAAW,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC7E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oEAAoE,EAAE,GAAG,EAAE;QAC7E,6DAA6D;QAC7D,oEAAoE;QACpE,+CAA+C;QAC/C,MAAM,CAAC,GAAG,EAAE,CACX,cAAc,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,YAAY,EAAE,CAAC,aAAa,CAAC,EAAE,CAAC,CAC3E,CAAC,OAAO,CAAC,iCAAiC,CAAC,CAAA;QAE5C,MAAM,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAC5F,iCAAiC,CACjC,CAAA;IACF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC7D,MAAM,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,YAAY,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IACjG,CAAC,CAAC,CAAA;AACH,CAAC,CAAC,CAAA"}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Docker smoke test — leaf-mount permission semantics.
|
|
3
|
-
*
|
|
4
|
-
* Concern raised in review: the Dockerfile pre-creates only the parent
|
|
5
|
-
* dirs (`/mnt`, `/mnt/user-data`, `/mnt/skills`) as root-owned 0555.
|
|
6
|
-
* The runtime claim is that:
|
|
7
|
-
*
|
|
8
|
-
* 1. When `outputs` IS bound, uid 1001 (the worker user) can write
|
|
9
|
-
* into it.
|
|
10
|
-
* 2. When a leaf path (e.g. `/mnt/user-data/uploads`) is NOT bound,
|
|
11
|
-
* the path simply does not exist — `stat` returns ENOENT, NOT
|
|
12
|
-
* a writable empty dir.
|
|
13
|
-
*
|
|
14
|
-
* Both of those are kernel-level mount-namespace properties, not
|
|
15
|
-
* unit-testable. This file exercises them against a real docker
|
|
16
|
-
* daemon. Run via:
|
|
17
|
-
*
|
|
18
|
-
* pnpm --filter @namzu/sandbox test:smoke
|
|
19
|
-
* # or, from the monorepo root:
|
|
20
|
-
* pnpm sandbox:smoke
|
|
21
|
-
*
|
|
22
|
-
* Excluded from the default `pnpm test` run by `vitest.config.ts`.
|
|
23
|
-
*
|
|
24
|
-
* Pre-requisites:
|
|
25
|
-
* - `docker` CLI on PATH.
|
|
26
|
-
* - A pre-built reference image. By default this test looks for
|
|
27
|
-
* `namzu-sandbox-worker:smoke`. Override with the
|
|
28
|
-
* `NAMZU_SANDBOX_SMOKE_IMAGE` env. Build the image once with:
|
|
29
|
-
* `docker build -t namzu-sandbox-worker:smoke -f packages/sandbox/worker/Dockerfile packages/sandbox`
|
|
30
|
-
* - A host-side scratch directory the test creates and tears down.
|
|
31
|
-
*
|
|
32
|
-
* On a developer machine without docker the test self-skips with a
|
|
33
|
-
* clear message. On CI (`process.env.CI === 'true'`) the same
|
|
34
|
-
* pre-conditions FAIL FAST instead of skipping, so a CI
|
|
35
|
-
* misconfiguration cannot silently mask a regression — see the
|
|
36
|
-
* `.github/workflows/sandbox-smoke.yml` workflow which is meant to
|
|
37
|
-
* always run with docker available.
|
|
38
|
-
*/
|
|
39
|
-
export {};
|
|
40
|
-
//# sourceMappingURL=leaf-permissions.smoke.test.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"leaf-permissions.smoke.test.d.ts","sourceRoot":"","sources":["../../../../src/backends/docker/__tests__/leaf-permissions.smoke.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG"}
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Docker smoke test — leaf-mount permission semantics.
|
|
3
|
-
*
|
|
4
|
-
* Concern raised in review: the Dockerfile pre-creates only the parent
|
|
5
|
-
* dirs (`/mnt`, `/mnt/user-data`, `/mnt/skills`) as root-owned 0555.
|
|
6
|
-
* The runtime claim is that:
|
|
7
|
-
*
|
|
8
|
-
* 1. When `outputs` IS bound, uid 1001 (the worker user) can write
|
|
9
|
-
* into it.
|
|
10
|
-
* 2. When a leaf path (e.g. `/mnt/user-data/uploads`) is NOT bound,
|
|
11
|
-
* the path simply does not exist — `stat` returns ENOENT, NOT
|
|
12
|
-
* a writable empty dir.
|
|
13
|
-
*
|
|
14
|
-
* Both of those are kernel-level mount-namespace properties, not
|
|
15
|
-
* unit-testable. This file exercises them against a real docker
|
|
16
|
-
* daemon. Run via:
|
|
17
|
-
*
|
|
18
|
-
* pnpm --filter @namzu/sandbox test:smoke
|
|
19
|
-
* # or, from the monorepo root:
|
|
20
|
-
* pnpm sandbox:smoke
|
|
21
|
-
*
|
|
22
|
-
* Excluded from the default `pnpm test` run by `vitest.config.ts`.
|
|
23
|
-
*
|
|
24
|
-
* Pre-requisites:
|
|
25
|
-
* - `docker` CLI on PATH.
|
|
26
|
-
* - A pre-built reference image. By default this test looks for
|
|
27
|
-
* `namzu-sandbox-worker:smoke`. Override with the
|
|
28
|
-
* `NAMZU_SANDBOX_SMOKE_IMAGE` env. Build the image once with:
|
|
29
|
-
* `docker build -t namzu-sandbox-worker:smoke -f packages/sandbox/worker/Dockerfile packages/sandbox`
|
|
30
|
-
* - A host-side scratch directory the test creates and tears down.
|
|
31
|
-
*
|
|
32
|
-
* On a developer machine without docker the test self-skips with a
|
|
33
|
-
* clear message. On CI (`process.env.CI === 'true'`) the same
|
|
34
|
-
* pre-conditions FAIL FAST instead of skipping, so a CI
|
|
35
|
-
* misconfiguration cannot silently mask a regression — see the
|
|
36
|
-
* `.github/workflows/sandbox-smoke.yml` workflow which is meant to
|
|
37
|
-
* always run with docker available.
|
|
38
|
-
*/
|
|
39
|
-
import { spawnSync } from 'node:child_process';
|
|
40
|
-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
41
|
-
import { tmpdir } from 'node:os';
|
|
42
|
-
import { join } from 'node:path';
|
|
43
|
-
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
44
|
-
import { createSandboxProvider } from '../../../index.js';
|
|
45
|
-
const IMAGE = process.env.NAMZU_SANDBOX_SMOKE_IMAGE ?? 'namzu-sandbox-worker:smoke';
|
|
46
|
-
const IS_CI = process.env.CI === 'true';
|
|
47
|
-
function dockerAvailable() {
|
|
48
|
-
const probe = spawnSync('docker', ['version', '--format', '{{.Server.Version}}'], {
|
|
49
|
-
stdio: 'ignore',
|
|
50
|
-
});
|
|
51
|
-
return probe.status === 0;
|
|
52
|
-
}
|
|
53
|
-
function imagePresent(image) {
|
|
54
|
-
const probe = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' });
|
|
55
|
-
return probe.status === 0;
|
|
56
|
-
}
|
|
57
|
-
const skipReason = !dockerAvailable()
|
|
58
|
-
? 'docker daemon not reachable — skipping smoke test'
|
|
59
|
-
: !imagePresent(IMAGE)
|
|
60
|
-
? `image ${IMAGE} not present — build it with \`docker build -t ${IMAGE} -f packages/sandbox/worker/Dockerfile packages/sandbox\` and re-run`
|
|
61
|
-
: null;
|
|
62
|
-
// CI fail-fast: the smoke workflow is meant to always have docker +
|
|
63
|
-
// the reference image. Skipping silently in that environment would
|
|
64
|
-
// let a regression land without the test even attempting to run.
|
|
65
|
-
// Locally (no `CI=true`) skipping with a hint is the right behaviour
|
|
66
|
-
// for a developer who hasn't built the image yet.
|
|
67
|
-
if (IS_CI && skipReason) {
|
|
68
|
-
throw new Error(`smoke test pre-condition not satisfied on CI: ${skipReason}. Configure the workflow to install docker and build the reference image before running.`);
|
|
69
|
-
}
|
|
70
|
-
describe.skipIf(skipReason !== null)('docker smoke — leaf permissions', () => {
|
|
71
|
-
let outputsHost;
|
|
72
|
-
beforeAll(() => {
|
|
73
|
-
outputsHost = mkdtempSync(join(tmpdir(), 'namzu-sandbox-smoke-out-'));
|
|
74
|
-
});
|
|
75
|
-
afterAll(() => {
|
|
76
|
-
if (outputsHost) {
|
|
77
|
-
rmSync(outputsHost, { recursive: true, force: true });
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
it('uid 1001 can write to the outputs bind, and the host sees the file', async () => {
|
|
81
|
-
const provider = createSandboxProvider({
|
|
82
|
-
backend: { tier: 'container', image: IMAGE },
|
|
83
|
-
layout: {
|
|
84
|
-
outputs: { source: { type: 'hostDir', hostPath: outputsHost } },
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
const sandbox = await provider.create();
|
|
88
|
-
try {
|
|
89
|
-
const result = await sandbox.exec('sh', [
|
|
90
|
-
'-c',
|
|
91
|
-
'id -u && echo "smoke" > /mnt/user-data/outputs/hello.txt',
|
|
92
|
-
]);
|
|
93
|
-
expect(result.exitCode).toBe(0);
|
|
94
|
-
expect(result.stdout.trim()).toBe('1001');
|
|
95
|
-
// Host-side read confirms the bind round-trips.
|
|
96
|
-
const hostContent = readFileSync(join(outputsHost, 'hello.txt'), 'utf8');
|
|
97
|
-
expect(hostContent.trim()).toBe('smoke');
|
|
98
|
-
}
|
|
99
|
-
finally {
|
|
100
|
-
await sandbox.destroy();
|
|
101
|
-
}
|
|
102
|
-
}, 60_000);
|
|
103
|
-
it('unbound leaves do not exist — uploads/transcripts/tool_results are ENOENT, not empty', async () => {
|
|
104
|
-
const provider = createSandboxProvider({
|
|
105
|
-
backend: { tier: 'container', image: IMAGE },
|
|
106
|
-
layout: {
|
|
107
|
-
outputs: { source: { type: 'hostDir', hostPath: outputsHost } },
|
|
108
|
-
// Intentionally leave uploads / toolResults / transcripts
|
|
109
|
-
// unbound. The Dockerfile pre-creates only the parents
|
|
110
|
-
// (`/mnt/user-data`, `/mnt/skills`); leaves should not
|
|
111
|
-
// exist and `stat` should fail.
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
const sandbox = await provider.create();
|
|
115
|
-
try {
|
|
116
|
-
for (const leafPath of [
|
|
117
|
-
'/mnt/user-data/uploads',
|
|
118
|
-
'/mnt/user-data/tool_results',
|
|
119
|
-
'/mnt/transcripts',
|
|
120
|
-
]) {
|
|
121
|
-
const result = await sandbox.exec('sh', ['-c', `stat ${leafPath} 2>&1; echo --rc=$?`]);
|
|
122
|
-
// `stat` returns non-zero on ENOENT; the rc line carries
|
|
123
|
-
// the failure. Either is the contract; we accept either
|
|
124
|
-
// "No such file" or "cannot stat" wording across distros.
|
|
125
|
-
expect(result.stdout).toMatch(/--rc=[1-9]/);
|
|
126
|
-
expect(result.stdout.toLowerCase()).toMatch(/no such file|cannot stat|does not exist/);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
finally {
|
|
130
|
-
await sandbox.destroy();
|
|
131
|
-
}
|
|
132
|
-
}, 60_000);
|
|
133
|
-
it('uid 1001 cannot mkdir into the root-owned 0555 parent /mnt/user-data', async () => {
|
|
134
|
-
const provider = createSandboxProvider({
|
|
135
|
-
backend: { tier: 'container', image: IMAGE },
|
|
136
|
-
layout: {
|
|
137
|
-
outputs: { source: { type: 'hostDir', hostPath: outputsHost } },
|
|
138
|
-
},
|
|
139
|
-
});
|
|
140
|
-
const sandbox = await provider.create();
|
|
141
|
-
try {
|
|
142
|
-
// The model trying to "create the missing leaf dir" hits
|
|
143
|
-
// the 0555 root-owned parent and is denied — exactly what
|
|
144
|
-
// distinguishes "not bound" from "writable empty dir".
|
|
145
|
-
const result = await sandbox.exec('sh', [
|
|
146
|
-
'-c',
|
|
147
|
-
'mkdir /mnt/user-data/fake 2>&1; echo --rc=$?',
|
|
148
|
-
]);
|
|
149
|
-
expect(result.stdout).toMatch(/--rc=[1-9]/);
|
|
150
|
-
expect(result.stdout.toLowerCase()).toMatch(/permission denied/);
|
|
151
|
-
}
|
|
152
|
-
finally {
|
|
153
|
-
await sandbox.destroy();
|
|
154
|
-
}
|
|
155
|
-
}, 60_000);
|
|
156
|
-
});
|
|
157
|
-
//# sourceMappingURL=leaf-permissions.smoke.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"leaf-permissions.smoke.test.js","sourceRoot":"","sources":["../../../../src/backends/docker/__tests__/leaf-permissions.smoke.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `resolveTimeoutMs` is the guest agent's only guard against a
|
|
3
|
-
* caller-requested `timeoutMs` that has no schema ceiling upstream (it
|
|
4
|
-
* traces back to the bash tool's model-authored `timeout` argument — see
|
|
5
|
-
* `packages/sdk/src/tools/builtins/bash.ts`). A request above the cap must
|
|
6
|
-
* be refused (the sibling `worker/server.js` answers `invalid_timeout` on
|
|
7
|
-
* response), not silently shortened — a caller that asked for more and got
|
|
8
|
-
* less without being told would believe its process was protected for the
|
|
9
|
-
* duration it actually asked for.
|
|
10
|
-
*
|
|
11
|
-
* Pure-function test, no socket/process involved, so it runs on every
|
|
12
|
-
* platform (unlike `backend.test.ts` / `transport.test.ts`, which spawn
|
|
13
|
-
* `/bin/sh` and skip on Windows).
|
|
14
|
-
*/
|
|
15
|
-
export {};
|
|
16
|
-
//# sourceMappingURL=agent-timeout-clamp.test.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"agent-timeout-clamp.test.d.ts","sourceRoot":"","sources":["../../../../src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG"}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `resolveTimeoutMs` is the guest agent's only guard against a
|
|
3
|
-
* caller-requested `timeoutMs` that has no schema ceiling upstream (it
|
|
4
|
-
* traces back to the bash tool's model-authored `timeout` argument — see
|
|
5
|
-
* `packages/sdk/src/tools/builtins/bash.ts`). A request above the cap must
|
|
6
|
-
* be refused (the sibling `worker/server.js` answers `invalid_timeout` on
|
|
7
|
-
* response), not silently shortened — a caller that asked for more and got
|
|
8
|
-
* less without being told would believe its process was protected for the
|
|
9
|
-
* duration it actually asked for.
|
|
10
|
-
*
|
|
11
|
-
* Pure-function test, no socket/process involved, so it runs on every
|
|
12
|
-
* platform (unlike `backend.test.ts` / `transport.test.ts`, which spawn
|
|
13
|
-
* `/bin/sh` and skip on Windows).
|
|
14
|
-
*/
|
|
15
|
-
import { createRequire } from 'node:module';
|
|
16
|
-
import { describe, expect, it } from 'vitest';
|
|
17
|
-
const require_ = createRequire(import.meta.url);
|
|
18
|
-
const agent = require_('../../../../agent/agent.cjs');
|
|
19
|
-
describe('agent.cjs resolveTimeoutMs', () => {
|
|
20
|
-
it('passes through a caller value within the cap', () => {
|
|
21
|
-
expect(agent.resolveTimeoutMs(10_000)).toBe(10_000);
|
|
22
|
-
});
|
|
23
|
-
it('falls back to the default for an omitted value', () => {
|
|
24
|
-
expect(agent.resolveTimeoutMs(undefined)).toBe(5 * 60 * 1000);
|
|
25
|
-
});
|
|
26
|
-
it('refuses a request far above the cap instead of honoring or silently shortening it', () => {
|
|
27
|
-
expect(() => agent.resolveTimeoutMs(Number.MAX_SAFE_INTEGER)).toThrow(/timeoutMs/);
|
|
28
|
-
expect(() => agent.resolveTimeoutMs(10 ** 12)).toThrow(/timeoutMs/);
|
|
29
|
-
});
|
|
30
|
-
it('refuses a non-finite or non-positive value', () => {
|
|
31
|
-
expect(() => agent.resolveTimeoutMs(0)).toThrow(/timeoutMs/);
|
|
32
|
-
expect(() => agent.resolveTimeoutMs(-1)).toThrow(/timeoutMs/);
|
|
33
|
-
expect(() => agent.resolveTimeoutMs(Number.NaN)).toThrow(/timeoutMs/);
|
|
34
|
-
expect(() => agent.resolveTimeoutMs(Number.POSITIVE_INFINITY)).toThrow(/timeoutMs/);
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
//# sourceMappingURL=agent-timeout-clamp.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"agent-timeout-clamp.test.js","sourceRoot":"","sources":["../../../../src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AAE7C,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAM/C,MAAM,KAAK,GAAG,QAAQ,CAAC,6BAA6B,CAAgB,CAAA;AAEpE,QAAQ,CAAC,4BAA4B,EAAE,GAAG,EAAE;IAC3C,EAAE,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACvD,MAAM,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACpD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,KAAK,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mFAAmF,EAAE,GAAG,EAAE;QAC5F,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;QAClF,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IACpE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACrD,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;QAC5D,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;QAC7D,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;QACrE,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IACpF,CAAC,CAAC,CAAA;AACH,CAAC,CAAC,CAAA"}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Firecracker `SandboxBackend` end-to-end over the loopback agent.
|
|
3
|
-
*
|
|
4
|
-
* Stubs the orchestrator control plane (`fetch` for `/sandboxes` create
|
|
5
|
-
* + `:delete`) and points the returned vsock handle at a unix-socket
|
|
6
|
-
* loopback running the REAL `agent/agent.cjs`. Proves the full
|
|
7
|
-
* {@link Sandbox} handle the SDK consumes — `exec` / `writeFile` /
|
|
8
|
-
* `readFile` / `listFiles` / `destroy` / `status` — speaks the NDJSON
|
|
9
|
-
* wire over the vsock transport, and that the readiness fence waits on
|
|
10
|
-
* the agent's healthz (not the orchestrator 2xx).
|
|
11
|
-
*/
|
|
12
|
-
export {};
|
|
13
|
-
//# sourceMappingURL=backend.test.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"backend.test.d.ts","sourceRoot":"","sources":["../../../../src/backends/firecracker/__tests__/backend.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
|