@namzu/sandbox 1.1.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 +474 -0
- package/LICENSE.md +110 -0
- package/README.md +148 -0
- package/dist/backends/aci-standby-pool/index.d.ts +104 -0
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -0
- package/dist/backends/aci-standby-pool/index.js +425 -0
- package/dist/backends/aci-standby-pool/index.js.map +1 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +40 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +1 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +157 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +1 -0
- package/dist/backends/docker/index.d.ts +118 -0
- package/dist/backends/docker/index.d.ts.map +1 -0
- package/dist/backends/docker/index.js +645 -0
- package/dist/backends/docker/index.js.map +1 -0
- package/dist/backends/firecracker/__tests__/backend.test.d.ts +13 -0
- package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/backend.test.js +353 -0
- package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +19 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +201 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +39 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +149 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +1 -0
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts +6 -0
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/protocol.test.js +77 -0
- package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/transport.test.d.ts +20 -0
- package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/transport.test.js +449 -0
- package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -0
- package/dist/backends/firecracker/index.d.ts +124 -0
- package/dist/backends/firecracker/index.d.ts.map +1 -0
- package/dist/backends/firecracker/index.js +334 -0
- package/dist/backends/firecracker/index.js.map +1 -0
- package/dist/backends/firecracker/protocol.d.ts +132 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -0
- package/dist/backends/firecracker/protocol.js +112 -0
- package/dist/backends/firecracker/protocol.js.map +1 -0
- package/dist/backends/firecracker/transport.d.ts +251 -0
- package/dist/backends/firecracker/transport.d.ts.map +1 -0
- package/dist/backends/firecracker/transport.js +524 -0
- package/dist/backends/firecracker/transport.js.map +1 -0
- package/dist/index.d.ts +611 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +376 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +28 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +670 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +54 -0
- package/src/backends/aci-standby-pool/index.ts +602 -0
- package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +169 -0
- package/src/backends/docker/index.ts +826 -0
- package/src/backends/firecracker/__tests__/backend.test.ts +418 -0
- package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +253 -0
- package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +166 -0
- package/src/backends/firecracker/__tests__/protocol.test.ts +90 -0
- package/src/backends/firecracker/__tests__/transport.test.ts +526 -0
- package/src/backends/firecracker/index.ts +528 -0
- package/src/backends/firecracker/protocol.ts +191 -0
- package/src/backends/firecracker/transport.ts +667 -0
- package/src/index.test.ts +731 -0
- package/src/index.ts +930 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @namzu/sandbox
|
|
2
|
+
|
|
3
|
+
Pluggable sandbox provider for [`@namzu/sdk`](../sdk). Four tiers,
|
|
4
|
+
each backed by the industrial-standard primitive for that
|
|
5
|
+
deployment shape. Same `SandboxProvider` surface the SDK consumes
|
|
6
|
+
across all of them — swapping tiers is a config change, not an
|
|
7
|
+
integration rewrite.
|
|
8
|
+
|
|
9
|
+
## Tier matrix (2026 industrial standard)
|
|
10
|
+
|
|
11
|
+
| Tier | Use case | Primitive | Cold-start | Local dev |
|
|
12
|
+
|---|---|---|---|---|
|
|
13
|
+
| `process` | Agent runs on the developer's own host (Claude Code-style "don't read `~/.ssh`") | bubblewrap (Linux/WSL2) or Seatbelt (macOS), via [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropic-experimental/sandbox-runtime) | Process spawn (~ms) | Native — no infra |
|
|
14
|
+
| `container` (`docker`) | App in `docker compose` locally or single-tenant prod replica | OCI container, seccomp default profile, tmpfs workdir, no-network default | 0.5–2s | `docker compose up` |
|
|
15
|
+
| `container` (`runsc`) | Trusted-tenant SaaS — what OpenAI Code Interpreter and [Modal](https://modal.com/blog/gvisor-savings-article) ship | Google [gVisor](https://gvisor.dev/docs) userspace kernel as Docker runtime | container start + ~100ms | Linux Docker only (no Docker Desktop on macOS) |
|
|
16
|
+
| `microvm` (`e2b`) | Adversarial multi-tenant SaaS, Python-REPL workloads | Firecracker microVM via [E2B](https://e2b.dev/docs/sandbox) managed service | ~150ms (snapshot/restore) | E2B API key from any laptop |
|
|
17
|
+
| `microvm` (`fly-machines`) | Adversarial multi-tenant SaaS, arbitrary tool-call workloads | Firecracker microVM via [Fly Machines](https://fly.io/docs/machines) | 250ms–1s | Fly API token from any laptop |
|
|
18
|
+
| `microvm` (`self-hosted`) | Same threat model, host insists on owning the scheduler | [`firecracker-containerd`](https://github.com/firecracker-microvm/firecracker-containerd) on KVM-enabled Linux | <300ms with snapshot restore | Lima/Colima Linux VM on macOS |
|
|
19
|
+
| `passthrough` | Tests and explicitly trusted environments | Direct host process — no isolation | n/a | n/a |
|
|
20
|
+
|
|
21
|
+
## Why these tiers (and not others)
|
|
22
|
+
|
|
23
|
+
The 2026 consensus across production agent platforms (AWS
|
|
24
|
+
Lambda/Fargate, Fly Machines, Replit, E2B, Modal, OpenAI Code
|
|
25
|
+
Interpreter, Anthropic Code Execution, Daytona) bifurcates cleanly
|
|
26
|
+
along the **trust boundary**:
|
|
27
|
+
|
|
28
|
+
- **Adversarial multi-tenant code execution → Firecracker microVMs.**
|
|
29
|
+
AWS, Fly, Replit, E2B, Daytona all converged here. The argument
|
|
30
|
+
is in [Fly's "Sandboxing and Workload Isolation"](https://fly.io/blog/sandboxing-and-workload-isolation)
|
|
31
|
+
and the [original Firecracker paper](https://www.usenix.org/conference/nsdi20/presentation/agache):
|
|
32
|
+
KVM-backed VMs are the only mainstream primitive with a
|
|
33
|
+
kernel-level trust boundary, and `jailer` plus snapshot/restore
|
|
34
|
+
makes them boot in 125ms.
|
|
35
|
+
- **Trusted-tenant or first-party workloads → gVisor.** Google's
|
|
36
|
+
GKE Sandbox, Modal, OpenAI Code Interpreter run gVisor's `runsc`.
|
|
37
|
+
Near-zero cold-start, runs on commodity Linux without nested
|
|
38
|
+
virt. Tradeoff: a userspace-kernel CVE is a tenant escape; a
|
|
39
|
+
Firecracker CVE generally is not.
|
|
40
|
+
- **Single-user dev workstation → bubblewrap / Seatbelt.** What
|
|
41
|
+
Anthropic itself ships with Claude Code via
|
|
42
|
+
`@anthropic-ai/sandbox-runtime`. The threat model is "don't let
|
|
43
|
+
the agent read `~/.ssh` or run `rm -rf ~`," not "tenant A vs
|
|
44
|
+
tenant B." Process-spawn cold-start.
|
|
45
|
+
- **Single-tenant or co-trusted tenants → plain Docker + seccomp.**
|
|
46
|
+
Northflank, Railway, Render, Compass-platform, GitHub Actions
|
|
47
|
+
runners. Adequate when the model is your model and the user is
|
|
48
|
+
your customer; insufficient when the prompt is the attacker.
|
|
49
|
+
|
|
50
|
+
`@namzu/sandbox` exposes all four as separate tiers so the host
|
|
51
|
+
picks the trust boundary that matches its threat model.
|
|
52
|
+
|
|
53
|
+
**What we deliberately do NOT build** is yet-another Firecracker
|
|
54
|
+
scheduler. That is E2B's and Fly's entire product, and writing
|
|
55
|
+
our own would be a years-long detour. We adapt to theirs and
|
|
56
|
+
reserve the `self-hosted` option for hosts that need to own the
|
|
57
|
+
scheduler for compliance or air-gap reasons.
|
|
58
|
+
|
|
59
|
+
## Cloud portability
|
|
60
|
+
|
|
61
|
+
The interface is cloud-agnostic. `docker` works on every cloud,
|
|
62
|
+
`e2b` and `fly-machines` are managed services not tied to any
|
|
63
|
+
cloud, `runsc` and `firecracker:self-hosted` need infrastructure
|
|
64
|
+
the host chooses (GKE Sandbox, AWS Fargate, self-hosted KVM, etc.).
|
|
65
|
+
Picking a stronger backend may imply picking a different cloud —
|
|
66
|
+
that's the host's call, not the SDK's.
|
|
67
|
+
|
|
68
|
+
## Egress allowlist policy
|
|
69
|
+
|
|
70
|
+
Every backend supports the same `EgressPolicy` shape:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
type EgressPolicy =
|
|
74
|
+
| { kind: 'deny-all' } // default
|
|
75
|
+
| { kind: 'allow-all' } // tests only
|
|
76
|
+
| { kind: 'static'; allowedHosts: readonly string[] }
|
|
77
|
+
| { kind: 'resolver'; resolve: () => Promise<readonly string[]> }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The `resolver` shape is **parameterless on purpose**. Hosts that
|
|
81
|
+
need per-tenant policies bake the tenant identity into the closure
|
|
82
|
+
that constructs the provider — exactly how compass-platform's
|
|
83
|
+
JWT-minting flow already works (the server knows the tenant when
|
|
84
|
+
it issues the JWT, the allowlist claim is baked in there). This
|
|
85
|
+
avoids the "where does the resolver get its context from"
|
|
86
|
+
plumbing problem; the host owns the closure, the SDK runtime
|
|
87
|
+
doesn't have to forward identity through `provider.create`.
|
|
88
|
+
|
|
89
|
+
## Status
|
|
90
|
+
|
|
91
|
+
This package is being built out across the `ses_004-native-agentic-runtime-and-sandbox`
|
|
92
|
+
design session in phases. Each phase ships one tier, fully
|
|
93
|
+
implemented + tested + documented:
|
|
94
|
+
|
|
95
|
+
- ✅ **P3.0** — Public surface (this commit). Backend interfaces,
|
|
96
|
+
tier discriminator, egress policy. Factory throws
|
|
97
|
+
`SandboxBackendNotImplementedError` until backends land.
|
|
98
|
+
- ⏳ **P3.1** — `container:docker` backend. Universal local-dev
|
|
99
|
+
default; ships first.
|
|
100
|
+
- ⏳ **P3.2** — `EgressPolicy` plumbing + reference egress proxy
|
|
101
|
+
(compass-platform pattern: HTTP CONNECT tunnel + JWT-claim
|
|
102
|
+
allowlist).
|
|
103
|
+
- ⏳ **P3.3** — `microvm:e2b` and `microvm:fly-machines` adapters.
|
|
104
|
+
Phase 2 production tier.
|
|
105
|
+
- ⏳ **P3.4** — `process` backend (Anthropic sandbox-runtime
|
|
106
|
+
adapter — bubblewrap/Seatbelt).
|
|
107
|
+
- ⏳ **P3.5** — `container:runsc` (gVisor) and
|
|
108
|
+
`microvm:self-hosted` (firecracker-containerd). Phase 3
|
|
109
|
+
adversarial-multi-tenant.
|
|
110
|
+
|
|
111
|
+
The interface here is what every backend implements; the staged
|
|
112
|
+
rollout is purely about turning each tier on, not about reshaping
|
|
113
|
+
the contract.
|
|
114
|
+
|
|
115
|
+
## Usage (post-implementation)
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { createSandboxProvider } from '@namzu/sandbox'
|
|
119
|
+
|
|
120
|
+
// Phase 1: ship now, works on every dev's laptop
|
|
121
|
+
const sandbox = createSandboxProvider({
|
|
122
|
+
backend: { tier: 'container', runtime: 'docker', image: 'namzu-worker:latest' },
|
|
123
|
+
defaultEgress: { kind: 'static', allowedHosts: ['api.openai.com', 'api.anthropic.com'] },
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Phase 2: production, adversarial multi-tenant, managed Firecracker
|
|
127
|
+
const sandbox = createSandboxProvider({
|
|
128
|
+
backend: { tier: 'microvm', service: 'e2b', apiKey: process.env.E2B_API_KEY! },
|
|
129
|
+
defaultEgress: {
|
|
130
|
+
kind: 'resolver',
|
|
131
|
+
resolve: async () => fetchAllowlistForTenant(tenantId),
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Phase 3: adversarial multi-tenant, self-hosted Firecracker on KVM
|
|
136
|
+
const sandbox = createSandboxProvider({
|
|
137
|
+
backend: {
|
|
138
|
+
tier: 'microvm',
|
|
139
|
+
service: 'self-hosted',
|
|
140
|
+
firecrackerBinary: '/usr/local/bin/firecracker',
|
|
141
|
+
kernelImage: '/var/lib/namzu/vmlinux',
|
|
142
|
+
rootfsImage: '/var/lib/namzu/rootfs.ext4',
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Wire into drainQuery / agent run config:
|
|
147
|
+
// sandboxProvider: sandbox
|
|
148
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Container Instances Standby Pool backend.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `docker/` — same {@link SandboxBackend} surface, same
|
|
5
|
+
* worker-HTTP contract, different shipping mechanism. Where docker
|
|
6
|
+
* `docker run`s a container on a local daemon, this backend PUTs an
|
|
7
|
+
* `Microsoft.ContainerInstance/containerGroups` resource that
|
|
8
|
+
* references a pre-warmed `Microsoft.StandbyPool/standbyContainerGroupPools`
|
|
9
|
+
* resource — Azure hands back a warm ACI in ~1.5 s instead of a
|
|
10
|
+
* cold 10-30 s spawn. Refill is automatic per the pool's
|
|
11
|
+
* `refillPolicy`.
|
|
12
|
+
*
|
|
13
|
+
* Workspace shipping:
|
|
14
|
+
* - Docker uses bind-mounts (`hostDir` source variant).
|
|
15
|
+
* - ACI has no host filesystem; this backend ONLY accepts
|
|
16
|
+
* `azureFileShare` source variants and translates them to ACI's
|
|
17
|
+
* `properties.volumes[] + container.properties.volumeMounts[]`
|
|
18
|
+
* shape. The Vandal-side (or any host) provisions per-task
|
|
19
|
+
* shares upstream and hands them in via the layout.
|
|
20
|
+
*
|
|
21
|
+
* Authentication:
|
|
22
|
+
* - Caller supplies a `getArmToken()` async function. Sandbox
|
|
23
|
+
* keeps zero auth dependencies (`@azure/identity` etc.) — the
|
|
24
|
+
* consumer's runtime owns Managed-Identity / AzureCLI / federated
|
|
25
|
+
* credential picking. Token is fetched on every ARM call so a
|
|
26
|
+
* short-lived token survives a long-running sandbox.
|
|
27
|
+
*
|
|
28
|
+
* Trust model:
|
|
29
|
+
* - ACI runs the container in a Microsoft-owned isolation host;
|
|
30
|
+
* inside, the worker is a non-root user (image's `USER namzu`).
|
|
31
|
+
* - The container group can be subnet-injected (no public IP) when
|
|
32
|
+
* `subnetId` is supplied. Without it the IP is public — fine for
|
|
33
|
+
* benchmarking, NOT acceptable for production. Caller decides.
|
|
34
|
+
* - The Confidential variant of Standby Pools (AMD SEV-SNP TEE) is
|
|
35
|
+
* a pool-side knob, not a backend knob — the backend never
|
|
36
|
+
* chooses; it just PUTs against whichever pool the caller named.
|
|
37
|
+
*/
|
|
38
|
+
import type { ResolvedContainerSandboxLayout } from '@namzu/sdk';
|
|
39
|
+
import type { SandboxBackend } from '../../index.js';
|
|
40
|
+
/**
|
|
41
|
+
* Authentication callback. Caller returns a fresh Azure Resource
|
|
42
|
+
* Manager bearer token (audience `https://management.azure.com/`).
|
|
43
|
+
* Backend invokes this on every ARM call so a long-running sandbox
|
|
44
|
+
* survives token rotation.
|
|
45
|
+
*/
|
|
46
|
+
export type ArmTokenProvider = () => Promise<string>;
|
|
47
|
+
export interface ACIStandbyPoolBackendInternalConfig {
|
|
48
|
+
readonly subscriptionId: string;
|
|
49
|
+
readonly resourceGroup: string;
|
|
50
|
+
readonly location: string;
|
|
51
|
+
/**
|
|
52
|
+
* Fully-qualified resource ID of the Standby Pool to claim from.
|
|
53
|
+
* Example:
|
|
54
|
+
* /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.StandbyPool/standbyContainerGroupPools/<pool>
|
|
55
|
+
*/
|
|
56
|
+
readonly standbyPoolResourceId: string;
|
|
57
|
+
/**
|
|
58
|
+
* Fully-qualified resource ID of the Container Group Profile the
|
|
59
|
+
* pool was created against.
|
|
60
|
+
*/
|
|
61
|
+
readonly containerGroupProfileResourceId: string;
|
|
62
|
+
/**
|
|
63
|
+
* Container Group Profile revision the pool's warm instances were
|
|
64
|
+
* built from. Defaults to 1.
|
|
65
|
+
*/
|
|
66
|
+
readonly containerGroupProfileRevision?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Pre-resolved layout. The backend requires every mount source to
|
|
69
|
+
* be `azureFileShare`; any other variant throws.
|
|
70
|
+
*/
|
|
71
|
+
readonly layout: ResolvedContainerSandboxLayout;
|
|
72
|
+
/**
|
|
73
|
+
* Authentication callback (see {@link ArmTokenProvider}).
|
|
74
|
+
*/
|
|
75
|
+
readonly getArmToken: ArmTokenProvider;
|
|
76
|
+
/**
|
|
77
|
+
* Optional subnet to inject the container group into (no public IP).
|
|
78
|
+
* Strongly recommended for production. When omitted, ACI assigns a
|
|
79
|
+
* public IP — fine for benchmarks, attack-surface for prod.
|
|
80
|
+
*/
|
|
81
|
+
readonly subnetId?: string;
|
|
82
|
+
readonly readyPollIntervalMs?: number;
|
|
83
|
+
readonly readyTimeoutMs?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Worker HTTP port (matches the image's listening port). Default 2024.
|
|
86
|
+
*/
|
|
87
|
+
readonly workerPort?: number;
|
|
88
|
+
readonly armApiVersion?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Prefix for the ACI container group name and the inner worker
|
|
91
|
+
* container. Defaults to a Namzu-branded label; consumers (e.g.
|
|
92
|
+
* Vandal) override via env / config to brand their own
|
|
93
|
+
* deployments. The runtime appends a sandbox id suffix; the
|
|
94
|
+
* combined name is sanitised to ARM's allowed character set.
|
|
95
|
+
*/
|
|
96
|
+
readonly containerNamePrefix?: string;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build a {@link SandboxBackend} backed by Azure Container Instances
|
|
100
|
+
* Standby Pool. Construction is synchronous; the ARM PUT happens on
|
|
101
|
+
* the first `create()`.
|
|
102
|
+
*/
|
|
103
|
+
export declare function buildAciStandbyPoolBackend(config: ACIStandbyPoolBackendInternalConfig): SandboxBackend;
|
|
104
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/backends/aci-standby-pool/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAEX,8BAA8B,EAQ9B,MAAM,YAAY,CAAA;AAEnB,OAAO,KAAK,EAAE,cAAc,EAAyB,MAAM,gBAAgB,CAAA;AAE3E;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;AAEpD,MAAM,WAAW,mCAAmC;IACnD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAA;IACtC;;;OAGG;IACH,QAAQ,CAAC,+BAA+B,EAAE,MAAM,CAAA;IAChD;;;OAGG;IACH,QAAQ,CAAC,6BAA6B,CAAC,EAAE,MAAM,CAAA;IAC/C;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,8BAA8B,CAAA;IAC/C;;OAEG;IACH,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAA;IACtC;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;IAChC;;OAEG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CACrC;AASD;;;;GAIG;AACH,wBAAgB,0BAA0B,CACzC,MAAM,EAAE,mCAAmC,GACzC,cAAc,CAQhB"}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Container Instances Standby Pool backend.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `docker/` — same {@link SandboxBackend} surface, same
|
|
5
|
+
* worker-HTTP contract, different shipping mechanism. Where docker
|
|
6
|
+
* `docker run`s a container on a local daemon, this backend PUTs an
|
|
7
|
+
* `Microsoft.ContainerInstance/containerGroups` resource that
|
|
8
|
+
* references a pre-warmed `Microsoft.StandbyPool/standbyContainerGroupPools`
|
|
9
|
+
* resource — Azure hands back a warm ACI in ~1.5 s instead of a
|
|
10
|
+
* cold 10-30 s spawn. Refill is automatic per the pool's
|
|
11
|
+
* `refillPolicy`.
|
|
12
|
+
*
|
|
13
|
+
* Workspace shipping:
|
|
14
|
+
* - Docker uses bind-mounts (`hostDir` source variant).
|
|
15
|
+
* - ACI has no host filesystem; this backend ONLY accepts
|
|
16
|
+
* `azureFileShare` source variants and translates them to ACI's
|
|
17
|
+
* `properties.volumes[] + container.properties.volumeMounts[]`
|
|
18
|
+
* shape. The Vandal-side (or any host) provisions per-task
|
|
19
|
+
* shares upstream and hands them in via the layout.
|
|
20
|
+
*
|
|
21
|
+
* Authentication:
|
|
22
|
+
* - Caller supplies a `getArmToken()` async function. Sandbox
|
|
23
|
+
* keeps zero auth dependencies (`@azure/identity` etc.) — the
|
|
24
|
+
* consumer's runtime owns Managed-Identity / AzureCLI / federated
|
|
25
|
+
* credential picking. Token is fetched on every ARM call so a
|
|
26
|
+
* short-lived token survives a long-running sandbox.
|
|
27
|
+
*
|
|
28
|
+
* Trust model:
|
|
29
|
+
* - ACI runs the container in a Microsoft-owned isolation host;
|
|
30
|
+
* inside, the worker is a non-root user (image's `USER namzu`).
|
|
31
|
+
* - The container group can be subnet-injected (no public IP) when
|
|
32
|
+
* `subnetId` is supplied. Without it the IP is public — fine for
|
|
33
|
+
* benchmarking, NOT acceptable for production. Caller decides.
|
|
34
|
+
* - The Confidential variant of Standby Pools (AMD SEV-SNP TEE) is
|
|
35
|
+
* a pool-side knob, not a backend knob — the backend never
|
|
36
|
+
* chooses; it just PUTs against whichever pool the caller named.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_READY_POLL_MS = 500;
|
|
39
|
+
const DEFAULT_READY_TIMEOUT_MS = 60_000;
|
|
40
|
+
const DEFAULT_WORKER_PORT = 2024;
|
|
41
|
+
const DEFAULT_ARM_API_VERSION = '2024-05-01-preview';
|
|
42
|
+
const ARM_BASE = 'https://management.azure.com';
|
|
43
|
+
const DEFAULT_CONTAINER_NAME_PREFIX = 'namzu-task';
|
|
44
|
+
/**
|
|
45
|
+
* Build a {@link SandboxBackend} backed by Azure Container Instances
|
|
46
|
+
* Standby Pool. Construction is synchronous; the ARM PUT happens on
|
|
47
|
+
* the first `create()`.
|
|
48
|
+
*/
|
|
49
|
+
export function buildAciStandbyPoolBackend(config) {
|
|
50
|
+
return {
|
|
51
|
+
tier: 'container',
|
|
52
|
+
name: 'aci-standby-pool',
|
|
53
|
+
async create(options) {
|
|
54
|
+
return await spawnAciSandbox(config, options);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Interpret one mount source. ACI accepts two source variants:
|
|
60
|
+
* - `azureFileShare` → emit an ACI `volume.azureFile` + matching `volumeMount`.
|
|
61
|
+
* - `inImage` → emit NOTHING; the container's own filesystem carries the path.
|
|
62
|
+
*
|
|
63
|
+
* Standby-Pool-warm flows MUST use `inImage` because Standby Pool's
|
|
64
|
+
* claim-time API rejects every `volumes[]` override (the volume set
|
|
65
|
+
* is profile-baked across all warm instances). Cold-spawn ACI flows
|
|
66
|
+
* can use either.
|
|
67
|
+
*
|
|
68
|
+
* The `hostDir` variant is for docker backends and is rejected here.
|
|
69
|
+
*/
|
|
70
|
+
function interpretSource(source, label) {
|
|
71
|
+
if (source.type === 'azureFileShare') {
|
|
72
|
+
return {
|
|
73
|
+
kind: 'azureFile',
|
|
74
|
+
source: {
|
|
75
|
+
storageAccountName: source.storageAccountName,
|
|
76
|
+
shareName: source.shareName,
|
|
77
|
+
storageAccountKey: source.storageAccountKey,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (source.type === 'inImage') {
|
|
82
|
+
return { kind: 'inImage' };
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`aci-standby-pool backend cannot consume mount source type ${JSON.stringify(source.type)} for ${label}; expected 'azureFileShare' or 'inImage'. The hostDir variant belongs to the docker backend.`);
|
|
85
|
+
}
|
|
86
|
+
function buildAzureFileVolumesFromLayout(layout) {
|
|
87
|
+
const volumes = [];
|
|
88
|
+
const volumeMounts = [];
|
|
89
|
+
let counter = 0;
|
|
90
|
+
function add(mount, label, readOnly) {
|
|
91
|
+
const interpreted = interpretSource(mount.source, label);
|
|
92
|
+
// `inImage` is a no-op — the image's own filesystem provides
|
|
93
|
+
// the path. The Standby-Pool-warm flow lives on this branch.
|
|
94
|
+
if (interpreted.kind === 'inImage')
|
|
95
|
+
return;
|
|
96
|
+
const source = interpreted.source;
|
|
97
|
+
const name = `vol-${label}-${counter++}`;
|
|
98
|
+
volumes.push({
|
|
99
|
+
name,
|
|
100
|
+
azureFile: {
|
|
101
|
+
shareName: source.shareName,
|
|
102
|
+
storageAccountName: source.storageAccountName,
|
|
103
|
+
storageAccountKey: source.storageAccountKey,
|
|
104
|
+
readOnly,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
volumeMounts.push({
|
|
108
|
+
name,
|
|
109
|
+
mountPath: mount.containerPath,
|
|
110
|
+
readOnly,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
add(layout.outputs, 'outputs', false);
|
|
114
|
+
if (layout.uploads)
|
|
115
|
+
add(layout.uploads, 'uploads', true);
|
|
116
|
+
if (layout.scratch)
|
|
117
|
+
add(layout.scratch, 'scratch', false);
|
|
118
|
+
if (layout.toolResults)
|
|
119
|
+
add(layout.toolResults, 'toolResults', true);
|
|
120
|
+
if (layout.transcripts)
|
|
121
|
+
add(layout.transcripts, 'transcripts', true);
|
|
122
|
+
if (layout.skills) {
|
|
123
|
+
for (const skill of layout.skills) {
|
|
124
|
+
add({ source: skill.source, containerPath: skill.containerPath }, `skill-${skill.id}`, true);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { volumes, volumeMounts };
|
|
128
|
+
}
|
|
129
|
+
function detectEnvironment() {
|
|
130
|
+
// ACI containers run Linux. The SandboxEnvironment enum is host-
|
|
131
|
+
// platform shape, not container internals — we pick the variant
|
|
132
|
+
// the consumer's code paths expect for a Linux namespace-isolated
|
|
133
|
+
// worker.
|
|
134
|
+
return 'linux-namespace';
|
|
135
|
+
}
|
|
136
|
+
let _sandboxIdCounter = 0;
|
|
137
|
+
function generateSandboxId() {
|
|
138
|
+
const ts = Date.now().toString(36);
|
|
139
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
140
|
+
_sandboxIdCounter += 1;
|
|
141
|
+
return `sbx_${ts}_${rand}_${_sandboxIdCounter}`;
|
|
142
|
+
}
|
|
143
|
+
async function armCall(url, method, getToken, body) {
|
|
144
|
+
const token = await getToken();
|
|
145
|
+
const init = {
|
|
146
|
+
method,
|
|
147
|
+
headers: {
|
|
148
|
+
Authorization: `Bearer ${token}`,
|
|
149
|
+
'content-type': 'application/json',
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
if (body !== undefined) {
|
|
153
|
+
init.body = JSON.stringify(body);
|
|
154
|
+
}
|
|
155
|
+
const res = await fetch(url, init);
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
throw new Error(`ARM ${method} ${url} → ${res.status}: ${text}`);
|
|
159
|
+
}
|
|
160
|
+
if (res.status === 204 || res.status === 202)
|
|
161
|
+
return undefined;
|
|
162
|
+
const ct = res.headers.get('content-type') ?? '';
|
|
163
|
+
if (ct.includes('application/json')) {
|
|
164
|
+
return (await res.json());
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
async function spawnAciSandbox(config, _options) {
|
|
169
|
+
const id = generateSandboxId();
|
|
170
|
+
const prefix = config.containerNamePrefix ?? DEFAULT_CONTAINER_NAME_PREFIX;
|
|
171
|
+
const cgName = `${prefix}-${id
|
|
172
|
+
.replace(/[^a-z0-9-]/gi, '')
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.slice(0, 50)}`;
|
|
175
|
+
const apiVersion = config.armApiVersion ?? DEFAULT_ARM_API_VERSION;
|
|
176
|
+
const workerPort = config.workerPort ?? DEFAULT_WORKER_PORT;
|
|
177
|
+
const armUrl = `${ARM_BASE}/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.ContainerInstance/containerGroups/${cgName}?api-version=${apiVersion}`;
|
|
178
|
+
const { volumes, volumeMounts } = buildAzureFileVolumesFromLayout(config.layout);
|
|
179
|
+
// Standby Pool's claim API rejects every property override that
|
|
180
|
+
// is NOT a `configMap`. The empty / no-mount cases (every source
|
|
181
|
+
// is `inImage`) MUST therefore omit `containers`, `volumes`, and
|
|
182
|
+
// `volumeMounts` entirely from the PUT body — even an empty
|
|
183
|
+
// array trips the BadRequest "ContainerGroup properties other
|
|
184
|
+
// than config map are not allowed" check. The fields land only
|
|
185
|
+
// when something real needs to ride through (e.g. cold-spawn ACI
|
|
186
|
+
// with per-task azureFileShare mounts, future flow).
|
|
187
|
+
const properties = {
|
|
188
|
+
containerGroupProfile: {
|
|
189
|
+
id: config.containerGroupProfileResourceId,
|
|
190
|
+
revision: config.containerGroupProfileRevision ?? 1,
|
|
191
|
+
},
|
|
192
|
+
standbyPoolProfile: {
|
|
193
|
+
id: config.standbyPoolResourceId,
|
|
194
|
+
},
|
|
195
|
+
...(config.subnetId ? { subnetIds: [{ id: config.subnetId }] } : {}),
|
|
196
|
+
};
|
|
197
|
+
if (volumes.length > 0) {
|
|
198
|
+
properties.volumes = volumes;
|
|
199
|
+
properties.containers = [
|
|
200
|
+
{
|
|
201
|
+
name: `${prefix}-worker`,
|
|
202
|
+
properties: { volumeMounts },
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
const body = {
|
|
207
|
+
location: config.location,
|
|
208
|
+
properties,
|
|
209
|
+
};
|
|
210
|
+
let claimed;
|
|
211
|
+
try {
|
|
212
|
+
claimed = await armCall(armUrl, 'PUT', config.getArmToken, body);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw new Error(`aci-standby-pool: failed to claim from pool — ${err instanceof Error ? err.message : String(err)}`);
|
|
216
|
+
}
|
|
217
|
+
const initialIp = claimed?.properties?.ipAddress?.ip;
|
|
218
|
+
let ip = initialIp;
|
|
219
|
+
try {
|
|
220
|
+
if (!ip) {
|
|
221
|
+
ip = await pollForRunningIp(armUrl, config.getArmToken, config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS, config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
222
|
+
}
|
|
223
|
+
const baseUrl = `http://${ip}:${workerPort}`;
|
|
224
|
+
await waitForWorkerReady(baseUrl, config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS, config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS);
|
|
225
|
+
let status = 'ready';
|
|
226
|
+
const rootDir = config.layout.outputs.containerPath;
|
|
227
|
+
return {
|
|
228
|
+
id,
|
|
229
|
+
get status() {
|
|
230
|
+
return status;
|
|
231
|
+
},
|
|
232
|
+
rootDir,
|
|
233
|
+
environment: detectEnvironment(),
|
|
234
|
+
async exec(command, argv, opts) {
|
|
235
|
+
status = 'busy';
|
|
236
|
+
try {
|
|
237
|
+
return await execViaWorker(baseUrl, command, argv, opts);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
status = 'ready';
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
async writeFile(path, content) {
|
|
244
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
|
|
245
|
+
const res = await fetch(`${baseUrl}/write-file`, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: { 'content-type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({
|
|
249
|
+
path,
|
|
250
|
+
content: buf.toString('base64'),
|
|
251
|
+
encoding: 'base64',
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
throw new Error(`write-file failed: HTTP ${res.status} ${await res.text()}`);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
async readFile(path) {
|
|
259
|
+
const res = await fetch(`${baseUrl}/read-file`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: { 'content-type': 'application/json' },
|
|
262
|
+
body: JSON.stringify({ path, encoding: 'base64' }),
|
|
263
|
+
});
|
|
264
|
+
if (!res.ok) {
|
|
265
|
+
throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`);
|
|
266
|
+
}
|
|
267
|
+
const json = (await res.json());
|
|
268
|
+
if (!json.ok || typeof json.content !== 'string') {
|
|
269
|
+
throw new Error(json.error ?? 'read-file: no content');
|
|
270
|
+
}
|
|
271
|
+
return Buffer.from(json.content, 'base64');
|
|
272
|
+
},
|
|
273
|
+
async listFiles(rootPath) {
|
|
274
|
+
return await listFilesViaWorker(baseUrl, rootPath);
|
|
275
|
+
},
|
|
276
|
+
async destroy() {
|
|
277
|
+
status = 'destroyed';
|
|
278
|
+
// ARM DELETE — let failures propagate. The Vandal-side
|
|
279
|
+
// lifecycle wraps this in its own try/catch with logging,
|
|
280
|
+
// so a silently swallowed error here means orphan ACI
|
|
281
|
+
// container groups pile up under the resource group with
|
|
282
|
+
// no observability handle. The Standby Pool's refill keeps
|
|
283
|
+
// the WARM side topped up; that has nothing to do with
|
|
284
|
+
// cleaning up a CLAIMED instance, which is exclusively the
|
|
285
|
+
// claimer's responsibility.
|
|
286
|
+
await armCall(armUrl, 'DELETE', config.getArmToken);
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
try {
|
|
292
|
+
await armCall(armUrl, 'DELETE', config.getArmToken);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// Preserve the readiness failure as the primary error; the
|
|
296
|
+
// caller cannot use a Sandbox handle yet, so best-effort ARM
|
|
297
|
+
// cleanup is the only reliable orphan-prevention hook here.
|
|
298
|
+
}
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function pollForRunningIp(armUrl, getToken, pollIntervalMs, timeoutMs) {
|
|
303
|
+
const deadline = Date.now() + timeoutMs;
|
|
304
|
+
while (Date.now() < deadline) {
|
|
305
|
+
const cg = await armCall(armUrl, 'GET', getToken);
|
|
306
|
+
const state = cg?.properties?.provisioningState;
|
|
307
|
+
const ip = cg?.properties?.ipAddress?.ip;
|
|
308
|
+
if (state === 'Succeeded' && ip)
|
|
309
|
+
return ip;
|
|
310
|
+
if (state === 'Failed') {
|
|
311
|
+
throw new Error('aci-standby-pool: container group provisioning failed');
|
|
312
|
+
}
|
|
313
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`aci-standby-pool: timed out waiting for container group IP (${timeoutMs}ms)`);
|
|
316
|
+
}
|
|
317
|
+
async function waitForWorkerReady(baseUrl, timeoutMs, pollIntervalMs) {
|
|
318
|
+
const deadline = Date.now() + timeoutMs;
|
|
319
|
+
while (Date.now() < deadline) {
|
|
320
|
+
try {
|
|
321
|
+
const res = await fetch(`${baseUrl}/healthz`, {
|
|
322
|
+
signal: AbortSignal.timeout(2000),
|
|
323
|
+
});
|
|
324
|
+
if (res.ok)
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// Network not ready yet, try again.
|
|
329
|
+
}
|
|
330
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
331
|
+
}
|
|
332
|
+
throw new Error(`aci-standby-pool: worker /healthz never responded (${timeoutMs}ms)`);
|
|
333
|
+
}
|
|
334
|
+
async function execViaWorker(baseUrl, command, argv, opts) {
|
|
335
|
+
const start = Date.now();
|
|
336
|
+
const res = await fetch(`${baseUrl}/execute`, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: { 'content-type': 'application/json' },
|
|
339
|
+
body: JSON.stringify({
|
|
340
|
+
command,
|
|
341
|
+
args: argv ?? [],
|
|
342
|
+
cwd: opts?.cwd,
|
|
343
|
+
env: opts?.env,
|
|
344
|
+
timeoutMs: opts?.timeout,
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok || !res.body) {
|
|
348
|
+
throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`);
|
|
349
|
+
}
|
|
350
|
+
let stdout = '';
|
|
351
|
+
let stderr = '';
|
|
352
|
+
let exitCode = -1;
|
|
353
|
+
let timedOut = false;
|
|
354
|
+
const decoder = new TextDecoder();
|
|
355
|
+
const reader = res.body.getReader();
|
|
356
|
+
let buffered = '';
|
|
357
|
+
for (;;) {
|
|
358
|
+
const { value, done } = await reader.read();
|
|
359
|
+
if (done)
|
|
360
|
+
break;
|
|
361
|
+
buffered += decoder.decode(value, { stream: true });
|
|
362
|
+
let newlineIdx = buffered.indexOf('\n');
|
|
363
|
+
while (newlineIdx !== -1) {
|
|
364
|
+
const line = buffered.slice(0, newlineIdx).trim();
|
|
365
|
+
buffered = buffered.slice(newlineIdx + 1);
|
|
366
|
+
if (line) {
|
|
367
|
+
try {
|
|
368
|
+
const event = JSON.parse(line);
|
|
369
|
+
if (event.type === 'stdout_delta')
|
|
370
|
+
stdout += event.data;
|
|
371
|
+
else if (event.type === 'stderr_delta')
|
|
372
|
+
stderr += event.data;
|
|
373
|
+
else if (event.type === 'result') {
|
|
374
|
+
exitCode = event.exitCode;
|
|
375
|
+
timedOut = event.timedOut;
|
|
376
|
+
}
|
|
377
|
+
else if (event.type === 'error') {
|
|
378
|
+
throw new Error(event.error);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
if (!(err instanceof SyntaxError))
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
newlineIdx = buffered.indexOf('\n');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
stdout,
|
|
391
|
+
stderr,
|
|
392
|
+
exitCode,
|
|
393
|
+
timedOut,
|
|
394
|
+
durationMs: Date.now() - start,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Recursively list regular files under `rootPath` by shelling out to
|
|
399
|
+
* the worker's `find` (GNU find on the Debian-based reference image).
|
|
400
|
+
* `-printf` emits one `<path>\t<size>` line per file; any other
|
|
401
|
+
* non-zero exit (notably `find: '<root>': No such file or directory`)
|
|
402
|
+
* is mapped to "empty listing" because the agent legitimately may not
|
|
403
|
+
* have produced anything in `rootPath` yet.
|
|
404
|
+
*/
|
|
405
|
+
async function listFilesViaWorker(baseUrl, rootPath) {
|
|
406
|
+
const result = await execViaWorker(baseUrl, 'find', [rootPath, '-type', 'f', '-printf', '%p\t%s\n'], undefined);
|
|
407
|
+
if (result.exitCode !== 0) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
const entries = [];
|
|
411
|
+
for (const rawLine of result.stdout.split('\n')) {
|
|
412
|
+
if (!rawLine)
|
|
413
|
+
continue;
|
|
414
|
+
const tab = rawLine.indexOf('\t');
|
|
415
|
+
if (tab < 0)
|
|
416
|
+
continue;
|
|
417
|
+
const path = rawLine.slice(0, tab);
|
|
418
|
+
const size = Number.parseInt(rawLine.slice(tab + 1), 10);
|
|
419
|
+
if (!path || !Number.isFinite(size))
|
|
420
|
+
continue;
|
|
421
|
+
entries.push({ path, size });
|
|
422
|
+
}
|
|
423
|
+
return entries;
|
|
424
|
+
}
|
|
425
|
+
//# sourceMappingURL=index.js.map
|