Files
nextcraft/apps/ai-service/ai_service/config.py
T
CIAgent 26b4a5be60 feat(P02): telemetry-wired sandbox spawn (Wave 3)
Task 2-3-01: create(learner_id, task_id) — telemetry sandboxes run a persistent
helper/inner namespace topology (offline inner ns; agent joins mount ns only and stays
online to reach the loopback ingest). Capture agent copied into the workspace (visible
in-ns at the bind), launched via sh -c with in-ns absolute paths (host cwd invalid after
the nsenter mount swap), stdin=DEVNULL daemonizes the agent (lifecycle tied to sandbox:
destroy reaps agent -> inner -> helper). Wire contract: frames strip URL-owned identity
(ingest extra=forbid anti-spoofing); spool keeps full events.

E2E test (real uvicorn on ephemeral port): exec in a live namespace sandbox -> events
arrive at WS ingest -> SQLite, ordered, sandbox-scoped. Pure-shell path (task_id=None)
asserts no capture agent. Full suite 216 green; ruff clean.

---ci---
phase: 2
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-003], partial: []}
---/ci---
2026-09-12 00:47:45 +00:00

80 lines
3.5 KiB
Python

"""Service settings — pydantic-settings, env prefix AI_, .env support."""
from pathlib import Path
from typing import Annotated
from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
# apps/ai-service/ (sandbox dir default is relative to the app, not the CWD)
_SERVICE_ROOT = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="AI_", env_file=".env", extra="ignore")
port: int = 8420
provider: str = "mock"
model: str = "gemma4:31b"
ollama_cloud_base_url: str = "https://ollama.com/v1"
ollama_cloud_api_key: str = "" # SecretStr adds friction here; never logged, never echoed
local_base_url: str = "http://localhost:11434/v1"
# "auto" sends response_format and degrades on 400; "off" never sends it
json_mode: str = "auto"
# v0.3 sandbox fabric (REQ-3-001): root holding per-sandbox workdirs.
# Relative paths resolve against the app dir (apps/ai-service/), not the CWD.
sandbox_dir: Path = _SERVICE_ROOT / "sandboxes"
# D-032: single-box capacity, no queue — pool full → API maps to 503.
sandbox_max_concurrent: int = 5
# Wall-clock ceiling per sandbox; the manager's async reaper destroys
# sandboxes idle past this age (same timer runs the G-2 workdir sweep).
sandbox_timeout_s: float = 900.0
# G-2: soft disk cap per sandbox workdir, enforced best-effort by the
# manager sweep (NOT kernel-enforced — no cgroup delegation/sudo here).
sandbox_max_workdir_mb: int = 512
# G-5 abuse control (NOT auth — KYC/auth is deferred; these keep the
# single-box pilot from melting down before identity lands):
#
# Server-side learner allowlist. Env form is a COMMA-SEPARATED string
# (e.g. AI_LEARNER_ALLOWLIST="pilot-learner,learner-2"); NoDecode skips
# pydantic-settings' JSON decoding of complex types and the validator
# below splits/strips/drops empties. Default: the single mock pilot id.
learner_allowlist: Annotated[list[str], NoDecode] = ["pilot-learner"]
# Max ACTIVE sandboxes per learner → API maps excess to 429.
sandbox_max_per_learner: int = 1
# Global create-rate ceiling (creates per rolling 60s window, shared
# across learners) → API maps excess to 429. In-memory, process-local.
sandbox_creates_per_min: int = 10
@field_validator("learner_allowlist", mode="before")
@classmethod
def _split_allowlist_csv(cls, value: object) -> object:
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return value
# D-027: SQLite path for telemetry/grades/variants/defenses stores.
db_path: Path = _SERVICE_ROOT / "ai_service" / "data" / "nextcraft.db"
# G-3 flood control (NOT backpressure-by-silence): max events ingested per
# (learner_id, task_id) trace before the WS endpoint closes the connection
# with 1008 and marks the trace INCOMPLETE_FLOODED. Drop-oldest is
# FORBIDDEN — it corrupts grading input (GRILL G-3).
telemetry_max_events_per_task: int = 50000
# Sandbox telemetry wiring (REQ-3-003): loopback host the in-sandbox capture
# agent dials to reach this service's WS ingest (the agent joins the sandbox
# mount ns but NOT the net ns — exec namespaces are offline, so the agent
# shares the host network and reaches the app over loopback). Port reuses
# `port` (A-004); only the host is configurable — never a second port.
telemetry_ingest_host: str = "127.0.0.1"