From d55072ff0f312e05090b336ea4e6ad1060a774f7 Mon Sep 17 00:00:00 2001 From: Edouard Bonlieu Date: Tue, 30 Jun 2026 13:56:25 +0200 Subject: [PATCH 1/2] add port security policy to the exposed port --- examples/21_port_security_policy.py | 134 ++++++++++++++++++++ examples/21_port_security_policy_async.py | 141 ++++++++++++++++++++++ koyeb/sandbox/__init__.py | 4 +- koyeb/sandbox/sandbox.py | 37 ++++-- koyeb/sandbox/utils.py | 61 +++++++++- uv.lock | 31 ++++- 6 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 examples/21_port_security_policy.py create mode 100644 examples/21_port_security_policy_async.py diff --git a/examples/21_port_security_policy.py b/examples/21_port_security_policy.py new file mode 100644 index 00000000..b4c6e7e6 --- /dev/null +++ b/examples/21_port_security_policy.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Verify exposed-port security policies: no auth, API key, and Basic Auth. + +Follows the same pattern as example 14 (expose_port) and adds a security +policy on the user-facing route so that unauthenticated requests are rejected. +""" + +import os +import sys +import time +import random +import string + +import httpx + +from koyeb import Sandbox +from koyeb.sandbox import ApiKey, BasicAuth + +API_KEY = "my-secret-api-key" +BA_USER = "admin" +BA_PASS = "s3cr3t" + + +def _suffix() -> str: + return "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + + +def _setup_server(sandbox: Sandbox) -> str: + """Write a test file, start an HTTP server on 8080, expose it, return the base URL.""" + sandbox.filesystem.write_file("/tmp/index.html", "

ok

") + sandbox.launch_process("python3 -m http.server 8080", cwd="/tmp") + time.sleep(3) + + exposed = sandbox.expose_port(8080) + print(f" Exposed at: {exposed.exposed_at}") + time.sleep(2) + return exposed.exposed_at + + +def demo_no_auth(api_token: str) -> None: + print("\n=== No security policy (public) ===") + sandbox = None + try: + sandbox = Sandbox.create( + image="koyeb/sandbox:slim", + name=f"sec-noauth-{_suffix()}", + api_token=api_token, + ) + base = _setup_server(sandbox) + + resp = httpx.get(f"{base}/index.html", timeout=15) + print(f" GET /index.html → {resp.status_code}") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + print(" ✓ Publicly accessible as expected") + finally: + if sandbox: + sandbox.delete() + + +def demo_api_key(api_token: str) -> None: + print("\n=== API key policy ===") + sandbox = None + try: + sandbox = Sandbox.create( + image="koyeb/sandbox:slim", + name=f"sec-apikey-{_suffix()}", + api_token=api_token, + exposed_port_security_policy=ApiKey(API_KEY), + ) + base = _setup_server(sandbox) + + # Without key → rejected + resp = httpx.get(f"{base}/index.html", timeout=15) + print(f" GET (no key) → {resp.status_code}") + assert resp.status_code in (401, 403), f"Expected 401/403, got {resp.status_code}" + print(" ✓ Rejected without key") + + # With correct key → accepted + resp = httpx.get( + f"{base}/index.html", + headers={"x-api-key": f"{API_KEY}"}, + timeout=15, + ) + print(f" GET (Bearer {API_KEY}) → {resp.status_code}") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + print(" ✓ Accepted with correct API key") + finally: + if sandbox: + sandbox.delete() + + +def demo_basic_auth(api_token: str) -> None: + print("\n=== Basic Auth policy ===") + sandbox = None + try: + sandbox = Sandbox.create( + image="koyeb/sandbox:slim", + name=f"sec-basicauth-{_suffix()}", + api_token=api_token, + exposed_port_security_policy=BasicAuth(username=BA_USER, password=BA_PASS), + ) + base = _setup_server(sandbox) + + # Without credentials → rejected + resp = httpx.get(f"{base}/index.html", timeout=15) + print(f" GET (no creds) → {resp.status_code}") + assert resp.status_code in (401, 403), f"Expected 401/403, got {resp.status_code}" + print(" ✓ Rejected without credentials") + + # With correct credentials → accepted + resp = httpx.get(f"{base}/index.html", auth=(BA_USER, BA_PASS), timeout=15) + print(f" GET ({BA_USER}:{BA_PASS}) → {resp.status_code}") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + print(" ✓ Accepted with correct Basic Auth credentials") + finally: + if sandbox: + sandbox.delete() + + +def main() -> int: + api_token = os.getenv("KOYEB_API_TOKEN") + if not api_token: + print("Error: KOYEB_API_TOKEN not set") + return 1 + + demo_no_auth(api_token) + demo_api_key(api_token) + demo_basic_auth(api_token) + print("\nAll assertions passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/21_port_security_policy_async.py b/examples/21_port_security_policy_async.py new file mode 100644 index 00000000..15052fee --- /dev/null +++ b/examples/21_port_security_policy_async.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Verify exposed-port security policies: no auth, API key, and Basic Auth (async variant). + +Follows the same pattern as example 14 (expose_port) and adds a security +policy on the user-facing route so that unauthenticated requests are rejected. +""" + +import asyncio +import os +import sys +import random +import string + +import httpx + +from koyeb import AsyncSandbox +from koyeb.sandbox import ApiKey, BasicAuth + +API_KEY = "my-secret-api-key" +BA_USER = "admin" +BA_PASS = "s3cr3t" + + +def _suffix() -> str: + return "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + + +async def _setup_server(sandbox: AsyncSandbox) -> str: + """Write a test file, start an HTTP server on 8080, expose it, return the base URL.""" + await sandbox.filesystem.write_file("/tmp/index.html", "

ok

") + await sandbox.launch_process("python3 -m http.server 8080", cwd="/tmp") + await asyncio.sleep(3) + + exposed = await sandbox.expose_port(8080) + print(f" Exposed at: {exposed.exposed_at}") + await asyncio.sleep(2) + return exposed.exposed_at + + +async def demo_no_auth(api_token: str) -> None: + print("\n=== No security policy (public) ===") + sandbox = None + try: + sandbox = await AsyncSandbox.create( + image="koyeb/sandbox:slim", + name=f"sec-noauth-{_suffix()}", + api_token=api_token, + ) + base = await _setup_server(sandbox) + + async with httpx.AsyncClient() as client: + resp = await client.get(f"{base}/index.html", timeout=15) + print(f" GET /index.html → {resp.status_code}") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + print(" ✓ Publicly accessible as expected") + finally: + if sandbox: + await sandbox.delete() + + +async def demo_api_key(api_token: str) -> None: + print("\n=== API key policy ===") + sandbox = None + try: + sandbox = await AsyncSandbox.create( + image="koyeb/sandbox:slim", + name=f"sec-apikey-{_suffix()}", + api_token=api_token, + exposed_port_security_policy=ApiKey(API_KEY), + ) + base = await _setup_server(sandbox) + + async with httpx.AsyncClient() as client: + # Without key → rejected + resp = await client.get(f"{base}/index.html", timeout=15) + print(f" GET (no key) → {resp.status_code}") + assert resp.status_code in (401, 403), f"Expected 401/403, got {resp.status_code}" + print(" ✓ Rejected without key") + + # With correct key → accepted + resp = await client.get( + f"{base}/index.html", + headers={"x-api-key": API_KEY}, + timeout=15, + ) + print(f" GET (x-api-key: {API_KEY}) → {resp.status_code}") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + print(" ✓ Accepted with correct API key") + finally: + if sandbox: + await sandbox.delete() + + +async def demo_basic_auth(api_token: str) -> None: + print("\n=== Basic Auth policy ===") + sandbox = None + try: + sandbox = await AsyncSandbox.create( + image="koyeb/sandbox:slim", + name=f"sec-basicauth-{_suffix()}", + api_token=api_token, + exposed_port_security_policy=BasicAuth(username=BA_USER, password=BA_PASS), + ) + base = await _setup_server(sandbox) + + async with httpx.AsyncClient() as client: + # Without credentials → rejected + resp = await client.get(f"{base}/index.html", timeout=15) + print(f" GET (no creds) → {resp.status_code}") + assert resp.status_code in (401, 403), f"Expected 401/403, got {resp.status_code}" + print(" ✓ Rejected without credentials") + + # With correct credentials → accepted + resp = await client.get( + f"{base}/index.html", + auth=(BA_USER, BA_PASS), + timeout=15, + ) + print(f" GET ({BA_USER}:{BA_PASS}) → {resp.status_code}") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + print(" ✓ Accepted with correct Basic Auth credentials") + finally: + if sandbox: + await sandbox.delete() + + +async def main() -> int: + api_token = os.getenv("KOYEB_API_TOKEN") + if not api_token: + print("Error: KOYEB_API_TOKEN not set") + return 1 + + await demo_no_auth(api_token) + await demo_api_key(api_token) + await demo_basic_auth(api_token) + print("\nAll assertions passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/koyeb/sandbox/__init__.py b/koyeb/sandbox/__init__.py index 4688c7c6..7d6aa13a 100644 --- a/koyeb/sandbox/__init__.py +++ b/koyeb/sandbox/__init__.py @@ -19,11 +19,13 @@ ) from .filesystem import FileInfo, SandboxFilesystem from .sandbox import AsyncSandbox, ExposedPort, ProcessInfo, Sandbox -from .utils import SandboxDeploymentError, SandboxError, SandboxServiceError, SandboxTimeoutError +from .utils import ApiKey, BasicAuth, SandboxDeploymentError, SandboxError, SandboxServiceError, SandboxTimeoutError __all__ = [ "Sandbox", "AsyncSandbox", + "ApiKey", + "BasicAuth", "ConfigFile", "Secret", "SandboxFilesystem", diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 7c67bbbd..06dfa690 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -11,7 +11,7 @@ import secrets import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from koyeb.api.api.deployments_api import DeploymentsApi from koyeb.api.exceptions import ApiException, NotFoundException @@ -22,6 +22,8 @@ from .executor_client import ConnectionInfo from .utils import ( + ApiKey, + BasicAuth, DEFAULT_INSTANCE_WAIT_TIMEOUT, DEFAULT_POLL_INTERVAL, SandboxDeploymentError, @@ -31,7 +33,6 @@ build_env_vars, create_deployment_definition, create_docker_source, - create_koyeb_sandbox_routes, create_sandbox_client, get_api_clients, logger, @@ -117,6 +118,7 @@ def create( wait_ready: bool = True, instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, + exposed_port_security_policy: Optional[Union[ApiKey, BasicAuth]] = None, env: Optional[Dict[str, Any]] = None, config_files: Optional[Dict[str, Any]] = None, region: Optional[str] = None, @@ -149,6 +151,11 @@ def create( exposed_port_protocol: Protocol to expose ports with ("http" or "http2"). If None, defaults to "http". If provided, must be one of "http" or "http2". + exposed_port_security_policy: Optional access control for the user-facing port (3031). + Pass ``ApiKey("my-key")`` to require an API key via + ``Authorization: Bearer ``, or + ``BasicAuth(username="u", password="p")`` to require HTTP Basic Auth. + If None, the port is publicly accessible (default). env: Environment variables config_files: Config files to create in the sandbox, as a dictionary mapping file paths to file contents. Values can be plain strings (default permissions 0644) @@ -195,6 +202,14 @@ def create( ... image="ghcr.io/myorg/myimage:latest", ... registry_secret="my-ghcr-secret" ... ) + + >>> # Protect the exposed port with an API key + >>> sandbox = Sandbox.create(exposed_port_security_policy=ApiKey("my-secret-key")) + + >>> # Protect the exposed port with Basic Auth + >>> sandbox = Sandbox.create( + ... exposed_port_security_policy=BasicAuth(username="admin", password="s3cr3t") + ... ) """ if api_token is None: api_token = os.getenv("KOYEB_API_TOKEN") @@ -208,6 +223,7 @@ def create( image=image, instance_type=instance_type, exposed_port_protocol=exposed_port_protocol, + exposed_port_security_policy=exposed_port_security_policy, env=env, config_files=config_files, region=region, @@ -248,6 +264,7 @@ def _create_sync( image: str = "koyeb/sandbox", instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, + exposed_port_security_policy: Optional[Union[ApiKey, BasicAuth]] = None, env: Optional[Dict[str, Any]] = None, config_files: Optional[Dict[str, Any]] = None, region: Optional[str] = None, @@ -278,9 +295,6 @@ def _create_sync( apps_api = clients.apps services_api = clients.services - # Always create routes (ports are always exposed, default to "http") - routes = create_koyeb_sandbox_routes() - # Generate secure sandbox secret sandbox_secret = secrets.token_urlsafe(32) @@ -312,8 +326,8 @@ def _create_sync( env_vars=env_vars, instance_type=instance_type, exposed_port_protocol=exposed_port_protocol, + exposed_port_security_policy=exposed_port_security_policy, region=region, - routes=routes, idle_timeout=idle_timeout, enable_tcp_proxy=enable_tcp_proxy, _experimental_enable_light_sleep=_experimental_enable_light_sleep, @@ -1245,6 +1259,7 @@ async def create( wait_ready: bool = True, instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, + exposed_port_security_policy: Optional[Union[ApiKey, BasicAuth]] = None, env: Optional[Dict[str, Any]] = None, config_files: Optional[Dict[str, Any]] = None, region: Optional[str] = None, @@ -1277,6 +1292,11 @@ async def create( exposed_port_protocol: Protocol to expose ports with ("http" or "http2"). If None, defaults to "http". If provided, must be one of "http" or "http2". + exposed_port_security_policy: Optional access control for the user-facing port. + Pass ``ApiKey("my-key")`` to require an API key via + ``x-api-key: ``, or + ``BasicAuth(username="u", password="p")`` to require HTTP Basic Auth. + If None, the port is publicly accessible (default). env: Environment variables config_files: Config files to create in the sandbox, as a dictionary mapping file paths to file contents. Values can be plain strings (default permissions 0644) @@ -1331,9 +1351,6 @@ async def create( clients = get_async_api_clients(api_token, host) - # Always create routes - routes = create_koyeb_sandbox_routes() - # Generate secure sandbox secret sandbox_secret = secrets.token_urlsafe(32) @@ -1365,8 +1382,8 @@ async def create( env_vars=env_vars, instance_type=instance_type, exposed_port_protocol=exposed_port_protocol, + exposed_port_security_policy=exposed_port_security_policy, region=region, - routes=routes, idle_timeout=idle_timeout, enable_tcp_proxy=enable_tcp_proxy, _experimental_enable_light_sleep=_experimental_enable_light_sleep, diff --git a/koyeb/sandbox/utils.py b/koyeb/sandbox/utils.py index 4482386f..9ea2d579 100644 --- a/koyeb/sandbox/utils.py +++ b/koyeb/sandbox/utils.py @@ -8,7 +8,7 @@ import os import shlex from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from koyeb.api import ApiClient, Configuration from koyeb.api.api import ( @@ -19,6 +19,7 @@ SecretsApi, ServicesApi, ) +from koyeb.api.models.basic_auth_policy import BasicAuthPolicy from koyeb.api.models.config_file import ConfigFile from koyeb.api.models.deployment_definition import DeploymentDefinition from koyeb.api.models.deployment_definition_type import DeploymentDefinitionType @@ -36,6 +37,7 @@ from koyeb.api.models.deployment_mesh import DeploymentMesh from koyeb.api.models.docker_source import DockerSource from koyeb.api.models.proxy_port_protocol import ProxyPortProtocol +from koyeb.api.models.security_policies import SecurityPolicies # Setup logging logger = logging.getLogger(__name__) @@ -60,6 +62,43 @@ VALID_DEPLOYMENT_PORT_PROTOCOLS = ("http", "http2") +@dataclass +class ApiKey: + """An API key used to restrict access to the user-facing sandbox port.""" + + key: str + + +@dataclass +class BasicAuth: + """HTTP Basic Auth credentials used to restrict access to the user-facing sandbox port.""" + + username: str + password: str + + +def _build_security_policy( + policy, +) -> Optional[SecurityPolicies]: + """ + Build a SecurityPolicies object from an ApiKey or BasicAuth instance. + + Raises: + ValueError: If policy is not an ApiKey or BasicAuth. + """ + if policy is None: + return None + if isinstance(policy, ApiKey): + return SecurityPolicies(api_keys=[policy.key]) + if isinstance(policy, BasicAuth): + return SecurityPolicies( + basic_auths=[BasicAuthPolicy(username=policy.username, password=policy.password)] + ) + raise ValueError( + "exposed_port_security_policy must be an ApiKey or BasicAuth instance" + ) + + def _validate_port_protocol(protocol: str) -> str: """ Validate port protocol using API model structure. @@ -376,20 +415,25 @@ def create_koyeb_sandbox_proxy_ports() -> List[DeploymentProxyPort]: ] -def create_koyeb_sandbox_routes() -> List[DeploymentRoute]: +def create_koyeb_sandbox_routes( + security_policy: Optional[SecurityPolicies] = None, +) -> List[DeploymentRoute]: """ Create route configuration for koyeb/sandbox image to make it publicly accessible. Creates two routes: - - Port 3030 accessible at /koyeb-sandbox/ - - Port 3031 accessible at / + - Port 3030 accessible at /koyeb-sandbox/ (control plane, no security policy) + - Port 3031 accessible at / (user-facing, optional security policy) + + Args: + security_policy: Optional security policy to apply to the user-facing port 3031 route. Returns: List of DeploymentRoute objects configured for koyeb/sandbox """ return [ DeploymentRoute(port=3030, path="/koyeb-sandbox/"), - DeploymentRoute(port=3031, path="/"), + DeploymentRoute(port=3031, path="/", security_policies=security_policy), ] @@ -399,6 +443,7 @@ def create_deployment_definition( env_vars: List[DeploymentEnv], instance_type: str, exposed_port_protocol: Optional[str] = None, + exposed_port_security_policy: Optional[Union[ApiKey, BasicAuth]] = None, region: Optional[str] = None, routes: Optional[List[DeploymentRoute]] = None, idle_timeout: int = 300, @@ -419,6 +464,8 @@ def create_deployment_definition( exposed_port_protocol: Protocol to expose ports with ("http" or "http2"). If None, defaults to "http". If provided, must be one of "http" or "http2". + exposed_port_security_policy: Optional security policy for the user-facing port 3031 route. + Pass ApiKey("my-key") or BasicAuth(username="u", password="p"). region: Region to deploy to. Defaults to KOYEB_REGION env var, or "na" if not set. routes: List of routes for public access idle_timeout: Number of seconds to wait before sleeping the instance if it receives no traffic @@ -444,6 +491,10 @@ def create_deployment_definition( protocol = _validate_port_protocol(protocol) ports = create_koyeb_sandbox_ports(protocol) + if routes is None: + security_policy = _build_security_policy(exposed_port_security_policy) + routes = create_koyeb_sandbox_routes(security_policy=security_policy) + # Create TCP proxy ports if enabled proxy_ports = None if enable_tcp_proxy: diff --git a/uv.lock b/uv.lock index 3f3a51de..01df92b6 100644 --- a/uv.lock +++ b/uv.lock @@ -6,10 +6,6 @@ resolution-markers = [ "python_full_version < '3.10'", ] -[options] -exclude-newer = "2026-06-20T16:28:02.207712Z" -exclude-newer-span = "P2D" - [[package]] name = "annotated-types" version = "0.7.0" @@ -479,6 +475,12 @@ dependencies = [ { name = "websockets" }, ] +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, + { name = "socksio" }, +] + [package.dev-dependencies] dev = [ { name = "autopep8" }, @@ -493,11 +495,14 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2" }, + { name = "pysocks", marker = "extra == 'socks'", specifier = ">=1.7.1" }, { name = "python-dateutil", specifier = ">=2.8.2" }, + { name = "socksio", marker = "extra == 'socks'", specifier = ">=1.0.0" }, { name = "typing-extensions", specifier = ">=4.7.1" }, { name = "urllib3", specifier = ">=2.1.0,<3.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] +provides-extras = ["socks"] [package.metadata.requires-dev] dev = [ @@ -909,6 +914,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -1046,6 +1060,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + [[package]] name = "toml" version = "0.10.2" From c6a94525d4511f1e2536617f14a33a4c4bdd70f1 Mon Sep 17 00:00:00 2001 From: Edouard Bonlieu Date: Tue, 30 Jun 2026 14:20:22 +0200 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- koyeb/sandbox/__init__.py | 9 ++++++++- koyeb/sandbox/sandbox.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/koyeb/sandbox/__init__.py b/koyeb/sandbox/__init__.py index 7d6aa13a..b3eca959 100644 --- a/koyeb/sandbox/__init__.py +++ b/koyeb/sandbox/__init__.py @@ -19,7 +19,14 @@ ) from .filesystem import FileInfo, SandboxFilesystem from .sandbox import AsyncSandbox, ExposedPort, ProcessInfo, Sandbox -from .utils import ApiKey, BasicAuth, SandboxDeploymentError, SandboxError, SandboxServiceError, SandboxTimeoutError +from .utils import ( + ApiKey, + BasicAuth, + SandboxDeploymentError, + SandboxError, + SandboxServiceError, + SandboxTimeoutError, +) __all__ = [ "Sandbox", diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 06dfa690..ac98d5c9 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -153,7 +153,7 @@ def create( If provided, must be one of "http" or "http2". exposed_port_security_policy: Optional access control for the user-facing port (3031). Pass ``ApiKey("my-key")`` to require an API key via - ``Authorization: Bearer ``, or + ``x-api-key: ``, or ``BasicAuth(username="u", password="p")`` to require HTTP Basic Auth. If None, the port is publicly accessible (default). env: Environment variables