1010from functools import lru_cache
1111from pathlib import Path
1212
13+ from pydantic import Field
1314from pydantic_settings import BaseSettings , SettingsConfigDict
1415
1516
@@ -42,12 +43,93 @@ class SandboxSettings(BaseSettings):
4243 """Idle reaper TTL: a sandbox unused this long is destroyed."""
4344
4445
46+ class StorageSettings (BaseSettings ):
47+ """Durable blob-store selection for uploads and artifacts.
48+
49+ ``local`` is a directory tree (dev / single node); ``s3`` is an
50+ S3-compatible bucket (prod / multi-node). The per-conversation
51+ workspace is unaffected -- this is the durable tier behind it.
52+ """
53+
54+ model_config = SettingsConfigDict (env_prefix = "PAW_STORAGE__" )
55+
56+ backend : str = "local"
57+ """``local`` or ``s3``."""
58+
59+ local_root : str = "./storage"
60+ """Root directory for the ``local`` backend."""
61+
62+ s3_bucket : str = ""
63+ """Bucket name for the ``s3`` backend."""
64+
65+ s3_prefix : str = ""
66+ """Key prefix within the bucket (namespacing, e.g. ``paw/``)."""
67+
68+ s3_region : str = ""
69+ """AWS region; empty = SDK default resolution."""
70+
71+ s3_endpoint_url : str = ""
72+ """Custom endpoint (e.g. MinIO / localstack); empty = real AWS."""
73+
74+
75+ class DockerSettings (BaseSettings ):
76+ """Container isolation knobs for the ``docker`` runner.
77+
78+ Defaults are the safe ones: no network, dropped capabilities,
79+ read-only rootfs, non-root user, resource ceilings. A wedged or
80+ hostile run is therefore bounded and cannot reach the host env, the
81+ database, or other tenants' workspaces.
82+ """
83+
84+ model_config = SettingsConfigDict (env_prefix = "PAW_DOCKER__" )
85+
86+ image : str = "python-agent-harness:latest"
87+ """Sandbox image; harness must be its entrypoint-able command.
88+ Pin by digest in production (``name@sha256:...``)."""
89+
90+ workdir : str = "/workspace"
91+ """Container path the conversation workspace is mounted at (cwd)."""
92+
93+ network : str = "none"
94+ """Container network mode; ``none`` = no egress (exfiltration/SSRF
95+ off). Override only with an explicit, filtered egress policy."""
96+
97+ mem_limit : str = "1g"
98+ """Hard memory ceiling (docker ``mem_limit`` syntax)."""
99+
100+ nano_cpus : int = 1_000_000_000
101+ """CPU quota in units of 1e-9 CPUs (1e9 = one core)."""
102+
103+ pids_limit : int = 256
104+ """Max PIDs in the container (fork-bomb ceiling)."""
105+
106+ user : str = "1000:1000"
107+ """Non-root uid:gid the harness runs as inside the container."""
108+
109+ read_only_rootfs : bool = True
110+ """Mount the container root filesystem read-only (scratch via tmpfs)."""
111+
112+ tmpfs_size : str = "256m"
113+ """Size of the writable ``/tmp`` tmpfs mounted into the container."""
114+
115+ stop_timeout : int = 5
116+ """Seconds to wait after SIGTERM before the daemon kills on destroy."""
117+
118+
45119class Settings (BaseSettings ):
46120 model_config = SettingsConfigDict (
47121 env_prefix = "PAW_" , env_file = ".env" , env_file_encoding = "utf-8" , extra = "ignore"
48122 )
49123
50124 db_url : str = "sqlite:///./paw.db"
125+ db_pool_size : int = 5
126+ """SQLAlchemy connection pool size (server DBs only; ignored for
127+ SQLite). One pool per web replica."""
128+ db_max_overflow : int = 10
129+ """Extra connections allowed beyond ``db_pool_size`` under load."""
130+ db_pool_recycle_s : int = 1800
131+ """Recycle a pooled connection after this many seconds, so a
132+ connection a proxy would drop is replaced proactively."""
51133 secret_key : str = "dev-only-insecure-key-change-me-0123456789abcdef"
52134 fernet_key : str = ""
53135 """Fernet key for the secrets store; empty = derived from
@@ -63,9 +145,12 @@ class Settings(BaseSettings):
63145 runner : str = "server"
64146 """Sandbox runner: ``server`` (one resident ``harness serve``
65147 process per sandbox: multi-turn memory, mid-run Q&A, protocol-level
66- cancel). Docker later."""
67- harness : HarnessSettings = HarnessSettings ()
68- sandbox : SandboxSettings = SandboxSettings ()
148+ cancel) or ``docker`` (the same, isolated in a per-sandbox
149+ container: no host env, no host filesystem, no network by default)."""
150+ harness : HarnessSettings = Field (default_factory = HarnessSettings )
151+ sandbox : SandboxSettings = Field (default_factory = SandboxSettings )
152+ docker : DockerSettings = Field (default_factory = DockerSettings )
153+ storage : StorageSettings = Field (default_factory = StorageSettings )
69154
70155 cors_origins : list [str ] = []
71156 """Extra allowed CORS origins for a split frontend."""
@@ -82,6 +167,40 @@ class Settings(BaseSettings):
82167 rate_limit_auth : int = 10
83168 """Max login/register attempts per client IP per window."""
84169
170+ rate_limit_backend : str = "memory"
171+ """Rate-limit store: ``memory`` (process-local, per-instance) or
172+ ``redis`` (shared across instances for a global limit)."""
173+ rate_limit_redis_url : str = ""
174+ """Redis URL for the ``redis`` backend; empty = localhost default."""
175+
176+ shutdown_drain_seconds : float = 25.0
177+ """On shutdown, how long to wait for in-flight runs to finish before
178+ exiting. Keep under the orchestrator's SIGTERM grace period so the
179+ drain completes before a forced kill; runs still in flight at the
180+ deadline are reconciled to ``error`` on the next startup."""
181+
182+ budget_enforce : bool = False
183+ """Enforce a per-user token budget before starting a run (the spend
184+ kill-switch). Off by default so existing/dev deployments are
185+ unaffected; turn on for a public billed platform."""
186+ budget_free_tokens : int = 1_000_000
187+ """Token allowance for a user with no purchased points (the free
188+ tier). Total budget = this + ``tokens_per_point`` × the user's
189+ points."""
190+ budget_tokens_per_point : int = 1000
191+ """How many tokens one account point is worth, converting the
192+ points buckets (plan_points + pack_points) into a token budget."""
193+
194+ max_upload_bytes : int = 100 * 1024 * 1024
195+ """Per-file upload size cap."""
196+ max_user_storage_bytes : int = 1024 * 1024 * 1024
197+ """Per-user total storage quota across all conversations (sum of
198+ uploaded file sizes). 0 disables the quota."""
199+ upload_allowed_types : list [str ] = []
200+ """Allow-list of upload content types by short name (e.g.
201+ ``["xlsx", "csv", "pdf", "png"]``), validated by magic bytes. Empty
202+ = no content validation (any type accepted; dev default)."""
203+
85204
86205@lru_cache (maxsize = 1 )
87206def get_settings () -> Settings :
0 commit comments