feat(proxy): publicPorts — per-port public preview for sandboxes#57
Merged
Conversation
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>
- 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>
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>
Collaborator
Author
|
E2E verified on wei-tapp (136.113.168.149, fork images v0.189.0-0g.1):
Note: the wei-tapp billing container is an older build, so |
- 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 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sandboxes today are forced
public: true(0G doesn't use Daytona OIDC), so every user port is world-reachable. This adds an optional"publicPorts": [8080, 3000]field on create: only listed ports are publicly reachable; all other ports fall back to Daytona's private-sandbox auth path (owner preview tokens still work — the restriction targets the public, not the owner). Omitting the field keeps today's all-ports-public behavior; existing sandboxes are untouched.Enforcement lives in the 0g-daytona fork (
0gfoundation/0g-daytona@0g/main, baseline v0.189.0, imagesdaytona-{api,proxy}:v0.189.0-0g.1on GCP AR):sandbox.publicPortsjsonb column + migration, DTO validation, normalize (dedupe/sort/reject reserved 22222/2280/33333/reject empty/cap 16), proxy-onlyGET /preview/:id/public-portsGetProxyTargetnow consults the allowlist (hand-written API call, no api-client-go regen; 4xx degrades to unrestricted so the patched proxy works against a stock API; cached 1h — the field is immutable after create)This PR (billing-proxy side):
publicPorts, the billing proxy fails the create with 502 and stops the orphanpreview_urls: {"8080": "http://8080-<id>.<PROXY_DOMAIN>"}cmd/user create --ports 8080,3000DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGEparams (official images default); also folds inBROKER_IMAGE+RUNNER_RUNTIMEparameterization (note: runner runtime default flips sysbox-runc → runc; kernel ≥5.12 hosts must setRUNNER_RUNTIME=sysbox-runcto keep feat(daytona): upgrade to v0.189 + Sysbox host-boundary isolation #51's host-boundary isolation)Test plan
internal/proxyunit tests: 15 validation cases + decorate supported/unsupported/no-domain — all passgo build ./... && go test ./...green in both repospublicPorts:[8080]sandbox with dual listeners — 8080 serves, 9090 blocked; unrestricted + sealed sandboxes unchanged (pending stack restart approval)🤖 Generated with Claude Code