@cloudflare/sandbox 0.10.2 → 0.11.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/Dockerfile CHANGED
@@ -2,9 +2,53 @@
2
2
  ARG BUN_VERSION=1
3
3
  # Node version — override via --build-arg NODE_VERSION=24
4
4
  ARG NODE_VERSION=24
5
+ # cloudflared release — shared across glibc and musl image variants.
6
+ # Bump this together with the architecture-specific checksums below.
7
+ ARG CLOUDFLARED_VERSION=2026.3.0
5
8
  FROM oven/bun:${BUN_VERSION} AS bun-binary
6
9
  FROM node:${NODE_VERSION}-slim AS node-runtime
7
10
 
11
+ # ============================================================================
12
+ # cloudflared (Cloudflare Tunnel daemon) — enables sandbox.tunnels.*
13
+ # Version pin lives at the top of this Dockerfile as a global ARG, shared
14
+ # by every image variant so they ship the same release.
15
+ # ============================================================================
16
+ FROM alpine:3.21 AS cloudflared-binary
17
+
18
+ ARG TARGETARCH
19
+ ARG CLOUDFLARED_VERSION
20
+ ARG CLOUDFLARED_SHA256_AMD64=4a9e50e6d6d798e90fcd01933151a90bf7edd99a0a55c28ad18f2e16263a5c30
21
+ ARG CLOUDFLARED_SHA256_ARM64=0755ba4cbab59980e6148367fcf53a8f3ec85a97deefd63c2420cf7850769bee
22
+
23
+ # Inject the host's extra CA bundle when downloading behind a TLS-intercepting
24
+ # proxy (e.g. Cloudflare WARP / Zero Trust). No-op when the secret isn't passed.
25
+ RUN --mount=type=secret,id=wrangler_ca \
26
+ if [ -f /run/secrets/wrangler_ca ] && [ -s /run/secrets/wrangler_ca ]; then \
27
+ cat /run/secrets/wrangler_ca >> /etc/ssl/certs/ca-certificates.crt; \
28
+ mkdir -p /usr/local/share/ca-certificates && \
29
+ cp /run/secrets/wrangler_ca /usr/local/share/ca-certificates/wrangler-dev-ca.crt; \
30
+ fi && \
31
+ apk add --no-cache ca-certificates curl && \
32
+ if [ -f /usr/local/share/ca-certificates/wrangler-dev-ca.crt ]; then \
33
+ update-ca-certificates; \
34
+ fi
35
+
36
+ RUN set -eux; \
37
+ arch="${TARGETARCH:-}"; \
38
+ if [ -z "$arch" ]; then \
39
+ arch="$(apk --print-arch)"; \
40
+ fi; \
41
+ case "$arch" in \
42
+ amd64|x86_64) suffix=amd64; sha="${CLOUDFLARED_SHA256_AMD64}" ;; \
43
+ arm64|aarch64) suffix=arm64; sha="${CLOUDFLARED_SHA256_ARM64}" ;; \
44
+ *) echo "Unsupported arch for cloudflared: $arch" >&2; exit 1 ;; \
45
+ esac; \
46
+ url="https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-${suffix}"; \
47
+ curl -fsSL -o /cloudflared "$url"; \
48
+ echo "$sha /cloudflared" | sha256sum -c -; \
49
+ chmod +x /cloudflared; \
50
+ /cloudflared --version
51
+
8
52
  # Sandbox container images (default and python variants)
9
53
  # Multi-stage build optimized for Turborepo monorepo
10
54
 
@@ -152,26 +196,8 @@ COPY --from=builder /app/packages/sandbox-container/dist/runtime/executors/javas
152
196
  # Users with custom startup scripts that call `bun /container-server/dist/index.js` need this
153
197
  COPY --from=builder /app/packages/sandbox-container/dist/index.js ./dist/
154
198
 
155
- # ============================================================================
156
- # cloudflared (Cloudflare Tunnel daemon) — enables sandbox.tunnels.*
157
- # Pinned version with sha256 verification per architecture.
158
- # Skipped in the musl/Alpine stage (no musl prebuilt).
159
- # ============================================================================
160
- ARG CLOUDFLARED_VERSION=2026.3.0
161
- ARG CLOUDFLARED_SHA256_AMD64=4a9e50e6d6d798e90fcd01933151a90bf7edd99a0a55c28ad18f2e16263a5c30
162
- ARG CLOUDFLARED_SHA256_ARM64=0755ba4cbab59980e6148367fcf53a8f3ec85a97deefd63c2420cf7850769bee
163
- RUN set -eux; \
164
- arch="$(dpkg --print-architecture)"; \
165
- case "$arch" in \
166
- amd64) suffix=amd64; sha="${CLOUDFLARED_SHA256_AMD64}" ;; \
167
- arm64) suffix=arm64; sha="${CLOUDFLARED_SHA256_ARM64}" ;; \
168
- *) echo "Unsupported arch for cloudflared: $arch" >&2; exit 1 ;; \
169
- esac; \
170
- url="https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-${suffix}"; \
171
- curl -fsSL -o /usr/local/bin/cloudflared "$url"; \
172
- echo "$sha /usr/local/bin/cloudflared" | sha256sum -c -; \
173
- chmod +x /usr/local/bin/cloudflared; \
174
- cloudflared --version
199
+ COPY --from=cloudflared-binary /cloudflared /usr/local/bin/cloudflared
200
+ RUN cloudflared --version
175
201
 
176
202
  # Inject the host's extra CA bundle when running locally behind a TLS-
177
203
  # intercepting proxy (e.g. Cloudflare WARP / Zero Trust). See
@@ -338,6 +364,11 @@ RUN apk add --no-cache bash file git curl libstdc++ libgcc s3fs-fuse fuse
338
364
 
339
365
  RUN sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf
340
366
 
367
+ # The Alpine variant uses the same cloudflared release asset as the glibc
368
+ # variants. It is a static binary that runs on musl without gcompat.
369
+ COPY --from=cloudflared-binary /cloudflared /usr/local/bin/cloudflared
370
+ RUN cloudflared --version
371
+
341
372
  COPY --from=builder /app/packages/sandbox-container/dist/sandbox-musl /container-server/sandbox
342
373
 
343
374
  WORKDIR /container-server
package/README.md CHANGED
@@ -137,8 +137,6 @@ Notes:
137
137
  seconds while DNS propagates, even after `get()` resolves.
138
138
  - `*.trycloudflare.com` buffers `text/event-stream` responses.
139
139
  WebSockets work fine.
140
- - The musl/Alpine image variant does not ship cloudflared (no upstream
141
- musl prebuilt); `sandbox.tunnels` is unavailable on that variant.
142
140
  - Local builds behind a TLS-intercepting proxy (e.g. Cloudflare WARP)
143
141
  need the host CA bundle injected at build time — see
144
142
  [DOCKER_README.md](../../DOCKER_README.md).
@@ -1,6 +1,6 @@
1
1
  import "../dist-B_eXrP83.js";
2
- import "../errors-8Hvune8K.js";
3
- import { h as streamFile, n as getSandbox } from "../sandbox-BcEq4aUF.js";
2
+ import "../errors-COsTRno_.js";
3
+ import { C as streamFile, b as validatePort, n as getSandbox, v as SandboxSecurityError, x as validateTunnelName } from "../sandbox-DQxTkLyY.js";
4
4
  import { DurableObject, env } from "cloudflare:workers";
5
5
  import { Hono } from "hono";
6
6
 
@@ -267,6 +267,50 @@ const OPENAPI_SCHEMA = {
267
267
  example: "/mnt/data"
268
268
  } }
269
269
  },
270
+ TunnelRequest: {
271
+ type: "object",
272
+ properties: { name: {
273
+ type: "string",
274
+ description: "Subdomain prefix for a named tunnel, such as `app`. Do not pass a full hostname. Omit to create or reuse an ephemeral tunnel.",
275
+ example: "app"
276
+ } }
277
+ },
278
+ Tunnel: {
279
+ type: "object",
280
+ required: [
281
+ "id",
282
+ "port",
283
+ "url",
284
+ "hostname",
285
+ "createdAt"
286
+ ],
287
+ properties: {
288
+ id: { type: "string" },
289
+ port: {
290
+ type: "integer",
291
+ description: "Container port served by the tunnel.",
292
+ example: 8080
293
+ },
294
+ url: {
295
+ type: "string",
296
+ format: "uri",
297
+ example: "https://app.example.com"
298
+ },
299
+ hostname: {
300
+ type: "string",
301
+ example: "app.example.com"
302
+ },
303
+ createdAt: {
304
+ type: "string",
305
+ format: "date-time"
306
+ },
307
+ name: {
308
+ type: "string",
309
+ description: "Present for named tunnels only.",
310
+ example: "app"
311
+ }
312
+ }
313
+ },
270
314
  ErrorResponse: {
271
315
  type: "object",
272
316
  required: ["error", "code"],
@@ -290,7 +334,8 @@ const OPENAPI_SCHEMA = {
290
334
  "pool_error",
291
335
  "mount_error",
292
336
  "unmount_error",
293
- "session_error"
337
+ "session_error",
338
+ "tunnel_error"
294
339
  ]
295
340
  }
296
341
  }
@@ -439,6 +484,103 @@ const OPENAPI_SCHEMA = {
439
484
  }
440
485
  }
441
486
  } },
487
+ "/v1/sandbox/{id}/tunnel/{port}": {
488
+ post: {
489
+ operationId: "createTunnel",
490
+ summary: "Create or reuse a tunnel for a sandbox port",
491
+ description: "Returns an existing tunnel for the port when one is already recorded, or provisions one when needed. The service must already be listening inside the sandbox. Omit `name` for an ephemeral `*.trycloudflare.com` tunnel, or pass `name` to choose the subdomain prefix for a named tunnel. Use a value such as `app`, not a full hostname.",
492
+ "x-codeSamples": [{
493
+ lang: "curl",
494
+ label: "Ephemeral tunnel",
495
+ source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/tunnel/8080 \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
496
+ }, {
497
+ lang: "curl",
498
+ label: "Named tunnel",
499
+ source: "curl -X POST https://$HOST/v1/sandbox/my-sandbox/tunnel/8080 \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"app\"}'"
500
+ }],
501
+ parameters: [{
502
+ name: "id",
503
+ in: "path",
504
+ required: true,
505
+ schema: { type: "string" },
506
+ description: "Sandbox instance name."
507
+ }, {
508
+ name: "port",
509
+ in: "path",
510
+ required: true,
511
+ schema: {
512
+ type: "integer",
513
+ minimum: 1024,
514
+ maximum: 65535
515
+ },
516
+ description: "Container port to tunnel. Port 3000 is reserved."
517
+ }],
518
+ requestBody: {
519
+ required: false,
520
+ content: { "application/json": { schema: { $ref: "#/components/schemas/TunnelRequest" } } }
521
+ },
522
+ responses: {
523
+ "200": {
524
+ description: "Tunnel created or reused.",
525
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Tunnel" } } }
526
+ },
527
+ "400": { $ref: "#/components/responses/InvalidRequest" },
528
+ "401": { $ref: "#/components/responses/Unauthorized" },
529
+ "502": {
530
+ description: "Tunnel creation failed.",
531
+ content: { "application/json": {
532
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
533
+ example: {
534
+ error: "tunnel failed: cloudflared could not be found",
535
+ code: "tunnel_error"
536
+ }
537
+ } }
538
+ }
539
+ }
540
+ },
541
+ delete: {
542
+ operationId: "deleteTunnel",
543
+ summary: "Delete the tunnel for a sandbox port",
544
+ description: "Stops the tunnel process for the port and removes any named-tunnel Cloudflare resources tracked by the sandbox.",
545
+ "x-codeSamples": [{
546
+ lang: "curl",
547
+ label: "Delete tunnel",
548
+ source: "curl -X DELETE https://$HOST/v1/sandbox/my-sandbox/tunnel/8080 \\\n -H \"Authorization: Bearer $SANDBOX_API_KEY\""
549
+ }],
550
+ parameters: [{
551
+ name: "id",
552
+ in: "path",
553
+ required: true,
554
+ schema: { type: "string" },
555
+ description: "Sandbox instance name."
556
+ }, {
557
+ name: "port",
558
+ in: "path",
559
+ required: true,
560
+ schema: {
561
+ type: "integer",
562
+ minimum: 1024,
563
+ maximum: 65535
564
+ },
565
+ description: "Container port whose tunnel should be deleted. Port 3000 is reserved."
566
+ }],
567
+ responses: {
568
+ "204": { description: "Tunnel deleted or already absent." },
569
+ "400": { $ref: "#/components/responses/InvalidRequest" },
570
+ "401": { $ref: "#/components/responses/Unauthorized" },
571
+ "502": {
572
+ description: "Tunnel deletion failed.",
573
+ content: { "application/json": {
574
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
575
+ example: {
576
+ error: "tunnel failed: cleanup failed",
577
+ code: "tunnel_error"
578
+ }
579
+ } }
580
+ }
581
+ }
582
+ }
583
+ },
442
584
  "/v1/sandbox/{id}/file/{path}": {
443
585
  get: {
444
586
  operationId: "readFile",
@@ -1632,6 +1774,24 @@ function hasCredentials(options) {
1632
1774
  function isStringArray(value) {
1633
1775
  return Array.isArray(value) && value.every((item) => typeof item === "string");
1634
1776
  }
1777
+ function parseTunnelOptions(rawBody) {
1778
+ if (!rawBody.trim()) return void 0;
1779
+ let body;
1780
+ try {
1781
+ body = JSON.parse(rawBody);
1782
+ } catch {
1783
+ return errorJson("Invalid JSON body", "invalid_request", 400);
1784
+ }
1785
+ if (!body || typeof body !== "object" || Array.isArray(body) || body.name === void 0) return;
1786
+ if (typeof body.name !== "string") return errorJson("name must be a string when provided", "invalid_request", 400);
1787
+ try {
1788
+ validateTunnelName(body.name);
1789
+ } catch (err) {
1790
+ if (err instanceof SandboxSecurityError) return errorJson(err.message, "invalid_request", 400);
1791
+ throw err;
1792
+ }
1793
+ return { name: body.name };
1794
+ }
1635
1795
  function validateMountOptions(options, binding) {
1636
1796
  if ("endpoint" in options && !hasEndpoint(options)) return errorJson("options.endpoint must be a string when provided", "invalid_request", 400);
1637
1797
  if (binding !== void 0 && typeof binding !== "string") return errorJson("binding must be a string when provided", "invalid_request", 400);
@@ -1865,6 +2025,30 @@ function createBridgeApp(config) {
1865
2025
  return errorJson(`write failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
1866
2026
  }
1867
2027
  });
2028
+ app.post(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
2029
+ const port = Number(c.req.param("port"));
2030
+ if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
2031
+ const options = parseTunnelOptions(await c.req.text());
2032
+ if (options instanceof Response) return options;
2033
+ const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
2034
+ try {
2035
+ const tunnel = await sandbox.tunnels.get(port, options);
2036
+ return c.json(tunnel);
2037
+ } catch (err) {
2038
+ return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
2039
+ }
2040
+ });
2041
+ app.delete(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
2042
+ const port = Number(c.req.param("port"));
2043
+ if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
2044
+ const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
2045
+ try {
2046
+ await sandbox.tunnels.destroy(port);
2047
+ return c.body(null, 204);
2048
+ } catch (err) {
2049
+ return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
2050
+ }
2051
+ });
1868
2052
  app.get(`${apiPrefix}/sandbox/:id/running`, async (c) => {
1869
2053
  const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
1870
2054
  try {