From 45e6487831748e587128e48be5395c40c9b3e346 Mon Sep 17 00:00:00 2001 From: Artur Mukhamadiev Date: Mon, 14 Sep 2026 22:01:01 +0300 Subject: [PATCH] integration: align backend container launch and serve frontend at root - Drop --init so the image supervisor stays namespace PID 1, as the runtime handoff requires; add --memory-swap=1g and nosuid,nodev tmpfs options to match the runtime's tested launch flags. - Mount only frontend css/, js/ and vendor/ at the root origin so index.html's relative asset paths resolve; dev/tests are not exposed. - Add X-Content-Type-Options: nosniff to every response. - Add CONFLUENCE_WEB_CONFLUENCE_PROXY (socks5/http, Confluence only) and CONFLUENCE_WEB_MODEL_TIMEOUT_SECONDS; disable environment proxy inheritance for both upstream clients; add socksio dependency. --- backend/app.py | 18 ++++++++++++++++-- backend/confluence.py | 10 +++++++++- backend/containers.py | 10 ++++++---- backend/model.py | 1 + backend/runner.py | 4 ++++ backend/settings.py | 21 ++++++++++++++++++++- requirements.txt | 1 + tests/backend/test_docker_live.py | 10 +++++++--- 8 files changed, 64 insertions(+), 11 deletions(-) diff --git a/backend/app.py b/backend/app.py index a318a87..62df801 100644 --- a/backend/app.py +++ b/backend/app.py @@ -47,6 +47,8 @@ CSP_POLICY = ( "frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" ) +FRONTEND_ASSET_DIRS = ("css", "js", "vendor") + MAX_VERIFY_BODY_BYTES = 128 * 1024 MAX_QUERY_BODY_BYTES = 6 * 16 * 1024 * 1024 + 64 * 1024 @@ -207,6 +209,8 @@ class SecurityMiddleware: headers.append((b"cache-control", b"no-store")) else: headers.extend([(b"content-security-policy", CSP_POLICY.encode()), (b"referrer-policy", b"no-referrer")]) + if not any(k.lower() == b"x-content-type-options" for k, _ in headers): + headers.append((b"x-content-type-options", b"nosniff")) message = {**message, "headers": headers} await send(message) @@ -289,6 +293,7 @@ def create_app( endpoint=app_settings.model_endpoint, context_window_tokens=app_settings.model_context_window_tokens, max_output_tokens=app_settings.model_max_output_tokens, + timeout=app_settings.model_timeout_seconds, ) else: raise RuntimeError(f"Unknown model_provider '{app_settings.model_provider}' in production mode") @@ -413,12 +418,16 @@ def create_app( # Request-scoped verification client client_factory = confluence_client_factory or ConfluenceClient + client_kwargs: Dict[str, Any] = {} + if app_settings.confluence_proxy: + client_kwargs["proxy"] = app_settings.confluence_proxy async with client_factory( base_url=canonical_url, pat=req.pat, approved_origins=app_settings.approved_confluence_origins, corporate_ca_path=app_settings.corporate_ca_path, timeout=15.0, + **client_kwargs, ) as client: await client.verify_auth() except asyncio.TimeoutError as exc: @@ -501,9 +510,14 @@ def create_app( headers=headers, ) - # Mount static frontend assets if configured + # Serve only the production frontend asset directories at the root origin. + # index.html references css/, js/ and vendor/ relatively; dev/, tests/ and + # documentation inside the frontend tree are never exposed. if app_settings.frontend_dist_dir and app_settings.frontend_dist_dir.exists(): - app.mount("/static", StaticFiles(directory=str(app_settings.frontend_dist_dir)), name="static") + for asset_dir in FRONTEND_ASSET_DIRS: + directory = app_settings.frontend_dist_dir / asset_dir + if directory.is_dir(): + app.mount(f"/{asset_dir}", StaticFiles(directory=str(directory)), name=f"frontend-{asset_dir}") return app diff --git a/backend/confluence.py b/backend/confluence.py index 9235de0..58eee5f 100644 --- a/backend/confluence.py +++ b/backend/confluence.py @@ -38,6 +38,7 @@ class ConfluenceClient: corporate_ca_path: Optional[str] = None, timeout: float = 30.0, transport: Optional[httpx.AsyncBaseTransport] = None, + proxy: Optional[str] = None, ): self.canonical_url = validate_confluence_url(base_url, approved_origins) self.pat = pat.strip() @@ -47,17 +48,24 @@ class ConfluenceClient: raise InvalidInputError("Confluence PAT exceeds 8 KiB limit") verify_param: Any = corporate_ca_path if corporate_ca_path else True + client_kwargs: Dict[str, Any] = {} + if transport is not None: + client_kwargs["transport"] = transport + elif proxy: + # Explicit deployment proxy for Confluence only; never taken from the environment. + client_kwargs["proxy"] = proxy self._client = httpx.AsyncClient( base_url=self.canonical_url, verify=verify_param, follow_redirects=False, + trust_env=False, timeout=httpx.Timeout(timeout), headers={ "Authorization": f"Bearer {self.pat}", "Accept": "application/json", "Accept-Encoding": "gzip, deflate", }, - transport=transport, + **client_kwargs, ) self._closed = False diff --git a/backend/containers.py b/backend/containers.py index 4ba4941..9d10355 100644 --- a/backend/containers.py +++ b/backend/containers.py @@ -145,7 +145,8 @@ class DockerContainerManager(ContainerManager): "run", "--name", container_name, "-i", # interactive attached stdin - "--init", + # No --init: the image's Python supervisor must remain namespace PID 1 + # (subreaper, signal immunity, independent 180 s deadline). "--rm", # automatically remove on exit if clean "--read-only", "--network", "none", @@ -153,10 +154,11 @@ class DockerContainerManager(ContainerManager): "-w", "/work", "-e", "HOME=/home/agent", "-e", "LANG=C.UTF-8", - "--tmpfs", "/work:size=256m,uid=10001,gid=10001", - "--tmpfs", "/tmp:size=64m,uid=10001,gid=10001", - "--tmpfs", "/home/agent:size=32m,uid=10001,gid=10001", + "--tmpfs", "/work:rw,nosuid,nodev,size=256m,uid=10001,gid=10001", + "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,uid=10001,gid=10001", + "--tmpfs", "/home/agent:rw,nosuid,nodev,size=32m,uid=10001,gid=10001", "--memory", "1g", + "--memory-swap", "1g", # no additional swap beyond the memory limit "--cpus", "1.0", "--pids-limit", "128", "--cap-drop", "ALL", diff --git a/backend/model.py b/backend/model.py index 8c97717..e1f758c 100644 --- a/backend/model.py +++ b/backend/model.py @@ -104,6 +104,7 @@ class OpenAIModelAdapter(ModelAdapter): self._client = httpx.AsyncClient( timeout=httpx.Timeout(timeout), + trust_env=False, headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", diff --git a/backend/runner.py b/backend/runner.py index 4be6ef9..dfef991 100644 --- a/backend/runner.py +++ b/backend/runner.py @@ -150,12 +150,16 @@ class QueryRunner: raise InvalidInputError("Prompt exceeds 16 MiB limit") # 3. Request-scoped Confluence client + client_kwargs: Dict[str, Any] = {} + if self.settings.confluence_proxy: + client_kwargs["proxy"] = self.settings.confluence_proxy confluence_client = self.confluence_client_factory( base_url=confluence_url, pat=confluence_pat, approved_origins=self.settings.approved_confluence_origins, corporate_ca_path=self.settings.corporate_ca_path, timeout=30.0, + **client_kwargs, ) confluence_dispatcher = ConfluenceDispatcher( confluence_client, max_calls=self.settings.max_confluence_calls diff --git a/backend/settings.py b/backend/settings.py index ecf077f..cba203e 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -21,6 +21,9 @@ class Settings(BaseModel): default_factory=lambda: ["https://approved.example.com"] ) corporate_ca_path: Optional[str] = None + # Optional explicit outbound proxy for Confluence only (socks5://, socks5h://, http://, https://). + # Model provider traffic never uses it. Environment proxy variables are ignored. + confluence_proxy: Optional[str] = None # Model provider configuration model_provider: str = "fake" # Explicit fake adapter or openai; unknown values fail app creation. @@ -29,6 +32,7 @@ class Settings(BaseModel): model_endpoint: Optional[str] = None model_context_window_tokens: int = 128_000 model_max_output_tokens: int = 4096 + model_timeout_seconds: float = 60.0 # per model call HTTP timeout # Container & Docker configuration runtime_image: str = "confluence-agent:latest" @@ -76,7 +80,20 @@ class Settings(BaseModel): raise ValueError(f"Invalid approved origin: {origin}") from e return v - @field_validator("query_timeout_seconds", "cleanup_timeout_seconds") + @field_validator("confluence_proxy") + @classmethod + def validate_proxy(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + v = v.strip() + if not v: + return None + parsed = urllib.parse.urlsplit(v) + if parsed.scheme not in ("socks5", "socks5h", "http", "https") or not parsed.hostname: + raise ValueError("confluence_proxy must be a socks5://, socks5h://, http:// or https:// URL") + return v + + @field_validator("query_timeout_seconds", "cleanup_timeout_seconds", "model_timeout_seconds") @classmethod def validate_deadline(cls, value): if not math.isfinite(value) or value <= 0: @@ -140,6 +157,7 @@ class Settings(BaseModel): return cls( approved_confluence_origins=origins, corporate_ca_path=os.getenv("CONFLUENCE_WEB_CORPORATE_CA_PATH"), + confluence_proxy=os.getenv("CONFLUENCE_WEB_CONFLUENCE_PROXY"), model_provider=os.getenv("CONFLUENCE_WEB_MODEL_PROVIDER", "fake"), model_name=os.getenv("CONFLUENCE_WEB_MODEL_NAME", "fake-model"), model_api_key=os.getenv("CONFLUENCE_WEB_MODEL_API_KEY"), @@ -150,6 +168,7 @@ class Settings(BaseModel): model_max_output_tokens=_parse_int( "CONFLUENCE_WEB_MODEL_MAX_OUTPUT_TOKENS", 4096 ), + model_timeout_seconds=_parse_float("CONFLUENCE_WEB_MODEL_TIMEOUT_SECONDS", 60.0), runtime_image=os.getenv( "CONFLUENCE_WEB_RUNTIME_IMAGE", "confluence-agent:latest" ), diff --git a/requirements.txt b/requirements.txt index cffc3a3..66301e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ httpx>=0.27.0 pydantic>=2.7.0 pytest-asyncio>=0.23.0 beautifulsoup4>=4.12.0 +socksio>=1.0.0 diff --git a/tests/backend/test_docker_live.py b/tests/backend/test_docker_live.py index d4a316e..4266b1f 100644 --- a/tests/backend/test_docker_live.py +++ b/tests/backend/test_docker_live.py @@ -175,11 +175,15 @@ async def test_docker_isolation_flags(): assert info["HostConfig"]["PidsLimit"] == 128 assert info["HostConfig"]["NanoCpus"] == 1_000_000_000 assert info["HostConfig"]["LogConfig"]["Type"] == "none" + assert info["HostConfig"]["MemorySwap"] == 1024 * 1024 * 1024 # no additional swap assert info["HostConfig"]["Tmpfs"] == { - "/work": "size=256m,uid=10001,gid=10001", - "/tmp": "size=64m,uid=10001,gid=10001", - "/home/agent": "size=32m,uid=10001,gid=10001", + "/work": "rw,nosuid,nodev,size=256m,uid=10001,gid=10001", + "/tmp": "rw,nosuid,nodev,size=64m,uid=10001,gid=10001", + "/home/agent": "rw,nosuid,nodev,size=32m,uid=10001,gid=10001", } + # The image entrypoint (supervisor) must be namespace PID 1: no docker-init. + assert info["HostConfig"].get("Init") is not True + assert info["Path"] != "/sbin/docker-init" assert info["Config"]["Labels"][settings.container_label_key + ".query_id"] == query_id assert "HOME=/home/agent" in info["Config"]["Env"] assert not any("MODEL_API_KEY=" in v or "CONFLUENCE_PAT=" in v for v in info["Config"]["Env"])