Skip to content

What's new in 0.7.0

Published on 2026-10-05: Python SDK rayito 0.7.0, TypeScript SDK rayito 0.7.0 and rayd 0.7.0.

0.7.0 brings EFS volumes (a live shared file system between sandboxes, inside your VPC), custom domain as an experimental feature, an environment for testing locally without an AWS account and security hardening that runs through rayd, the SDKs, the infrastructure templates and the release. As always, everything that has a cost is off by default: without the new options, a sandbox makes no new call to AWS.

At a glance

What's new Python TypeScript Needs Idle cost Guide
EFS volumes (experimental) volumes={"/mnt/x": vol}, VolumeStore, EfsVolumes volumes: { "/mnt/x": vol }, VolumeStore, EfsVolumes optional rayito-base-caps-efs image with rayd 0.7.0, a VPC of your own and the efs-volumes stack $0 empty; EFS per GB EFS volumes
Custom domain (experimental) CustomDomain(...) + rayito[custom-domain] extra new CustomDomain({...}) + CloudFront peers a domain, an ACM certificate and the custom-domain stack $0 at rest Custom domain
Test locally — — Docker and a clone of the repository $0, no AWS account Test locally
Security hardening no code to change no code to change rayd 0.7.0 for what runs in the sandbox — Security

A few behavior changes ask you to review your configuration before upgrading: they are in How to upgrade from 0.6.x.

EFS volumes

An NFS file system in your account that rayd mounts inside the sandbox: the analog of E2B's Volume. Unlike persist= (an S3 copy that is restored on create and saved on pause), an EFS volume is shared live among several sandboxes. Each volume is an EFS access point with its own directory and POSIX user 1000:1000.

They are experimental for two reasons: they require an optional image and they live inside your VPC. The mount itself is accepted on real AWS in Python and TypeScript: write, read-only, pause and resume (also after the tunnel's credentials expire) and full deletion (Measured on real AWS).

1. Check the VPC and deploy the stack

EfsVolumes.check() reviews a VPC that already exists without creating anything (only ec2:Describe*): subnets in different AZs, free IPs, DNS and how it would reach the internet. deploy() checks it again and refuses if any finding is FAIL; if it passes, it creates only new resources inside it (an encrypted file system, a mount target per subnet, two security groups, a dedicated egress connector and the execution role's policy) and never touches the VPC, its subnets, route tables or existing groups.

from rayito import EfsVolumes

efs = EfsVolumes(region="us-east-1")  # does not call AWS
report = efs.check(
    vpc_id="vpc-0123456789abcdef0",
    subnet_ids=["subnet-0123456789abcdef0"],
)
for finding in report.findings:
    print(finding.level, finding.code, finding.message)

status = efs.deploy(  # refuses if any finding is FAIL
    vpc_id="vpc-0123456789abcdef0",
    subnet_ids=["subnet-0123456789abcdef0"],
)
print(status.outputs["ConnectorArn"], status.outputs["CallerPolicyArn"])
vol = efs.volume_store().create("agent-data-7")
import { EfsVolumes } from "rayito"; // + npm i @aws-sdk/client-ec2 @aws-sdk/client-efs

const efs = new EfsVolumes({ region: "us-east-1" }); // does not call AWS
const report = await efs.check({
  vpcId: "vpc-0123456789abcdef0",
  subnetIds: ["subnet-0123456789abcdef0"],
});
for (const finding of report.findings) {
  console.log(finding.level, finding.code, finding.message);
}

const status = await efs.deploy({
  vpcId: "vpc-0123456789abcdef0",
  subnetIds: ["subnet-0123456789abcdef0"],
});
console.log(status.outputs.ConnectorArn, status.outputs.CallerPolicyArn);
const vol = await (await efs.volumeStore()).create("agent-data-7");
rayito doctor --efs-vpc-id vpc-0123456789abcdef0 \
  --efs-subnet-ids subnet-0123456789abcdef0
rayito stack deploy efs-volumes \
  --param VpcId=vpc-0123456789abcdef0 \
  --param SubnetIds=subnet-0123456789abcdef0
rayito stack status efs-volumes

Attach the CallerPolicyArn output to the execution role you will launch the sandboxes with and keep ConnectorArn.

2. Publish the image with amazon-efs-utils

The default images do not change. volumes= only mounts on rayito-base-caps-efs, a caps variant that adds EFS's mount helper (≈ 198 MB more image; the memory snapshot does not change) and that you publish yourself with --with-efs:

make image-publish-caps-efs BUCKET=amzn-s3-demo-bucket
# or, step by step:
rayito image zip image image/rayito-image-efs.zip --sidecar kernel-sidecar --with-efs
rayito image publish --artifact image/rayito-image-efs.zip --with-efs \
    --os-capabilities ALL --bucket amzn-s3-demo-bucket --base-image-version 1

rayito image publish --with-efs checks, before calling AWS, that the zip carries the variant marker and that you ask for --os-capabilities ALL. rayito-base-caps-efs also counts as a caps variant for mounts= and telemetry=.

3. Mount the volumes

from rayito import Sandbox, VolumeStore

store = VolumeStore(file_system_id="fs-0123abcd", region="us-east-1")
data = store.create("agent-data-7")
with Sandbox.create(
    "rayito-base-caps-efs",
    execution_role_arn="arn:aws:iam::123456789012:role/rayito-execution",
    egress=["arn:aws:lambda:us-east-1:123456789012:network-connector:rayito-efs"],
    volumes={"/mnt/data": data},
) as sbx:
    sbx.commands.run("echo hello > /mnt/data/greeting.txt")
    print(sbx.volumes["/mnt/data"].state)  # "mounted"
import { Sandbox, VolumeStore } from "rayito";

const store = new VolumeStore({ fileSystemId: "fs-0123abcd", region: "us-east-1" });
const data = await store.create("agent-data-7");
await using sbx = await Sandbox.create({
  template: "rayito-base-caps-efs",
  executionRoleArn: "arn:aws:iam::123456789012:role/rayito-execution",
  egress: ["arn:aws:lambda:us-east-1:123456789012:network-connector:rayito-efs"],
  volumes: { "/mnt/data": data },
});
await sbx.commands.run("echo hello > /mnt/data/greeting.txt");
console.log((await sbx.volumes()).get("/mnt/data")?.state); // "mounted"

create() validates the request before launching anything (1 to 4 volumes, paths under /mnt/ or /home/user/, execution_role_arn= and the caps variant), mounts the volumes in the same ConfigureSandbox as the other options and only returns with all of them mounted. If one fails, it terminates the sandbox (unless keep_on_failure) and raises VolumeMountException (TypeScript: VolumeMountError) with a closed code (network, iam_denied, not_found, tls, helper_missing, timeout, invalid_path). reincarnate() mounts them again in the successor. In the E2B shim, Volume does real CRUD over E2B(volume_store=...) and volume_mounts= mounts with volume_connector_arn= (E2B shim).

A single egress connector

A MicroVM supports a single egress connector, and the volume needs your VPC's to reach the mount target. That is why volumes= without egress=, with INTERNET_EGRESS or with two connectors is InvalidArgumentException (TypeScript: InvalidArgumentError) before launching anything. A sandbox with a volume only has internet through your VPC (a NAT or a transit gateway and a connector that allows it); the efs-volumes stack's only lets NFS out. check()'s internet-egress finding reminds you of it (Internet and volumes).

Read-only: through IAM, not through the ro option

EfsVolume(read_only=True) mounts with ro, but that alone does not prevent writing from the sandbox. What makes a volume read-only is the execution role's policy: deploy the stack with read_only_access_point_arns (TypeScript: readOnlyAccessPointArns; CLI: --param ReadOnlyAccessPointArns=...), which denies ClientWrite to those access points, or with allow_write=False if none should allow writes (Truly read-only).

Pausing with pending writes and the mount target down loses data

If, when pausing, there are writes that have not yet reached EFS and the mount target does not respond, rayd cannot flush them in time, AWS terminates the MicroVM and what was not flushed is lost (pause() raises SandboxNotFoundException). If a piece of data matters, close it with fsync or sync before pause() (What rayd does with each volume).

Delete everything

rayito stack destroy efs-volumes deletes the connector, the mount targets, the security groups and the IAM, but always keeps the file system and its data. To really delete them:

from rayito import EfsVolumes

EfsVolumes(region="us-east-1").destroy(delete_file_system=True)
import { EfsVolumes } from "rayito";

await new EfsVolumes({ region: "us-east-1" }).destroy({ deleteFileSystem: true });

Full guides: EFS volumes and EFS volumes in your VPC.

Custom domain (experimental)

Experimental: not verified end to end on real AWS

CustomDomain is published so you can try it, and its API may change in a minor. Verified on real AWS: the routing CloudFront Function compiles and routes in the real runtime (correct token, no token, wrong token, expired route), and the deployment creates the KeyValueStore and the Function, with a rollback and a deletion that leave nothing behind. Not verified: creating the CloudFront distribution and serving traffic through it (HTTP/1.1, WebSocket, route propagation, JWE renewal), because the project's test account does not allow creating distributions. Nobody has yet opened a custom-domain URL deployed with this template.

Without this feature, a sandbox port only answers to whoever sends the AWS proxy headers that get_host() returns: a browser, a webhook or an <iframe> cannot. With a custom domain, each registered port has a normal HTTPS URL under your domain, https://<port>-<sandbox_id>.sbx.example.com, served by a CloudFront distribution in your account whose Function checks the traffic token and sets the proxy headers itself.

import secrets

from rayito import CustomDomain, Sandbox

domain = CustomDomain(public_domain="sbx.example.com")  # does not call AWS
domain.deploy(certificate_arn="arn:aws:acm:us-east-1:<account>:certificate/<id>")

with Sandbox.create("rayito-base", allowed_ports=[8000]) as sbx:
    jwe = sbx.get_host(8000).headers["x-aws-proxy-auth"]
    route = domain.register(
        sbx.sandbox_id, 8000, endpoint=sbx.endpoint, jwe=jwe,
        traffic_token=secrets.token_urlsafe(32), ttl_seconds=2400,
    )
    print(f"https://{route.host}")
    domain.unregister(sbx.sandbox_id, 8000)
import { randomBytes } from "node:crypto";
import { CustomDomain, Sandbox } from "rayito";

const domain = new CustomDomain({ publicDomain: "sbx.example.com" }); // does not call AWS
await domain.deploy({ certificateArn: "arn:aws:acm:us-east-1:<account>:certificate/<id>" });

await using sbx = await Sandbox.create({ template: "rayito-base", allowedPorts: [8000] });
const jwe = (await sbx.getHost(8000)).headers["x-aws-proxy-auth"] ?? "";
const route = await domain.register(sbx.sandboxId, 8000, {
  endpoint: sbx.endpoint,
  jwe,
  trafficToken: randomBytes(32).toString("base64url"),
  ttlSeconds: 2400,
});
console.log(`https://${route.host}`);
await domain.unregister(sbx.sandboxId, 8000);
rayito domain deploy --public-domain sbx.example.com \
  --certificate-arn arn:aws:acm:us-east-1:<account>:certificate/<id>
rayito domain status
rayito domain destroy

What is worth knowing:

  • register() requires a traffic token: a route is only public with an explicit public=True (TypeScript: public: true), never by default.
  • deploy(alternate_domain_names=[...]) (--alternate-domain-name) replaces the *.sbx.example.com wildcard with exact hostnames.
  • In Python you need the pip install "rayito[custom-domain]" extra (the KeyValueStore data plane signs with SigV4A); in TypeScript, the peers @aws-sdk/client-cloudformation, @aws-sdk/client-cloudfront-keyvaluestore and @aws-sdk/signature-v4a. Without instantiating CustomDomain none is loaded.
  • Sandbox.create(domain=) is still not wired up: it raises UnimplementedError without calling AWS. Today you register the route yourself and route.host is the final hostname.

If you try it in an account that allows creating distributions, tell us how it went in a GitHub issue with [custom-domain] in the title, always using example.com and 123456789012 instead of your real data. Guide: Custom domain.

Test locally with Docker and Floci

For those who contribute or want to review a change before spending on AWS: a Docker Compose environment in the repository runs the Python and TypeScript SDKs against a real rayd (the product image, with rayd as PID 1) and against Floci, an AWS emulator. No account, no credentials and no cost.

make local-up      # builds and starts floci, runner and guest
make local-e2e     # runs the Python and TypeScript `local` tests
make local-down    # stops everything and deletes the volumes

It covers commands, files, PTY, run_code, the pause and resume state machine, the optional stacks, secrets, the metadata index, events and webhooks, among others. What depends on the real platform (the AWS proxy, IMDS, egress, mounts, the memory snapshot, cost) still needs AWS. No container is privileged, nothing publishes ports on your machine and the emulator has no internet access. The local-e2e workflow runs it in CI without secrets. Guide: Test locally.

Security hardening

0.7.0 incorporates a full security review. Most changes ask nothing of you except upgrading; those that change a behavior are in How to upgrade. The threat model and the detail of each control are in Security and in SECURITY.md.

rayd (needs an image with rayd 0.7.0)

  • The lifecycle hooks check who opened the connection: the sandbox's code can no longer terminate the sandbox, repeat the validation or impersonate the platform's startup that way, and each attempt is counted as an anomaly.
  • File operations work on descriptors without following symbolic links, and rayd's internal processes start with a minimal environment.
  • The agent's ports have a connection cap, the identity of the user's processes has a closed range and rayd checks kernel processes before signaling them.
  • persist= binds the sandbox to its persistence prefix: with a per-tenant prefix, a sandbox cannot reach another's persisted HOME even if they share an execution role.
  • The secret gateway validates routes against an allowlist (also in the SDK, before any call) and no longer returns the injected credential in the response headers.

Two of these controls (the startup origin and the connection cap) remain pending acceptance on real AWS.

rayito sandbox proxy: it only forwards requests whose Host and Origin are the expected ones (loopback, <id>.localhost or those you declare with --allowed-host and --allow-origin), a single message per connection and at most --max-connections at a time. A web page open in your browser can no longer use the sandbox's service with your token (Local proxy).

Templates and images: Template.build does not follow symbolic links, applies .dockerignore with Docker's semantics and warns if the context is about to package sensitive files (.env*, .git/, keys). rayito image zip and make image-publish reject symbolic links in the image tree (.dockerignore).

Git credentials: clone/push/pull with username and password always restore the URL without credentials, run without hooks or credential helpers and refuse if the git configuration rewrites URLs. Even so, treat the token as revealed to the sandbox's code: use short-lived, single-repository tokens (Git).

IAM and the optional stacks' Lambdas

  • infra/iam.yaml separates SandboxLauncherPolicy (launch only) from ImagePublisherPolicy (publish only); CallerPolicy remains the union. RayitoTemplateBuilder only reads rayito/* from the bucket, and sizes-guard also denies publishing images.
  • events-webhooks: the forwarder authenticates each line before reading it, discards old, repeated events or events that do not advance the lifecycle, rate-limits paused/resumed per sandbox and does not lose the batch if a write fails. A broken or slow webhook no longer blocks the others, each delivery also carries the timestamped rayito-signature signature and a rotated secret stops being used in 5 minutes. Each Lambda writes only its rows and its logs, and the caller has one policy per task (Which policy each call needs).
  • Webhook signing secrets are kept out of secrets=, and each optional stack's zip is uploaded to rayito/stacks/<component>/ and compared before being reused.

SDK and CLI: AWS errors are sanitized on more paths, a command's or PTY's output is kept with a cap of 64 MiB per descriptor (Stored output), the CLI neutralizes escape sequences in logs and messages, a custom access token must have at least 16 bytes and the proxy JWE no longer appears in repr or in console.log.

Supply chain: signing rayd and the image requires the maintainer's approval, assets are built without credentials and cannot be replaced from the release, the release does not restore caches and pins its tools by version and hash, and the verification recipe binds the signature to the exact tag you install (Verify a release).

How to upgrade from 0.6.x

  1. Upgrade the SDK.

    pip install -U "rayito[cli]"    # with custom domain: "rayito[cli,custom-domain]"
    
    pnpm add rayito@latest
    
  2. Republish your image on rayd 0.7.0 (Set up AWS). The compatibility series moves to 0.7: rayito doctor requires the rayd from the rayd-v0.7.0 tag for SDK 0.7 (Compatibility), because volumes= and the agent hardening live in rayd. Against an earlier image, volumes= fails closed with UnimplementedError and terminates the sandbox it just launched.

  3. Redeploy the stacks you use. rayito events deploy uploads the new code of the events-webhooks Lambdas; also redeploy infra/iam.yaml to get the separated policies (IAM). EventsOperatorPolicy still exists in 0.7 but is deprecated: attach EventsLauncherPolicy, EventsReaderPolicy or EventsWebhookAdminPolicy to each identity.
  4. Review these behavior changes:
What Before Since 0.7.0
rayito sandbox proxy --bind 0.0.0.0 --allow-remote was enough requires --allowed-host; a different Host (e.g. behind a reverse proxy) gets 421 without --allowed-host, a foreign Origin 403, and a request with ambiguous delimiting 400
.dockerignore of Template.build * crossed / * and ? do not cross /: *.pyc only excludes those at the root; use **/*.pyc
Custom access token (access_token=, RAYITO_ACCESS_TOKEN) any length at least 16 decoded bytes, or InvalidArgumentException / InvalidArgumentError
Explicit checkpoint or restore toward another base from a sandbox with persist= allowed permission_denied
Output of commands.run and PTY no cap 64 MiB per descriptor (the end is kept and truncated says so); adjustable with max_output_bytes / maxOutputBytes
allow route in SecretGateway that rayd would never let through accepted InvalidArgumentException / InvalidArgumentError before calling AWS
secrets= with a rayito/webhooks/* secret accepted InvalidArgumentException / InvalidArgumentError
register_webhook with an unreachable URL (invalid port or host) accepted InvalidArgumentException / InvalidArgumentError
git with credentials and url.*.insteadOf in the configuration carried on GitAuthException / GitAuthError

Without new options, the rest of your code stays the same: there are no incompatible changes in the public API from 0.6.x outside this table.

What 0.7.0 does not bring

  • Sandbox.create(domain=) and get_host()/expose() returning the custom domain's URL: they remain in development.
  • The Volume content operations of the shim (read_file, write_file... without a sandbox): Rayito has no data plane outside a MicroVM, so they keep raising UnimplementedError.

Full details, change by change: Changelog.