GitHub Codespaces Nodes

Run a temporary, browser-accessible Cacophony node from GitHub Codespaces.

When to use this path

Use physical nodes for hardware-bound work. Codespaces are not a good fit for real-time audio loops, GPU workloads, or local Docker image builds. Use the Azure remote-build path for Cacophony container images.

Minimal flow

REPO="owner/repo"

# 1. Resolve bootstrap material, create the Codespace, install caco there,
#    and enroll it as a dynamic Cacophony node.
caco codespace new --repo "$REPO" --display-name my-experiment

# If your org does not offer the default machine (`basicLinux32gb`), pass
# --machine explicitly or export CACO_CODESPACE_MACHINE.

# To target an enterprise GitHub host (e.g. ghe.example.com), pass --host or set
# CACO_CODESPACE_HOST; it also derives from the --repo/--source-repo URL host.
# caco codespace new --repo "owner/repo" --host ghe.example.com

# 2. Verify the enrolled `cs-<hash>` node is visible.
caco node list
caco codespace ls

# 3. Spawn an agent on the new node.
CS_HASH="8hexhash"
AGENT_ID="agent-id"
caco agent new --node "cs-${CS_HASH}" --type claude --project myproj

# 4. Attach from the usual surfaces.
caco agent attach --id "$AGENT_ID"

What the command does

  1. Resolve bootstrap material first

    The bootstrap URL comes from --rendezvous, CACO_BOOTSTRAP_URL, legacy CACO_RENDEZVOUS_URL or pki.bootstrap.public_url; the token from CACO_BOOTSTRAP_TOKEN, legacy CACO_ENROLL_TOKEN, CACO_BOOTSTRAP_TOKEN_FILE, ~/.cacophony/tokens/node.token or the configured pki.bootstrap sources. If either is missing, nothing is provisioned. The URL must be reachable from GitHub: private, loopback, RFC1918, unique-local, link-local and Tailscale/CGNAT literal IPs are refused unless CACO_CODESPACE_ALLOW_PRIVATE_BOOTSTRAP=1 marks an operator-managed tunnel.

  2. Create the Codespace

    gh codespace create --machine "$MACHINE" --default-permissions, with no interactive prompts; MACHINE is CACO_CODESPACE_MACHINE or basicLinux32gb. For an enterprise host, --host or CACO_CODESPACE_HOST (or the host in --repo) sets GH_HOST for creation, secrets and the SSH bootstrap alike.

  3. Project secrets for first start

    gh codespace user-secret set stores the bootstrap token and URL, CACO_NODE and readable provider material such as CACO_LITELLM_MASTER_KEY, CACO_GOOGLE_API_KEY and CACO_PI_AUTH_JSON as per-Codespace secrets, passed through GH_USER_SECRET_VALUE rather than process arguments. During bootstrap they move into owner-only files under ~/.cacophony/secrets/codespace/, and the CACOPHONY_*_FILE variables point there.

  4. Enroll over SSH

    Unless --no-wait is given, it opens gh codespace ssh, clones Cacophony alongside when the target repository lacks its devcontainer hook, installs caco if missing (falling back to cargo install --path crates/caco), runs caco codespace enroll, which joins through /v1/bootstrap/join, writes ~/.cacophony/bin/run-caco-daemon.sh and runs caco up --skip-update. The script is sent as a remote shell command, so repositories that are not Cacophony enroll reliably too.

  5. Report

    It prints the Codespace name, the derived cs-<hash> node ID, the secret projection status and the enrollment result. Confirm the node with caco node list or caco codespace ls, then remove or rotate the temporary Codespace secrets.

Public mesh routing must match certificate identity. After enrollment, any static node that GitHub-hosted Codespaces dial over a public Funnel or relay path needs an advertised public_host and public_cluster_port that are both reachable from GitHub and present in the node certificate SAN. For example, the root cluster's ms-mac node advertises the operator-managed ms-mac.example.ts.net Funnel DNS name on port 443 rather than either the private Tailnet IP or a Tailnet-only alias, so peer probes validate before beads-primary traffic routes. Static private-mesh peers continue to use the node's private host:cluster_port; the public endpoint is only the alternate route for dynamic/public callers that cannot reach the private mesh address.

Security notes

Implemented commands

Shipped surface. The CLI supports caco codespace new, ls, stop, resume, revoke, remove, rekey, and enroll. The design-stage caco codespace secret ... commands are not implemented yet.

Lifecycle commands

CommandPurpose
caco codespace lsList live GitHub Codespaces and derived cs-${CS_HASH} node IDs.
caco codespace statusRead-only desired-vs-real reconcile view over declared codespace_nodes pools (consumes Config::reconcile_codespace_nodes); add --json for machine output. Resolves real per-member mesh liveness from the daemon node list (reachable-joined / unreachable-declared / missing), degrading network-resiliently to unknown when the daemon view is unavailable. Pure inspection: no Codespace creation, secrets, or mesh mutation.
caco codespace reconcile --dry-runRead-only desired-vs-real reconcile plan over declared codespace_nodes pools: renders which pool members to create / no-op / leave-suspended / skip by reusing the same Config::reconcile_codespace_nodes core as status, with no cloud mutation, cert issuance, or secret projection. Without --dry-run it errors rather than implying a mutation it cannot perform; the mutating reconcile verbs remain deferred.
caco codespace stop "cs-${CS_HASH}"Stop a Codespace while preserving its volume.
caco codespace resume "cs-${CS_HASH}"Wake a suspended Codespace through GitHub SSH.
caco codespace revoke "cs-${CS_HASH}"Remove the dynamic mesh peer pin while leaving the Codespace available for inspection.
caco codespace rekey "cs-${CS_HASH}"Prepare explicit identity rotation after a stale pubkey pin or rebuild.
# caco codespace remove "cs-${CS_HASH}"Destructive: uncomment only after caco codespace ls confirms the stopped Codespace is disposable; prefer stop first.
caco codespace enroll [--reinit]Enroll or repair enrollment from inside the Codespace using CACO_NODE, CACO_BOOTSTRAP_URL, and CACO_BOOTSTRAP_TOKEN.
caco ssh "cs-${CS_HASH}" -- <command>SSH-family operator access for Codespace nodes; resolves the node ID through caco codespace ls and delegates to gh codespace ssh.
caco "@cs-${CS_HASH}" version / caco "@cs-${CS_HASH}" tuiRun a caco command or TUI in the live Codespace through the same GitHub Codespaces SSH path.

codespace_nodes pool (planned, design-stage)

caco codespace new provisions one Codespace at a time, and each becomes a dynamic cs-<hash> mesh peer. For recurring pools, the codespace_nodes config key declares the desired set of Codespace-hosted microVM nodes instead.

Design summary

codespace_nodes:
  cs:                                          # prefix; becomes cs-0, cs-1, ...
    github_identity: harryaskham_microsoft     # which gh account/token
    host_project: infinity-microsoft/picasso   # repo the codespace is created from
    machine: largePremiumLinux                 # 16c/64GB/128GB (verified available)
    replicas: 3
    # optional: variant (isolated default), ts_tag, region/idle_timeout, ...

At config load the codespace_nodes.<prefix> map with replicas: N desugars into N real NodeEntrys merged into config.nodes named prefix-0 .. prefix-(N-1). Each entry gets the standard static-node shape (Tailscale MagicDNS host, microVM SSH, daemon service, speech: disabled, role: default, and health_expectations.expected_unreachable: true so the fleet/health surfaces treat Codespace auto-suspend as expected), plus a provenance marker so the reconcile path and operator surfaces know the entry is Codespace-backed. This is the key design choice: desugar into nodes, not into dynamic_nodes (dynamic_nodes are regex join-templates; codespace_nodes are concrete known-name static nodes). Every existing node resolver — caco node list, caco ssh, @prefix-1, fleet/health, beads routing, TUI Nodes — then sees them as ordinary static nodes with no Codespace-awareness required.

Reconcile commands (read-only views implemented; mutating verbs planned)

The only Codespace-aware operator surface is the reconciler. Its read-only desired-vs-real views have shipped; the mutating convergence verbs remain planned. Each invocation compares the desired pool against live mesh state:

CommandStatus / behavior
caco codespace status Implemented. Read-only desired-vs-real reconcile view over declared codespace_nodes pools, resolving real per-member mesh liveness (--json for machine output). Pure inspection — no creation, secrets, or mesh mutation.
caco codespace reconcile --dry-run Implemented. Read-only reconcile plan (which members to create / no-op / leave-suspended / skip), reusing the same Config::reconcile_codespace_nodes core as status; without --dry-run it errors rather than implying a mutation it cannot perform.
caco codespaces init / caco codespaces reconcile / caco codespaces upFor each desugared prefix-K, check whether a live cert-joined caco node prefix-K is in the mesh. If yes, no-op. If no, idempotently gh codespace create, caco cert issue --node prefix-K (authority side, same path as the micro-0 proof), push the cert plus Tailscale auth key (tagged tag:caco-cs-pool) plus joined config as Codespace secrets / bootstrap inputs, run the in-Codespace microVM bootstrap, and wait until prefix-K reports healthy/cert-joined.
caco codespaces down / caco codespaces destroySuspend or delete the backing Codespace(s) for a prefix; the node-id-to-Codespace-name mapping reuses the existing stop/resume resolution.

The in-Codespace bootstrap is the same proven path captured in the micro-0 rollout runbook (/dev/kvm access, cloud-hypervisor plus seed cache, caco microvm preflight, guest Tailscale join, caco microvm node up, then caco up --skip-update). See docs/microvm-node-rollout.md for the verified per-node sequence the reconciler will drive.

Why static-node desugaring, not dynamic

The verified feasibility pass (this design's source) confirmed that a GitHub Codespace on Azure exposes /dev/kvm, kvm_intel nested=Y, vmx/svm, cgroup v2, /dev/net/tun, and /dev/vhost-vsock, with passwordless sudo; caco microvm preflight passes every required hardware/kernel check. The only bootstrap gaps are the hypervisor binary and the cache directories, which the per-pool init handles. Because each prefix-K is a concrete known-name node with a stable Tailscale MagicDNS hostname, treating them as static NodeEntrys lets every existing node resolver work unchanged. The separate dynamic_nodes template surface stays reserved for regex-named join patterns (for example the existing cs- rendezvous path used by ad-hoc caco codespace new Codespaces); the two surfaces are complementary, not overlapping.

Decomposition (planned beads)

Container-hosted operation

The repo-owned deploy/codespaces/ path runs an already-published Cacophony image inside the Codespace. It is runtime-only: CACO_CODESPACE_IMAGE must point at an image produced by Azure remote build, and the compose file must not grow a local build: stanza.

mkdir -p deploy/codespaces/runtime/{state,secrets}
# Write secret values directly to files instead of exporting plaintext tokens.
# Example: your-secret-command bootstrap-token > deploy/codespaces/runtime/secrets/bootstrap-token
export GIT_SHA="$(git rev-parse --short HEAD)"
export ACR_LOGIN_SERVER="${CACO_ACR_LOGIN_SERVER:?set ACR login server}"
export CACO_CODESPACE_IMAGE="${ACR_LOGIN_SERVER}/cacophony:${GIT_SHA}"
export CACO_CODESPACE_CONFIG="$HOME/.cacophony/config.yaml"
just codespaces-container-validate
docker compose -f deploy/codespaces/docker-compose.yml pull
docker compose -f deploy/codespaces/docker-compose.yml up -d

Secrets today

Codespaces start with no application secrets. caco codespace new only attempts to project bootstrap values used for enrollment: CACO_BOOTSTRAP_TOKEN, CACO_BOOTSTRAP_URL, CACO_NODE, plus legacy aliases CACO_ENROLL_TOKEN and CACO_RENDEZVOUS_URL. The bootstrap path uses per-Codespace user secrets through gh codespace user-secret set NAME --codespace "$CODESPACE_NAME" and passes values through GH_USER_SECRET_VALUE instead of command-line arguments. The repo bootstrap immediately materializes known token variables into files under ~/.cacophony/secrets/codespace/ and unsets plaintext variables before running caco up.

Per-task provider-secret commands are not available yet. For now, use these supported paths and prefer *_FILE variables wherever possible:

Troubleshooting

For design-stage background, use the internal architecture and key-distribution notes in the repository tree. Treat examples for not-yet-shipped subcommands as design notes, not operator commands.