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'"
|
"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_VERIFY_BODY_BYTES = 128 * 1024
|
||||||
MAX_QUERY_BODY_BYTES = 6 * 16 * 1024 * 1024 + 64 * 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"))
|
headers.append((b"cache-control", b"no-store"))
|
||||||
else:
|
else:
|
||||||
headers.extend([(b"content-security-policy", CSP_POLICY.encode()), (b"referrer-policy", b"no-referrer")])
|
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}
|
message = {**message, "headers": headers}
|
||||||
await send(message)
|
await send(message)
|
||||||
|
|
||||||
@ -289,6 +293,7 @@ def create_app(
|
|||||||
endpoint=app_settings.model_endpoint,
|
endpoint=app_settings.model_endpoint,
|
||||||
context_window_tokens=app_settings.model_context_window_tokens,
|
context_window_tokens=app_settings.model_context_window_tokens,
|
||||||
max_output_tokens=app_settings.model_max_output_tokens,
|
max_output_tokens=app_settings.model_max_output_tokens,
|
||||||
|
timeout=app_settings.model_timeout_seconds,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Unknown model_provider '{app_settings.model_provider}' in production mode")
|
raise RuntimeError(f"Unknown model_provider '{app_settings.model_provider}' in production mode")
|
||||||
@ -413,12 +418,16 @@ def create_app(
|
|||||||
|
|
||||||
# Request-scoped verification client
|
# Request-scoped verification client
|
||||||
client_factory = confluence_client_factory or ConfluenceClient
|
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(
|
async with client_factory(
|
||||||
base_url=canonical_url,
|
base_url=canonical_url,
|
||||||
pat=req.pat,
|
pat=req.pat,
|
||||||
approved_origins=app_settings.approved_confluence_origins,
|
approved_origins=app_settings.approved_confluence_origins,
|
||||||
corporate_ca_path=app_settings.corporate_ca_path,
|
corporate_ca_path=app_settings.corporate_ca_path,
|
||||||
timeout=15.0,
|
timeout=15.0,
|
||||||
|
**client_kwargs,
|
||||||
) as client:
|
) as client:
|
||||||
await client.verify_auth()
|
await client.verify_auth()
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
@ -501,9 +510,14 @@ def create_app(
|
|||||||
headers=headers,
|
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():
|
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
|
return app
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class ConfluenceClient:
|
|||||||
corporate_ca_path: Optional[str] = None,
|
corporate_ca_path: Optional[str] = None,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
transport: Optional[httpx.AsyncBaseTransport] = None,
|
transport: Optional[httpx.AsyncBaseTransport] = None,
|
||||||
|
proxy: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.canonical_url = validate_confluence_url(base_url, approved_origins)
|
self.canonical_url = validate_confluence_url(base_url, approved_origins)
|
||||||
self.pat = pat.strip()
|
self.pat = pat.strip()
|
||||||
@ -47,17 +48,24 @@ class ConfluenceClient:
|
|||||||
raise InvalidInputError("Confluence PAT exceeds 8 KiB limit")
|
raise InvalidInputError("Confluence PAT exceeds 8 KiB limit")
|
||||||
|
|
||||||
verify_param: Any = corporate_ca_path if corporate_ca_path else True
|
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(
|
self._client = httpx.AsyncClient(
|
||||||
base_url=self.canonical_url,
|
base_url=self.canonical_url,
|
||||||
verify=verify_param,
|
verify=verify_param,
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
|
trust_env=False,
|
||||||
timeout=httpx.Timeout(timeout),
|
timeout=httpx.Timeout(timeout),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {self.pat}",
|
"Authorization": f"Bearer {self.pat}",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Accept-Encoding": "gzip, deflate",
|
"Accept-Encoding": "gzip, deflate",
|
||||||
},
|
},
|
||||||
transport=transport,
|
**client_kwargs,
|
||||||
)
|
)
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
|
|||||||
@ -145,7 +145,8 @@ class DockerContainerManager(ContainerManager):
|
|||||||
"run",
|
"run",
|
||||||
"--name", container_name,
|
"--name", container_name,
|
||||||
"-i", # interactive attached stdin
|
"-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
|
"--rm", # automatically remove on exit if clean
|
||||||
"--read-only",
|
"--read-only",
|
||||||
"--network", "none",
|
"--network", "none",
|
||||||
@ -153,10 +154,11 @@ class DockerContainerManager(ContainerManager):
|
|||||||
"-w", "/work",
|
"-w", "/work",
|
||||||
"-e", "HOME=/home/agent",
|
"-e", "HOME=/home/agent",
|
||||||
"-e", "LANG=C.UTF-8",
|
"-e", "LANG=C.UTF-8",
|
||||||
"--tmpfs", "/work:size=256m,uid=10001,gid=10001",
|
"--tmpfs", "/work:rw,nosuid,nodev,size=256m,uid=10001,gid=10001",
|
||||||
"--tmpfs", "/tmp:size=64m,uid=10001,gid=10001",
|
"--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,uid=10001,gid=10001",
|
||||||
"--tmpfs", "/home/agent:size=32m,uid=10001,gid=10001",
|
"--tmpfs", "/home/agent:rw,nosuid,nodev,size=32m,uid=10001,gid=10001",
|
||||||
"--memory", "1g",
|
"--memory", "1g",
|
||||||
|
"--memory-swap", "1g", # no additional swap beyond the memory limit
|
||||||
"--cpus", "1.0",
|
"--cpus", "1.0",
|
||||||
"--pids-limit", "128",
|
"--pids-limit", "128",
|
||||||
"--cap-drop", "ALL",
|
"--cap-drop", "ALL",
|
||||||
|
|||||||
@ -104,6 +104,7 @@ class OpenAIModelAdapter(ModelAdapter):
|
|||||||
|
|
||||||
self._client = httpx.AsyncClient(
|
self._client = httpx.AsyncClient(
|
||||||
timeout=httpx.Timeout(timeout),
|
timeout=httpx.Timeout(timeout),
|
||||||
|
trust_env=False,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@ -150,12 +150,16 @@ class QueryRunner:
|
|||||||
raise InvalidInputError("Prompt exceeds 16 MiB limit")
|
raise InvalidInputError("Prompt exceeds 16 MiB limit")
|
||||||
|
|
||||||
# 3. Request-scoped Confluence client
|
# 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(
|
confluence_client = self.confluence_client_factory(
|
||||||
base_url=confluence_url,
|
base_url=confluence_url,
|
||||||
pat=confluence_pat,
|
pat=confluence_pat,
|
||||||
approved_origins=self.settings.approved_confluence_origins,
|
approved_origins=self.settings.approved_confluence_origins,
|
||||||
corporate_ca_path=self.settings.corporate_ca_path,
|
corporate_ca_path=self.settings.corporate_ca_path,
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
|
**client_kwargs,
|
||||||
)
|
)
|
||||||
confluence_dispatcher = ConfluenceDispatcher(
|
confluence_dispatcher = ConfluenceDispatcher(
|
||||||
confluence_client, max_calls=self.settings.max_confluence_calls
|
confluence_client, max_calls=self.settings.max_confluence_calls
|
||||||
|
|||||||
@ -21,6 +21,9 @@ class Settings(BaseModel):
|
|||||||
default_factory=lambda: ["https://approved.example.com"]
|
default_factory=lambda: ["https://approved.example.com"]
|
||||||
)
|
)
|
||||||
corporate_ca_path: Optional[str] = None
|
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 configuration
|
||||||
model_provider: str = "fake" # Explicit fake adapter or openai; unknown values fail app creation.
|
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_endpoint: Optional[str] = None
|
||||||
model_context_window_tokens: int = 128_000
|
model_context_window_tokens: int = 128_000
|
||||||
model_max_output_tokens: int = 4096
|
model_max_output_tokens: int = 4096
|
||||||
|
model_timeout_seconds: float = 60.0 # per model call HTTP timeout
|
||||||
|
|
||||||
# Container & Docker configuration
|
# Container & Docker configuration
|
||||||
runtime_image: str = "confluence-agent:latest"
|
runtime_image: str = "confluence-agent:latest"
|
||||||
@ -76,7 +80,20 @@ class Settings(BaseModel):
|
|||||||
raise ValueError(f"Invalid approved origin: {origin}") from e
|
raise ValueError(f"Invalid approved origin: {origin}") from e
|
||||||
return v
|
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
|
@classmethod
|
||||||
def validate_deadline(cls, value):
|
def validate_deadline(cls, value):
|
||||||
if not math.isfinite(value) or value <= 0:
|
if not math.isfinite(value) or value <= 0:
|
||||||
@ -140,6 +157,7 @@ class Settings(BaseModel):
|
|||||||
return cls(
|
return cls(
|
||||||
approved_confluence_origins=origins,
|
approved_confluence_origins=origins,
|
||||||
corporate_ca_path=os.getenv("CONFLUENCE_WEB_CORPORATE_CA_PATH"),
|
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_provider=os.getenv("CONFLUENCE_WEB_MODEL_PROVIDER", "fake"),
|
||||||
model_name=os.getenv("CONFLUENCE_WEB_MODEL_NAME", "fake-model"),
|
model_name=os.getenv("CONFLUENCE_WEB_MODEL_NAME", "fake-model"),
|
||||||
model_api_key=os.getenv("CONFLUENCE_WEB_MODEL_API_KEY"),
|
model_api_key=os.getenv("CONFLUENCE_WEB_MODEL_API_KEY"),
|
||||||
@ -150,6 +168,7 @@ class Settings(BaseModel):
|
|||||||
model_max_output_tokens=_parse_int(
|
model_max_output_tokens=_parse_int(
|
||||||
"CONFLUENCE_WEB_MODEL_MAX_OUTPUT_TOKENS", 4096
|
"CONFLUENCE_WEB_MODEL_MAX_OUTPUT_TOKENS", 4096
|
||||||
),
|
),
|
||||||
|
model_timeout_seconds=_parse_float("CONFLUENCE_WEB_MODEL_TIMEOUT_SECONDS", 60.0),
|
||||||
runtime_image=os.getenv(
|
runtime_image=os.getenv(
|
||||||
"CONFLUENCE_WEB_RUNTIME_IMAGE", "confluence-agent:latest"
|
"CONFLUENCE_WEB_RUNTIME_IMAGE", "confluence-agent:latest"
|
||||||
),
|
),
|
||||||
|
|||||||
@ -8,3 +8,4 @@ httpx>=0.27.0
|
|||||||
pydantic>=2.7.0
|
pydantic>=2.7.0
|
||||||
pytest-asyncio>=0.23.0
|
pytest-asyncio>=0.23.0
|
||||||
beautifulsoup4>=4.12.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"]["PidsLimit"] == 128
|
||||||
assert info["HostConfig"]["NanoCpus"] == 1_000_000_000
|
assert info["HostConfig"]["NanoCpus"] == 1_000_000_000
|
||||||
assert info["HostConfig"]["LogConfig"]["Type"] == "none"
|
assert info["HostConfig"]["LogConfig"]["Type"] == "none"
|
||||||
|
assert info["HostConfig"]["MemorySwap"] == 1024 * 1024 * 1024 # no additional swap
|
||||||
assert info["HostConfig"]["Tmpfs"] == {
|
assert info["HostConfig"]["Tmpfs"] == {
|
||||||
"/work": "size=256m,uid=10001,gid=10001",
|
"/work": "rw,nosuid,nodev,size=256m,uid=10001,gid=10001",
|
||||||
"/tmp": "size=64m,uid=10001,gid=10001",
|
"/tmp": "rw,nosuid,nodev,size=64m,uid=10001,gid=10001",
|
||||||
"/home/agent": "size=32m,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 info["Config"]["Labels"][settings.container_label_key + ".query_id"] == query_id
|
||||||
assert "HOME=/home/agent" in info["Config"]["Env"]
|
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"])
|
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