Test locally (Docker + Floci)¶
In the repository since 0.7.0 (What's new).
A Docker environment to run the Python and TypeScript SDKs against a real
rayd and an emulated AWS, with no AWS account, no credentials and no
cost. It serves the development loop and reviewing a PR before spending on
acceptance against real AWS, which remains the one that closes a milestone
or a release.
What it starts¶
┌──────────── internal network "aws" (no Internet) ─────────┐
│ floci AWS emulator on :4566 │
│ runner Python (uv) + Node (pnpm), the tests │
│ └─ guest product image, rayd as PID 1 │
│ (shares the runner's network: loopback) │
└───────────────────────────────────────────────────────────┘
- floci: Floci 2.1.0 (MIT), pinned by digest. It emulates S3, DynamoDB, Secrets Manager, CloudFormation, STS, CloudWatch Logs and the Lambda MicroVMs control plane.
- guest: the
image/Dockerfileimage as is, withraydand the kernel sidecar. It acts as the MicroVM: the tests call its hooks (/ready,/run,/suspend,/resume,/terminate) the way the platform would. - runner: the toolchains for both SDKs. Dependencies are installed in
volumes, never in the
.venvor thenode_moduleson your machine. It runs as uid 993, outside the sandbox range (1000-65535) like the platform's agent:raydrejects the/runand/terminatethat come from a sandbox uid. If you brought the environment up with an earlier version and did not take it down, runmake local-downbeforemake local-upso the volumes are created with the new owner.
Nothing publishes ports on your machine, no container is privileged or
mounts the Docker socket, and Floci has no outbound access to the Internet.
The reasoning behind each decision is in
docs/research/2026-10-local-testing.md.
Requirements¶
- Docker with Compose v2 (Docker Desktop, Colima, Lima or OrbStack all work).
- An arm64 host (Apple Silicon, Graviton or GitHub's
ubuntu-24.04-armrunner): the product image's base only exists for arm64. On x86 it works with QEMU emulation, much more slowly. - About 6 GB of free disk for Docker. As for memory, 3 GB is enough to run
the environment, but compiling
raydinside Docker needs about 6 GB: theaws-sdk-s3crate (S3 mounts) alone goes past 2 GB when compiled. With less, compileraydon your machine and pass it withLOCAL_RAYD_BIN(below). makeandpython3(to copy the sidecar into the guest's context).- Only for the agent tests (below):
uvand an AWS session on your machine, becausemake local-bedrock-keyruns withuv run.
Usage¶
make local-up # builds and starts floci, runner and guest; installs dependencies
make local-e2e # runs the Python and TypeScript `local` tests
make local-down # stops everything and deletes the volumes
make local-up compiles rayd inside Docker the first time (a few
minutes, no Rust on your machine). If you already have the binary from
make build (static, musl, with cargo zigbuild, built on Linux or in a
Linux VM), pass it and you save the compilation; it is the way to go if your
Docker VM has less than 6 GB and rustc dies with SIGKILL:
To run only a part, the LOCAL_E2E_ARGS arguments go to pytest, and each
suite can be launched on its own inside the runner:
make local-e2e LOCAL_E2E_ARGS="-k commands"
docker compose -f dev/local/compose.yaml exec runner \
bash -c 'cd clients/python && uv run --no-sync pytest tests/local -m local -k secret'
docker compose -f dev/local/compose.yaml exec runner \
bash -c 'cd clients/typescript && pnpm exec vitest run --project local'
Agents against a real model¶
The agent tests (test_local_agents.py and agents.local.test.ts) run
sbx.agent with OpenCode and deepagents inside
a local sandbox, with Claude on Amazon Bedrock as the model and
bedrock_gateway / bedrockGateway as the only egress. In each runtime
and in both SDKs they check that the agent uses its tools, continues a
session, aborts without leaving live processes, respects AgentLimits
(steps, tokens and timeout), emits the expected event stream, cannot read
the credential, does not reach the Internet on its own while the model
does answer, and that no telemetry is on by default. Without a key they are
skipped, so make local-e2e and CI still run with no credentials.
Cost and activation
- By default: off. Without
make local-bedrock-keythe agent tests are skipped and nothing leaves the local environment. - Activates:
make local-agent-up(swaps the guest for the variant with OpenCode, ripgrep, deepagents and its runner fromdev/local/agent/, withCAP_NET_ADMINso the egress deny-all is really applied) andAWS_PROFILE=<your-profile> make local-bedrock-key. - AWS resources and calls: no resources. The key is a
bedrock:CallWithBearerTokenURL presigned on your machine (valid for as long as your session, at most 12 h); each suite makes about thirtyConverseStream/Conversecalls fromraydthrough the gateway. The secret lives in Floci's Secrets Manager. - Approximate cost: cents per run, in Bedrock tokens (Claude Haiku 4.5, us-east-1, checked on 2026-10-06: https://aws.amazon.com/bedrock/pricing/).
- IAM:
bedrock:CallWithBearerTokenandbedrock:InvokeModel*on the inference profile, and model access already enabled in the account (Rayito does not enable it for you). - How to turn it off:
make local-down(deletes the runner's tmpfs with the key) or do not runmake local-bedrock-key. -
Example:
The key goes from the host to the runner through a pipe and ends up in
/tmp/rayito-local-bedrock-key (RAYITO_LOCAL_BEDROCK_KEY_FILE), a tmpfs
that dies with the container: never in a file on your machine or in an
environment variable. The tests look for it inside the sandbox to answer
yes or no and never print it. The model and its region are changed with
RAYITO_E2E_BEDROCK_MODEL and RAYITO_E2E_BEDROCK_REGION (by default,
Claude Haiku 4.5 in us-east-1). A test marked as an expected failure is a
reminder that the timeout still does not kill what the agent's shell
launched.
Other providers without third-party keys¶
test_local_agent_providers.py genuinely tests the OpenAI-style paths of
the agent providers with what you already have on
AWS, with no accounts on OpenAI or other providers:
| Path | Gateway and AgentModel |
What it checks |
|---|---|---|
bedrock-chat |
openai_compatible_gateway to bedrock-runtime (/openai/v1), gpt-oss-120b |
tools, session, closed egress, unreadable key |
mantle-chat |
openai_compatible_gateway to bedrock-mantle (/v1) |
the same |
mantle-responses |
provider="openai" (Responses API) to bedrock-mantle |
one turn without tools with token usage |
litellm |
litellm_gateway to a local LiteLLM proxy in front of Bedrock |
tools, session, closed egress, unreadable key |
The first three use the same key from make local-bedrock-key. The LiteLLM
one is created by make local-litellm-up (dev/local/agent/litellm.sh):
it starts LiteLLM, pinned by digest, on the environment's network with the
name litellm and no ports on the host; it gives it a certificate from a
test CA that it adds to the guest's system store (which rayd uses to
verify the gateway's upstream; since rayd reads it at startup, restart
the guest, so do not run it with a live sandbox), and leaves the master key
in the runner's tmpfs. LiteLLM talks to Bedrock with your session's
temporary credentials, which only travel as the container's environment
variables; when they expire, repeat make local-litellm-up.
make local-agent-up
AWS_PROFILE=<your-profile> make local-bedrock-key
AWS_PROFILE=<your-profile> make local-litellm-up
make local-e2e LOCAL_E2E_ARGS="-k providers"
make local-litellm-down
bedrock-mantle departs from the OpenAI API in two points (measured on
2026-10-07): the response.output_item.done of a tool call arrives with
id: null and it rejects a resent assistant message without id or
status. That is why mantle-responses only tests a turn without tools;
against the OpenAI API the full path is not genuinely tested. OpenAI,
Gemini, Azure OpenAI, OpenRouter, Groq, Mistral, DeepSeek and xAI are
covered only by the unit tests against a fake upstream.
Model providers without keys¶
The provider tests (test_local_agent_providers_fake.py and
agent-providers.local.test.ts) test the nine presets of the
agent providers (OpenAI, Gemini, Azure OpenAI,
OpenRouter, Groq, Mistral, DeepSeek, xAI and LiteLLM) with OpenCode and
with deepagents, without real keys or Internet. make local-providers-up
adds to the agents variant a fake HTTPS upstream
(dev/local/providers/fake_upstream.py): on the internal providers
network it answers to the names of the nine providers, with a single-use CA
that only rayd trusts (the guest's SSL_CERT_FILE), and replies the
minimum valid Chat Completions, Responses and Gemini. Per preset and
runtime, with egress closed, the tests check that:
- the agent completes a turn through the gateway;
- the upstream receives the secret value in the preset's header
(
authorization,x-goog-api-keyorapi-key), and the marker the runtimes see never leaves, neither in headers nor in the body; - only allowlisted paths get through, and a path outside it receives 403
from
raydwithout reaching the upstream.
CI runs them in local-e2e after make local-e2e.
Your deployed agent: AgentProject¶
test_local_agent_project.py tests AgentProject
against the local rayd: run(), deploy(), invoke(), stream() and
stop() with the custom runtime (CustomRuntime: a command that speaks v1
and an HTTP app, both in dev/local/agent/apps) and, with the fake upstream
of make local-providers-up, with OpenCode and deepagents. The guest shares
the runner network namespace, so the deployment client talks to the agent
host over loopback, without TLS; everything else (the sandbox, the gateways,
the host in service mode, the limits) is the production code. It also checks
that a second invoke() with the slot full gets busy, that the deployment
does not hold the credential and that stop() terminates the sandbox. It
runs with make local-providers-e2e, and skips itself when the guest is not
the one from make local-agent-up.
The container that export() writes is tested separately, with the host's
Docker: make agent-export-test builds the exported context for
linux/arm64, starts the container and probes /ping and /invocations
(JSON, SSE and the token) inside it.
The sample agents, from detection to the container¶
make agent-zero-config-test takes the sixteen agents in
testdata/agent-samples/ (LangGraph with FastAPI, LangChain, deepagents, Strands,
Strands harness, Hermes, a Flask app, a native AgentCore agent, LangGraph.js,
LangChain.js, deepagents-js, Strands TypeScript and its harness, the Vercel AI SDK, an Express
app and OpenCode), detects them with no options, exports their Docker context,
builds it for linux/arm64 with the host's Docker and tests it against a fake
Bedrock (dev/local/providers/fake_bedrock.py, which calls the add tool and
answers with its result): /ping, an invocation with a tool, the SSE stream, the
service token, the host queue and the 429 with three simultaneous requests, and
the restart of an app that dies. It uses no AWS and no keys. RAYITO_ZC_SAMPLES=a,b
picks samples and RAYITO_ZC_ENDPOINT_HOST is the name the container uses to see
the host (host.docker.internal by default; host.lima.internal with Lima). A Docker
with BuildKit is required: the host's Docker client may not ship buildx, in which
case run it with the virtual machine's docker.
Smoke test against the real APIs¶
test_local_agent_providers_smoke.py (Python) and
agent-providers-smoke.local.test.ts (TypeScript) do a short turn of each
runtime against the real API of every provider whose key you export, with
the sandbox's egress closed: the agent answers with text and the input and
output token usage is greater than zero. Both read the presets from the
same catalogue (testdata/agent/provider-catalogue.json), and
make local-providers-smoke runs the Python one first and then the
TypeScript one. It runs on top of make local-agent-up, without the fake
upstream: with it up, both are skipped.
Cost and activation
- By default: off. Each preset is skipped if you do not export
RAYITO_SMOKE_<PRESET>_SECRET(OPENAI,GEMINI,AZURE_OPENAI,OPENROUTER,GROQ,MISTRAL,DEEPSEEK,XAIorLITELLM). - Activates:
make local-providers-smokewith, per preset, the key inRAYITO_SMOKE_<PRESET>_SECRET(withoutBearer) and the model inRAYITO_SMOKE_<PRESET>_MODEL; Azure also needsRAYITO_SMOKE_AZURE_OPENAI_RESOURCEand LiteLLMRAYITO_SMOKE_LITELLM_UPSTREAM. The variables reach the runner by name, never by value on the command line. - AWS resources and calls: none. The key lives in Floci's Secrets
Manager;
raydmakes one or two calls to the provider per runtime through the gateway. - Approximate cost: each run carries
AgentLimits(max_total_tokens=20000)(maxTotalTokens: 20_000in TypeScript) and a prompt without tools. With a model of up to 0.20 USD per million input tokens, both runtimes together stay under 0.01 USD per provider and SDK (under 0.02 USD with both SDKs). Choose the cheapest model in your account. - IAM: none on AWS. The key only needs to call the model; set a spending limit on it at the provider.
- How to turn it off: do not export the variables, or
make local-down. -
Example:
How it works inside¶
The tests (clients/python/tests/local/ and clients/typescript/tests/local/)
build a LocalGuestControlPlane: another adapter of the ControlPlane
port, which wraps the real plane pointed at Floci. run-microvm,
get-microvm, list-microvms and terminate-microvm go through the real
adapter (Floci validates the requests against the service model); what
Floci does not emulate is replaced by the guest:
| Operation | On AWS | Locally |
|---|---|---|
| MicroVM endpoint | *.lambda-microvm.<region>.on.aws over TLS |
127.0.0.1, h2c over loopback |
| Proxy token | create-microvm-auth-token |
a fixed value (the proxy validates it, not rayd) |
/run |
called by the platform after restoring the snapshot | called by the local plane after /ready |
| Pause and resume | suspend-microvm / resume-microvm |
the guest's /suspend and /resume hooks |
| Terminate | terminate-microvm |
/terminate: rayd exits and Docker restarts it |
The published SDK does not change and does not know this exists: the tests
hand it the plane and the transport through the parameters it already has
(control_plane= and transport= in Python, controlPlane and
transport in TypeScript). Without the RAYITO_LOCAL_GUEST variable,
which only the runner defines, the local tests are skipped.
What it covers and what it does not¶
| Area | Locally | Only against real AWS |
|---|---|---|
Commands, files, PTY, run_code, contexts, connect, metrics |
yes, against the real rayd |
|
| Pause and resume | rayd's state machine |
memory snapshot and restore |
OptionalStacks (rayito stack) |
deploy, status, redeploy and delete of six stacks (metadata-index, secrets-access, otlp-export, s3-mounts, sizes-guard, templates), plus events-webhooks from LifecycleEvents |
custom-domain and efs-volumes; real IAM permissions and cost |
Metadata index, SecretStore, secrets= |
yes | |
rayito CLI, Python and TypeScript |
stack, image, sandbox create --detach, info, exec, metrics, list and kill, and the create and connect terminals (TypeScript), with the same scenarios as against real AWS |
sandbox proxy (it needs the AWS proxy), doctor with IAM and image builds |
MCP server (rayito-mcp) |
over stdio, the shared testdata/mcp/tools.json contract and the startup errors; the six tools against a sandbox over in-process --http (TypeScript) |
the six tools over stdio against a MicroVM |
Local dashboard rayito dashboard (TypeScript) |
the read-only routes validated with api-v1.schema.json and the security guards (no session, an action without --allow-actions, billed cost off) |
Cost Explorer and the real bulk actions |
Secrets gateway (gateways=) |
the ConfigureSandbox section and the listener |
the HTTPS forwarding to the upstream |
Template.build |
zip upload to S3 and create-microvm-image |
the real image build and its /ready |
| Events and webhooks | the stack, the webhooks, create(events=) and the three Lambdas run in process, with a signed delivery to a local receiver |
the CloudWatch Logs subscription and the HTTPS delivery with its SSRF filter |
| AWS proxy (JWE, 403, keepalives) | no | yes |
sbx.agent (OpenCode, deepagents) through the gateway |
yes, with make local-agent-up and a Bedrock key: real egress deny-all, tools, session, abort, limits, events |
the first exec and the memory in a MicroVM |
Provider presets (openai_gateway…litellm_gateway) |
yes, with make local-providers-up: the header that reaches the upstream, the marker that does not leave, the 403 outside the allowlist and one turn of each runtime against a fake upstream |
each API's real response (optional smoke test with your key) |
| IMDS blocking, egress, S3 mounts | no: they need privileges in the guest (except egress with make local-agent-up) |
yes |
| Persistence and presigned transfers | no: rayd uses IMDS and requires HTTPS |
yes |
| Cost, quotas, startup times | no | yes |
Floci has some differences from AWS that the tests take into account (a
secret's version, ListMicrovmImageVersions of an image that does not
exist yet); they are in the research notes.
Emulator security¶
Floci is third-party open source and its API has no authentication, so the compose file treats it as untrusted: unprivileged user, no capabilities, read-only filesystem, no Docker socket, no Internet and no ports on the host. Version 2.1.0 fixes the critical advisories published to date. Before bumping the version (Dependabot opens the PR), read its release notes and its security advisories.
In CI¶
The local-e2e workflow runs make local-up and make local-e2e on
ubuntu-24.04-arm on PRs that touch the local environment, rayd
(crates/, proto/, Cargo.lock), the SDKs and their tools (clients/,
without the CHANGELOGs) or the shared data (testdata/, limits.json), every
night and by hand (workflow_dispatch). It uses no repository credentials or
secrets. It is the check that replaces a full AWS regression when a change only
touches an SDK or its tools.