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.
This commit is contained in:
parent
a9908a533f
commit
45e6487831
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user