Skip to content
AuthorPascal DateAugust 10, 2026 Rev1.2

Set up your own robot + sim, and get a brain

The simulator is per person. The brain is already running — you ask for an instance, you do not build one.

Read this first: what already exists, and what you actually set up

Already running, centrally — do not rebuild it:

  • The brain host is the 4090 (ssh 4090, user bot). The image is built, instances are running, and brain-pascal serves the installable PWA at https://phyai4090.tailc7b34f.ts.net/.
  • The shared central memory (brain-memory, :9103) that every brain attaches to.

What you set up, on your own machine:

  • The Gazebo simulator (or a real robot).
  • The robot-side MCP serversr1ctl up brings up both.

How you get a brain: you get an instance on the 4090, pointed at your machine over the tailnet. That is one brainctl up on that host — a few seconds, no build, no Docker on your side. See step 2.

Building your own brain host is the exception, not the path. It is an appendix for when you genuinely have no tailnet route to the 4090, or you are working on the brain image itself.


What you are building

   ┌──────────── your machine / VM ─────────────┐        ┌──── the 4090 (exists) ────┐
   │                                            │        │                           │
   │   grasp-service :9210                      │◀───────│  brain-<you>   :80NN      │
   │   r1-abstraction :9220                     │  MCP/  │  (one container,          │
   │            │                               │  SSE   │   your robot)             │
   │            ▼                    over the   │  over  │        │                  │
   │   galaxea_agent  :8004 :8003 :8001          │ tailnet│        ▼                  │
   │            │          (+ rosbridge :9090)  │        │  brain-memory :9103       │
   │            ▼                               │        │  (shared)                 │
   │   ROS 2 Jazzy ──▶ Gazebo Harmonic sim      │        └───────────────────────────┘
   └────────────────────────────────────────────┘

Everything in the left box is yours and comes up with one command (r1ctl up). Everything in the right box already exists; you just need a container in it. The rest of this page is prerequisites and verification.

Read this before you provision the VM — cameras need a real GPU

Gazebo's camera sensors render on the GPU through an X display. A headless VM, or one with no GPU passthrough, will run the simulator perfectly happily — physics, MoveIt, Nav 2, the robot spawns — and publish no camera frames at all. Nothing fails loudly; you just get an empty scene from every perception call.

So: give the VM a GPU and an X server on :0, or accept that you are testing everything except perception and grasping. This one decision determines whether the setup is useful to you. See platform notes.


0. Prerequisites

The machine. Ubuntu 24.04 with ROS 2 Jazzy — the sim is Gazebo Harmonic on Jazzy, and the install scripts assume that pairing (apt package names, pip --break-system-packages, Python 3.12). x86-64 is the tested architecture; see platform notes for Apple silicon and WSL2.

Access and tools:

Need Why Check
SSH access to the private github.com/binabik-ai org r1ctl clones four private repos (more with the opt-in services), and uv pulls the private robot-mcp-kit as a git dependency ssh -T git@github.com
uv every Python service uses it curl -LsSf https://astral.sh/uv/install.sh \| sh
Tailscale required — your brain instance lives on the 4090 and reaches your robot servers over the tailnet tailscale status
tmux, psmisc (fuser), iproute2 (ss), x11-utils (xdpyinfo) r1ctl uses all four; without fuser stale ports are never freed, without ss the port checks silently pass command -v tmux fuser ss xdpyinfo
sudo one-time apt/rosdep steps during the sim build
Docker (only for the appendix) not needed at all for the normal path — the brain runs on the 4090 docker run --rm hello-world

Keys. Have these ready before you start:

  • GEMINI_API_KEY — the robot-side perception grounding. Without it the sim's perception nodes come up but cannot ground a scene, and hybrid segmentation degrades to plain SAM 3. r1ctl does not ask for it or store it (BIN-439): put it in the secret store this robot's identity reaches, or — with no store — in galaxea_agent/.env, the file the MCP windows and the perception node's environment come from. Each service resolves it for itself.
  • GROQ_API_KEY / ANTHROPIC_API_KEY — the brain's keys. These live in brain.env on the 4090 and are already configured there; you need them only if you build your own brain host (appendix). An instance can instead fetch them per-instance from a key store — see Per-instance secrets.
  • MEMORY_TOKEN — the bearer token for the central memory (:9103), and the only credential that must be byte-identical on two machines: the robot pushes lessons with it and the memory container validates them, so the robot's copy and the brain host's copy are one value in two places. Get it from the brain host (brain.env) rather than inventing one; a fresh value on the robot alone does not fail loudly, it 401s every push and fleet learning simply stops (BIN-430). It is optional in the sense that memory auth can be off — but if it is set anywhere, it must be set the same everywhere.

Check it rather than trusting it, and without ever printing a credential — compare fingerprints on the two hosts:

# on the robot, and again on the brain host with brain.env
v=$(grep -E '^MEMORY_TOKEN=' r1.env | cut -d= -f2- | tr -d '"'"'"' \r')
printf '%s  len=%s\n' "$(printf %s "$v" | sha256sum | cut -c1-12)" "${#v}"

Two different fingerprints is the bug, and it is invisible any other way. This is the general habit for any shared secret: compare a hash, never the value.

  • A reachable SAM 3 segmentation endpoint (SAM3_SERVER_URL) — see the honest limitation below.

You can put these in a secret store instead of a file (BIN-426)

Everything above assumes hand-edited .env files, which is still the supported path and still the default. If you would rather not keep keys in files, give the robot an identity — secrets/<ROBOT_ID>.env beside r1ctl, or r1ctl up --secrets-file <path> — holding a bootstrap credential and BINABIK_SECRETS_BACKEND (google or infisical). Each service then fetches its own keys at runtime.

It is genuinely optional and there is no flag day: a name the store does not hold keeps whatever the .env gave it, so a stack can migrate one credential at a time. Details and the "did it actually work" check: the robot runbook.

MEMORY_TOKEN is the credential the store helps most with, because it is the one value that has to match on two hosts: with both sides reading the same name from one store, a rotation stops being "edit two files on two machines and hope no third copy was missed". It is seeded in binabik-dev as of 2026-09-01.

One caveat while BIN-439 finishes: r1ctl still writes MEMORY_TOKEN's literal value into the grasp launch line, so that service reports it as 1 from environment even when the store holds it. The store value is correct and simply not yet the path taken there — the brain host already resolves entirely from store.

Without a reachable SAM 3 server, perceive() and grasp() will not work

The sim's perception stack posts to a SAM 3 server for segmentation. Its default is a lab-internal address your VM cannot route to, and the failure mode is a hang followed by every perceive() returning no cloud — so grasping never gets a point cloud.

Your options: point the sim's perception at a SAM 3 server you can actually reach; or deploy your own off-robot segmenter (segmentation-modal runs SAM 3 on Modal); or accept a stack that navigates, moves the arms and streams camera frames but does not perceive objects. Decide this up front — it is the second most common reason a from-scratch setup feels broken.

Setting SAM3_SERVER_URL does take effect on a clean checkout (BIN-151): r1ctl exports it, gazebo_sim.sh pushes it into the sim session and passes sam_server_url:=… to the perception launch, and perception.launch.py defaults from the variable and probes the server's /health at launch, warning loudly and naming the URL when it doesn't answer. So a wrong or unreachable endpoint tells you at bring-up instead of at the first grasp — see the R1 page.


1. The simulator and the robot-side servers

Both come from one launcher, r1ctl, in binabik-r1-host:

eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_ed25519    # r1ctl clones over SSH
git clone git@github.com:binabik-ai/binabik-r1-host.git ~/rap/binabik-r1-host
cd ~/rap/binabik-r1-host
./r1ctl env                     # writes r1.env from the committed example

On a fresh machine that first env call writes only r1.env — the second env file (galaxea_agent/.env, which holds the VLM configuration and the BINABIK_SECRETS_FILE pointer) can't be created until galaxea_agent has been cloned, so install creates it then. Neither file gets a credential written into it by the launcher.

Now edit r1.env before you install — two settings are wrong by default on any machine whose user is not ros:

# ~/rap/binabik-r1-host/r1.env
COLCON_WS=$HOME/colcon_ws        # where the sim's ROS workspace is SOURCED from
WS=$HOME/colcon_ws               # where it is BUILT — the same path (r1ctl mirrors one to the other)
SAM3_SERVER_URL=http://<your-sam3-host>:8001

The colcon workspace path is the single most common fresh-machine failure

The sim launcher defaults to /home/ros/colcon_ws — a lab path. On any other user that directory does not exist, the workspace fails to source silently (the script deliberately does not use set -e), and all four sim windows then die with "package r1pro_sim not found".

And it is one workspace with two names: sim/gazebo_sim.sh reads COLCON_WS, while galaxea_isaac_moveit/install_sim_deps.sh reads WS. Since BIN-149 r1ctl no longer lets them disagree — set either and it sets the other, and set both to different paths and it exits, naming which script reads which, because that state is unresolvable: the sim would be built in one workspace and sourced from the other. Setting both to the same path, as above, is still the clearest thing to write.

With neither set, each script keeps its own default — and if /home/ros/colcon_ws is not creatable by you, up says so before anything long runs. The failure that replaced was install reaching install_sim_deps.sh and dying on mkdir: cannot create directory '/home/ros', forty lines into pip output.

Also check what python3 a tmux pane resolves — before you blame the install

If your ~/.bashrc auto-activates conda (or any other environment), every tmux pane gets that interpreter, because a pane starts a fresh interactive bash and re-sources the rc file. ROS Python nodes run #!/usr/bin/env python3, so rosbridge dies on No module named 'yaml' while the other four galaxea servers — which run their venv's interpreter by path — come up fine. A stack that reads 6/7 healthy for a cause outside all seven.

r1ctl up preflights this and stops with the remedy, which is not conda deactivate in your own shell (the pane never sees it):

conda config --set auto_activate_base false

R1_ROS_PYTHON_CHECK=0 opts out. The full mechanism.

A line in r1.env that fails stops the launcher

r1.env is sourced before r1ctl prints a word, under set -euo pipefail. Current versions name the file, the line and the command; older ones exited non-zero in total silence. Prefer literal values here — a $( ) substitution's subshell inherits set -e, so one non-zero command inside it kills the launcher. R1_ENV=/dev/null ./r1ctl status confirms the file is the cause.

Then install and launch:

./r1ctl install                 # clones the repos, builds venvs, builds the sim workspace
                                #   → writes galaxea_agent/.env (config + identity pointer)
./r1ctl up                      # launches the sim, then every MCP server

install can still report success when the sim workspace failed to build

A failed colcon build is deliberately non-fatal (a missing apt dependency is the usual cause — it will tell you to sudo apt install libnlopt-cxx-dev), so install done. still prints and the failure surfaces one step later, as up timing out on the MCP ports. Read install's output rather than trusting its exit.

A failed galaxea install, by contrast, is now fatal: those are :8001/:8003/:8004, the ports up calls required, so a cheerful install done. over them was only ever hiding the cause (details). If pip says "Neither 'setup.py' nor 'pyproject.toml' found", that is a galaxea submodule at the right commit with an empty working treegit submodule update --init is a no-op against it, and install force-checks it out for you now.

rosdep is the dependency mechanism, and it no longer needs root

The sim's ROS dependencies come from the packages' own package.xml files, resolved by rosdep install --from-paths. rosdep init writes under /etc and so wanted sudo, whose failure used to be swallowed — after which rosdep resolved nothing while the build succeeded and the sim started, giving a stack with /clock advancing, MoveIt up, and no controller loaded, so every trajectory was accepted and nothing moved. r1ctl install now points rosdep at a user-writable sources list, and the installer repeats its verdict at the end (BIN-231). This is the one place the sudo in the prerequisites above is no longer needed for resolution — only for the apt packages rosdep then installs.

install clones galaxea_agent, r1-abstraction, grasp-service and galaxea_isaac_moveit on main, then builds the colcon workspace. The optional services are cloned only when their flag is set at install time — with VISION_ENABLE=1 that means binabik-r1-vision and binabik-scene-perception, in a --system-site-packages venv so rclpy resolves (BIN-143). up asks Launch the Gazebo sim now? [y/N] — answer y the first time. (Non-interactively, set SIM_AUTOLAUNCH=1; otherwise the sim is skipped and the whole bring-up then fails at the port check.)

SIM_AUTOLAUNCH=1 alone is not enough — a tty beats it

up checks whether stdin is a tty before it looks at SIM_AUTOLAUNCH, so an interactive run asks the question regardless of the variable, and a run whose stdout is piped blocks forever on a prompt you cannot see. For an unattended launch, redirect stdin as well:

SIM_AUTOLAUNCH=1 ./r1ctl up </dev/null

That does not cost you the controlling terminal, which tmux still needs — only the tty on stdin, which is the thing being tested. And do not wrap it in tmux -L <socket>: TMUX makes r1ctl's own tmux calls land on that socket, so r1 and robot are created there and r1ctl status, r1ctl down and tmux attach -t r1 all report an empty stack.

main is the branch — and the repo name lies

Despite the name, this is the Gazebo sim, not Isaac. main is the unified tree: one branch for the sim and the robot since BIN-276, selecting per host by $ROS_DISTRO and backend:=. r1ctl checks it out for you (GALAXEA_ISAAC_BRANCH, default main).

It used to be jazzy, because main was then an old Ignition Fortress / ROS 2 Humble port running ign gazebo — a command that does not exist on Jazzy, so the server never started and there was no camera. That port is gone, and so is jazzy: it was merged into main and deleted upstream, so a git checkout jazzy copied from an older runbook now fails with a missing-ref error (BIN-478). The pre-collapse tips are kept frozen as frozen/humble and frozen/jazzy for reference and take no commits; do not build from them.

Rebuild the r1pro_* colcon packages after any branch switch — a stale install/ keeps running the old code, which is the part of the old warning that is still true.

If you ever switch branches by hand, rebuild the workspace or the stale install/ keeps running the old code.

What you should have now. Two tmux sessions:

Session Windows What
robot monitor, sim, moveit, nav2, perception Gazebo + MoveIt + Nav 2 + the perception nodes. monitor is created first so DISPLAY, BINABIK_SECRETS_FILE and SAM3_SERVER_URL can be pushed into the session before the others exist.
r1 rosbridge, nav2-mcp, perception-mcp, r1-manip-mcp, ros-mcp, adapter, grasp rosbridge + galaxea's MCP servers + r1-abstraction (:9220) + grasp-service (:9210)

r1ctl up waits for the MCP ports and exits non-zero if the required ones (9090 8001 8003 8004) never opened, so a silent half-failure is not possible. :8005 (ros-mcp) is optional and only warns. Check any window with ./r1ctl logs <window>, or ./r1ctl status for the port summary.

The full r1ctl surface — every subcommand, env var, port and window — is in the R1 robot stack.


2. Your brain instance — on the 4090, not on your machine

The brain host already exists and the image is already built. You need one container in it, pointed at your machine. Nothing is installed on your side.

What you provide: your machine's tailnet hostname — the brain reaches your :9220 and :9210 over Tailscale, so join the tailnet first:

tailscale up
tailscale status | head -1        # note YOUR machine's tailnet name
ss -ltn | grep -E ':(9220|9210)'  # both should show 0.0.0.0

Bound to 127.0.0.1 instead of 0.0.0.0, a remote brain cannot connect — that is the most common reason an instance comes up with no tools.

One legitimate exception: a tailscale serve proxy in front of them

If you put tailscale serve --bg --tcp 9220 tcp://127.0.0.1:9220 in front of a service (the workaround for a host-level firewall), r1ctl up binds that service to 127.0.0.1 on purpose and says so as it launches — because 0.0.0.0 over a proxy that already holds the tailnet addresses is EADDRINUSE and the server dies instantly. The brain still connects to <host>:9220, through the proxy. So read the pair together: loopback plus a proxy is correct; loopback alone is the failure above. The whole story.

Then ask for an instance (or run it yourself if you have access to the 4090):

ssh 4090
cd ~/sage/orchestrator/brain-host
./brainctl up <you> --robot <your-tailnet-host> --port <free port  8001>
./brainctl ls                     # prints the HTTPS URL to open

That is it — a few seconds, because robots/r1.yaml is the built-in default manifest and the image is already there. Open the https://…ts.net URL ls prints: it is a secure context, so the full UI works and it installs as a PWA.

Don't disturb the instances that are already running

brain-pascal on :8001 is the primary and owns the PWA root — leave it alone, give your instance its own --port, and test changes on a scratch instance rather than recreating someone else's. Why, and the exact commands, are Running a brain → launch an instance.

Everything else an instance can do — a different planner model, Hydra overrides, extra MCP servers, memory scoping, the HTTPS root, updating it later — is Running a brain.


3. Verify, bottom-up

Check the layers in order — the first one that fails is the one to fix, and a failure higher up is almost always a symptom of a lower layer.

1. The sim is stepping and the cameras publish.

tmux attach -t robot                       # window 'sim' — Gazebo, robot spawned
ros2 topic hz /head_camera/image           # ~2 Hz. SILENCE = no GPU/display (see above)

2. The MCP servers listen.

./r1ctl status                             # SERVICE / PORT / STATE — every required port present
ss -ltn | grep -E ':(9220|9210)'           # both on 0.0.0.0 (or loopback + a serve proxy, above)

3. The adapter answers, and perception round-trips.

curl -s -o /dev/null -w '%{http_code}\n' --max-time 6 http://127.0.0.1:9220/sse   # 200
./r1ctl logs adapter

4. Your brain instance attached to your robot — run this on the 4090, against your instance's port:

ssh 4090 'curl -s http://localhost:<your port>/api/readyz | jq .'

Expect status: ready, tools_connected: true, and r1-abstraction + grasp true in mcp_servers. If they are false, the brain cannot reach your machine: check that up printed (<your-host> → 100.x.y.z) rather than a resolve warning, that your servers are bound to 0.0.0.0, and that tailscale ping <your-host> answers from the 4090. Note the route is /api/readyz/api/admin/readyz returns the SPA's HTML with a 200 and reads as a false pass.

5. Drive it. Open the URL brainctl ls prints, type "what do you see" or "pick up the coke can", and open the Debug panel to watch it plan and call tools. What the UI can do — the Visual Command view, the live sub-module telemetry, teaching — is in Using the brain.


Platform notes

Platform Verdict
Ubuntu 24.04, x86-64, NVIDIA GPU, Xorg on :0 The reference setup. Everything on this page works, cameras included.
Apple silicon (macOS, native) The sim runs well, on a different ROS install (RoboStack, no VM) but through the same r1ctl commands — including the robot-side MCP servers, so a brain can drive it over the tailnet. What it cannot do is depth, so no grasp perception. Run the brain elsewhere. Running the simulator → macOS.
A Linux VM on Apple silicon Not recommended: the sim's Python dependencies include x86-64-only wheels, so the workspace build fails on arm64 Linux. Use the native macOS path.
WSL2 Camera rendering depends on the WSL GPU/WSLg stack and is not something we have verified. Expect to debug rendering.
Headless / no GPU The stack comes up and the robot moves, but no camera frames — so no perception and no grasping. Fine for planner and navigation work, misleading for anything visual.

Both simulator paths, with the capability matrix and every platform-specific gotcha, are in Running the simulator.



Appendix — running a brain on your own machine

You probably don't need this. Use it only when you genuinely have no tailnet route to the 4090, you are working offline, or you are changing the brain image itself. Otherwise get an instance — it is faster and it is what everyone else runs.

The brain is a Docker container built from four repos:

mkdir -p ~/binabik-src && cd ~/binabik-src
git clone git@github.com:binabik-ai/robot-voice-chat.git
git clone git@github.com:binabik-ai/robot-mcp-kit.git
git clone git@github.com:binabik-ai/robot-mcp-memory.git
git clone git@github.com:binabik-ai/binabik-brain-host.git

Then set BRAIN_SRC to that directory, write a brain.env, and build the image — the host prerequisites and both of those steps are Running a brain → prerequisites. The only part that is specific to sharing one machine with the robot is what you pass as --robot, next.

What to pass as --robot when the brain and the robot share a host

This is the part that catches everyone. The brain runs in a bridge-network container, so it does not share the host's network namespace. --robot must be a name or address that, from inside the container, reaches your host's :9220 and :9210.

--robot localhost looks like it works and never does

getent resolves localhost to 127.0.0.1, so brainctl prints (localhost → 127.0.0.1) with no warning — but inside the container that is the container's own loopback, where nothing is listening. You get a brain that starts, reports ready, and has zero robot tools. Same trap for 127.0.0.1 and for --robot $(hostname), which Ubuntu usually maps to 127.0.1.1.

Pass an IP address of the host instead:

GW=$(docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}')
./brainctl up mine --robot "$GW" --port 8100
./brainctl up mine --robot "$(tailscale ip -4 | head -1)" --port 8100

Either way up prints a WARNING: could not resolve …. Ignore it here — there is no name to look up, which is correct for a literal IP; the container routes to it directly.

On one machine, keep the brain off the robot's ports

brainctl would default to 8001 — exactly where galaxea's nav2-mcp listens. Worse, r1ctl up frees nine ports on every reset (9090 8001 8003 8004 8005 9220 9210 9240 9230), so it would kill the brain's published port too. Use something well clear of the robot stack; this appendix uses 8100.

Two more conditions:

  • The robot servers must be bound to 0.0.0.0, not 127.0.0.1. r1-abstraction, grasp-service and the vision service already are; galaxea's own servers bind loopback, which is fine because only the adapter (running on the host) talks to them.
  • A third form, --robot host.docker.internal, works but is fragile: that hostname is mapped into the container only as part of the central-memory wiring, so adding --no-memory silently removes it and every robot URL fails to resolve. The two options above have no such coupling — prefer them.

When it doesn't work

Symptom Cause / fix
All four ROS windows in robot die with "package r1pro_sim not found" The colcon workspace wasn't sourced — COLCON_WS/WS still point at /home/ros/colcon_ws. Set it in r1.env (either name; r1ctl mirrors it), then ./r1ctl install.
r1ctl exits: "COLCON_WS and WS name DIFFERENT workspaces" Exactly what it says — the sim would be built in one and sourced from the other. Set one, or both to the same path (BIN-149).
r1ctl prints nothing at all and returns non-zero A line in r1.env failed while it was being sourced. Current versions name the line; confirm with R1_ENV=/dev/null ./r1ctl status.
Six of seven servers up but :9090 never opens, No module named 'yaml' The pane's python3 is conda's. conda config --set auto_activate_base falsenot conda deactivatewhy.
The adapter/grasp window dies with bind on address ('0.0.0.0', 9220) A tailscale serve proxy owns the port. up normally detects it and binds loopback — details.
install fails on galaxea with "Neither 'setup.py' nor 'pyproject.toml' found" A submodule at the right commit with an empty working tree; install force-checks-out now, so pull binabik-r1-host and re-run it.
The sim comes up healthy but nothing moves/clock advances, MoveIt is up, no controller loaded rosdep resolved nothing (historically a swallowed sudo rosdep init). Pull binabik-r1-host and re-run install; ROSDEP_STRICT=1 makes it fail loudly (BIN-231).
Sim runs, robot spawns, no camera frames No X display or no GPU. r1ctl warns (X display ':0' not reachable) but continues. Give the VM a GPU + Xorg on :0.
Every perceive() returns no cloud; grasps never start SAM3_SERVER_URL unreachable — the default is lab-internal. See the SAM 3 limitation.
r1ctl up exits non-zero: "the galaxea MCP servers are not listening" The bring-up genuinely failed. Attach to r1 and read the failing window — usually the sim never came up (so move_group never appeared), or a venv is missing.
r1ctl up refuses: "tmux session name collision" Another tmux session's name starts with r1 (r1up, r1-debug). tmux resolves targets by prefix, so galaxea's servers would land in the wrong session. Rename it, or run r1ctl outside tmux.
uv sync fails on robot-mcp-kit Your git isn't authenticated to the private org. Load your SSH key.
Brain boots, but a role answers "this provider has no credential" That provider's key is absent from both the key store and brain.env. The brain no longer exits over it (BIN-239) — pick a provider that has a key from the config panel, or set the missing one and recreate the instance. docker logs <container> 2>&1 \| grep credentials names it.
Brain is ready but has no robot tools Almost always a wrong --robot — see the localhost trap. Confirm with curl /api/readyz, then check the (name → IP) line in the up output.
Brain chats, but every mission is refused naming a variable The planner has no credential — by default ANTHROPIC_API_KEY. Since BIN-239 it does not silently fall back to Groq; set the key and recreate, or choose a planner with a key in the config panel.
VISION_ENABLE=1 dies with "the vision stack is incomplete: …" The flag has to be set for the install too, and it is not persisted: VISION_ENABLE=1 ./r1ctl install (or put VISION_ENABLE=1 in r1.env, which also makes the next up self-heal). BIN-143.

More R1-side detail — the smoke-test scripts, per-server env tables, and the lab-robot recovery procedures — is in the R1 robot stack.