Skip to content

feat(daytona): upgrade to v0.189 + Sysbox host-boundary isolation#51

Merged
Wilbert957 merged 2 commits into
mainfrom
feat/upgrade-daytona-v0189
Jul 2, 2026
Merged

feat(daytona): upgrade to v0.189 + Sysbox host-boundary isolation#51
Wilbert957 merged 2 commits into
mainfrom
feat/upgrade-daytona-v0189

Conversation

@Wilbert957

@Wilbert957 Wilbert957 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Bump 4 daytona images to v0.189.0-amd64 (from pinned Feb 2026 sha)
  • New init container grant-admin-perms grants the daytona-admin API key the full v0.189 permission enum (v0.189 introduces api_key.permissions; existing admin keys carry migration default {} → 403 on writes)
  • Client.ListSandboxes handles the v0.189 {items, nextCursor} paginated envelope. Silent-empty regression was blocking /api/sandbox_list, owner-scoped /api/sandbox, /api/sessions, /api/archive-all, and archiveRunningOnShutdown
  • Security hardening (see dedicated section below): outer-layer Sysbox on the runner + native cross-sandbox network isolation. Compose diff: runtime: sysbox-runc on runner + INTER_SANDBOX_NETWORK_ENABLED=false on runner env; the old runner-firewall iptables sidecar from PR fix(runner): enforce cross-sandbox network isolation via firewall sidecar #45 is removed (its DOCKER-USER rule silently no-ops on hosts without br_netfilter)

Host-side dependency (Sysbox install + sysbox-runc registration + Docker version pin) is managed by 0g-tapp#5 — see companion review comment on Docker 27 pin + Sysbox 0.7 compat.


Security hardening — two questions

Multi-tenant sandboxes on one wei-tapp host raise two separate isolation questions. This PR addresses both.

Q1. Can a user in one sandbox get root on the host?

Status before this PR: Yes, trivially. daytona-runner v0.189 hard-codes HostConfig.Privileged = true for every CPU sandbox it spawns (apps/runner/pkg/docker/container_configs.go:205). Combined with the sandbox image's passwordless sudo, that means:

  • Container uid=0 is the host kernel's uid=0 (no user-namespace remap; standard runc shares the host kernel directly)
  • All Linux capabilities are granted (CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_NET_ADMIN, …)
  • AppArmor / SELinux is disabled per SecurityOpt: [label=disable]
  • Full device access (/dev/* reachable)

Without any CVE, a tenant can:

  • sudo mount --bind /path/on/host /mnt/exfil — read any host path
  • sudo mknod /dev/sda b 8 0 && dd if=/dev/sda … — read raw host disks
  • Any kernel-CVE PoC that assumes CAP_SYS_ADMIN

Fix: runtime: sysbox-runc on the runner service. The host dockerd starts the runner container under sysbox-runc, which puts the entire runner into a user namespace. Runner's inner DinD + every user sandbox it later creates all live inside that user-ns. Container uid=0 is mapped to host uid=362144 (a normal user).

Effect chain:

  1. Tenant sandbox root does something privileged → succeeds inside the nested user-ns
  2. If the tenant escapes their sandbox container into the runner (e.g., via a container-runtime CVE) → still user-ns uid=0 = host uid=362144
  3. To escalate to host root, the tenant would have to break out of the user namespace itself, not out of the container. That's kernel enforcement code, hardened for a decade+

Concrete escape CVEs that stop working under this design: CVE-2019-5736 (runc), CVE-2022-0185 (fsconfig), CVE-2024-21626 (runc file-descriptor leak) — all require CAP_SYS_ADMIN on the initial user-ns, which the tenant never holds.

Residual risk (honest):

  • The tenant is still root inside their user-ns, so they can create nested containers, bind-mount their own paths, run kernel-fuzzing tools — anything that stays inside the ns is unhindered
  • Kernel bugs that require no capabilities at all (very rare, mostly theoretical) are unaffected by Sysbox
  • Host disk pressure: a runaway sandbox can still fill /var/lib/docker; that's a quota problem, not an isolation one

Q2. Can a user in one sandbox reach another sandbox?

Split by attack vector:

Q2a — Network path (sandbox A ↔ sandbox B over IP)

Status before this PR: On the Feb 2026 stack, sandboxes shared the runner's default docker0 bridge with enable_icc: true. PR #45 tried to fix it by inserting iptables -I DOCKER-USER 1 -i docker0 -o docker0 -j DROP via a sidecar. That rule only fires when the host has the br_netfilter kernel module loaded — default GCP Ubuntu 24.04 does not load it, so the rule was silently a no-op (verified on wei-tapp2: pkts=0 after 20+ minutes). Sandbox A could reach sandbox B on any port.

Fix: INTER_SANDBOX_NETWORK_ENABLED=false on the runner service (v0.189 native). daytona-runner now creates a dedicated bridge runner-bridge with com.docker.network.bridge.enable_icc: false, and spawns every sandbox on it. dockerd itself drops container-to-container packets on that bridge — no host kernel module needed. The old runner-firewall sidecar is removed.

Verified:

# From sandbox B (running python3 -m http.server 9000 on sandbox A):
curl http://<sandbox-A-ip>:9000/  → Connection timed out (HTTP 000, exit 28)
bash -c 'echo > /dev/tcp/<sandbox-A-ip>/9000'  → TCP-BLOCKED

Q2b — Filesystem / process path (sandbox A reads sandbox B's files or processes)

Status before this PR: Blocked by standard Docker container namespacing — each sandbox has its own PID, mount, IPC, user, UTS namespaces. Privileged: true grants full capabilities within the container's own namespaces; it does not let one container see into another container's private namespaces.

This PR does not change that behavior. What it verifies (and what a design review should check):

  • /var/run/docker.sock is not mounted into user sandboxes — a compromised sandbox cannot use the Docker API to docker exec into a sibling. Confirmed: ls -la /var/run/docker.sock inside a running sandbox returns No such file or directory.
  • Sandboxes don't share writable volumes with each other. Only per-sandbox volumes are mounted (verified via docker inspect on user containers).

Residual risk:

  • If a future change accidentally mounts docker.sock into a sandbox (for e.g. dev-mode "run docker in your sandbox" feature), Q2b re-opens completely. Worth a linting check.

Summary matrix

Attack Before PR #51 After PR #51 Enforced by
sandbox root → host root Open Blocked Sysbox user-ns (host kernel)
sandbox A network → sandbox B network Effectively open (br_netfilter not loaded) Blocked dockerd icc=false on runner-bridge
sandbox A fs / processes → sandbox B Blocked Blocked Docker container namespaces (unchanged)
sandbox → spawn privileged sibling container via docker.sock N/A (socket not mounted) N/A Daytona-runner default (unchanged)

Host-side prerequisite

sysbox-runc must be registered as a runtime in the host's /etc/docker/daemon.json, and the host must run Sysbox 0.7.0+ against Docker 27.x (Sysbox has not yet released a version compatible with Docker 28+ — see the 0g-tapp#5 review comment). If those aren't in place, docker compose up on the runner fails with unknown or invalid runtime name: sysbox-runc — safe-fail rather than silent-fall-back.


Test plan (end-to-end on wei-tapp2, a real testnet tapp deployment)

  • fresh v0.189 install: grant-admin-perms skipped (idempotent guard), full lifecycle ok
  • Feb 2026 → v0.189 upgrade with pre-existing sandboxes: pg_dump → change tags → docker compose up -d → migrations auto-run (65 → 111) → grant-admin-perms fires (UPDATE 1) → classsandboxClass schema migration preserves data → old sandbox start/stop still work → new sandbox creates cleanly
  • Rollback via pg_dump restore + revert tags → schema back to class, old sandbox intact
  • ssh-access, toolbox on normal sandbox → 200; on sealed sandbox → 403
  • owner-scoped list and admin sandbox_list both return correct count after pagination fix
  • real chain settlement: 4 vouchers settled on-chain via settleFeesWithTEE, provider earnings updated
  • stop→start preserves user data (/home/daytona/marker.txt survives)
  • Q1 verified: runner container Runtime=sysbox-runc, uid_map 0 362144 65536; user sandbox uid_map also remapped; sudo mount --bind / inside sandbox mounts within nested user-ns only, no visibility into host /
  • Q2a verified: curl http://<sandbox-A-ip>:9000/ from sandbox B → Connection timed out; raw bash /dev/tcp/<A>/9000TCP-BLOCKED
  • Q2b verified: /var/run/docker.sock not present in sandbox; sandboxes have independent PID/mount namespaces
  • sealed sandbox: image content digest resolved, SANDBOX_SEAL_ATTESTATION env injected, 0g-sealed:true label set, ssh-access + toolbox blocked at billing-proxy
  • go build ./... + go test ./internal/daytona/... ./internal/proxy/...

Follow-up (out of scope, filed as PR comment)

Move to drop-DinD for Sysbox at the inner (user sandbox) boundary would additionally isolate user containers from the runner itself, but requires resolving the internal registry reachability question. This PR takes the outer-boundary approach because it needs no other topology changes.

🤖 Generated with Claude Code

@Wilbert957
Wilbert957 force-pushed the feat/upgrade-daytona-v0189 branch from bbd2157 to 8d9ead2 Compare July 2, 2026 09:19
…solation

Compose changes:
- 4 daytona image tags: pinned Feb 2026 sha → v0.189.0-amd64
- New init container `grant-admin-perms` grants the daytona-admin API
  key the full v0.189 permission enum. v0.189 introduces
  `api_key.permissions` and returns 403 on write ops when the column
  is empty (pre-existing admin keys carry the migration default `{}`).
  Guarded by `permissions='{}'` so it fires only on upgrade paths.
- Enable native cross-sandbox network isolation via runner env
  `INTER_SANDBOX_NETWORK_ENABLED=false` (v0.179+). Runner spawns
  sandbox containers on a dedicated bridge with icc disabled; direct
  L3 between sandbox IPs is dropped by dockerd.
- Remove the `runner-firewall` sidecar (from PR #45). That sidecar's
  DOCKER-USER iptables rule only fires when the host has br_netfilter
  loaded, which is not the case on default GCP Ubuntu images. Native
  v0.189 isolation supersedes it end-to-end.

Code changes:
- `Client.ListSandboxes` now handles the v0.189 paginated envelope
  `{items, nextCursor}` (was `[...]` in Feb 2026) and walks the cursor.
  Falls back to flat-array decoding for backwards compatibility.
  Uses `?limit=200` (v0.189 max; ignored by older versions).

Without the ListSandboxes fix, admin `/api/sandbox_list?wallet=`,
owner-scoped `/api/sandbox`, `/api/sessions`, `/api/archive-all` and
`archiveRunningOnShutdown` all silently return empty on v0.189.

Tested end-to-end on wei-tapp2 (a real tapp deployment, on-chain register
+ authorizeInvalidator + provider register + smoke test):
  * fresh v0.189 install → grant skipped, full lifecycle ok
  * Feb 2026 → v0.189 upgrade with pre-existing sandboxes → grant fires
    (UPDATE 1), old sandbox survives `class` → `sandboxClass` migration,
    start/stop still work, new sandbox creates cleanly on v0.189
  * ssh-access, toolbox on normal sandbox → 200; on sealed sandbox → 403
  * list (owner + admin) → correct after pagination fix
  * voucher settle → 4 vouchers landed on chain, provider earnings updated
  * stop→start data persistence → `/home/daytona/marker.txt` survives
  * cross-sandbox isolation → curl / raw TCP from sandbox B to sandbox A
    times out (bridge changed from docker0 172.17 to 172.20 with icc=false)
  * pg_dump / restore rollback path preserved
@Wilbert957
Wilbert957 force-pushed the feat/upgrade-daytona-v0189 branch from 8d9ead2 to 8398d8e Compare July 2, 2026 09:21
@Wilbert957 Wilbert957 changed the title feat(daytona): upgrade to v0.189 — pagination + admin-perm init feat(daytona): upgrade to v0.189 — pagination + admin-perm + native isolation Jul 2, 2026
@Wilbert957

Copy link
Copy Markdown
Collaborator Author

Follow-up out of scope: sandbox-runtime hardening

Recording the sandbox privilege-escalation surface so we don't lose it. Not fixing here — this PR is about the Daytona version bump + native cross-sandbox network isolation.

What's still there

The user container that Daytona spawns per sandbox is:

  • Privileged: true (CPU sandboxes; hard-coded in daytona-runner v0.189 at apps/runner/pkg/docker/container_configs.go:205)
  • SecurityOpt: [label=disable] (SELinux/AppArmor off)
  • Runtime: runc (host kernel shared, no user-ns remap)
  • Passwordless sudo inside → any workload can escalate to container root and use every capability

Anything a user runs inside a sandbox can mount --bind, load kernel modules, walk /dev/*, and reach the host kernel directly. Cross-sandbox network is now isolated (this PR), but host isolation isn't.

How Daytona says to fix it

Daytona already ships the hook: daytona-runner reads CONTAINER_RUNTIME env and hands it to Docker as hostConfig.Runtime (apps/runner/cmd/runner/config/config.go:33). Their docs point at sysbox-runc. Sysbox's user-ns remap turns "root in container" into "unprivileged user on host".

Why it doesn't drop in cleanly for us

Our compose runs runner: privileged: true — DinD. The nested dockerd doesn't inherit host runtimes, so CONTAINER_RUNTIME=sysbox-runc on the runner fails with "unknown or invalid runtime name". Daytona's own production model is drop-DinD: mount the host docker socket, remove privileged, let the host's dockerd (with sysbox-runc) create the sandbox containers.

I tried the drop-DinD swap end-to-end on wei-tapp2. Runner + host docker + Sysbox 0.7 all work individually — the wall is our internal registry:6000 service. Under DinD it was reachable because Daytona-runner mediated the pull from inside app-net; under drop-DinD, sandbox containers are on the host default bridge and can't resolve registry:6000 at all.

Two viable follow-up shapes (both are their own PR):

  1. Publish registry on 127.0.0.1:6000 on the host and rewrite INTERNAL_REGISTRY_URL accordingly. Small compose diff, keeps INTER_SANDBOX_NETWORK_ENABLED=false native isolation.
  2. Set CONTAINER_NETWORK=<app-net> on the runner so sandboxes attach to the compose network. Fixes DNS but loses the native isolation (they'd share a bridge with the rest of the stack).

Host side (Docker + Sysbox install + version pin) is 0g-tapp's concern — see companion comment on 0g-tapp#5. Filing this here so whoever picks up the sandbox side has the design context.

Sets docker-compose `runtime: sysbox-runc` on the runner service. The host
dockerd starts runner under sysbox-runc, putting the entire runner (its
DinD + all user sandbox containers) inside a user namespace. Container
uid 0 maps to a non-root uid on host (verified: `0 362144 65536`), so
even a fully privileged user sandbox that escapes its container boundary
lands as a non-root uid on host.

Preserves the DinD topology, the internal registry, and the v0.189
native cross-sandbox network isolation. Compose diff is one field.

Requires sysbox-runc registered on the host dockerd — that side is
managed by 0g-tapp gcp-cvm build-gcp-tapp.sh `ENABLE_SYSBOX=1`.

Verified end-to-end on wei-tapp2:
  * runner container Runtime=sysbox-runc, uid_map remapped
  * user sandbox uid_map also `0 362144 65536` (inherits runner's user-ns)
  * `sudo mount --bind /` inside sandbox mounts within nested user-ns only
  * lifecycle (create/stop/start/delete), ssh-access, toolbox all pass
  * cross-sandbox isolation (icc=false on runner-bridge) still enforced
  * voucher settlement on-chain still works
@Wilbert957 Wilbert957 changed the title feat(daytona): upgrade to v0.189 — pagination + admin-perm + native isolation feat(daytona): upgrade to v0.189 + Sysbox host-boundary isolation Jul 2, 2026
@Wilbert957
Wilbert957 merged commit 47553d5 into main Jul 2, 2026
Wilbert957 added a commit that referenced this pull request Jul 4, 2026
v0.189 paginates GET /api/snapshots as {items,total,page,totalPages} with a
default page size of 100. ListSnapshots read only the first page, so the list
silently truncated once the registry held >100 snapshots — breaking sealed-create
image resolution (snapshot not found) and risking wrongful derived-tag GC.

Walk every page until page >= totalPages, mirroring the ListSandboxes cursor walk
added in #51. Add unit tests covering the multi-page walk (the prior tests only
exercised the single-page shape).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@Wilbert957

Copy link
Copy Markdown
Collaborator Author

Host-dependency finding: br_netfilter must be loaded on the CVM (new hard requirement from this PR)

Deploying this compose to a fresh GCP CVM (wei-tapp, 136.111.63.175) surfaced a runner crash-loop that a first deploy on wei-tapp2 didn't hit. Root cause is a host-provisioning gap that this PR turns from latent into fatal.

Symptom

runner container crash-loops (Restarting, all other services healthy). Inner DinD log:

level=error msg="Handler for POST /v1.51/networks/create returned error: cannot restrict
inter-container communication or run without the userland proxy:
stat /proc/sys/net/bridge/bridge-nf-call-iptables: no such file or directory:
set environment variable DOCKER_IGNORE_BR_NETFILTER_ERROR=1 to ignore"
ERR Error creating Docker client wrapper error="failed to create runner-bridge network ..."

Root cause

The runner's inner dockerd is Docker 28, which hard-requires /proc/sys/net/bridge/bridge-nf-call-iptables to create a bridge with enable_icc=false. That sysctl only exists when the br_netfilter kernel module is loaded on the host.

This PR's INTER_SANDBOX_NETWORK_ENABLED=false makes daytona-runner create runner-bridge with icc disabled — so br_netfilter went from "optional" (old iptables sidecar path, PR #45) to a hard boot requirement for the runner. Ironically it's the same module PR #45's DOCKER-USER rule silently no-op'd without; now its absence is fatal instead of silent.

Confirmed diff between the two hosts (everything else identical — Docker 27.5.1, Sysbox 0.7.0, overlay2, kernel 6.17.0-101x-gcp, subuid/subgid):

wei-tapp (fresh, broken) wei-tapp2 (working)
br_netfilter loaded no yes
/proc/sys/net/bridge/bridge-nf-call-iptables missing present

Fix (applied on wei-tapp)

echo br_netfilter > /etc/modules-load.d/br_netfilter.conf
modprobe br_netfilter

After loading + restarting the runner: runner healthy, runner-bridge created with icc=false, cross-sandbox isolation active.

Action item — 0g-tapp CVM build

This belongs in the CVM provisioning (0g-tapp gcp-cvm/build-gcp-tapp.sh, next to ENABLE_SYSBOX): persist br_netfilter via /etc/modules-load.d/ so every new CVM has it at boot. Without it, every fresh CVM running this compose will runner-crash-loop. wei-tapp2 only worked because the module happened to be loaded already.

(Found while redeploying this compose to wei-tapp. This PR is correct; the gap is host-side, but it's this PR that makes the dependency load-bearing — worth documenting in the deploy runbook / CLAUDE.md host prerequisites too.)

Wilbert957 added a commit that referenced this pull request Jul 9, 2026
* feat(proxy): publicPorts — per-port public preview for sandboxes

Create requests may now carry "publicPorts": [8080, ...]; only listed ports
are publicly reachable, the rest fall back to Daytona's private-sandbox auth.
Enforcement lives in the 0g-daytona fork (v0.189.0-0g.1 images); this side:

- validate at the boundary (range/reserved/cap; sealed must include 8080)
- round-trip check: if the Daytona backend drops the field (stock image),
  fail the create with 502 and stop the orphan instead of silently handing
  out an unrestricted sandbox
- enrich create responses with ready-to-use preview_urls
- cmd/user --ports flag; compose api/proxy images parameterized
  (DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE, official images as default)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(compose): parameterize broker image and runner runtime

- BROKER_IMAGE: registry-agnostic broker image ref (same pattern as
  SANDBOX_IMAGE from #54)
- RUNNER_RUNTIME: sysbox-runc needs host kernel >=5.12 (shiftfs/idmapped
  mounts; on 5.10 sysbox cannot write resolv.conf). Default flips to runc —
  hosts that can run sysbox must now opt in with RUNNER_RUNTIME=sysbox-runc
  to keep the host-boundary isolation from #51.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(compose): self-host all third-party images under REGISTRY_PREFIX

Upstream Daytona went closed source (github main emptied; last open tag
v0.190.0), so Docker Hub availability of daytonaio/* images is not
guaranteed — and everything else was one Hub outage away from blocking
deploys. All third-party images are now mirrored into our own registry
(crane copy, upstream digests preserved — @sha256 pins unchanged) and
referenced via a single REGISTRY_PREFIX:

- daytona api/proxy: 0g-daytona fork v0.189.0-0g.1 (publicPorts) — replaces
  the per-image DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE vars
- daytona runner/ssh-gateway: v0.189.0 mirrors
- redis/postgres/dex/minio/registry: digest-pinned mirrors; the previously
  floating postgres:16-alpine (grant-admin-perms) is now digest-pinned too
- daytonaio/sandbox:0.5.0-slim mirrored as insurance (DEFAULT_SNAPSHOT ref
  unchanged for now — renaming it would re-key the registered snapshot)

Default prefix: GCP Artifact Registry. Aliyun-VPC hosts set REGISTRY_PREFIX
to the eliza mirror (push pending a fresh cr_temp_user token).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs+web: surface publicPorts in user frontend, CLI and API references

- web/user.html: "Public ports" input in the create modal (auto-adds 8080
  for sealed-only providers), request pass-through, and a ports badge on
  sandbox rows
- docs/CLI.md: --ports flag + response example with preview_urls
- docs/API_REFERENCE(.zh).md: publicPorts field, semantics, 502 behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Wilbert957 added a commit that referenced this pull request Jul 16, 2026
#58)

* feat(proxy): publicPorts — per-port public preview for sandboxes

Create requests may now carry "publicPorts": [8080, ...]; only listed ports
are publicly reachable, the rest fall back to Daytona's private-sandbox auth.
Enforcement lives in the 0g-daytona fork (v0.189.0-0g.1 images); this side:

- validate at the boundary (range/reserved/cap; sealed must include 8080)
- round-trip check: if the Daytona backend drops the field (stock image),
  fail the create with 502 and stop the orphan instead of silently handing
  out an unrestricted sandbox
- enrich create responses with ready-to-use preview_urls
- cmd/user --ports flag; compose api/proxy images parameterized
  (DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE, official images as default)



* feat(compose): parameterize broker image and runner runtime

- BROKER_IMAGE: registry-agnostic broker image ref (same pattern as
  SANDBOX_IMAGE from #54)
- RUNNER_RUNTIME: sysbox-runc needs host kernel >=5.12 (shiftfs/idmapped
  mounts; on 5.10 sysbox cannot write resolv.conf). Default flips to runc —
  hosts that can run sysbox must now opt in with RUNNER_RUNTIME=sysbox-runc
  to keep the host-boundary isolation from #51.



* feat(compose): self-host all third-party images under REGISTRY_PREFIX

Upstream Daytona went closed source (github main emptied; last open tag
v0.190.0), so Docker Hub availability of daytonaio/* images is not
guaranteed — and everything else was one Hub outage away from blocking
deploys. All third-party images are now mirrored into our own registry
(crane copy, upstream digests preserved — @sha256 pins unchanged) and
referenced via a single REGISTRY_PREFIX:

- daytona api/proxy: 0g-daytona fork v0.189.0-0g.1 (publicPorts) — replaces
  the per-image DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE vars
- daytona runner/ssh-gateway: v0.189.0 mirrors
- redis/postgres/dex/minio/registry: digest-pinned mirrors; the previously
  floating postgres:16-alpine (grant-admin-perms) is now digest-pinned too
- daytonaio/sandbox:0.5.0-slim mirrored as insurance (DEFAULT_SNAPSHOT ref
  unchanged for now — renaming it would re-key the registered snapshot)

Default prefix: GCP Artifact Registry. Aliyun-VPC hosts set REGISTRY_PREFIX
to the eliza mirror (push pending a fresh cr_temp_user token).



* docs+web: surface publicPorts in user frontend, CLI and API references

- web/user.html: "Public ports" input in the create modal (auto-adds 8080
  for sealed-only providers), request pass-through, and a ports badge on
  sandbox rows
- docs/CLI.md: --ports flag + response example with preview_urls
- docs/API_REFERENCE(.zh).md: publicPorts field, semantics, 502 behavior



---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Wilbert957 added a commit that referenced this pull request Jul 20, 2026
…63)

* feat(proxy): publicPorts — per-port public preview for sandboxes (#57)

* feat(proxy): publicPorts — per-port public preview for sandboxes

Create requests may now carry "publicPorts": [8080, ...]; only listed ports
are publicly reachable, the rest fall back to Daytona's private-sandbox auth.
Enforcement lives in the 0g-daytona fork (v0.189.0-0g.1 images); this side:

- validate at the boundary (range/reserved/cap; sealed must include 8080)
- round-trip check: if the Daytona backend drops the field (stock image),
  fail the create with 502 and stop the orphan instead of silently handing
  out an unrestricted sandbox
- enrich create responses with ready-to-use preview_urls
- cmd/user --ports flag; compose api/proxy images parameterized
  (DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE, official images as default)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(compose): parameterize broker image and runner runtime

- BROKER_IMAGE: registry-agnostic broker image ref (same pattern as
  SANDBOX_IMAGE from #54)
- RUNNER_RUNTIME: sysbox-runc needs host kernel >=5.12 (shiftfs/idmapped
  mounts; on 5.10 sysbox cannot write resolv.conf). Default flips to runc —
  hosts that can run sysbox must now opt in with RUNNER_RUNTIME=sysbox-runc
  to keep the host-boundary isolation from #51.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(compose): self-host all third-party images under REGISTRY_PREFIX

Upstream Daytona went closed source (github main emptied; last open tag
v0.190.0), so Docker Hub availability of daytonaio/* images is not
guaranteed — and everything else was one Hub outage away from blocking
deploys. All third-party images are now mirrored into our own registry
(crane copy, upstream digests preserved — @sha256 pins unchanged) and
referenced via a single REGISTRY_PREFIX:

- daytona api/proxy: 0g-daytona fork v0.189.0-0g.1 (publicPorts) — replaces
  the per-image DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE vars
- daytona runner/ssh-gateway: v0.189.0 mirrors
- redis/postgres/dex/minio/registry: digest-pinned mirrors; the previously
  floating postgres:16-alpine (grant-admin-perms) is now digest-pinned too
- daytonaio/sandbox:0.5.0-slim mirrored as insurance (DEFAULT_SNAPSHOT ref
  unchanged for now — renaming it would re-key the registered snapshot)

Default prefix: GCP Artifact Registry. Aliyun-VPC hosts set REGISTRY_PREFIX
to the eliza mirror (push pending a fresh cr_temp_user token).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs+web: surface publicPorts in user frontend, CLI and API references

- web/user.html: "Public ports" input in the create modal (auto-adds 8080
  for sealed-only providers), request pass-through, and a ports badge on
  sandbox rows
- docs/CLI.md: --ports flag + response example with preview_urls
- docs/API_REFERENCE(.zh).md: publicPorts field, semantics, 502 behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(serving): let an appOwner delegate a service to another provider wallet (#56)

Adds authorizeProvider/revokeProvider to SandboxServing so one appId can be
commercially represented by a wallet other than its TappRegistry owner,
without touching balance/nonce/voucher storage — each delegate keeps its own
fully isolated services/Account entry, exactly like a self-registered
provider today. This avoids the shared-balance overcommit race a naive
provider-supports-N-services rekeying would introduce across independently
deployed billing-proxy instances.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat!: v2 — provider IS the TEE signer (owner-managed services) (#60)

* docs: provider-is-signer v2 design (Chinese, for review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(serving)!: v2 — provider IS the TEE signer

Provider addresses in the ledger (services key, voucher payee, balance
buckets, earnings) are now the TEE signer addresses of TappRegistry
nodes. Management moves to the appId owner:

- addOrUpdateService(signer, ...) — owner registers a node's service;
  signer must be an active TappRegistry node of the appId
- removeService(signer) — owner-called (the signer key dies with the
  machine); sweeps pending earnings to the owner, keeps user balances
  refundable and nonce watermarks intact
- withdrawEarnings(signer) — owner-called, pays the owner
- settlement now requires recovered == v.provider: a node can never
  settle vouchers naming another node as payee (cross-signing closed)
- drop #56 authorizeProvider/revokeProvider — delegate wallets no
  longer exist in this model

BREAKING: new deployment required (ledger key semantics changed);
not an in-place beacon upgrade. 34/34 Foundry tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(billing)!: derive provider identity from the TEE key; OWNER_ADDRESS config

- chain.Client: providerAddr = the TEE key's own address (provider IS the
  TEE signer); new ProviderAddress() accessor
- cmd/billing: all provider-keyed wiring (voucher payee, settler queue,
  pricing lookup, alerts, /api/providers, /api/info) uses the derived
  address; /api/info adds owner_address
- settler: rotation gate — while the local signer is not an active
  TappRegistry node, hold the queue instead of submitting (a fresh
  machine's vouchers would all settle INVALID_SIGNATURE and dead-letter
  real revenue until add-node-onchain lands); fail-open on RPC errors
- config: OWNER_ADDRESS replaces PROVIDER_ADDRESS (admin + display only);
  owner is ALWAYS an admin, ADMIN_ADDRESSES is now additive instead of
  replacing the default
- cmd/provider: register gains --signer and is signed by the owner key
  (OWNER_KEY env, PROVIDER_KEY legacy alias); deregister → remove-service;
  withdraw takes --signer and pays the owner; new rotate subcommand
  (re-register new signer with old terms + remove old, with runbook);
  authorize-provider/revoke-provider removed with the contract feature
- cmd/user: acknowledge sanity check updated — provider must be an active
  node of the appId (it is no longer the app owner)
- e2e_test: drop removed ProviderAddress config field (note: the e2e
  harness has pre-existing compile rot vs the current proxy.NewHandler
  signature, predating this change)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(broker,docs)!: v2 companion — broker top-up guard, OWNER_ADDRESS config surface, docs

- broker monitor: skip top-ups for providers whose service was removed
  on-chain (rotated-out TEE signer can never settle; deposits there would
  strand user funds behind the refund flow) + test
- compose/.env.example: OWNER_ADDRESS replaces PROVIDER_ADDRESS
- CLAUDE.md: identity-model section, updated register flow and env docs
- docs/CLI.md: register --signer / remove-service / rotate / withdraw
  rewritten for owner-signed management; rotation runbook
- contracts README (en+zh): v2 interface tables + design notes
- /api/info documents owner_address

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(dev): point dev environment at the v2 SandboxServing deployment

New dev stack (bound to TappRegistry 0x2Ce8…65c9):
  Proxy  0x3D0F2D62A60c8e62095671FfB23D15Cc4C98ca7c
  Beacon 0xBF04734BC87E12aB81E21bb4018b9bFa4c118721
  Impl   0x47a8E809Cd81b94eD19874da73C0E3F82DD90E5C (verified on chainscan)

Replaces the retired dev proxy 0x2024eB0C…E9b3 in contracts READMEs
(with an upgrade-history entry) and in the checkbal/checkuserbal
hardcoded defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config)!: resolve app owner on-chain — drop OWNER_ADDRESS entirely

The appId owner is derivable: getAppInfo(BACKEND_APP_NAME).owner. Configuring
it duplicated on-chain truth and would drift on owner transfer.

- Handler.AppOwner: TTL-cached (1 min) chain lookup wired from cmd/billing;
  isAdmin = static ADMIN_ADDRESSES ∪ live owner; serves the stale value on
  RPC failure so a flaky RPC can't lock the owner out
- config: OwnerAddress field/env/validation removed; AdminList() is the
  extra-admins list only
- startup: logs resolved owner; errors on appId drift
  (services[signer].appId != BACKEND_APP_NAME)
- /api/info: owner_address resolved live, app_id added
- compose/.env.example/docs updated; config surface is now just
  BACKEND_APP_NAME (+ optional ADMIN_ADDRESSES)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(indexer): liveness = node membership, not app ownership

isLiveProvider still compared the provider address against
getAppInfo(appId).owner — a v1 assumption. In v2 the provider IS the
node's TEE signer and never equals the owner, so every v2 provider was
dropped and the broker marketplace (GET /api/providers) stayed empty.

Check tapp.getNode(appId, provider).addedAt != 0 instead (same fix
cmd/user acknowledge already got). Fail-open on RPC errors preserved;
revalidate() flows through the same path.

Reported in live verification on PR #60.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): provider context = this node's TEE signer, not the wallet

The dashboard used the connected wallet as the provider address
(serviceExists/services/providerEarnings) and called the v1 ABI
(5-arg addOrUpdateService, no-arg withdrawEarnings) — in v2 the owner
logs in (the TEE signer has no connectable key), so every provider
panel showed Not Registered and the register/withdraw buttons reverted.

- provider context now comes from /api/info provider_address
  (one dashboard = one node; nothing to switch)
- ABI updated to v2: addOrUpdateService(signer,…), withdrawEarnings(signer),
  removeService(signer); register/withdraw txs are owner-signed
- connect hint explains the owner-wallet login model

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(billing): admin routes in main.go missed the dynamic owner check

Seven operator endpoints (registry pull/gc, sessions, audit-log, queue
summary/dlq/aggregate) gated on cfg.Chain.IsAdmin — the static
ADMIN_ADDRESSES list — so the appId owner got 403 on them (e.g.
dashboard image import) while handler-registered admin routes worked.

Route everything through Handler.IsAdmin (static list ∪ live app owner)
and delete the static config IsAdmin so future routes can't regress to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(provider-cli): snapshot/gc subcommands use the admin (owner) key

snapshot / snapshots / delete-snapshot / gc-images sign EIP-191 admin API
requests — the signing wallet must be an admin (the app owner or an
ADMIN_ADDRESSES wallet), not the retired provider-wallet identity. Route
them through resolveOwnerKey (OWNER_KEY, PROVIDER_KEY legacy alias) and
drop the now-dead resolveKey helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dashboard): show the app owner; disambiguate from the contract admin

The dashboard displayed only contract.owner() labeled "Owner" — the
proxy's upgrade admin, which in v2 is NOT who operates this node. Add an
"App Owner (admin)" row (from /api/info owner_address, with a tooltip
explaining it's the wallet to log in with) and relabel the contract row
to "Contract Admin". Provider row labeled "Provider (TEE signer)".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): v2 testnet deployment supersedes the v1 proxies

New testnet stack bound to the canonical TappRegistry 0x95a0…511c:
  Proxy  0x3490B9053AC46F7Bf71A1ceBffcB2be2C1405b41
  Beacon 0x79D6D7B5468AA134360bf73cc667FC63f704B62d
  Impl   0x7a1A5FC5B1A6AC1127e2D8b63400615B2ea49C47 (verified on chainscan)

v1 proxies 0xA07b0033…FC12c and 0x3d4d8a05…cf6f retired (refund-only).
Stake note now points at the registry's minStakeAmount instead of a
hardcoded historical value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): testnet v2 proxy is bound to TappRegistry 0x2Ce8

Deployed pointing at 0x95a0 by mistake; repointed to 0x2Ce8 via
setTappRegistry (tx before any state existed — clean switch, matches
testnet-aliyun.env).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skill): user skill — snapshot catalog + v2 drift fixes

- Step 5: standard snapshots with fixed specs (ubuntu 1C/1G, openclaw
  2C/4G) and the snapshot-vs-custom-resources rule (API rejects --cpu
  with --snapshot; verified live)
- providers example: provider address IS the TEE signer in v2, no
  separate signer field; prices are per-minute
- troubleshooting: signerVersion → TappRegistry ackVersion, re-ack also
  triggers on node changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skill): snapshot names match the live testnet (0g-ubuntu22 / 0g-openclaw)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): restore 0x2Ce8 registry fix (accidentally reverted by f1abc9d)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): dev section — add TappRegistry (0x2Ce8) to table and env snippet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contracts): zh dev section — add TappRegistry row and env line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skill): regression fixes — sealed/ports coverage, upload API change, OpenClaw auth model

Live regression against the v2 testnet (v0.1.1 CLI) found:

- sealed: bare --sealed is rejected (needs --snapshot; image must be in the
  provider's internal registry) — example fixed, preconditions documented
- publicPorts: --ports was entirely undocumented; added usage, rules
  (max 16 / system ports / immutable / sealed⇒8080), preview_urls, and
  https proxy protocol on the hosted testnet
- toolbox files/upload: v0.189 switched to multipart — JSON base64 body is
  dead; Mode A fallback now uploads via exec + base64
- OpenClaw (three drifts): claude setup-token output is an OAuth token
  (sk-ant-oat…), NOT an ANTHROPIC_API_KEY — register via ; current openclaw refuses lan bind without a pre-set gateway
  auth token (no longer auto-generated); nohup inside exec dies with the
  session — use setsid (verified: gateway survives, port 200)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(api): /api/providers — provider is the derived TEE signer; deregisterService → removeService

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skill): rewrite provider skill for the v2 identity model

The old skill predated even the TappRegistry migration (register
--tee-signer, signerVersion, provider-wallet stake in SandboxServing).
Full rewrite:

- identity model up front: provider = node TEE signer (ledger only),
  owner does all management (OWNER_KEY), nothing configured in .env
- first-time setup: tapp-cli register-onchain + authorizeInvalidator +
  register --signer; .env-change ⇒ volumesHash ⇒ ackVersion warning
- new sections: rotation runbook (settler queue-hold, add-node-first,
  rotate CLI, refund path) and resource quotas (org quota is the only
  total cap; DEFAULT_RUNNER_* useless; fresh-install env vs live-DB SQL)
- withdraw --signer pays the owner; snapshots via OWNER_KEY; standard
  base snapshots 0g-ubuntu22 / 0g-openclaw
- troubleshooting rebuilt from live incidents (402 after env change,
  held voucher queue, admin-only, imageHashes empty, SSH host display)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* docs(skill): new-domain checklist + domain drift from the testnet rename (#61)

- user skill: provider domain examples updated to provider-private-sandbox.0g.ai
  (broker URL unchanged)
- provider skill: new 5-item domain checklist (apex→8082, wildcard→4000,
  wildcard cert, tcp 2222, grey-cloud) with the acceptance probe, plus two
  troubleshooting rows (broker offline via orange-cloud WAF; wildcard hitting
  the nginx default page)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(plugin): bump plugin version to 2.0.0 (#62)

* chore(plugin): bump marketplace plugin version to 2.0.0 for the v2 release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(plugin): bump plugin.json to 2.0.0 as well

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant