feat(daytona): upgrade to v0.189 + Sysbox host-boundary isolation#51
Conversation
bbd2157 to
8d9ead2
Compare
…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
8d9ead2 to
8398d8e
Compare
Follow-up out of scope: sandbox-runtime hardeningRecording 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:
Anything a user runs inside a sandbox can How Daytona says to fix it Daytona already ships the hook: Why it doesn't drop in cleanly for us Our compose runs 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 Two viable follow-up shapes (both are their own PR):
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
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>
Host-dependency finding:
|
| 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.)
* 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>
#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>
…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>
Summary
grant-admin-permsgrants the daytona-admin API key the full v0.189 permission enum (v0.189 introducesapi_key.permissions; existing admin keys carry migration default{}→ 403 on writes)Client.ListSandboxeshandles the v0.189{items, nextCursor}paginated envelope. Silent-empty regression was blocking/api/sandbox_list, owner-scoped/api/sandbox,/api/sessions,/api/archive-all, andarchiveRunningOnShutdownruntime: sysbox-runcon runner +INTER_SANDBOX_NETWORK_ENABLED=falseon runner env; the oldrunner-firewalliptables 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 withoutbr_netfilter)Host-side dependency (Sysbox install +
sysbox-runcregistration + 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 = truefor every CPU sandbox it spawns (apps/runner/pkg/docker/container_configs.go:205). Combined with the sandbox image's passwordlesssudo, that means:uid=0is the host kernel'suid=0(no user-namespace remap; standardruncshares the host kernel directly)CAP_SYS_ADMIN,CAP_SYS_MODULE,CAP_NET_ADMIN, …)SecurityOpt: [label=disable]/dev/*reachable)Without any CVE, a tenant can:
sudo mount --bind /path/on/host /mnt/exfil— read any host pathsudo mknod /dev/sda b 8 0 && dd if=/dev/sda …— read raw host disksCAP_SYS_ADMINFix:
runtime: sysbox-runcon 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. Containeruid=0is mapped to hostuid=362144(a normal user).Effect chain:
uid=0= hostuid=362144Concrete 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_ADMINon the initial user-ns, which the tenant never holds.Residual risk (honest):
/var/lib/docker; that's a quota problem, not an isolation oneQ2. 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
docker0bridge withenable_icc: true. PR #45 tried to fix it by insertingiptables -I DOCKER-USER 1 -i docker0 -o docker0 -j DROPvia a sidecar. That rule only fires when the host has thebr_netfilterkernel 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=falseon the runner service (v0.189 native). daytona-runner now creates a dedicated bridgerunner-bridgewithcom.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 oldrunner-firewallsidecar is removed.Verified:
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: truegrants 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.sockis not mounted into user sandboxes — a compromised sandbox cannot use the Docker API todocker execinto a sibling. Confirmed:ls -la /var/run/docker.sockinside a running sandbox returnsNo such file or directory.docker inspecton user containers).Residual risk:
Summary matrix
Host-side prerequisite
sysbox-runcmust 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 upon the runner fails withunknown 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)
grant-admin-permsskipped (idempotent guard), full lifecycle okpg_dump→ change tags →docker compose up -d→ migrations auto-run (65 → 111) →grant-admin-permsfires (UPDATE 1) →class→sandboxClassschema migration preserves data → old sandbox start/stop still work → new sandbox creates cleanlypg_dumprestore + revert tags → schema back toclass, old sandbox intact/home/daytona/marker.txtsurvives)Runtime=sysbox-runc, uid_map0 362144 65536; user sandbox uid_map also remapped;sudo mount --bind /inside sandbox mounts within nested user-ns only, no visibility into host/curl http://<sandbox-A-ip>:9000/from sandbox B →Connection timed out; rawbash /dev/tcp/<A>/9000→TCP-BLOCKED/var/run/docker.socknot present in sandbox; sandboxes have independent PID/mount namespacesSANDBOX_SEAL_ATTESTATIONenv injected,0g-sealed:truelabel set, ssh-access + toolbox blocked at billing-proxygo 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