diff --git a/.github/workflows/pr-craft-compose-integration.yml b/.github/workflows/pr-craft-compose-integration.yml index 18b0c30eba..64bd5f183d 100644 --- a/.github/workflows/pr-craft-compose-integration.yml +++ b/.github/workflows/pr-craft-compose-integration.yml @@ -51,7 +51,7 @@ env: SANDBOX_CONTAINER_IMAGE: "onyxdotapp/sandbox:ci" # Read by api_server on boot; Sandbox containers call back via this URL on the # compose bridge. - SANDBOX_API_SERVER_URL: "http://api_server:8080" + ONYX_SERVER_URL: "http://api_server:8080" jobs: changes: diff --git a/.github/workflows/pr-craft-k8s-tests.yml b/.github/workflows/pr-craft-k8s-tests.yml index a13dda9c94..0aaa679c09 100644 --- a/.github/workflows/pr-craft-k8s-tests.yml +++ b/.github/workflows/pr-craft-k8s-tests.yml @@ -45,7 +45,7 @@ env: SANDBOX_SERVICE_ACCOUNT_NAME: "sandbox" SANDBOX_CONTAINER_IMAGE: "localhost:5001/onyx-sandbox:ci" # Sandbox pod resource requests live in values-ci.yaml (configMap.SANDBOX_POD_*). - SANDBOX_API_SERVER_URL: "http://onyx-api-service.onyx.svc.cluster.local:8080" + ONYX_SERVER_URL: "http://onyx-api-service.onyx.svc.cluster.local:8080" # Proxy lives in release namespace `onyx`. SANDBOX_PROXY_HOST is set # later to the proxy Service ClusterIP — the runner has no cluster DNS, diff --git a/.vscode/.env.k8s.template b/.vscode/.env.k8s.template index c46a751590..896452f9e5 100644 --- a/.vscode/.env.k8s.template +++ b/.vscode/.env.k8s.template @@ -140,7 +140,7 @@ SANDBOX_CONTAINER_IMAGE=onyxdotapp/sandbox:dev # How sandboxes reach the api_server: out through the egress proxy to the host # gateway, not internal cluster DNS. Linux-kind devs sub their own host gateway. -SANDBOX_API_SERVER_URL=http://host.docker.internal:3000 +ONYX_SERVER_URL=http://host.docker.internal:3000/api SANDBOX_NAMESPACE=onyx-sandboxes diff --git a/backend/onyx/sandbox_proxy/addons/gate.py b/backend/onyx/sandbox_proxy/addons/gate.py index b1e0908be7..722f6b1b8b 100644 --- a/backend/onyx/sandbox_proxy/addons/gate.py +++ b/backend/onyx/sandbox_proxy/addons/gate.py @@ -60,7 +60,7 @@ from onyx.sandbox_proxy.logging_utils import ( from onyx.sandbox_proxy.request_evaluator import RequestEvaluator from onyx.server.features.build.configs import ( MCP_SESSION_TAG_HEADER, - SANDBOX_API_SERVER_URL, + ONYX_SERVER_URL, SANDBOX_APPROVAL_WAIT_TIMEOUT_SECONDS, ) from onyx.server.features.build.db import action_approval @@ -91,9 +91,9 @@ PARSER_MAX_BODY_BYTES = 32 * 1024 * 1024 # in-cluster name resolving to an internal IP, while still denying every other port # on that host (e.g. a co-located Redis/Postgres reachable at the same hostname). def _parse_api_server() -> tuple[str | None, int | None]: - if not SANDBOX_API_SERVER_URL: + if not ONYX_SERVER_URL: return None, None - parsed = urlparse(SANDBOX_API_SERVER_URL) + parsed = urlparse(ONYX_SERVER_URL) host = (parsed.hostname or "").lower() or None port = parsed.port or (443 if parsed.scheme == "https" else 80) return host, port diff --git a/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py b/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py index 75c0aecd8b..e7b282f273 100644 --- a/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py +++ b/backend/onyx/sandbox_proxy/resolvers/onyx_pat.py @@ -1,9 +1,9 @@ """Onyx-API PAT credential resolver. -Claims requests bound for the Onyx API host (the host of -``SANDBOX_API_SERVER_URL``) and sets both auth headers to the sandbox's real -per-sandbox PAT, read encrypted off ``Sandbox.encrypted_pat``. The tenant is -embedded in the PAT itself, so no separate tenant header is injected. +Claims requests bound for the Onyx API host (the host of ``ONYX_SERVER_URL``) +and sets both auth headers to the sandbox's real per-sandbox PAT, read +encrypted off ``Sandbox.encrypted_pat``. The tenant is embedded in the PAT +itself, so no separate tenant header is injected. """ from __future__ import annotations @@ -24,7 +24,7 @@ from onyx.sandbox_proxy.credential_injection import ( InjectionContext, ) from onyx.sandbox_proxy.logging_utils import full_log_id, short_log_id -from onyx.server.features.build.configs import SANDBOX_API_SERVER_URL +from onyx.server.features.build.configs import ONYX_SERVER_URL from onyx.server.features.build.db.sandbox import get_sandbox_by_id from onyx.utils.logger import setup_logger @@ -35,7 +35,7 @@ class OnyxPatResolver(CredentialResolver): """Injects the sandbox's Onyx API PAT on requests to the configured API host.""" def __init__(self) -> None: - parsed = urlparse(SANDBOX_API_SERVER_URL) if SANDBOX_API_SERVER_URL else None + parsed = urlparse(ONYX_SERVER_URL) if ONYX_SERVER_URL else None host = parsed.hostname if parsed else None self._api_host = host.lower() if host else None if parsed is not None and host: diff --git a/backend/onyx/server/features/build/configs.py b/backend/onyx/server/features/build/configs.py index 585ac7d14a..938157815e 100644 --- a/backend/onyx/server/features/build/configs.py +++ b/backend/onyx/server/features/build/configs.py @@ -97,9 +97,9 @@ ENABLE_OPENCODE_DEBUGGING = ( os.environ.get("ENABLE_OPENCODE_DEBUGGING", "false").lower() == "true" ) -# Must be set when SANDBOX_BACKEND=kubernetes (no default — varies per -# deployment). -SANDBOX_API_SERVER_URL = os.environ.get("SANDBOX_API_SERVER_URL", "") +# Complete Onyx API base URL reachable from the sandbox, including any path +# prefix. Must be set when SANDBOX_BACKEND=kubernetes. +ONYX_SERVER_URL = os.environ.get("ONYX_SERVER_URL", "") # ============================================================================== # Sandbox egress proxy diff --git a/backend/onyx/server/features/build/sandbox/README.md b/backend/onyx/server/features/build/sandbox/README.md index e7f83088d5..b26eecb234 100644 --- a/backend/onyx/server/features/build/sandbox/README.md +++ b/backend/onyx/server/features/build/sandbox/README.md @@ -54,9 +54,13 @@ The Docker backend is intentionally the closest single-VM analogue of the Kubern #### Docker mode trust boundary -`api_server` and `background` mount the host Docker socket so they can drive sandbox containers. Anything that can talk to that socket is effectively root on the host — only enable Craft on hosts you fully control. Sandbox containers themselves run unprivileged: `--security-opt no-new-privileges`, `--cap-drop ALL`, `user=1000:1000`, no Docker socket, and a fixed env allowlist (`ONYX_PAT` + `ONYX_SERVER_URL`). +`api_server` and `background` mount the host Docker socket so they can drive sandbox containers. Anything that can talk to that socket is effectively root on the host — only enable Craft on hosts you fully control. Sandbox containers themselves run unprivileged: `--security-opt no-new-privileges`, `--cap-drop ALL`, `user=1000:1000`, no Docker socket, and a fixed env allowlist (`ONYX_PAT` + `ONYX_SERVER_URL` + `ONYX_API_PREFIX`). -`SANDBOX_API_SERVER_URL` must be the **public** HTTPS URL that the agent reaches Onyx through (same way any onyx-cli client would). Compose hostnames like `http://api_server:8080` do not resolve from inside the sandbox bridge. +`ONYX_SERVER_URL` is the complete API base URL used by both the host-side +manager and the sandbox client. The Craft Compose overlay defaults it to +`http://onyx-craft-api:8080`, a private alias on the sandbox bridge. Public +reverse-proxy overrides must include their API path prefix, for example +`https://onyx.your-org.example/api`. On EC2 the Docker bridge by default routes to `169.254.169.254` (IMDS), which can hand out IAM credentials. `install.sh --include-craft` installs a host-level `DOCKER-USER` iptables rule to drop sandbox→IMDS traffic when it has sudo/iptables access, and prints the manual command otherwise. There is no application-level fallback — fix this at the host firewall. @@ -169,10 +173,10 @@ SANDBOX_SERVICE_ACCOUNT_NAME=sandbox # No storage credentials required ```bash -# Public URL the sandbox agent uses to reach Onyx (HTTPS, externally resolvable — -# compose hostnames like http://api_server:8080 will not resolve from inside the -# sandbox bridge). -SANDBOX_API_SERVER_URL=https://onyx.your-org.example +# Complete API base URL. The Craft Compose overlay defaults to its private +# http://onyx-craft-api:8080 alias; override only to route through a public +# reverse proxy. +ONYX_SERVER_URL=https://onyx.your-org.example/api # Host path of the Docker socket mounted into api_server/background SANDBOX_DOCKER_SOCKET=/var/run/docker.sock # Default: /var/run/docker.sock @@ -222,7 +226,8 @@ uv run pytest backend/tests/integration/tests/craft/k8s/test_kubernetes_sandbox. - Confirm `api_server` actually has the Docker socket: `docker compose exec api_server ls -l /var/run/docker.sock` - Confirm the dedicated bridge exists: `docker network inspect onyx_craft_sandbox` (created by `install.sh --include-craft`, or run `docker network create onyx_craft_sandbox` manually) - Check sandbox logs: `docker logs sandbox-` -- Confirm `SANDBOX_API_SERVER_URL` is a publicly resolvable HTTPS URL (the agent cannot reach `http://api_server:8080` from inside the sandbox bridge) +- Confirm `ONYX_SERVER_URL` resolves from the sandbox bridge and includes any + API path prefix. The Compose default is `http://onyx-craft-api:8080`. ### Sandbox Stuck in PROVISIONING (Kubernetes) @@ -252,7 +257,7 @@ uv run pytest backend/tests/integration/tests/craft/k8s/test_kubernetes_sandbox. - **Sandbox app containers and sidecar init containers** do not receive FileStore, S3, or MinIO credentials - **Network policies** can restrict sandbox egress traffic - **Resource limits** prevent resource exhaustion -- **Docker containers** run with `--security-opt no-new-privileges`, `--cap-drop ALL`, `user=1000:1000`, no Docker socket, and a fixed env allowlist (`ONYX_PAT` + `ONYX_SERVER_URL`) +- **Docker containers** run with `--security-opt no-new-privileges`, `--cap-drop ALL`, `user=1000:1000`, no Docker socket, and a fixed env allowlist (`ONYX_PAT` + `ONYX_SERVER_URL` + `ONYX_API_PREFIX`) - **Docker network isolation** is enforced by joining only the dedicated `onyx_craft_sandbox` bridge — compose's default network (postgres/redis/minio/model servers) is unreachable by DNS from inside a sandbox - **EC2 IMDS** must be blocked at the host firewall (`install.sh --include-craft` installs a `DOCKER-USER` iptables rule on EC2 when sudo is available) — there is no app-level fallback diff --git a/backend/onyx/server/features/build/sandbox/docker/docker_sandbox_manager.py b/backend/onyx/server/features/build/sandbox/docker/docker_sandbox_manager.py index 0f344dee80..75be357c59 100644 --- a/backend/onyx/server/features/build/sandbox/docker/docker_sandbox_manager.py +++ b/backend/onyx/server/features/build/sandbox/docker/docker_sandbox_manager.py @@ -29,6 +29,7 @@ Sandbox containers run with: - no Docker socket mount - no S3 / MinIO / Postgres / Redis / FileStore credentials in env - a fixed env allowlist (``ONYX_PAT``, ``ONYX_SERVER_URL``, + ``ONYX_API_PREFIX``, opencode auth/config only) - only the dedicated sandbox bridge network — never compose's default network. As a result api_server / postgres / redis / minio / @@ -47,8 +48,9 @@ Outbound communication is intentionally limited to: 1. Public internet over HTTPS (the bridge has default internet egress; block at the host's ``DOCKER-USER`` chain if you need a stricter posture, e.g. for EC2 IMDS). -2. The Onyx API via ``ONYX_SERVER_URL`` — which must be the *public* HTTPS URL - the agent reaches just like any other onyx-cli client. +2. The Onyx API via the complete ``ONYX_SERVER_URL`` API base. The Craft + overlay uses a private alias on the sandbox bridge by default; deployments + may instead provide a public HTTPS API URL. Most control-plane traffic from api_server → sandbox uses the Docker Engine API (``docker exec``). Prompt/event transport uses opencode-serve over @@ -82,10 +84,10 @@ from onyx.db.enums import SandboxStatus from onyx.file_store.file_store import get_default_file_store from onyx.server.features.build.configs import ( ATTACHMENTS_DIRECTORY, + ONYX_SERVER_URL, OPENCODE_DISABLED_TOOLS, OPENCODE_SERVE_PORT, OPENCODE_SERVER_PASSWORD, - SANDBOX_API_SERVER_URL, SANDBOX_CONTAINER_IMAGE, SANDBOX_DOCKER_CPU_LIMIT, SANDBOX_DOCKER_MEMORY_LIMIT, @@ -326,9 +328,9 @@ _COMPOSE_INTERNAL_HOSTNAMES = { def _looks_like_internal_compose_host(url: str) -> bool: """Heuristic: Does ``url`` reference a compose-internal service hostname? - Used to warn deployers that pointed SANDBOX_API_SERVER_URL at the - api_server's compose DNS name. Sandboxes can't resolve that — they only join - the craft bridge network — so the URL must be the public Onyx URL. + Used to warn deployers that pointed ONYX_SERVER_URL at a service available + only on Compose's default network. Sandboxes can resolve the dedicated + ``onyx-craft-api`` alias, but not these unrelated service names. """ if not url: return False @@ -542,10 +544,10 @@ def build_container_create_kwargs( ``firewall-init.sh`` to read ``ca.crt``. That volume also contains root-only ``ca.key``; the agent runs as UID 1000 after init. - ``ONYX_SERVER_URL`` must be the *public* Onyx URL (the one onyx-cli inside - the sandbox will hit over HTTPS) — not an internal compose DNS name. We emit - a warning if it looks like the latter, since reaching it would require the - sandbox to be on the compose default network. + ``ONYX_SERVER_URL`` is the complete API base used by onyx-cli inside the + sandbox. The default ``onyx-craft-api`` alias is attached to the sandbox + bridge; we warn about other Compose DNS names because they exist only on the + default network. ``opencode_password`` is generated per-provision by the manager and injected as the env var named by ``OPENCODE_SERVER_PASSWORD``. The api_server reads @@ -556,16 +558,19 @@ def build_container_create_kwargs( """ if _looks_like_internal_compose_host(api_server_url): logger.warning( - "SANDBOX_API_SERVER_URL=%s looks like an internal compose hostname. Sandboxes only " - "join the craft bridge network and reach the API server like any other public client, " - "so this URL must resolve publicly (e.g. https://onyx.your-org.com). Internal DNS will " - "fail and the agent will see 'connection refused'.", + "ONYX_SERVER_URL=%s looks like an internal compose hostname. Sandboxes only " + "join the craft bridge network, so default-network DNS will fail. Use the " + "http://onyx-craft-api:8080 bridge alias or a public API base such as " + "https://onyx.your-org.com/api.", api_server_url, ) env: dict[str, str] = { "ONYX_PAT": onyx_pat, "ONYX_SERVER_URL": api_server_url, + # The deployment URL is already the exact API base. Disable the CLI's + # compatibility prefix for direct services and prefixed ingress URLs. + "ONYX_API_PREFIX": "", OPENCODE_SERVER_PASSWORD: opencode_password, "OPENCODE_CONFIG_CONTENT": opencode_config_json, } @@ -832,11 +837,11 @@ class DockerSandboxManager(SandboxManager): ) -> SandboxInfo: if not onyx_pat: raise ValueError("onyx_pat is required for Docker sandbox provisioning.") - if not SANDBOX_API_SERVER_URL: + if not ONYX_SERVER_URL: raise ValueError( - "SANDBOX_API_SERVER_URL must be set for Docker sandbox provisioning." + "ONYX_SERVER_URL must be set for Docker sandbox provisioning." ) - validate_sandbox_api_url(SANDBOX_API_SERVER_URL) + validate_sandbox_api_url(ONYX_SERVER_URL) logger.info( "Provisioning Docker sandbox %s for user %s, tenant %s.", @@ -999,7 +1004,7 @@ class DockerSandboxManager(SandboxManager): tenant_id=tenant_id, image=self._image, onyx_pat=onyx_pat, - api_server_url=SANDBOX_API_SERVER_URL, + api_server_url=ONYX_SERVER_URL, network=self._network_name, volume_name=volume_name, memory_limit=self._memory_limit, diff --git a/backend/onyx/server/features/build/sandbox/kubernetes/kubernetes_sandbox_manager.py b/backend/onyx/server/features/build/sandbox/kubernetes/kubernetes_sandbox_manager.py index ccb5371c9e..d62fb872e3 100644 --- a/backend/onyx/server/features/build/sandbox/kubernetes/kubernetes_sandbox_manager.py +++ b/backend/onyx/server/features/build/sandbox/kubernetes/kubernetes_sandbox_manager.py @@ -64,10 +64,10 @@ from onyx.cache.interface import CACHE_TRANSIENT_ERRORS from onyx.db.enums import SandboxStatus from onyx.file_store.file_store import get_default_file_store from onyx.server.features.build.configs import ( + ONYX_SERVER_URL, OPENCODE_DISABLED_TOOLS, OPENCODE_SERVE_PORT, OPENCODE_SERVER_PASSWORD, - SANDBOX_API_SERVER_URL, SANDBOX_CONTAINER_IMAGE, SANDBOX_NAMESPACE, SANDBOX_NEXTJS_PORT_END, @@ -1048,11 +1048,11 @@ class KubernetesSandboxManager(SandboxManager): if not onyx_pat: raise ValueError("onyx_pat is required for Kubernetes sandbox provisioning") - if not SANDBOX_API_SERVER_URL: + if not ONYX_SERVER_URL: raise ValueError( - "SANDBOX_API_SERVER_URL must be set for Kubernetes sandbox provisioning" + "ONYX_SERVER_URL must be set for Kubernetes sandbox provisioning" ) - validate_sandbox_api_url(SANDBOX_API_SERVER_URL) + validate_sandbox_api_url(ONYX_SERVER_URL) if not SANDBOX_PROXY_HOST: raise ValueError( "SANDBOX_PROXY_HOST must be set for Kubernetes sandbox provisioning" diff --git a/backend/onyx/server/features/build/sandbox/util/api_url_check.py b/backend/onyx/server/features/build/sandbox/util/api_url_check.py index c5302c7ac8..81da394b74 100644 --- a/backend/onyx/server/features/build/sandbox/util/api_url_check.py +++ b/backend/onyx/server/features/build/sandbox/util/api_url_check.py @@ -1,10 +1,10 @@ -"""Provision-time validation of SANDBOX_API_SERVER_URL. +"""Provision-time validation of ONYX_SERVER_URL. -SANDBOX_API_SERVER_URL is the full base URL a sandbox client uses, any API -path prefix included. The classic misconfiguration is a public URL without -its /api prefix, which fails far from the cause: every LLM-gateway and -onyx-cli call inside the sandbox 404s. Probe once per process at provision -time and fail with the corrected value instead. +ONYX_SERVER_URL is the full base URL a client uses, any API path prefix +included. The classic misconfiguration is a public URL without its /api +prefix, which fails far from the cause: every LLM-gateway and onyx-cli call +inside the sandbox 404s. Probe once per process at provision time and fail +with the corrected value instead. """ import threading @@ -68,7 +68,7 @@ def validate_sandbox_api_url(api_server_url: str) -> None: response = _probe_health(base) if response is None: logger.warning( - "Could not probe %s/health to validate SANDBOX_API_SERVER_URL; " + "Could not probe %s/health to validate ONYX_SERVER_URL; " "skipping the prefix check", base, ) @@ -91,14 +91,14 @@ def validate_sandbox_api_url(api_server_url: str) -> None: if prefixed_response is not None and _is_health_response(prefixed_response): raise OnyxError( OnyxErrorCode.INTERNAL_ERROR, - f"SANDBOX_API_SERVER_URL={api_server_url!r} is missing its API path " + f"ONYX_SERVER_URL={api_server_url!r} is missing its API path " f"prefix: {base}/health does not serve the API health payload " f"(HTTP {response.status_code}) while {prefixed}/health does. " - f"Set SANDBOX_API_SERVER_URL to {prefixed!r} — the full base URL a " + f"Set ONYX_SERVER_URL to {prefixed!r} — the full base URL a " "sandbox client uses.", ) logger.warning( - "SANDBOX_API_SERVER_URL=%s: %s/health did not serve the API health " + "ONYX_SERVER_URL=%s: %s/health did not serve the API health " "payload (HTTP %s) and %s/health did not either; sandbox API calls " "will likely fail. Verify the URL is the full base URL including any " "path prefix.", diff --git a/backend/tests/external_dependency_unit/craft/test_provisioning_lock.py b/backend/tests/external_dependency_unit/craft/test_provisioning_lock.py index 148f7ba5a2..516084189a 100644 --- a/backend/tests/external_dependency_unit/craft/test_provisioning_lock.py +++ b/backend/tests/external_dependency_unit/craft/test_provisioning_lock.py @@ -133,7 +133,7 @@ def lock_env( preconditions (URL/proxy host) satisfied.""" monkeypatch.setattr(factory, "CACHE_BACKEND", CacheBackendType.REDIS) monkeypatch.setattr( - kubernetes_sandbox_manager, "SANDBOX_API_SERVER_URL", "http://api-server" + kubernetes_sandbox_manager, "ONYX_SERVER_URL", "http://api-server" ) monkeypatch.setattr( kubernetes_sandbox_manager, "SANDBOX_PROXY_HOST", "sandbox-proxy" diff --git a/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py b/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py index 3e9c670070..88e75a68e5 100644 --- a/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py +++ b/backend/tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py @@ -49,7 +49,7 @@ def test_claims_then_resolve_round_trips_minted_pat( matched_actions=None, ) - monkeypatch.setattr(onyx_pat_mod, "SANDBOX_API_SERVER_URL", f"https://{_API_HOST}") + monkeypatch.setattr(onyx_pat_mod, "ONYX_SERVER_URL", f"https://{_API_HOST}") resolver = OnyxPatResolver() assert resolver.claims(MagicMock(host=_API_HOST, port=443), ctx) is True assert resolver.claims(MagicMock(host="slack.com", port=443), ctx) is False diff --git a/backend/tests/unit/build/test_opencode_serve_ready_password_reset.py b/backend/tests/unit/build/test_opencode_serve_ready_password_reset.py index 43d82f464a..5c1e51431a 100644 --- a/backend/tests/unit/build/test_opencode_serve_ready_password_reset.py +++ b/backend/tests/unit/build/test_opencode_serve_ready_password_reset.py @@ -101,7 +101,7 @@ def test_reuse_existing_pod_clears_stale_tombstone() -> None: mgr._terminated_sandboxes.add(sandbox_id) with ( - mock.patch.object(k8s_mod, "SANDBOX_API_SERVER_URL", "http://api"), + mock.patch.object(k8s_mod, "ONYX_SERVER_URL", "http://api"), mock.patch.object(k8s_mod, "SANDBOX_PROXY_HOST", "proxy.local"), mock.patch.object(mgr, "_pod_exists_and_healthy", return_value=True), mock.patch.object(mgr, "_ensure_service_exists"), diff --git a/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_manager_config.py b/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_manager_config.py index 2f4301bc98..aaf402c711 100644 --- a/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_manager_config.py +++ b/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_manager_config.py @@ -250,7 +250,7 @@ def proxy_kwargs() -> ContainerCreateKwargs: tenant_id=TENANT_ID, image="onyxdotapp/sandbox:test", onyx_pat=SANDBOX_PROXY_INJECTED_PLACEHOLDER, - api_server_url="https://onyx.example.com", + api_server_url="https://onyx.example.com/api", network="onyx_craft_sandbox", volume_name="onyx-craft-sandbox-12345678", memory_limit="2g", @@ -359,6 +359,7 @@ def test_container_kwargs_env_allowlist_excludes_storage_credentials( # Required env assert env["ONYX_PAT"] == "pat-redacted" assert env["ONYX_SERVER_URL"] == "http://api_server:8080" + assert env["ONYX_API_PREFIX"] == "" # opencode-serve transport wiring assert env["OPENCODE_SERVER_PASSWORD"] == _OPENCODE_PASSWORD assert env["OPENCODE_CONFIG_CONTENT"] == _OPENCODE_CONFIG_JSON @@ -493,6 +494,7 @@ def test_container_kwargs_env_is_a_minimal_allowlist( assert set(env.keys()) == { "ONYX_PAT", "ONYX_SERVER_URL", + "ONYX_API_PREFIX", "OPENCODE_SERVER_PASSWORD", "OPENCODE_CONFIG_CONTENT", } @@ -526,7 +528,7 @@ def test_container_kwargs_mounts_tmp_as_tmpfs( def test_container_kwargs_warns_on_internal_compose_host( caplog: pytest.LogCaptureFixture, ) -> None: - """Deployers that point SANDBOX_API_SERVER_URL at compose DNS get warned.""" + """Deployers that point ONYX_SERVER_URL at compose DNS get warned.""" import logging with caplog.at_level(logging.WARNING): @@ -563,7 +565,7 @@ def test_container_kwargs_no_warning_for_public_url( tenant_id=TENANT_ID, image="onyxdotapp/sandbox:test", onyx_pat="pat", - api_server_url="https://onyx.example.com", + api_server_url="https://onyx.example.com/api", network="onyx_craft_sandbox", volume_name="vol", memory_limit="2g", @@ -656,10 +658,11 @@ def test_proxy_kwargs_env_contains_proxy_and_ca_keys( contract vars. """ env = proxy_kwargs["environment"] - # The legacy 4-key core is preserved; ONYX_PAT is the proxy placeholder in - # this posture (real value lives in Postgres, proxy injects on wire). + # The compatibility core is preserved; ONYX_PAT is the proxy placeholder + # in this posture (real value lives in Postgres, proxy injects on wire). assert env["ONYX_PAT"] == SANDBOX_PROXY_INJECTED_PLACEHOLDER - assert env["ONYX_SERVER_URL"] == "https://onyx.example.com" + assert env["ONYX_SERVER_URL"] == "https://onyx.example.com/api" + assert env["ONYX_API_PREFIX"] == "" assert env["OPENCODE_SERVER_PASSWORD"] == _OPENCODE_PASSWORD assert env["OPENCODE_CONFIG_CONTENT"] == _OPENCODE_CONFIG_JSON # firewall-init.sh contract. @@ -729,6 +732,7 @@ def test_proxy_kwargs_env_is_a_locked_allowlist( # Legacy core "ONYX_PAT", "ONYX_SERVER_URL", + "ONYX_API_PREFIX", "OPENCODE_SERVER_PASSWORD", "OPENCODE_CONFIG_CONTENT", # firewall-init.sh contract @@ -777,7 +781,7 @@ def test_proxy_kwargs_requires_ca_volume() -> None: tenant_id=TENANT_ID, image="onyxdotapp/sandbox:test", onyx_pat="pat", - api_server_url="https://onyx.example.com", + api_server_url="https://onyx.example.com/api", network="onyx_craft_sandbox", volume_name="vol", memory_limit="2g", diff --git a/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_serve_plumbing.py b/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_serve_plumbing.py index 772d3375c0..012019d1e5 100644 --- a/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_serve_plumbing.py +++ b/backend/tests/unit/onyx/server/features/build/sandbox/test_docker_serve_plumbing.py @@ -46,6 +46,8 @@ def _bare_manager() -> DockerSandboxManager: mgr._agent_instructions_template_path = ( # type: ignore[attr-defined] dsm.Path(dsm.__file__).parent.parent.parent / "AGENTS.template.md" ) + mgr._image_checked = False # type: ignore[attr-defined] + mgr._image_check_lock = dsm.threading.Lock() # type: ignore[attr-defined] mgr._init_serve_state() return mgr @@ -316,7 +318,7 @@ def test_provision_generates_fresh_password_and_injects_into_container_env( every later request 401s. """ monkeypatch.setattr(dsm, "DEV_MODE", True) - monkeypatch.setattr(dsm, "SANDBOX_API_SERVER_URL", "https://onyx.example.com") + monkeypatch.setattr(dsm, "ONYX_SERVER_URL", "https://onyx.example.com/api") monkeypatch.setattr(dsm, "validate_sandbox_api_url", lambda *_: None) # Skip the actual readiness HTTP probe — that needs a real container. monkeypatch.setattr( @@ -376,6 +378,7 @@ def test_provision_generates_fresh_password_and_injects_into_container_env( assert set(env.keys()) == { "ONYX_PAT", "ONYX_SERVER_URL", + "ONYX_API_PREFIX", OPENCODE_SERVER_PASSWORD, "OPENCODE_CONFIG_CONTENT", } @@ -475,7 +478,7 @@ def test_provision_removes_container_when_history_restore_fails( home. """ monkeypatch.setattr(dsm, "DEV_MODE", True) - monkeypatch.setattr(dsm, "SANDBOX_API_SERVER_URL", "https://onyx.example.com") + monkeypatch.setattr(dsm, "ONYX_SERVER_URL", "https://onyx.example.com/api") monkeypatch.setattr(dsm, "validate_sandbox_api_url", lambda *_: None) mgr = _bare_manager() diff --git a/backend/tests/unit/onyx/server/features/build/sandbox/test_k8s_push_error_mapping.py b/backend/tests/unit/onyx/server/features/build/sandbox/test_k8s_push_error_mapping.py index e0c56ce947..5632b19220 100644 --- a/backend/tests/unit/onyx/server/features/build/sandbox/test_k8s_push_error_mapping.py +++ b/backend/tests/unit/onyx/server/features/build/sandbox/test_k8s_push_error_mapping.py @@ -41,6 +41,7 @@ from onyx.server.features.build.sandbox.image.sandbox_daemon.contract import ( ) from onyx.server.features.build.sandbox.kubernetes import sidecar_client from onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager import ( + OPENCODE_HISTORY_RESTORE_TIMEOUT_SECONDS, KubernetesSandboxManager, _build_targz, ) @@ -703,7 +704,7 @@ def test_restore_opencode_history_posts_archive_to_sidecar( assert archive_file.read() == archive_body assert sha256_hex == hashlib.sha256(archive_body).hexdigest() assert operation_label == "opencode history restore" - assert timeout_seconds == 300.0 + assert timeout_seconds == OPENCODE_HISTORY_RESTORE_TIMEOUT_SECONDS calls.append("restore") sidecar_client = MagicMock() @@ -737,7 +738,7 @@ def test_restore_opencode_history_marks_sidecar_ready_when_no_snapshot( assert sandbox_id == expected_sandbox_id assert endpoint_path == SIDECAR_OPENCODE_HISTORY_MARK_RESTORED_PATH assert operation_label == "opencode history restore marker" - assert timeout_seconds == 300.0 + assert timeout_seconds == OPENCODE_HISTORY_RESTORE_TIMEOUT_SECONDS sidecar_client = MagicMock() sidecar_client.post_empty.side_effect = fake_mark_restored @@ -753,7 +754,7 @@ def test_provision_cleans_up_pod_when_opencode_history_restore_fails( ) -> None: import onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager as ksm - monkeypatch.setattr(ksm, "SANDBOX_API_SERVER_URL", "http://api-server") + monkeypatch.setattr(ksm, "ONYX_SERVER_URL", "http://api-server") monkeypatch.setattr(ksm, "validate_sandbox_api_url", lambda *_: None) monkeypatch.setattr(ksm, "SANDBOX_PROXY_HOST", "proxy.local") @@ -798,7 +799,7 @@ def test_provision_existing_healthy_pod_does_not_restore_opencode_history( ) -> None: import onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager as ksm - monkeypatch.setattr(ksm, "SANDBOX_API_SERVER_URL", "http://api-server") + monkeypatch.setattr(ksm, "ONYX_SERVER_URL", "http://api-server") monkeypatch.setattr(ksm, "validate_sandbox_api_url", lambda *_: None) monkeypatch.setattr(ksm, "SANDBOX_PROXY_HOST", "proxy.local") @@ -836,7 +837,7 @@ def test_provision_conflicting_healthy_pod_skips_startup_restore( ) -> None: import onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager as ksm - monkeypatch.setattr(ksm, "SANDBOX_API_SERVER_URL", "http://api-server") + monkeypatch.setattr(ksm, "ONYX_SERVER_URL", "http://api-server") monkeypatch.setattr(ksm, "validate_sandbox_api_url", lambda *_: None) monkeypatch.setattr(ksm, "SANDBOX_PROXY_HOST", "proxy.local") @@ -886,7 +887,7 @@ def test_provision_conflicting_not_ready_pod_runs_startup_restore( ) -> None: import onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager as ksm - monkeypatch.setattr(ksm, "SANDBOX_API_SERVER_URL", "http://api-server") + monkeypatch.setattr(ksm, "ONYX_SERVER_URL", "http://api-server") monkeypatch.setattr(ksm, "validate_sandbox_api_url", lambda *_: None) monkeypatch.setattr(ksm, "SANDBOX_PROXY_HOST", "proxy.local") @@ -948,7 +949,7 @@ def test_provision_conflicting_not_ready_pod_restore_failure_does_not_cleanup( ) -> None: import onyx.server.features.build.sandbox.kubernetes.kubernetes_sandbox_manager as ksm - monkeypatch.setattr(ksm, "SANDBOX_API_SERVER_URL", "http://api-server") + monkeypatch.setattr(ksm, "ONYX_SERVER_URL", "http://api-server") monkeypatch.setattr(ksm, "validate_sandbox_api_url", lambda *_: None) monkeypatch.setattr(ksm, "SANDBOX_PROXY_HOST", "proxy.local") diff --git a/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py b/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py index 29271564b6..7dae26a034 100644 --- a/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py +++ b/backend/tests/unit/sandbox_proxy/test_onyx_pat_resolver.py @@ -1,6 +1,6 @@ """Unit tests for `OnyxPatResolver`. -Coverage: the host claim rule (incl. inert when `SANDBOX_API_SERVER_URL` is +Coverage: the host claim rule (incl. inert when `ONYX_SERVER_URL` is unset), header rendering on both auth headers, and the fail-closed cases the dispatcher maps to a 403. The encrypt/store/read round-trip against a real DB is pinned in `tests/external_dependency_unit/sandbox_proxy/test_onyx_pat_resolver.py`. @@ -70,7 +70,7 @@ def _ctx() -> InjectionContext: @pytest.fixture def resolver(monkeypatch: pytest.MonkeyPatch) -> OnyxPatResolver: # __init__ captures the API host, so the patch must precede construction. - monkeypatch.setattr(onyx_pat, "SANDBOX_API_SERVER_URL", _API_URL) + monkeypatch.setattr(onyx_pat, "ONYX_SERVER_URL", _API_URL) return OnyxPatResolver() @@ -91,7 +91,7 @@ def test_claims_requires_matching_host_and_port(resolver: OnyxPatResolver) -> No def test_claims_matches_explicit_port(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(onyx_pat, "SANDBOX_API_SERVER_URL", "http://internal:8080") + monkeypatch.setattr(onyx_pat, "ONYX_SERVER_URL", "http://internal:8080") resolver = OnyxPatResolver() assert ( resolver.claims(make_flow(host="internal", port=8080).request, _ctx()) is True @@ -102,7 +102,7 @@ def test_claims_matches_explicit_port(monkeypatch: pytest.MonkeyPatch) -> None: def test_inert_when_api_url_unset(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(onyx_pat, "SANDBOX_API_SERVER_URL", "") + monkeypatch.setattr(onyx_pat, "ONYX_SERVER_URL", "") assert ( OnyxPatResolver().claims(make_flow(host=_API_HOST, port=443).request, _ctx()) is False diff --git a/cli/README.md b/cli/README.md index fcbc17b252..36539aa7e5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -31,7 +31,8 @@ Environment variables override config file values: | Variable | Required | Description | |----------|----------|-------------| -| `ONYX_SERVER_URL` | No | Server URL (default: `https://cloud.onyx.app`) | +| `ONYX_SERVER_URL` | No | Server origin or already-prefixed API base (default: `https://cloud.onyx.app`) | +| `ONYX_API_PREFIX` | No | API path prefix (default: `/api`); set to empty for direct backend access | | `ONYX_PAT` | No | Personal access token for authentication (required if no config file) | | `ONYX_PERSONA_ID` | No | Default agent/persona ID | | `ONYX_STREAM_MARKDOWN` | No | Enable/disable progressive markdown rendering (true/false) | diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index 0241cae1f9..26aa3a10f8 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -36,8 +36,8 @@ type Client struct { } // NewClient creates a new API client from config. -// ServerURL is the server origin (e.g. "https://cloud.onyx.app"). -// APIURL appends the /api prefix to form the API base URL. +// ServerURL may be a server origin or an API base that already includes the +// configured API prefix. func NewClient(cfg config.OnyxCliConfig) *Client { var transport *http.Transport if t, ok := http.DefaultTransport.(*http.Transport); ok { diff --git a/cli/internal/api/client_test.go b/cli/internal/api/client_test.go index 5af9b9e514..56948f4146 100644 --- a/cli/internal/api/client_test.go +++ b/cli/internal/api/client_test.go @@ -29,7 +29,7 @@ func TestSearch_Success(t *testing.T) { if r.Method != "POST" { t.Errorf("method = %s, want POST", r.Method) } - if !strings.HasSuffix(r.URL.Path, "/search") { + if r.URL.Path != "/api/search" { t.Errorf("path = %s, want /api/search", r.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index dff34ae25b..2e49956a2d 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -11,6 +11,7 @@ import ( const ( EnvServerURL = "ONYX_SERVER_URL" + EnvAPIPrefix = "ONYX_API_PREFIX" EnvAPIKey = "ONYX_PAT" EnvAgentID = "ONYX_PERSONA_ID" EnvSSHHostKey = "ONYX_SSH_HOST_KEY" @@ -56,18 +57,38 @@ func (c OnyxCliConfig) IsConfigured() bool { return c.APIKey != "" } -// APIURL appends the API prefix (default "/api") to the server origin. -// Set ONYX_API_PREFIX="" for direct backend access without the proxy prefix. -func APIURL(serverURL string) string { +func apiPrefix() string { prefix := "/api" - if v, ok := os.LookupEnv("ONYX_API_PREFIX"); ok { - prefix = v + if value, ok := os.LookupEnv(EnvAPIPrefix); ok { + prefix = value } - u := strings.TrimRight(serverURL, "/") + return strings.Trim(prefix, "/") +} + +// APIURL returns the API base for a server origin or an already-prefixed URL. +// Set ONYX_API_PREFIX="" for direct backend access without a proxy path. +func APIURL(serverURL string) string { + baseURL := strings.TrimRight(serverURL, "/") + prefix := apiPrefix() if prefix == "" { - return u + return baseURL } - return u + "/" + strings.Trim(prefix, "/") + + suffix := "/" + prefix + if strings.HasSuffix(baseURL, suffix) { + return baseURL + } + return baseURL + suffix +} + +// OnyxWebURL removes an API prefix when the configured URL already includes it. +func OnyxWebURL(serverURL string) string { + baseURL := strings.TrimRight(serverURL, "/") + prefix := apiPrefix() + if prefix == "" { + return baseURL + } + return strings.TrimSuffix(baseURL, "/"+prefix) } // ConfigDir returns ~/.config/onyx-cli diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 2bdbfb9def..3b41e1eef9 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -254,14 +254,16 @@ func TestSaveCreatesParentDirs(t *testing.T) { } func TestAPIURL(t *testing.T) { + t.Setenv(EnvAPIPrefix, "/api") cases := []struct { input string want string }{ {"https://cloud.onyx.app", "https://cloud.onyx.app/api"}, {"https://cloud.onyx.app/", "https://cloud.onyx.app/api"}, + {"https://cloud.onyx.app/api", "https://cloud.onyx.app/api"}, + {"https://cloud.onyx.app/api/", "https://cloud.onyx.app/api"}, {"http://localhost:8080", "http://localhost:8080/api"}, - {"http://localhost:3000", "http://localhost:3000/api"}, } for _, tc := range cases { got := APIURL(tc.input) @@ -271,20 +273,42 @@ func TestAPIURL(t *testing.T) { } } +func TestAPIURLCustomPrefix(t *testing.T) { + t.Setenv(EnvAPIPrefix, "/onyx/api") + for _, input := range []string{ + "https://onyx.example", + "https://onyx.example/onyx/api", + } { + got := APIURL(input) + if got != "https://onyx.example/onyx/api" { + t.Errorf("APIURL(%q) = %q", input, got) + } + } +} + func TestAPIURLEmptyPrefix(t *testing.T) { - t.Setenv("ONYX_API_PREFIX", "") + t.Setenv(EnvAPIPrefix, "") + if got := APIURL("http://localhost:8080/"); got != "http://localhost:8080" { + t.Errorf("APIURL() = %q", got) + } +} + +func TestOnyxWebURL(t *testing.T) { + t.Setenv(EnvAPIPrefix, "/api") cases := []struct { input string want string }{ - {"http://localhost:8080", "http://localhost:8080"}, - {"http://localhost:8080/", "http://localhost:8080"}, + {"https://cloud.onyx.app/api", "https://cloud.onyx.app"}, + {"https://cloud.onyx.app/api/", "https://cloud.onyx.app"}, + {"https://onyx.example/base/api", "https://onyx.example/base"}, {"https://cloud.onyx.app", "https://cloud.onyx.app"}, + {"http://localhost:8080", "http://localhost:8080"}, } for _, tc := range cases { - got := APIURL(tc.input) + got := OnyxWebURL(tc.input) if got != tc.want { - t.Errorf("APIURL(%q) = %q, want %q", tc.input, got, tc.want) + t.Errorf("OnyxWebURL(%q) = %q, want %q", tc.input, got, tc.want) } } } diff --git a/cli/internal/embedded/SKILL.md b/cli/internal/embedded/SKILL.md index 8066f700f3..1138bc7880 100644 --- a/cli/internal/embedded/SKILL.md +++ b/cli/internal/embedded/SKILL.md @@ -34,7 +34,8 @@ export ONYX_PAT="your-pat" | Variable | Required | Description | | ----------------- | -------- | -------------------------------------------------------- | -| `ONYX_SERVER_URL` | No | Onyx server URL (default: `https://cloud.onyx.app`) | +| `ONYX_SERVER_URL` | No | Server origin or already-prefixed API base (default: `https://cloud.onyx.app`) | +| `ONYX_API_PREFIX` | No | API path prefix (default: `/api`); empty for direct backend access | | `ONYX_PAT` | Yes | Personal access token for authentication (unless config file exists) | | `ONYX_PERSONA_ID` | No | Default agent/persona ID | | `ONYX_STREAM_MARKDOWN` | No | Enable/disable progressive markdown rendering (true/false) | diff --git a/cli/internal/testutil/testutil.go b/cli/internal/testutil/testutil.go index 16a2bc4a24..559b7e76cd 100644 --- a/cli/internal/testutil/testutil.go +++ b/cli/internal/testutil/testutil.go @@ -13,9 +13,11 @@ import ( "github.com/onyx-dot-app/onyx/cli/internal/iostreams" ) -// NewClient creates a test API client pointed at the given URL. -func NewClient(url string) *api.Client { - return api.NewClient(config.OnyxCliConfig{ServerURL: url, APIKey: "test-key"}) +// NewClient creates a test API client pointed at the given server URL. +func NewClient(serverURL string) *api.Client { + return api.NewClient( + config.OnyxCliConfig{ServerURL: serverURL, APIKey: "test-key"}, + ) } // StatusServer returns an httptest.Server that always responds with the given status code. diff --git a/cli/internal/tui/commands.go b/cli/internal/tui/commands.go index 3e5ffbcecd..60534dfa8a 100644 --- a/cli/internal/tui/commands.go +++ b/cli/internal/tui/commands.go @@ -49,7 +49,7 @@ func handleSlashCommand(m Model, text string) (Model, tea.Cmd) { return cmdNew(m) case "/connectors": - url := strings.TrimRight(m.config.ServerURL, "/") + "/admin/indexing/status" + url := config.OnyxWebURL(m.config.ServerURL) + "/admin/indexing/status" if browser.OpenBrowser(url) { m.viewport.addInfo("Opened " + url + " in browser") } else { @@ -58,7 +58,7 @@ func handleSlashCommand(m Model, text string) (Model, tea.Cmd) { return m, nil case "/settings": - url := strings.TrimRight(m.config.ServerURL, "/") + "/app/settings/general" + url := config.OnyxWebURL(m.config.ServerURL) + "/app/settings/general" if browser.OpenBrowser(url) { m.viewport.addInfo("Opened " + url + " in browser") } else { diff --git a/deployment/docker_compose/docker-compose.craft.yml b/deployment/docker_compose/docker-compose.craft.yml index 836e203470..cc31bca154 100644 --- a/deployment/docker_compose/docker-compose.craft.yml +++ b/deployment/docker_compose/docker-compose.craft.yml @@ -8,9 +8,7 @@ services: api_server: environment: - SANDBOX_BACKEND=${SANDBOX_BACKEND:-docker} - # Public Onyx URL; Internal compose hostnames don't resolve from the - # sandbox bridge. - - SANDBOX_API_SERVER_URL=${SANDBOX_API_SERVER_URL:-} + - ONYX_SERVER_URL=${ONYX_SERVER_URL:-http://onyx-craft-api:8080} - SANDBOX_CONTAINER_IMAGE=${SANDBOX_CONTAINER_IMAGE:-onyxdotapp/sandbox:${IMAGE_TAG:-latest}} # Pinned: Must match the compose `networks:` block + install.sh literal. # An env_file leak would point Python at a non-existent network. @@ -30,9 +28,10 @@ services: volumes: - ${SANDBOX_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock networks: - # Bridge join lets api_server proxy the Next.js preview iframe. - - default - - onyx_craft_sandbox + default: + onyx_craft_sandbox: + aliases: + - onyx-craft-api # service_healthy, not service_started: The proxy binds its listener only # after initial sync, so service_started would race startup provisions. depends_on: @@ -44,7 +43,7 @@ services: # Must match api_server so the idle-cleanup celery task uses the same # backend to snapshot + terminate sandboxes. - SANDBOX_BACKEND=${SANDBOX_BACKEND:-docker} - - SANDBOX_API_SERVER_URL=${SANDBOX_API_SERVER_URL:-} + - ONYX_SERVER_URL=${ONYX_SERVER_URL:-http://onyx-craft-api:8080} - SANDBOX_CONTAINER_IMAGE=${SANDBOX_CONTAINER_IMAGE:-onyxdotapp/sandbox:${IMAGE_TAG:-latest}} # See api_server above re: Pinned env_file-override vars. - SANDBOX_DOCKER_NETWORK=onyx_craft_sandbox @@ -85,7 +84,7 @@ services: # OnyxPatResolver reads this at boot to know which host to claim for PAT # injection. Without it, /me requests pass through bare with the # placeholder bearer and api_server 403s. - - SANDBOX_API_SERVER_URL=${SANDBOX_API_SERVER_URL:-} + - ONYX_SERVER_URL=${ONYX_SERVER_URL:-http://onyx-craft-api:8080} volumes: - sandbox_proxy_ca:/var/lib/sandbox-proxy/ca # RO: the proxy only inspects (events watcher + initial sync). diff --git a/deployment/docker_compose/env.template b/deployment/docker_compose/env.template index b5647632bb..073faaa48a 100644 --- a/deployment/docker_compose/env.template +++ b/deployment/docker_compose/env.template @@ -21,15 +21,17 @@ IMAGE_TAG=latest ## kubernetes by image tag. # SANDBOX_BACKEND=docker # -## SANDBOX_API_SERVER_URL must be a *public* HTTPS URL the sandbox can reach -## the same way any other Onyx client does — internal compose hostnames like -## "http://api_server:8080" do not resolve from inside the sandbox bridge. -# SANDBOX_API_SERVER_URL=https://onyx.your-org.example +## ONYX_SERVER_URL is the complete API base URL used by Onyx clients. The Craft +## compose overlay defaults it to its private onyx-craft-api network alias, +## which works on Docker Desktop and Linux without exposing another host port. +## Override only when routing sandbox API traffic through a public reverse +## proxy, including the /api path prefix served by that proxy. +# ONYX_SERVER_URL=https://onyx.your-org.example/api # ## Sandbox bridge network. Created by install.sh (or manually: ## docker network create onyx_craft_sandbox -## Sandboxes join only this network — they cannot reach postgres, redis, -## minio, or any other compose service by name. +## Sandboxes join only this network. The firewall forces API and public egress +## through sandbox-proxy; postgres, redis, and minio stay off the bridge. # SANDBOX_DOCKER_NETWORK=onyx_craft_sandbox # ## Per-sandbox container resource limits. Defaults match K8s pod *requests* diff --git a/deployment/helm/charts/onyx/Chart.yaml b/deployment/helm/charts/onyx/Chart.yaml index ce5a09249a..9ecd04da9c 100644 --- a/deployment/helm/charts/onyx/Chart.yaml +++ b/deployment/helm/charts/onyx/Chart.yaml @@ -5,7 +5,7 @@ home: https://www.onyx.app/ sources: - "https://github.com/onyx-dot-app/onyx" type: application -version: 0.7.2 +version: 0.7.3 appVersion: latest annotations: category: Productivity @@ -17,12 +17,9 @@ annotations: image: docker.io/onyxdotapp/onyx-backend:latest artifacthub.io/changes: | - kind: changed - description: The inference and indexing model servers now run on a distroless Docker - Hardened Image (no shell or package manager) as nonroot (the onyx user, UID 1001). Custom CA - certificates for the model servers are merged with certifi's public roots in Python - at startup instead of via update-ca-certificates. This chart version requires the - distroless model-server image — upgrade the chart and the model-server image - together (see values.yaml). + description: Replace configMap.SANDBOX_API_SERVER_URL with + configMap.ONYX_SERVER_URL for Craft sandbox API routing. Set it to the + exact API base, including /api when routing through the standard ingress. dependencies: # Helm uses the first condition path that exists: `postgresqlOperator.enabled` # is unset by default, so this falls through to `postgresql.enabled`. Set it diff --git a/deployment/helm/charts/onyx/templates/craft-validation.yaml b/deployment/helm/charts/onyx/templates/craft-validation.yaml index 75aba096bf..7b25967a95 100644 --- a/deployment/helm/charts/onyx/templates/craft-validation.yaml +++ b/deployment/helm/charts/onyx/templates/craft-validation.yaml @@ -5,8 +5,8 @@ {{- if and $sandboxBackend (ne $sandboxBackend "kubernetes") }} {{- fail "configMap.SANDBOX_BACKEND must be \"kubernetes\" when configMap.ENABLE_CRAFT is enabled in the Helm chart" }} {{- end }} -{{- if not (index .Values.configMap "SANDBOX_API_SERVER_URL") }} -{{- fail "configMap.SANDBOX_API_SERVER_URL must be set when configMap.ENABLE_CRAFT is enabled" }} +{{- if not (index .Values.configMap "ONYX_SERVER_URL") }} +{{- fail "configMap.ONYX_SERVER_URL must be set when configMap.ENABLE_CRAFT is enabled" }} {{- end }} {{- $auth := .Values.auth | default dict }} {{- $sandboxPushSecret := (index $auth "sandboxPushSecret") | default dict }} diff --git a/deployment/helm/charts/onyx/templates/sandbox-podtemplate.yaml b/deployment/helm/charts/onyx/templates/sandbox-podtemplate.yaml index ba1a56fe95..0d4eb0ea22 100644 --- a/deployment/helm/charts/onyx/templates/sandbox-podtemplate.yaml +++ b/deployment/helm/charts/onyx/templates/sandbox-podtemplate.yaml @@ -25,7 +25,7 @@ kubelet evicts sandbox pods first under node disk pressure. */}} {{- $proxyPort := include "onyx.sandboxProxyPort" . }} {{- $proxyHost := include "onyx.sandboxProxyHost" . }} {{- $caConfigMap := (index $cm "SANDBOX_PROXY_CA_CONFIGMAP") | default "sandbox-proxy-ca-bundle" }} -{{- $serverUrl := (index $cm "SANDBOX_API_SERVER_URL") }} +{{- $serverUrl := (index $cm "ONYX_SERVER_URL") }} {{- $sandboxPod := .Values.sandboxPod | default dict }} {{- $caBundleFile := "/etc/ssl/sandbox/ca-bundle.crt" }} apiVersion: v1 @@ -140,6 +140,8 @@ template: env: - { name: ONYX_PAT, value: "replaced_by_egress_proxy" } - { name: ONYX_SERVER_URL, value: "{{ $serverUrl }}" } + {{/* The deployment value is the exact API base; do not append the CLI default. */}} + - { name: ONYX_API_PREFIX, value: "" } - { name: GH_TOKEN, value: "replaced_by_egress_proxy" } - { name: GH_NO_UPDATE_NOTIFIER, value: "1" } - { name: OPENCODE_DATA_HOME, value: "/workspace/opencode-data" } diff --git a/deployment/helm/charts/onyx/values-ci.yaml b/deployment/helm/charts/onyx/values-ci.yaml index c9ab0ccc37..4e7331460a 100644 --- a/deployment/helm/charts/onyx/values-ci.yaml +++ b/deployment/helm/charts/onyx/values-ci.yaml @@ -43,7 +43,7 @@ configMap: # No model server in this lane; skips the GPU-status probe that would hang API startup. DISABLE_MODEL_SERVER: "true" # Sandbox pods call the chart-managed API service, matching production. - SANDBOX_API_SERVER_URL: "http://onyx-api-service.onyx.svc.cluster.local:8080" + ONYX_SERVER_URL: "http://onyx-api-service.onyx.svc.cluster.local:8080" # Sandbox image the kind cluster pulls (built+pushed by the CI workflow). SANDBOX_CONTAINER_IMAGE: "localhost:5001/onyx-sandbox:ci" # Tiny requests so the sandbox pod schedules alongside the full Onyx stack on diff --git a/deployment/helm/charts/onyx/values-localdev.yaml b/deployment/helm/charts/onyx/values-localdev.yaml index b7e44bbaf3..797596c080 100644 --- a/deployment/helm/charts/onyx/values-localdev.yaml +++ b/deployment/helm/charts/onyx/values-localdev.yaml @@ -79,7 +79,8 @@ codeInterpreter: configMap: ENABLE_CRAFT: "true" # Host gateway (Docker Desktop), not internal cluster DNS; Linux-kind devs sub their own. - SANDBOX_API_SERVER_URL: "http://host.docker.internal:3000" + # Full base URL, path prefix included — the Next dev server proxies the API under /api. + ONYX_SERVER_URL: "http://host.docker.internal:3000/api" ENABLE_PAID_ENTERPRISE_EDITION_FEATURES: "true" sandboxProxy: diff --git a/deployment/helm/charts/onyx/values.yaml b/deployment/helm/charts/onyx/values.yaml index 6b4390364d..42a36287e2 100644 --- a/deployment/helm/charts/onyx/values.yaml +++ b/deployment/helm/charts/onyx/values.yaml @@ -1648,9 +1648,12 @@ configMap: SANDBOX_BACKEND: "kubernetes" SANDBOX_NAMESPACE: "onyx-sandboxes" SANDBOX_SERVICE_ACCOUNT_NAME: "sandbox" + # ONYX_SERVER_URL must be the full API base URL, path prefix included — e.g. + # "https://onyx.example/api" through the public proxy, or + # "http://-api-service..svc.cluster.local:8080" directly at the + # API service (no prefix). It is required when ENABLE_CRAFT=true. SANDBOX_PROXY_CA_SECRET: "sandbox-proxy-ca" SANDBOX_PROXY_CA_CONFIGMAP: "sandbox-proxy-ca-bundle" - # -- Craft sandbox RBAC (rendered when configMap.ENABLE_CRAFT=true). craft: # Extra SAs (release ns) to also grant sandbox RBAC, e.g. a separate worker SA. diff --git a/docs/craft/dev/local-kubernetes.md b/docs/craft/dev/local-kubernetes.md index bff817ec95..a6d97d9ba9 100644 --- a/docs/craft/dev/local-kubernetes.md +++ b/docs/craft/dev/local-kubernetes.md @@ -371,7 +371,7 @@ For Craft development, the required vars (already in the template) are: ENABLE_CRAFT=true SANDBOX_BACKEND=kubernetes SANDBOX_CONTAINER_IMAGE=onyxdotapp/sandbox:dev -SANDBOX_API_SERVER_URL=http://onyx-api-service.onyx.svc.cluster.local:8080 +ONYX_SERVER_URL=http://onyx-api-service.onyx.svc.cluster.local:8080 ONYX_SANDBOX_PUSH_PRIVATE_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ``` diff --git a/docs/craft/docker/docker-compose-overview.md b/docs/craft/docker/docker-compose-overview.md index 7ec15b4828..11c077d95f 100644 --- a/docs/craft/docker/docker-compose-overview.md +++ b/docs/craft/docker/docker-compose-overview.md @@ -30,7 +30,6 @@ bash "$WT"/deployment/docker_compose/install.sh --local --include-craft cat >> ~/onyx_data/deployment/.env <<'ENV' ENABLE_CRAFT=true SANDBOX_BACKEND=docker -SANDBOX_API_SERVER_URL=http://host.docker.internal:3001 HOST_PORT=3001 ENV @@ -49,12 +48,8 @@ ENV ## Prerequisites -- macOS with **Docker Desktop** (or OrbStack) — these provide `host.docker.internal` - resolution from inside the `onyx_craft_sandbox` bridge network, which the - sandbox container needs to reach api_server. -- On Linux, replace `http://host.docker.internal:3001` with your machine's - reachable address (or use `--add-host` workarounds). Native Linux Docker - does *not* resolve `host.docker.internal` by default. +- Docker Desktop, OrbStack, or Docker Engine on Linux. The Craft overlay uses + the private `onyx-craft-api` bridge alias on every platform. - ~80 GB free Docker disk. Onyx's full stack pulls ~30 GB; local image builds add another 10–15 GB; build cache balloons to 40+ GB if you let it. See [OpenSearch read-only block](#opensearch-flipped-into-read-only-mode-disk-full) below. @@ -70,7 +65,7 @@ These must end up in `~/onyx_data/deployment/.env` after install: |---|---|---| | `ENABLE_CRAFT=true` | yes | `--include-craft` sets this (fresh installs and existing `.env`). | | `SANDBOX_BACKEND=docker` | yes | `--include-craft` sets this alongside `ENABLE_CRAFT`. | -| `SANDBOX_API_SERVER_URL=http://host.docker.internal:3001` | yes | Provision raises `ValueError("SANDBOX_API_SERVER_URL must be set")` without it. Must be a URL the sandbox container can reach **from the `onyx_craft_sandbox` bridge** — compose-internal hostnames (`api_server`, `nginx`) won't resolve there. Match the port to `HOST_PORT`. | +| `ONYX_SERVER_URL` | optional | Complete API base URL. The Craft overlay defaults to `http://onyx-craft-api:8080` on the private sandbox bridge. Override with a public URL only when desired, including its `/api` path prefix. | | `HOST_PORT=3001` | only if 3000 conflicts | Default is 3000; nginx binds this on the host. Free up 3000 or change here. | | `IMAGE_TAG` | optional | Uses the normal compose default (`latest`) unless set. Craft uses this same tag for the sandbox image, so do not set a separate sandbox image for normal deployments. There are **no** Craft-specific app/backend images — Craft is enabled at runtime via `ENABLE_CRAFT=true` (above). See [image architecture](../infra/image-architecture.md). | | `ONYX_BACKEND_IMAGE` | only when running unreleased PRs | Lets you override just the backend image without forcing model-server / web-server to the same tag. | @@ -122,12 +117,11 @@ adapt the prompts (Standard mode = `2`, keep existing env = blank). ### 3. Fix the .env On an existing `.env`, `--include-craft` writes `ENABLE_CRAFT=true` and -`SANDBOX_BACKEND=docker` for you (on both the update and restart paths). It -does **not** set the host-specific values, so append those yourself: +`SANDBOX_BACKEND=docker` for you (on both the update and restart paths). +Set `HOST_PORT` only when the default port is unavailable: ```bash cat >> ~/onyx_data/deployment/.env <<'ENV' -SANDBOX_API_SERVER_URL=http://host.docker.internal:3001 HOST_PORT=3001 ENV ``` diff --git a/docs/craft/features/egress-proxy-and-approvals/README.md b/docs/craft/features/egress-proxy-and-approvals/README.md index c6859e2876..a0d2c4ae59 100644 --- a/docs/craft/features/egress-proxy-and-approvals/README.md +++ b/docs/craft/features/egress-proxy-and-approvals/README.md @@ -491,7 +491,7 @@ The proxy uses first-claim-wins credential dispatch. Resolvers must keep ### Onyx PAT Resolver `OnyxPatResolver` claims requests whose host and port match -`SANDBOX_API_SERVER_URL`. +`ONYX_SERVER_URL`. It loads the sandbox row and decrypts `Sandbox.encrypted_pat`, then injects both Onyx API key header names as bearer tokens. The tenant is embedded in the PAT, @@ -747,7 +747,8 @@ Relevant config: - `ENABLE_CRAFT=true` enables Craft chart resources. - `SANDBOX_BACKEND` is `kubernetes` or `docker`. -- `SANDBOX_API_SERVER_URL` must be set for sandbox API calls and PAT injection. +- `ONYX_SERVER_URL` must be the complete API base for sandbox API calls and PAT + injection. - `SANDBOX_PROXY_HOST` and `SANDBOX_PROXY_PORT` tell sandboxes where to proxy. - `SANDBOX_PROXY_LISTEN_PORT` and `SANDBOX_PROXY_HEALTHZ_PORT` configure the proxy process. diff --git a/docs/craft/kubernetes/craft-eks-runbook.md b/docs/craft/kubernetes/craft-eks-runbook.md index 3e54a20f37..430bf8d965 100644 --- a/docs/craft/kubernetes/craft-eks-runbook.md +++ b/docs/craft/kubernetes/craft-eks-runbook.md @@ -210,7 +210,7 @@ sandboxProxy: - ElastiCache: `REDIS_HOST`, `REDIS_SSL=true`, `REDIS_SSL_CERT_REQS=none`, `auth.redis.enabled=false` (no auth token). - OpenSearch (v4.0 search backend): `ONYX_DISABLE_VESPA=true`, `ENABLE_OPENSEARCH_INDEXING/RETRIEVAL_FOR_ONYX=true`, `USING_AWS_MANAGED_OPENSEARCH=true`, `OPENSEARCH_REST_API_PORT=443`, `OPENSEARCH_USE_SSL=true`, `OPENSEARCH_ADMIN_USERNAME=admin`. - S3: `S3_FILE_STORE_BUCKET_NAME`, `S3_ENDPOINT_URL=""`, `AWS_REGION_NAME`. -- Craft: `ENABLE_CRAFT=true`, `SANDBOX_API_SERVER_URL=http://onyx-api-service.onyx.svc.cluster.local:8080`, `auth.sandboxPushSecret.enabled=true`. (`SANDBOX_SERVICE_ACCOUNT_NAME`/`SANDBOX_CONTAINER_IMAGE` default correctly.) +- Craft: `ENABLE_CRAFT=true`, `ONYX_SERVER_URL=http://onyx-api-service.onyx.svc.cluster.local:8080`, `auth.sandboxPushSecret.enabled=true`. (`SANDBOX_SERVICE_ACCOUNT_NAME`/`SANDBOX_CONTAINER_IMAGE` default correctly.) ### Images Use an immutable release tag for Kubernetes customer Craft deployments, e.g.