codex-autorunner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1465 @@
1
+ import dataclasses
2
+ import ipaddress
3
+ import json
4
+ from os import PathLike
5
+ from pathlib import Path
6
+ from typing import IO, Any, Dict, List, Optional, Union, cast
7
+
8
+ import yaml
9
+
10
+ from ..housekeeping import HousekeepingConfig, parse_housekeeping_config
11
+
12
+ try:
13
+ from dotenv import load_dotenv
14
+ except ModuleNotFoundError: # pragma: no cover
15
+
16
+ def load_dotenv(
17
+ dotenv_path: Optional[Union[str, PathLike[str]]] = None,
18
+ stream: Optional[IO[str]] = None,
19
+ verbose: bool = False,
20
+ override: bool = False,
21
+ interpolate: bool = True,
22
+ encoding: Optional[str] = None,
23
+ ) -> bool:
24
+ return False
25
+
26
+
27
+ CONFIG_FILENAME = ".codex-autorunner/config.yml"
28
+ ROOT_CONFIG_FILENAME = "codex-autorunner.yml"
29
+ ROOT_OVERRIDE_FILENAME = "codex-autorunner.override.yml"
30
+ CONFIG_VERSION = 2
31
+ TWELVE_HOUR_SECONDS = 12 * 60 * 60
32
+
33
+ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
34
+ "version": CONFIG_VERSION,
35
+ "mode": "repo",
36
+ "docs": {
37
+ "todo": ".codex-autorunner/TODO.md",
38
+ "progress": ".codex-autorunner/PROGRESS.md",
39
+ "opinions": ".codex-autorunner/OPINIONS.md",
40
+ "spec": ".codex-autorunner/SPEC.md",
41
+ "summary": ".codex-autorunner/SUMMARY.md",
42
+ "snapshot": ".codex-autorunner/SNAPSHOT.md",
43
+ "snapshot_state": ".codex-autorunner/snapshot_state.json",
44
+ },
45
+ "codex": {
46
+ "binary": "codex",
47
+ "args": ["--yolo", "exec", "--sandbox", "danger-full-access"],
48
+ "terminal_args": ["--yolo"],
49
+ "model": None,
50
+ "reasoning": None,
51
+ # Optional model tiers for different Codex invocations.
52
+ # If codex.models.large is unset/null, callers should avoid passing --model
53
+ # so Codex uses the user's default/global profile model.
54
+ "models": {
55
+ "small": "gpt-5.1-codex-mini",
56
+ "large": None,
57
+ },
58
+ },
59
+ "prompt": {
60
+ "prev_run_max_chars": 6000,
61
+ "template": ".codex-autorunner/prompt.txt",
62
+ },
63
+ "runner": {
64
+ "sleep_seconds": 5,
65
+ "stop_after_runs": None,
66
+ "max_wallclock_seconds": None,
67
+ },
68
+ "git": {
69
+ "auto_commit": False,
70
+ "commit_message_template": "[codex] run #{run_id}",
71
+ },
72
+ "github": {
73
+ "enabled": True,
74
+ "pr_draft_default": True,
75
+ "sync_commit_mode": "auto", # none|auto|always
76
+ # Bounds the agentic sync step in GitHubService.sync_pr (seconds).
77
+ "sync_agent_timeout_seconds": 1800,
78
+ },
79
+ "server": {
80
+ "host": "127.0.0.1",
81
+ "port": 4173,
82
+ "base_path": "",
83
+ "access_log": False,
84
+ "auth_token_env": "",
85
+ "allowed_hosts": [],
86
+ "allowed_origins": [],
87
+ },
88
+ "notifications": {
89
+ "enabled": "auto",
90
+ "events": ["run_finished", "run_error", "tui_idle"],
91
+ "tui_idle_seconds": 60,
92
+ "discord": {
93
+ "webhook_url_env": "CAR_DISCORD_WEBHOOK_URL",
94
+ },
95
+ "telegram": {
96
+ "bot_token_env": "CAR_TELEGRAM_BOT_TOKEN",
97
+ "chat_id_env": "CAR_TELEGRAM_CHAT_ID",
98
+ },
99
+ },
100
+ "telegram_bot": {
101
+ "enabled": False,
102
+ "mode": "polling",
103
+ "bot_token_env": "CAR_TELEGRAM_BOT_TOKEN",
104
+ "chat_id_env": "CAR_TELEGRAM_CHAT_ID",
105
+ "parse_mode": "HTML",
106
+ "debug": {
107
+ "prefix_context": False,
108
+ },
109
+ "allowed_chat_ids": [],
110
+ "allowed_user_ids": [],
111
+ "require_topics": False,
112
+ "defaults": {
113
+ "approval_mode": "yolo",
114
+ "approval_policy": "on-request",
115
+ "sandbox_policy": "dangerFullAccess",
116
+ "yolo_approval_policy": "never",
117
+ "yolo_sandbox_policy": "dangerFullAccess",
118
+ },
119
+ "concurrency": {
120
+ "max_parallel_turns": 2,
121
+ "per_topic_queue": True,
122
+ },
123
+ "media": {
124
+ "enabled": True,
125
+ "images": True,
126
+ "voice": True,
127
+ "files": True,
128
+ "max_image_bytes": 10_000_000,
129
+ "max_voice_bytes": 10_000_000,
130
+ "max_file_bytes": 10_000_000,
131
+ "image_prompt": "Describe the image.",
132
+ },
133
+ "shell": {
134
+ "enabled": True,
135
+ "timeout_ms": 120000,
136
+ "max_output_chars": 3800,
137
+ },
138
+ "command_registration": {
139
+ "enabled": True,
140
+ "scopes": [
141
+ {"type": "default", "language_code": ""},
142
+ {"type": "all_group_chats", "language_code": ""},
143
+ ],
144
+ },
145
+ "state_file": ".codex-autorunner/telegram_state.json",
146
+ "app_server_command_env": "CAR_TELEGRAM_APP_SERVER_COMMAND",
147
+ "app_server_command": ["codex", "app-server"],
148
+ "app_server": {
149
+ "max_handles": 20,
150
+ "idle_ttl_seconds": 3600,
151
+ },
152
+ "polling": {
153
+ "timeout_seconds": 30,
154
+ "allowed_updates": ["message", "edited_message", "callback_query"],
155
+ },
156
+ },
157
+ "terminal": {
158
+ "idle_timeout_seconds": TWELVE_HOUR_SECONDS,
159
+ },
160
+ "voice": {
161
+ "enabled": True,
162
+ "provider": "openai_whisper",
163
+ "latency_mode": "balanced",
164
+ "chunk_ms": 600,
165
+ "sample_rate": 16_000,
166
+ "warn_on_remote_api": True,
167
+ "push_to_talk": {
168
+ "max_ms": 15_000,
169
+ "silence_auto_stop_ms": 1_200,
170
+ "min_hold_ms": 150,
171
+ },
172
+ "providers": {
173
+ "openai_whisper": {
174
+ "api_key_env": "OPENAI_API_KEY",
175
+ "model": "whisper-1",
176
+ "base_url": None,
177
+ "temperature": 0,
178
+ "language": None,
179
+ "redact_request": True,
180
+ }
181
+ },
182
+ },
183
+ "log": {
184
+ "path": ".codex-autorunner/codex-autorunner.log",
185
+ "max_bytes": 10_000_000,
186
+ "backup_count": 3,
187
+ },
188
+ "server_log": {
189
+ "path": ".codex-autorunner/codex-server.log",
190
+ "max_bytes": 10_000_000,
191
+ "backup_count": 3,
192
+ },
193
+ "static_assets": {
194
+ "cache_root": ".codex-autorunner/static-cache",
195
+ "max_cache_entries": 5,
196
+ "max_cache_age_days": 30,
197
+ },
198
+ "housekeeping": {
199
+ "enabled": True,
200
+ "interval_seconds": 3600,
201
+ "min_file_age_seconds": 600,
202
+ "dry_run": False,
203
+ "rules": [
204
+ {
205
+ "name": "run_logs",
206
+ "kind": "directory",
207
+ "path": ".codex-autorunner/runs",
208
+ "glob": "run-*.log",
209
+ "recursive": False,
210
+ "max_files": 200,
211
+ "max_total_bytes": 500_000_000,
212
+ "max_age_days": 30,
213
+ },
214
+ {
215
+ "name": "terminal_image_uploads",
216
+ "kind": "directory",
217
+ "path": ".codex-autorunner/uploads/terminal-images",
218
+ "glob": "*",
219
+ "recursive": False,
220
+ "max_files": 500,
221
+ "max_total_bytes": 200_000_000,
222
+ "max_age_days": 14,
223
+ },
224
+ {
225
+ "name": "telegram_images",
226
+ "kind": "directory",
227
+ "path": ".codex-autorunner/uploads/telegram-images",
228
+ "glob": "*",
229
+ "recursive": False,
230
+ "max_files": 500,
231
+ "max_total_bytes": 200_000_000,
232
+ "max_age_days": 14,
233
+ },
234
+ {
235
+ "name": "telegram_voice",
236
+ "kind": "directory",
237
+ "path": ".codex-autorunner/uploads/telegram-voice",
238
+ "glob": "*",
239
+ "recursive": False,
240
+ "max_files": 500,
241
+ "max_total_bytes": 500_000_000,
242
+ "max_age_days": 14,
243
+ },
244
+ {
245
+ "name": "telegram_files",
246
+ "kind": "directory",
247
+ "path": ".codex-autorunner/uploads/telegram-files",
248
+ "glob": "*",
249
+ "recursive": True,
250
+ "max_files": 500,
251
+ "max_total_bytes": 500_000_000,
252
+ "max_age_days": 14,
253
+ },
254
+ {
255
+ "name": "github_context",
256
+ "kind": "directory",
257
+ "path": ".codex-autorunner/github_context",
258
+ "glob": "*",
259
+ "recursive": False,
260
+ "max_files": 200,
261
+ "max_total_bytes": 100_000_000,
262
+ "max_age_days": 30,
263
+ },
264
+ ],
265
+ },
266
+ }
267
+
268
+ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
269
+ "version": CONFIG_VERSION,
270
+ "mode": "hub",
271
+ "terminal": {
272
+ "idle_timeout_seconds": TWELVE_HOUR_SECONDS,
273
+ },
274
+ "telegram_bot": {
275
+ "enabled": False,
276
+ "mode": "polling",
277
+ "bot_token_env": "CAR_TELEGRAM_BOT_TOKEN",
278
+ "chat_id_env": "CAR_TELEGRAM_CHAT_ID",
279
+ "parse_mode": "HTML",
280
+ "debug": {
281
+ "prefix_context": False,
282
+ },
283
+ "allowed_chat_ids": [],
284
+ "allowed_user_ids": [],
285
+ "require_topics": False,
286
+ "defaults": {
287
+ "approval_mode": "yolo",
288
+ "approval_policy": "on-request",
289
+ "sandbox_policy": "dangerFullAccess",
290
+ "yolo_approval_policy": "never",
291
+ "yolo_sandbox_policy": "dangerFullAccess",
292
+ },
293
+ "concurrency": {
294
+ "max_parallel_turns": 2,
295
+ "per_topic_queue": True,
296
+ },
297
+ "media": {
298
+ "enabled": True,
299
+ "images": True,
300
+ "voice": True,
301
+ "files": True,
302
+ "max_image_bytes": 10_000_000,
303
+ "max_voice_bytes": 10_000_000,
304
+ "max_file_bytes": 10_000_000,
305
+ "image_prompt": "Describe the image.",
306
+ },
307
+ "shell": {
308
+ "enabled": False,
309
+ "timeout_ms": 120000,
310
+ "max_output_chars": 3800,
311
+ },
312
+ "command_registration": {
313
+ "enabled": True,
314
+ "scopes": [
315
+ {"type": "default", "language_code": ""},
316
+ {"type": "all_group_chats", "language_code": ""},
317
+ ],
318
+ },
319
+ "state_file": ".codex-autorunner/telegram_state.json",
320
+ "app_server_command_env": "CAR_TELEGRAM_APP_SERVER_COMMAND",
321
+ "app_server_command": ["codex", "app-server"],
322
+ "app_server": {
323
+ "max_handles": 20,
324
+ "idle_ttl_seconds": 3600,
325
+ },
326
+ "polling": {
327
+ "timeout_seconds": 30,
328
+ "allowed_updates": ["message", "edited_message", "callback_query"],
329
+ },
330
+ },
331
+ "hub": {
332
+ "repos_root": ".",
333
+ # Hub-managed git worktrees live here (depth=1 scan). Each worktree is treated as a repo.
334
+ "worktrees_root": "worktrees",
335
+ "manifest": ".codex-autorunner/manifest.yml",
336
+ "discover_depth": 1,
337
+ "auto_init_missing": True,
338
+ # Where to pull system updates from (defaults to main upstream)
339
+ "update_repo_url": "https://github.com/Git-on-my-level/codex-autorunner.git",
340
+ "update_repo_ref": "main",
341
+ "log": {
342
+ "path": ".codex-autorunner/codex-autorunner-hub.log",
343
+ "max_bytes": 10_000_000,
344
+ "backup_count": 3,
345
+ },
346
+ },
347
+ "server": {
348
+ "host": "127.0.0.1",
349
+ "port": 4173,
350
+ "base_path": "",
351
+ "access_log": False,
352
+ "auth_token_env": "",
353
+ "allowed_hosts": [],
354
+ "allowed_origins": [],
355
+ },
356
+ # Hub already has hub.log, but we still support an explicit server_log for consistency.
357
+ "server_log": None,
358
+ "static_assets": {
359
+ "cache_root": ".codex-autorunner/static-cache",
360
+ "max_cache_entries": 5,
361
+ "max_cache_age_days": 30,
362
+ },
363
+ "housekeeping": {
364
+ "enabled": True,
365
+ "interval_seconds": 3600,
366
+ "min_file_age_seconds": 600,
367
+ "dry_run": False,
368
+ "rules": [
369
+ {
370
+ "name": "run_logs",
371
+ "kind": "directory",
372
+ "path": ".codex-autorunner/runs",
373
+ "glob": "run-*.log",
374
+ "recursive": False,
375
+ "max_files": 200,
376
+ "max_total_bytes": 500_000_000,
377
+ "max_age_days": 30,
378
+ },
379
+ {
380
+ "name": "terminal_image_uploads",
381
+ "kind": "directory",
382
+ "path": ".codex-autorunner/uploads/terminal-images",
383
+ "glob": "*",
384
+ "recursive": False,
385
+ "max_files": 500,
386
+ "max_total_bytes": 200_000_000,
387
+ "max_age_days": 14,
388
+ },
389
+ {
390
+ "name": "telegram_images",
391
+ "kind": "directory",
392
+ "path": ".codex-autorunner/uploads/telegram-images",
393
+ "glob": "*",
394
+ "recursive": False,
395
+ "max_files": 500,
396
+ "max_total_bytes": 200_000_000,
397
+ "max_age_days": 14,
398
+ },
399
+ {
400
+ "name": "telegram_voice",
401
+ "kind": "directory",
402
+ "path": ".codex-autorunner/uploads/telegram-voice",
403
+ "glob": "*",
404
+ "recursive": False,
405
+ "max_files": 500,
406
+ "max_total_bytes": 500_000_000,
407
+ "max_age_days": 14,
408
+ },
409
+ {
410
+ "name": "telegram_files",
411
+ "kind": "directory",
412
+ "path": ".codex-autorunner/uploads/telegram-files",
413
+ "glob": "*",
414
+ "recursive": True,
415
+ "max_files": 500,
416
+ "max_total_bytes": 500_000_000,
417
+ "max_age_days": 14,
418
+ },
419
+ {
420
+ "name": "github_context",
421
+ "kind": "directory",
422
+ "path": ".codex-autorunner/github_context",
423
+ "glob": "*",
424
+ "recursive": False,
425
+ "max_files": 200,
426
+ "max_total_bytes": 100_000_000,
427
+ "max_age_days": 30,
428
+ },
429
+ {
430
+ "name": "update_cache",
431
+ "kind": "directory",
432
+ "path": "~/.codex-autorunner/update_cache",
433
+ "glob": "*",
434
+ "recursive": True,
435
+ "max_files": 2000,
436
+ "max_total_bytes": 1_000_000_000,
437
+ "max_age_days": 30,
438
+ },
439
+ {
440
+ "name": "update_log",
441
+ "kind": "file",
442
+ "path": "~/.codex-autorunner/update-standalone.log",
443
+ "max_bytes": 5_000_000,
444
+ },
445
+ ],
446
+ },
447
+ }
448
+
449
+ # Backwards-compatible alias for repo defaults
450
+ DEFAULT_CONFIG = DEFAULT_REPO_CONFIG
451
+
452
+
453
+ class ConfigError(Exception):
454
+ """Raised when configuration is invalid."""
455
+
456
+
457
+ @dataclasses.dataclass
458
+ class LogConfig:
459
+ path: Path
460
+ max_bytes: int
461
+ backup_count: int
462
+
463
+
464
+ @dataclasses.dataclass
465
+ class StaticAssetsConfig:
466
+ cache_root: Path
467
+ max_cache_entries: int
468
+ max_cache_age_days: Optional[int]
469
+
470
+
471
+ @dataclasses.dataclass
472
+ class RepoConfig:
473
+ raw: Dict[str, Any]
474
+ root: Path
475
+ version: int
476
+ mode: str
477
+ docs: Dict[str, Path]
478
+ codex_binary: str
479
+ codex_args: List[str]
480
+ codex_terminal_args: List[str]
481
+ codex_model: Optional[str]
482
+ codex_reasoning: Optional[str]
483
+ prompt_prev_run_max_chars: int
484
+ prompt_template: Optional[Path]
485
+ runner_sleep_seconds: int
486
+ runner_stop_after_runs: Optional[int]
487
+ runner_max_wallclock_seconds: Optional[int]
488
+ git_auto_commit: bool
489
+ git_commit_message_template: str
490
+ server_host: str
491
+ server_port: int
492
+ server_base_path: str
493
+ server_access_log: bool
494
+ server_auth_token_env: str
495
+ server_allowed_hosts: List[str]
496
+ server_allowed_origins: List[str]
497
+ notifications: Dict[str, Any]
498
+ terminal_idle_timeout_seconds: Optional[int]
499
+ log: LogConfig
500
+ server_log: LogConfig
501
+ voice: Dict[str, Any]
502
+ static_assets: StaticAssetsConfig
503
+ housekeeping: HousekeepingConfig
504
+
505
+ def doc_path(self, key: str) -> Path:
506
+ return self.root / self.docs[key]
507
+
508
+
509
+ @dataclasses.dataclass
510
+ class HubConfig:
511
+ raw: Dict[str, Any]
512
+ root: Path
513
+ version: int
514
+ mode: str
515
+ repos_root: Path
516
+ worktrees_root: Path
517
+ manifest_path: Path
518
+ discover_depth: int
519
+ auto_init_missing: bool
520
+ update_repo_url: str
521
+ update_repo_ref: str
522
+ server_host: str
523
+ server_port: int
524
+ server_base_path: str
525
+ server_access_log: bool
526
+ server_auth_token_env: str
527
+ server_allowed_hosts: List[str]
528
+ server_allowed_origins: List[str]
529
+ log: LogConfig
530
+ server_log: LogConfig
531
+ static_assets: StaticAssetsConfig
532
+ housekeeping: HousekeepingConfig
533
+
534
+
535
+ # Alias used by existing code paths that only support repo mode
536
+ Config = RepoConfig
537
+
538
+
539
+ def _merge_defaults(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
540
+ merged = cast(Dict[str, Any], json.loads(json.dumps(base)))
541
+ for key, value in overrides.items():
542
+ if isinstance(value, dict) and key in merged and isinstance(merged[key], dict):
543
+ merged[key] = _merge_defaults(merged[key], value)
544
+ else:
545
+ merged[key] = value
546
+ return merged
547
+
548
+
549
+ def _load_yaml_dict(path: Path) -> Dict[str, Any]:
550
+ if not path.exists():
551
+ return {}
552
+ try:
553
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
554
+ except yaml.YAMLError as exc:
555
+ raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
556
+ except Exception as exc:
557
+ raise ConfigError(f"Failed to read config file {path}: {exc}") from exc
558
+ if not isinstance(data, dict):
559
+ raise ConfigError(f"Config file must be a mapping: {path}")
560
+ return data
561
+
562
+
563
+ def _load_root_config(root: Path) -> Dict[str, Any]:
564
+ merged: Dict[str, Any] = {}
565
+ base_path = root / ROOT_CONFIG_FILENAME
566
+ base = _load_yaml_dict(base_path)
567
+ if base:
568
+ merged = _merge_defaults(merged, base)
569
+ override_path = root / ROOT_OVERRIDE_FILENAME
570
+ try:
571
+ override = _load_yaml_dict(override_path)
572
+ except ConfigError as exc:
573
+ raise ConfigError(
574
+ f"Invalid override config {override_path}; fix or delete it: {exc}"
575
+ ) from exc
576
+ if override:
577
+ merged = _merge_defaults(merged, override)
578
+ return merged
579
+
580
+
581
+ def load_root_defaults(root: Path, mode: str) -> Dict[str, Any]:
582
+ """Load repo/hub defaults from the root config + override file."""
583
+ raw = _load_root_config(root)
584
+ if not raw:
585
+ return {}
586
+ if "repo" in raw or "hub" in raw:
587
+ if mode == "hub":
588
+ return raw.get("hub", {}) if isinstance(raw.get("hub"), dict) else {}
589
+ return raw.get("repo", {}) if isinstance(raw.get("repo"), dict) else {}
590
+ return raw
591
+
592
+
593
+ def resolve_config_data(
594
+ root: Path, mode: str, overrides: Optional[Dict[str, Any]] = None
595
+ ) -> Dict[str, Any]:
596
+ if mode not in ("repo", "hub"):
597
+ raise ConfigError(f"Invalid mode '{mode}'; expected 'hub' or 'repo'")
598
+ base = DEFAULT_HUB_CONFIG if mode == "hub" else DEFAULT_REPO_CONFIG
599
+ merged = _merge_defaults(base, load_root_defaults(root, mode))
600
+ if overrides:
601
+ merged = _merge_defaults(merged, overrides)
602
+ return merged
603
+
604
+
605
+ def _normalize_base_path(path: Optional[str]) -> str:
606
+ """Normalize base path to either '' or a single-leading-slash path without trailing slash."""
607
+ if not path:
608
+ return ""
609
+ normalized = str(path).strip()
610
+ if not normalized.startswith("/"):
611
+ normalized = "/" + normalized
612
+ normalized = normalized.rstrip("/")
613
+ return normalized or ""
614
+
615
+
616
+ def _parse_static_assets_config(
617
+ cfg: Optional[Dict[str, Any]],
618
+ root: Path,
619
+ defaults: Dict[str, Any],
620
+ ) -> StaticAssetsConfig:
621
+ if not isinstance(cfg, dict):
622
+ cfg = defaults
623
+ cache_root_raw = cfg.get("cache_root", defaults.get("cache_root"))
624
+ cache_root = Path(str(cache_root_raw))
625
+ if not cache_root.is_absolute():
626
+ cache_root = root / cache_root
627
+ max_cache_entries = int(
628
+ cfg.get("max_cache_entries", defaults.get("max_cache_entries", 0))
629
+ )
630
+ max_cache_age_days_raw = cfg.get(
631
+ "max_cache_age_days", defaults.get("max_cache_age_days")
632
+ )
633
+ max_cache_age_days = (
634
+ int(max_cache_age_days_raw) if max_cache_age_days_raw is not None else None
635
+ )
636
+ return StaticAssetsConfig(
637
+ cache_root=cache_root,
638
+ max_cache_entries=max_cache_entries,
639
+ max_cache_age_days=max_cache_age_days,
640
+ )
641
+
642
+
643
+ def find_nearest_config_path(start: Path) -> Optional[Path]:
644
+ """Return the closest .codex-autorunner/config.yml walking upward from start."""
645
+ start = start.resolve()
646
+ search_dir = start if start.is_dir() else start.parent
647
+ for current in [search_dir] + list(search_dir.parents):
648
+ candidate = current / CONFIG_FILENAME
649
+ if candidate.exists():
650
+ return candidate
651
+ return None
652
+
653
+
654
+ def load_dotenv_for_root(root: Path) -> None:
655
+ """
656
+ Best-effort load of environment variables for the provided repo root.
657
+
658
+ We intentionally load from deterministic locations rather than relying on
659
+ process CWD (which differs for installed entrypoints, launchd, etc.).
660
+ """
661
+ try:
662
+ root = root.resolve()
663
+ candidates = [
664
+ root / ".env",
665
+ root / ".codex-autorunner" / ".env",
666
+ ]
667
+
668
+ for candidate in candidates:
669
+ if candidate.exists():
670
+ # Prefer repo-local .env over inherited process env to avoid stale keys
671
+ # (common when running via launchd/daemon or with a global shell export).
672
+ load_dotenv(dotenv_path=candidate, override=True)
673
+ except Exception:
674
+ # Never fail config loading due to dotenv issues.
675
+ pass
676
+
677
+
678
+ def load_config_data(config_path: Path) -> Dict[str, Any]:
679
+ """Load, merge, and return a raw config dict for the given config path."""
680
+ load_dotenv_for_root(config_path.parent.parent.resolve())
681
+ try:
682
+ with config_path.open("r", encoding="utf-8") as f:
683
+ data = yaml.safe_load(f) or {}
684
+ except yaml.YAMLError as exc:
685
+ raise ConfigError(f"Invalid YAML in {config_path}: {exc}") from exc
686
+ except Exception as exc:
687
+ raise ConfigError(f"Failed to read config file {config_path}: {exc}") from exc
688
+ if not isinstance(data, dict):
689
+ raise ConfigError(f"Config file must be a mapping: {config_path}")
690
+ mode = data.get("mode", "repo")
691
+ root = config_path.parent.parent.resolve()
692
+ return resolve_config_data(root, mode, data)
693
+
694
+
695
+ def load_config(start: Path) -> Union[RepoConfig, HubConfig]:
696
+ """
697
+ Load the nearest config walking upward from the provided path.
698
+ Returns a RepoConfig or HubConfig depending on the mode.
699
+ """
700
+ config_path = find_nearest_config_path(start)
701
+ if not config_path:
702
+ raise ConfigError(
703
+ f"Missing config file; expected to find {CONFIG_FILENAME} in {start} or parents"
704
+ )
705
+ merged = load_config_data(config_path)
706
+ mode = merged.get("mode", "repo")
707
+ if mode == "hub":
708
+ _validate_hub_config(merged)
709
+ return _build_hub_config(config_path, merged)
710
+ if mode == "repo":
711
+ _validate_repo_config(merged)
712
+ return _build_repo_config(config_path, merged)
713
+ raise ConfigError(f"Invalid mode '{mode}'; expected 'hub' or 'repo'")
714
+
715
+
716
+ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
717
+ root = config_path.parent.parent.resolve()
718
+ docs = {
719
+ "todo": Path(cfg["docs"]["todo"]),
720
+ "progress": Path(cfg["docs"]["progress"]),
721
+ "opinions": Path(cfg["docs"]["opinions"]),
722
+ "spec": Path(cfg["docs"]["spec"]),
723
+ "summary": Path(cfg["docs"]["summary"]),
724
+ "snapshot": Path(cfg["docs"].get("snapshot", ".codex-autorunner/SNAPSHOT.md")),
725
+ "snapshot_state": Path(
726
+ cfg["docs"].get("snapshot_state", ".codex-autorunner/snapshot_state.json")
727
+ ),
728
+ }
729
+ voice_cfg = cfg.get("voice") if isinstance(cfg.get("voice"), dict) else {}
730
+ voice_cfg = cast(Dict[str, Any], voice_cfg)
731
+ template_val = cfg["prompt"].get("template")
732
+ template = root / template_val if template_val else None
733
+ term_args = cfg["codex"].get("terminal_args") or []
734
+ terminal_cfg = cfg.get("terminal") if isinstance(cfg.get("terminal"), dict) else {}
735
+ terminal_cfg = cast(Dict[str, Any], terminal_cfg)
736
+ idle_timeout_value = terminal_cfg.get("idle_timeout_seconds")
737
+ idle_timeout_seconds: Optional[int]
738
+ if idle_timeout_value is None:
739
+ idle_timeout_seconds = None
740
+ else:
741
+ idle_timeout_seconds = int(idle_timeout_value)
742
+ if idle_timeout_seconds <= 0:
743
+ idle_timeout_seconds = None
744
+ notifications_cfg = (
745
+ cfg.get("notifications") if isinstance(cfg.get("notifications"), dict) else {}
746
+ )
747
+ notifications_cfg = cast(Dict[str, Any], notifications_cfg)
748
+ log_cfg = cfg.get("log", {})
749
+ log_cfg = cast(Dict[str, Any], log_cfg if isinstance(log_cfg, dict) else {})
750
+ server_log_cfg = cfg.get("server_log", {}) or {}
751
+ server_log_cfg = cast(
752
+ Dict[str, Any], server_log_cfg if isinstance(server_log_cfg, dict) else {}
753
+ )
754
+ return RepoConfig(
755
+ raw=cfg,
756
+ root=root,
757
+ version=int(cfg["version"]),
758
+ mode="repo",
759
+ docs=docs,
760
+ codex_binary=cfg["codex"]["binary"],
761
+ codex_args=list(cfg["codex"].get("args", [])),
762
+ codex_terminal_args=list(term_args) if isinstance(term_args, list) else [],
763
+ codex_model=cfg["codex"].get("model"),
764
+ codex_reasoning=cfg["codex"].get("reasoning"),
765
+ prompt_prev_run_max_chars=int(cfg["prompt"]["prev_run_max_chars"]),
766
+ prompt_template=template,
767
+ runner_sleep_seconds=int(cfg["runner"]["sleep_seconds"]),
768
+ runner_stop_after_runs=cfg["runner"].get("stop_after_runs"),
769
+ runner_max_wallclock_seconds=cfg["runner"].get("max_wallclock_seconds"),
770
+ git_auto_commit=bool(cfg["git"].get("auto_commit", False)),
771
+ git_commit_message_template=str(cfg["git"].get("commit_message_template")),
772
+ server_host=str(cfg["server"].get("host")),
773
+ server_port=int(cfg["server"].get("port")),
774
+ server_base_path=_normalize_base_path(cfg["server"].get("base_path", "")),
775
+ server_access_log=bool(cfg["server"].get("access_log", False)),
776
+ server_auth_token_env=str(cfg["server"].get("auth_token_env", "")),
777
+ server_allowed_hosts=list(cfg["server"].get("allowed_hosts") or []),
778
+ server_allowed_origins=list(cfg["server"].get("allowed_origins") or []),
779
+ notifications=notifications_cfg,
780
+ terminal_idle_timeout_seconds=idle_timeout_seconds,
781
+ log=LogConfig(
782
+ path=root / log_cfg.get("path", DEFAULT_REPO_CONFIG["log"]["path"]),
783
+ max_bytes=int(
784
+ log_cfg.get("max_bytes", DEFAULT_REPO_CONFIG["log"]["max_bytes"])
785
+ ),
786
+ backup_count=int(
787
+ log_cfg.get("backup_count", DEFAULT_REPO_CONFIG["log"]["backup_count"])
788
+ ),
789
+ ),
790
+ server_log=LogConfig(
791
+ path=root
792
+ / server_log_cfg.get("path", DEFAULT_REPO_CONFIG["server_log"]["path"]),
793
+ max_bytes=int(
794
+ server_log_cfg.get(
795
+ "max_bytes", DEFAULT_REPO_CONFIG["server_log"]["max_bytes"]
796
+ )
797
+ ),
798
+ backup_count=int(
799
+ server_log_cfg.get(
800
+ "backup_count",
801
+ DEFAULT_REPO_CONFIG["server_log"]["backup_count"],
802
+ )
803
+ ),
804
+ ),
805
+ voice=voice_cfg,
806
+ static_assets=_parse_static_assets_config(
807
+ cfg.get("static_assets"), root, DEFAULT_REPO_CONFIG["static_assets"]
808
+ ),
809
+ housekeeping=parse_housekeeping_config(cfg.get("housekeeping")),
810
+ )
811
+
812
+
813
+ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
814
+ root = config_path.parent.parent.resolve()
815
+ hub_cfg = cfg["hub"]
816
+ log_cfg = hub_cfg["log"]
817
+ server_log_cfg = cfg.get("server_log")
818
+ # Default to hub log if server_log is not configured.
819
+ if not isinstance(server_log_cfg, dict):
820
+ server_log_cfg = {
821
+ "path": log_cfg["path"],
822
+ "max_bytes": log_cfg["max_bytes"],
823
+ "backup_count": log_cfg["backup_count"],
824
+ }
825
+ return HubConfig(
826
+ raw=cfg,
827
+ root=root,
828
+ version=int(cfg["version"]),
829
+ mode="hub",
830
+ repos_root=(root / hub_cfg["repos_root"]).resolve(),
831
+ worktrees_root=(root / hub_cfg["worktrees_root"]).resolve(),
832
+ manifest_path=root / hub_cfg["manifest"],
833
+ discover_depth=int(hub_cfg["discover_depth"]),
834
+ auto_init_missing=bool(hub_cfg["auto_init_missing"]),
835
+ update_repo_url=str(hub_cfg.get("update_repo_url", "")),
836
+ update_repo_ref=str(hub_cfg.get("update_repo_ref", "main")),
837
+ server_host=str(cfg["server"]["host"]),
838
+ server_port=int(cfg["server"]["port"]),
839
+ server_base_path=_normalize_base_path(cfg["server"].get("base_path", "")),
840
+ server_access_log=bool(cfg["server"].get("access_log", False)),
841
+ server_auth_token_env=str(cfg["server"].get("auth_token_env", "")),
842
+ server_allowed_hosts=list(cfg["server"].get("allowed_hosts") or []),
843
+ server_allowed_origins=list(cfg["server"].get("allowed_origins") or []),
844
+ log=LogConfig(
845
+ path=root / log_cfg["path"],
846
+ max_bytes=int(log_cfg["max_bytes"]),
847
+ backup_count=int(log_cfg["backup_count"]),
848
+ ),
849
+ server_log=LogConfig(
850
+ path=root / str(server_log_cfg.get("path", log_cfg["path"])),
851
+ max_bytes=int(server_log_cfg.get("max_bytes", log_cfg["max_bytes"])),
852
+ backup_count=int(
853
+ server_log_cfg.get("backup_count", log_cfg["backup_count"])
854
+ ),
855
+ ),
856
+ static_assets=_parse_static_assets_config(
857
+ cfg.get("static_assets"), root, DEFAULT_HUB_CONFIG["static_assets"]
858
+ ),
859
+ housekeeping=parse_housekeeping_config(cfg.get("housekeeping")),
860
+ )
861
+
862
+
863
+ def _validate_version(cfg: Dict[str, Any]) -> None:
864
+ if cfg.get("version") != CONFIG_VERSION:
865
+ raise ConfigError(f"Unsupported config version; expected {CONFIG_VERSION}")
866
+
867
+
868
+ def _is_loopback_host(host: str) -> bool:
869
+ if host == "localhost":
870
+ return True
871
+ try:
872
+ return ipaddress.ip_address(host).is_loopback
873
+ except ValueError:
874
+ return False
875
+
876
+
877
+ def _validate_server_security(server: Dict[str, Any]) -> None:
878
+ allowed_hosts = server.get("allowed_hosts")
879
+ if allowed_hosts is not None and not isinstance(allowed_hosts, list):
880
+ raise ConfigError("server.allowed_hosts must be a list of strings if provided")
881
+ if isinstance(allowed_hosts, list):
882
+ for entry in allowed_hosts:
883
+ if not isinstance(entry, str):
884
+ raise ConfigError("server.allowed_hosts must be a list of strings")
885
+
886
+ allowed_origins = server.get("allowed_origins")
887
+ if allowed_origins is not None and not isinstance(allowed_origins, list):
888
+ raise ConfigError(
889
+ "server.allowed_origins must be a list of strings if provided"
890
+ )
891
+ if isinstance(allowed_origins, list):
892
+ for entry in allowed_origins:
893
+ if not isinstance(entry, str):
894
+ raise ConfigError("server.allowed_origins must be a list of strings")
895
+
896
+ host = str(server.get("host", ""))
897
+ if not _is_loopback_host(host) and not allowed_hosts:
898
+ raise ConfigError(
899
+ "server.allowed_hosts must be set when binding to a non-loopback host"
900
+ )
901
+
902
+
903
+ def _validate_repo_config(cfg: Dict[str, Any]) -> None:
904
+ _validate_version(cfg)
905
+ if cfg.get("mode") != "repo":
906
+ raise ConfigError("Repo config must set mode: repo")
907
+ docs = cfg.get("docs")
908
+ if not isinstance(docs, dict):
909
+ raise ConfigError("docs must be a mapping")
910
+ for key in ("todo", "progress", "opinions", "spec", "summary"):
911
+ if not isinstance(docs.get(key), str) or not docs[key]:
912
+ raise ConfigError(f"docs.{key} must be a non-empty string path")
913
+ codex = cfg.get("codex")
914
+ if not isinstance(codex, dict):
915
+ raise ConfigError("codex section must be a mapping")
916
+ if not codex.get("binary"):
917
+ raise ConfigError("codex.binary is required")
918
+ if not isinstance(codex.get("args", []), list):
919
+ raise ConfigError("codex.args must be a list")
920
+ if "terminal_args" in codex and not isinstance(
921
+ codex.get("terminal_args", []), list
922
+ ):
923
+ raise ConfigError("codex.terminal_args must be a list if provided")
924
+ if (
925
+ "model" in codex
926
+ and codex.get("model") is not None
927
+ and not isinstance(codex.get("model"), str)
928
+ ):
929
+ raise ConfigError("codex.model must be a string or null if provided")
930
+ if (
931
+ "reasoning" in codex
932
+ and codex.get("reasoning") is not None
933
+ and not isinstance(codex.get("reasoning"), str)
934
+ ):
935
+ raise ConfigError("codex.reasoning must be a string or null if provided")
936
+ if "models" in codex:
937
+ models = codex.get("models")
938
+ if models is not None and not isinstance(models, dict):
939
+ raise ConfigError("codex.models must be a mapping or null if provided")
940
+ if isinstance(models, dict):
941
+ for key in ("small", "large"):
942
+ if (
943
+ key in models
944
+ and models.get(key) is not None
945
+ and not isinstance(models.get(key), str)
946
+ ):
947
+ raise ConfigError(f"codex.models.{key} must be a string or null")
948
+ prompt = cfg.get("prompt")
949
+ if not isinstance(prompt, dict):
950
+ raise ConfigError("prompt section must be a mapping")
951
+ if not isinstance(prompt.get("prev_run_max_chars", 0), int):
952
+ raise ConfigError("prompt.prev_run_max_chars must be an integer")
953
+ runner = cfg.get("runner")
954
+ if not isinstance(runner, dict):
955
+ raise ConfigError("runner section must be a mapping")
956
+ if not isinstance(runner.get("sleep_seconds", 0), int):
957
+ raise ConfigError("runner.sleep_seconds must be an integer")
958
+ for k in ("stop_after_runs", "max_wallclock_seconds"):
959
+ val = runner.get(k)
960
+ if val is not None and not isinstance(val, int):
961
+ raise ConfigError(f"runner.{k} must be an integer or null")
962
+ git = cfg.get("git")
963
+ if not isinstance(git, dict):
964
+ raise ConfigError("git section must be a mapping")
965
+ if not isinstance(git.get("auto_commit", False), bool):
966
+ raise ConfigError("git.auto_commit must be boolean")
967
+ github = cfg.get("github", {})
968
+ if github is not None and not isinstance(github, dict):
969
+ raise ConfigError("github section must be a mapping if provided")
970
+ if isinstance(github, dict):
971
+ if "enabled" in github and not isinstance(github.get("enabled"), bool):
972
+ raise ConfigError("github.enabled must be boolean")
973
+ if "pr_draft_default" in github and not isinstance(
974
+ github.get("pr_draft_default"), bool
975
+ ):
976
+ raise ConfigError("github.pr_draft_default must be boolean")
977
+ if "sync_commit_mode" in github and not isinstance(
978
+ github.get("sync_commit_mode"), str
979
+ ):
980
+ raise ConfigError("github.sync_commit_mode must be a string")
981
+ if "sync_agent_timeout_seconds" in github and not isinstance(
982
+ github.get("sync_agent_timeout_seconds"), int
983
+ ):
984
+ raise ConfigError("github.sync_agent_timeout_seconds must be an integer")
985
+ server = cfg.get("server")
986
+ if not isinstance(server, dict):
987
+ raise ConfigError("server section must be a mapping")
988
+ if not isinstance(server.get("host", ""), str):
989
+ raise ConfigError("server.host must be a string")
990
+ if not isinstance(server.get("port", 0), int):
991
+ raise ConfigError("server.port must be an integer")
992
+ if "base_path" in server and not isinstance(server.get("base_path", ""), str):
993
+ raise ConfigError("server.base_path must be a string if provided")
994
+ if "access_log" in server and not isinstance(server.get("access_log", False), bool):
995
+ raise ConfigError("server.access_log must be boolean if provided")
996
+ if "auth_token_env" in server and not isinstance(
997
+ server.get("auth_token_env", ""), str
998
+ ):
999
+ raise ConfigError("server.auth_token_env must be a string if provided")
1000
+ _validate_server_security(server)
1001
+ notifications_cfg = cfg.get("notifications")
1002
+ if notifications_cfg is not None:
1003
+ if not isinstance(notifications_cfg, dict):
1004
+ raise ConfigError("notifications section must be a mapping if provided")
1005
+ if "enabled" in notifications_cfg:
1006
+ enabled_val = notifications_cfg.get("enabled")
1007
+ if not (
1008
+ isinstance(enabled_val, bool)
1009
+ or enabled_val is None
1010
+ or (isinstance(enabled_val, str) and enabled_val.lower() == "auto")
1011
+ ):
1012
+ raise ConfigError(
1013
+ "notifications.enabled must be boolean, null, or 'auto'"
1014
+ )
1015
+ events = notifications_cfg.get("events")
1016
+ if events is not None and not isinstance(events, list):
1017
+ raise ConfigError("notifications.events must be a list if provided")
1018
+ if isinstance(events, list):
1019
+ for entry in events:
1020
+ if not isinstance(entry, str):
1021
+ raise ConfigError("notifications.events must be a list of strings")
1022
+ tui_idle_seconds = notifications_cfg.get("tui_idle_seconds")
1023
+ if tui_idle_seconds is not None:
1024
+ if not isinstance(tui_idle_seconds, (int, float)):
1025
+ raise ConfigError(
1026
+ "notifications.tui_idle_seconds must be a number if provided"
1027
+ )
1028
+ if tui_idle_seconds < 0:
1029
+ raise ConfigError(
1030
+ "notifications.tui_idle_seconds must be >= 0 if provided"
1031
+ )
1032
+ discord_cfg = notifications_cfg.get("discord")
1033
+ if discord_cfg is not None and not isinstance(discord_cfg, dict):
1034
+ raise ConfigError("notifications.discord must be a mapping if provided")
1035
+ if isinstance(discord_cfg, dict):
1036
+ if "enabled" in discord_cfg and not isinstance(
1037
+ discord_cfg.get("enabled"), bool
1038
+ ):
1039
+ raise ConfigError("notifications.discord.enabled must be boolean")
1040
+ if "webhook_url_env" in discord_cfg and not isinstance(
1041
+ discord_cfg.get("webhook_url_env"), str
1042
+ ):
1043
+ raise ConfigError(
1044
+ "notifications.discord.webhook_url_env must be a string"
1045
+ )
1046
+ telegram_cfg = notifications_cfg.get("telegram")
1047
+ if telegram_cfg is not None and not isinstance(telegram_cfg, dict):
1048
+ raise ConfigError("notifications.telegram must be a mapping if provided")
1049
+ if isinstance(telegram_cfg, dict):
1050
+ if "enabled" in telegram_cfg and not isinstance(
1051
+ telegram_cfg.get("enabled"), bool
1052
+ ):
1053
+ raise ConfigError("notifications.telegram.enabled must be boolean")
1054
+ if "bot_token_env" in telegram_cfg and not isinstance(
1055
+ telegram_cfg.get("bot_token_env"), str
1056
+ ):
1057
+ raise ConfigError(
1058
+ "notifications.telegram.bot_token_env must be a string"
1059
+ )
1060
+ if "chat_id_env" in telegram_cfg and not isinstance(
1061
+ telegram_cfg.get("chat_id_env"), str
1062
+ ):
1063
+ raise ConfigError("notifications.telegram.chat_id_env must be a string")
1064
+ if "thread_id_env" in telegram_cfg and not isinstance(
1065
+ telegram_cfg.get("thread_id_env"), str
1066
+ ):
1067
+ raise ConfigError(
1068
+ "notifications.telegram.thread_id_env must be a string"
1069
+ )
1070
+ if "thread_id" in telegram_cfg:
1071
+ thread_id = telegram_cfg.get("thread_id")
1072
+ if thread_id is not None and not isinstance(thread_id, int):
1073
+ raise ConfigError(
1074
+ "notifications.telegram.thread_id must be an integer or null"
1075
+ )
1076
+ if "thread_id_map" in telegram_cfg:
1077
+ thread_id_map = telegram_cfg.get("thread_id_map")
1078
+ if not isinstance(thread_id_map, dict):
1079
+ raise ConfigError(
1080
+ "notifications.telegram.thread_id_map must be a mapping"
1081
+ )
1082
+ for key, value in thread_id_map.items():
1083
+ if not isinstance(key, str) or not isinstance(value, int):
1084
+ raise ConfigError(
1085
+ "notifications.telegram.thread_id_map must map strings to integers"
1086
+ )
1087
+ terminal_cfg = cfg.get("terminal")
1088
+ if terminal_cfg is not None:
1089
+ if not isinstance(terminal_cfg, dict):
1090
+ raise ConfigError("terminal section must be a mapping if provided")
1091
+ idle_timeout_seconds = terminal_cfg.get("idle_timeout_seconds")
1092
+ if idle_timeout_seconds is not None and not isinstance(
1093
+ idle_timeout_seconds, int
1094
+ ):
1095
+ raise ConfigError(
1096
+ "terminal.idle_timeout_seconds must be an integer or null"
1097
+ )
1098
+ if isinstance(idle_timeout_seconds, int) and idle_timeout_seconds < 0:
1099
+ raise ConfigError("terminal.idle_timeout_seconds must be >= 0")
1100
+ log_cfg = cfg.get("log")
1101
+ if not isinstance(log_cfg, dict):
1102
+ raise ConfigError("log section must be a mapping")
1103
+ for key in ("path",):
1104
+ if not isinstance(log_cfg.get(key, ""), str):
1105
+ raise ConfigError(f"log.{key} must be a string path")
1106
+ for key in ("max_bytes", "backup_count"):
1107
+ if not isinstance(log_cfg.get(key, 0), int):
1108
+ raise ConfigError(f"log.{key} must be an integer")
1109
+ server_log_cfg = cfg.get("server_log", {})
1110
+ if server_log_cfg is not None and not isinstance(server_log_cfg, dict):
1111
+ raise ConfigError("server_log section must be a mapping or null")
1112
+ if isinstance(server_log_cfg, dict):
1113
+ if "path" in server_log_cfg and not isinstance(
1114
+ server_log_cfg.get("path", ""), str
1115
+ ):
1116
+ raise ConfigError("server_log.path must be a string path")
1117
+ for key in ("max_bytes", "backup_count"):
1118
+ if key in server_log_cfg and not isinstance(server_log_cfg.get(key), int):
1119
+ raise ConfigError(f"server_log.{key} must be an integer")
1120
+ voice_cfg = cfg.get("voice", {})
1121
+ if voice_cfg is not None and not isinstance(voice_cfg, dict):
1122
+ raise ConfigError("voice section must be a mapping if provided")
1123
+ _validate_static_assets_config(cfg, scope="repo")
1124
+ _validate_housekeeping_config(cfg)
1125
+ _validate_telegram_bot_config(cfg)
1126
+
1127
+
1128
+ def _validate_hub_config(cfg: Dict[str, Any]) -> None:
1129
+ _validate_version(cfg)
1130
+ if cfg.get("mode") != "hub":
1131
+ raise ConfigError("Hub config must set mode: hub")
1132
+ hub_cfg = cfg.get("hub")
1133
+ if not isinstance(hub_cfg, dict):
1134
+ raise ConfigError("hub section must be a mapping")
1135
+ if not isinstance(hub_cfg.get("repos_root", ""), str):
1136
+ raise ConfigError("hub.repos_root must be a string path")
1137
+ if not isinstance(hub_cfg.get("worktrees_root", ""), str):
1138
+ raise ConfigError("hub.worktrees_root must be a string path")
1139
+ if not isinstance(hub_cfg.get("manifest", ""), str):
1140
+ raise ConfigError("hub.manifest must be a string path")
1141
+ if hub_cfg.get("discover_depth") not in (None, 1):
1142
+ raise ConfigError("hub.discover_depth is fixed to 1 for now")
1143
+ if not isinstance(hub_cfg.get("auto_init_missing", True), bool):
1144
+ raise ConfigError("hub.auto_init_missing must be boolean")
1145
+ if "update_repo_url" in hub_cfg and not isinstance(
1146
+ hub_cfg.get("update_repo_url"), str
1147
+ ):
1148
+ raise ConfigError("hub.update_repo_url must be a string")
1149
+ if "update_repo_ref" in hub_cfg and not isinstance(
1150
+ hub_cfg.get("update_repo_ref"), str
1151
+ ):
1152
+ raise ConfigError("hub.update_repo_ref must be a string")
1153
+ log_cfg = hub_cfg.get("log")
1154
+ if not isinstance(log_cfg, dict):
1155
+ raise ConfigError("hub.log section must be a mapping")
1156
+ for key in ("path",):
1157
+ if not isinstance(log_cfg.get(key, ""), str):
1158
+ raise ConfigError(f"hub.log.{key} must be a string path")
1159
+ for key in ("max_bytes", "backup_count"):
1160
+ if not isinstance(log_cfg.get(key, 0), int):
1161
+ raise ConfigError(f"hub.log.{key} must be an integer")
1162
+ server = cfg.get("server")
1163
+ if not isinstance(server, dict):
1164
+ raise ConfigError("server section must be a mapping")
1165
+ if not isinstance(server.get("host", ""), str):
1166
+ raise ConfigError("server.host must be a string")
1167
+ if not isinstance(server.get("port", 0), int):
1168
+ raise ConfigError("server.port must be an integer")
1169
+ if "base_path" in server and not isinstance(server.get("base_path", ""), str):
1170
+ raise ConfigError("server.base_path must be a string if provided")
1171
+ if "access_log" in server and not isinstance(server.get("access_log", False), bool):
1172
+ raise ConfigError("server.access_log must be boolean if provided")
1173
+ if "auth_token_env" in server and not isinstance(
1174
+ server.get("auth_token_env", ""), str
1175
+ ):
1176
+ raise ConfigError("server.auth_token_env must be a string if provided")
1177
+ _validate_server_security(server)
1178
+ server_log_cfg = cfg.get("server_log")
1179
+ if server_log_cfg is not None and not isinstance(server_log_cfg, dict):
1180
+ raise ConfigError("server_log section must be a mapping or null")
1181
+ if isinstance(server_log_cfg, dict):
1182
+ if "path" in server_log_cfg and not isinstance(
1183
+ server_log_cfg.get("path", ""), str
1184
+ ):
1185
+ raise ConfigError("server_log.path must be a string path")
1186
+ for key in ("max_bytes", "backup_count"):
1187
+ if key in server_log_cfg and not isinstance(server_log_cfg.get(key), int):
1188
+ raise ConfigError(f"server_log.{key} must be an integer")
1189
+ _validate_static_assets_config(cfg, scope="hub")
1190
+ _validate_housekeeping_config(cfg)
1191
+ _validate_telegram_bot_config(cfg)
1192
+
1193
+
1194
+ def _validate_housekeeping_config(cfg: Dict[str, Any]) -> None:
1195
+ housekeeping_cfg = cfg.get("housekeeping")
1196
+ if housekeeping_cfg is None:
1197
+ return
1198
+ if not isinstance(housekeeping_cfg, dict):
1199
+ raise ConfigError("housekeeping section must be a mapping if provided")
1200
+ if "enabled" in housekeeping_cfg and not isinstance(
1201
+ housekeeping_cfg.get("enabled"), bool
1202
+ ):
1203
+ raise ConfigError("housekeeping.enabled must be boolean")
1204
+ if "interval_seconds" in housekeeping_cfg and not isinstance(
1205
+ housekeeping_cfg.get("interval_seconds"), int
1206
+ ):
1207
+ raise ConfigError("housekeeping.interval_seconds must be an integer")
1208
+ interval_seconds = housekeeping_cfg.get("interval_seconds")
1209
+ if isinstance(interval_seconds, int) and interval_seconds <= 0:
1210
+ raise ConfigError("housekeeping.interval_seconds must be greater than 0")
1211
+ if "min_file_age_seconds" in housekeeping_cfg and not isinstance(
1212
+ housekeeping_cfg.get("min_file_age_seconds"), int
1213
+ ):
1214
+ raise ConfigError("housekeeping.min_file_age_seconds must be an integer")
1215
+ min_file_age_seconds = housekeeping_cfg.get("min_file_age_seconds")
1216
+ if isinstance(min_file_age_seconds, int) and min_file_age_seconds < 0:
1217
+ raise ConfigError("housekeeping.min_file_age_seconds must be >= 0")
1218
+ if "dry_run" in housekeeping_cfg and not isinstance(
1219
+ housekeeping_cfg.get("dry_run"), bool
1220
+ ):
1221
+ raise ConfigError("housekeeping.dry_run must be boolean")
1222
+ rules = housekeeping_cfg.get("rules")
1223
+ if rules is not None and not isinstance(rules, list):
1224
+ raise ConfigError("housekeeping.rules must be a list if provided")
1225
+ if isinstance(rules, list):
1226
+ for idx, rule in enumerate(rules):
1227
+ if not isinstance(rule, dict):
1228
+ raise ConfigError(
1229
+ f"housekeeping.rules[{idx}] must be a mapping if provided"
1230
+ )
1231
+ if "name" in rule and not isinstance(rule.get("name"), str):
1232
+ raise ConfigError(
1233
+ f"housekeeping.rules[{idx}].name must be a string if provided"
1234
+ )
1235
+ if "kind" in rule:
1236
+ kind = rule.get("kind")
1237
+ if not isinstance(kind, str):
1238
+ raise ConfigError(
1239
+ f"housekeeping.rules[{idx}].kind must be a string"
1240
+ )
1241
+ if kind not in ("directory", "file"):
1242
+ raise ConfigError(
1243
+ f"housekeeping.rules[{idx}].kind must be 'directory' or 'file'"
1244
+ )
1245
+ if "path" in rule and not isinstance(rule.get("path"), str):
1246
+ raise ConfigError(f"housekeeping.rules[{idx}].path must be a string")
1247
+ if "glob" in rule and not isinstance(rule.get("glob"), str):
1248
+ raise ConfigError(
1249
+ f"housekeeping.rules[{idx}].glob must be a string if provided"
1250
+ )
1251
+ if "recursive" in rule and not isinstance(rule.get("recursive"), bool):
1252
+ raise ConfigError(
1253
+ f"housekeeping.rules[{idx}].recursive must be boolean if provided"
1254
+ )
1255
+ for key in (
1256
+ "max_files",
1257
+ "max_total_bytes",
1258
+ "max_age_days",
1259
+ "max_bytes",
1260
+ "max_lines",
1261
+ ):
1262
+ if key in rule and not isinstance(rule.get(key), int):
1263
+ raise ConfigError(
1264
+ f"housekeeping.rules[{idx}].{key} must be an integer if provided"
1265
+ )
1266
+ value = rule.get(key)
1267
+ if isinstance(value, int) and value < 0:
1268
+ raise ConfigError(f"housekeeping.rules[{idx}].{key} must be >= 0")
1269
+
1270
+
1271
+ def _validate_static_assets_config(cfg: Dict[str, Any], scope: str) -> None:
1272
+ static_cfg = cfg.get("static_assets")
1273
+ if static_cfg is None:
1274
+ return
1275
+ if not isinstance(static_cfg, dict):
1276
+ raise ConfigError(f"{scope}.static_assets must be a mapping if provided")
1277
+ cache_root = static_cfg.get("cache_root")
1278
+ if cache_root is not None and not isinstance(cache_root, str):
1279
+ raise ConfigError(f"{scope}.static_assets.cache_root must be a string")
1280
+ max_entries = static_cfg.get("max_cache_entries")
1281
+ if max_entries is not None and not isinstance(max_entries, int):
1282
+ raise ConfigError(f"{scope}.static_assets.max_cache_entries must be an integer")
1283
+ if isinstance(max_entries, int) and max_entries < 0:
1284
+ raise ConfigError(f"{scope}.static_assets.max_cache_entries must be >= 0")
1285
+ max_age_days = static_cfg.get("max_cache_age_days")
1286
+ if max_age_days is not None and not isinstance(max_age_days, int):
1287
+ raise ConfigError(
1288
+ f"{scope}.static_assets.max_cache_age_days must be an integer or null"
1289
+ )
1290
+ if isinstance(max_age_days, int) and max_age_days < 0:
1291
+ raise ConfigError(f"{scope}.static_assets.max_cache_age_days must be >= 0")
1292
+
1293
+
1294
+ def _validate_telegram_bot_config(cfg: Dict[str, Any]) -> None:
1295
+ telegram_cfg = cfg.get("telegram_bot")
1296
+ if telegram_cfg is None:
1297
+ return
1298
+ if not isinstance(telegram_cfg, dict):
1299
+ raise ConfigError("telegram_bot section must be a mapping if provided")
1300
+ if "enabled" in telegram_cfg and not isinstance(telegram_cfg.get("enabled"), bool):
1301
+ raise ConfigError("telegram_bot.enabled must be boolean")
1302
+ if "mode" in telegram_cfg and not isinstance(telegram_cfg.get("mode"), str):
1303
+ raise ConfigError("telegram_bot.mode must be a string")
1304
+ if "parse_mode" in telegram_cfg:
1305
+ parse_mode = telegram_cfg.get("parse_mode")
1306
+ if parse_mode is not None and not isinstance(parse_mode, str):
1307
+ raise ConfigError("telegram_bot.parse_mode must be a string or null")
1308
+ if isinstance(parse_mode, str):
1309
+ normalized = parse_mode.strip().lower()
1310
+ if normalized and normalized not in ("html", "markdown", "markdownv2"):
1311
+ raise ConfigError(
1312
+ "telegram_bot.parse_mode must be HTML, Markdown, MarkdownV2, or null"
1313
+ )
1314
+ debug_cfg = telegram_cfg.get("debug")
1315
+ if debug_cfg is not None and not isinstance(debug_cfg, dict):
1316
+ raise ConfigError("telegram_bot.debug must be a mapping if provided")
1317
+ if isinstance(debug_cfg, dict):
1318
+ if "prefix_context" in debug_cfg and not isinstance(
1319
+ debug_cfg.get("prefix_context"), bool
1320
+ ):
1321
+ raise ConfigError("telegram_bot.debug.prefix_context must be boolean")
1322
+ for key in ("bot_token_env", "chat_id_env", "app_server_command_env"):
1323
+ if key in telegram_cfg and not isinstance(telegram_cfg.get(key), str):
1324
+ raise ConfigError(f"telegram_bot.{key} must be a string")
1325
+ for key in ("allowed_chat_ids", "allowed_user_ids"):
1326
+ if key in telegram_cfg and not isinstance(telegram_cfg.get(key), list):
1327
+ raise ConfigError(f"telegram_bot.{key} must be a list")
1328
+ if "require_topics" in telegram_cfg and not isinstance(
1329
+ telegram_cfg.get("require_topics"), bool
1330
+ ):
1331
+ raise ConfigError("telegram_bot.require_topics must be boolean")
1332
+ defaults_cfg = telegram_cfg.get("defaults")
1333
+ if defaults_cfg is not None and not isinstance(defaults_cfg, dict):
1334
+ raise ConfigError("telegram_bot.defaults must be a mapping if provided")
1335
+ if isinstance(defaults_cfg, dict):
1336
+ if "approval_mode" in defaults_cfg and not isinstance(
1337
+ defaults_cfg.get("approval_mode"), str
1338
+ ):
1339
+ raise ConfigError("telegram_bot.defaults.approval_mode must be a string")
1340
+ for key in (
1341
+ "approval_policy",
1342
+ "sandbox_policy",
1343
+ "yolo_approval_policy",
1344
+ "yolo_sandbox_policy",
1345
+ ):
1346
+ if (
1347
+ key in defaults_cfg
1348
+ and defaults_cfg.get(key) is not None
1349
+ and not isinstance(defaults_cfg.get(key), str)
1350
+ ):
1351
+ raise ConfigError(
1352
+ f"telegram_bot.defaults.{key} must be a string or null"
1353
+ )
1354
+ concurrency_cfg = telegram_cfg.get("concurrency")
1355
+ if concurrency_cfg is not None and not isinstance(concurrency_cfg, dict):
1356
+ raise ConfigError("telegram_bot.concurrency must be a mapping if provided")
1357
+ if isinstance(concurrency_cfg, dict):
1358
+ if "max_parallel_turns" in concurrency_cfg and not isinstance(
1359
+ concurrency_cfg.get("max_parallel_turns"), int
1360
+ ):
1361
+ raise ConfigError(
1362
+ "telegram_bot.concurrency.max_parallel_turns must be an integer"
1363
+ )
1364
+ if "per_topic_queue" in concurrency_cfg and not isinstance(
1365
+ concurrency_cfg.get("per_topic_queue"), bool
1366
+ ):
1367
+ raise ConfigError(
1368
+ "telegram_bot.concurrency.per_topic_queue must be boolean"
1369
+ )
1370
+ media_cfg = telegram_cfg.get("media")
1371
+ if media_cfg is not None and not isinstance(media_cfg, dict):
1372
+ raise ConfigError("telegram_bot.media must be a mapping if provided")
1373
+ if isinstance(media_cfg, dict):
1374
+ if "enabled" in media_cfg and not isinstance(media_cfg.get("enabled"), bool):
1375
+ raise ConfigError("telegram_bot.media.enabled must be boolean")
1376
+ if "images" in media_cfg and not isinstance(media_cfg.get("images"), bool):
1377
+ raise ConfigError("telegram_bot.media.images must be boolean")
1378
+ if "voice" in media_cfg and not isinstance(media_cfg.get("voice"), bool):
1379
+ raise ConfigError("telegram_bot.media.voice must be boolean")
1380
+ if "files" in media_cfg and not isinstance(media_cfg.get("files"), bool):
1381
+ raise ConfigError("telegram_bot.media.files must be boolean")
1382
+ for key in ("max_image_bytes", "max_voice_bytes", "max_file_bytes"):
1383
+ value = media_cfg.get(key)
1384
+ if value is not None and not isinstance(value, int):
1385
+ raise ConfigError(f"telegram_bot.media.{key} must be an integer")
1386
+ if isinstance(value, int) and value <= 0:
1387
+ raise ConfigError(f"telegram_bot.media.{key} must be greater than 0")
1388
+ if "image_prompt" in media_cfg and not isinstance(
1389
+ media_cfg.get("image_prompt"), str
1390
+ ):
1391
+ raise ConfigError("telegram_bot.media.image_prompt must be a string")
1392
+ shell_cfg = telegram_cfg.get("shell")
1393
+ if shell_cfg is not None and not isinstance(shell_cfg, dict):
1394
+ raise ConfigError("telegram_bot.shell must be a mapping if provided")
1395
+ if isinstance(shell_cfg, dict):
1396
+ if "enabled" in shell_cfg and not isinstance(shell_cfg.get("enabled"), bool):
1397
+ raise ConfigError("telegram_bot.shell.enabled must be boolean")
1398
+ for key in ("timeout_ms", "max_output_chars"):
1399
+ value = shell_cfg.get(key)
1400
+ if value is not None and not isinstance(value, int):
1401
+ raise ConfigError(f"telegram_bot.shell.{key} must be an integer")
1402
+ if isinstance(value, int) and value <= 0:
1403
+ raise ConfigError(f"telegram_bot.shell.{key} must be greater than 0")
1404
+ command_reg_cfg = telegram_cfg.get("command_registration")
1405
+ if command_reg_cfg is not None and not isinstance(command_reg_cfg, dict):
1406
+ raise ConfigError("telegram_bot.command_registration must be a mapping")
1407
+ if isinstance(command_reg_cfg, dict):
1408
+ if "enabled" in command_reg_cfg and not isinstance(
1409
+ command_reg_cfg.get("enabled"), bool
1410
+ ):
1411
+ raise ConfigError(
1412
+ "telegram_bot.command_registration.enabled must be boolean"
1413
+ )
1414
+ if "scopes" in command_reg_cfg:
1415
+ scopes = command_reg_cfg.get("scopes")
1416
+ if not isinstance(scopes, list):
1417
+ raise ConfigError(
1418
+ "telegram_bot.command_registration.scopes must be a list"
1419
+ )
1420
+ for scope in scopes:
1421
+ if isinstance(scope, str):
1422
+ continue
1423
+ if not isinstance(scope, dict):
1424
+ raise ConfigError(
1425
+ "telegram_bot.command_registration.scopes must contain strings or mappings"
1426
+ )
1427
+ scope_payload = scope.get("scope")
1428
+ if scope_payload is not None and not isinstance(scope_payload, dict):
1429
+ raise ConfigError(
1430
+ "telegram_bot.command_registration.scopes.scope must be a mapping"
1431
+ )
1432
+ if "type" in scope and not isinstance(scope.get("type"), str):
1433
+ raise ConfigError(
1434
+ "telegram_bot.command_registration.scopes.type must be a string"
1435
+ )
1436
+ language_code = scope.get("language_code")
1437
+ if language_code is not None and not isinstance(language_code, str):
1438
+ raise ConfigError(
1439
+ "telegram_bot.command_registration.scopes.language_code must be a string or null"
1440
+ )
1441
+ if "state_file" in telegram_cfg and not isinstance(
1442
+ telegram_cfg.get("state_file"), str
1443
+ ):
1444
+ raise ConfigError("telegram_bot.state_file must be a string path")
1445
+ if "app_server_command" in telegram_cfg and not isinstance(
1446
+ telegram_cfg.get("app_server_command"), (list, str)
1447
+ ):
1448
+ raise ConfigError("telegram_bot.app_server_command must be a list or string")
1449
+ polling_cfg = telegram_cfg.get("polling")
1450
+ if polling_cfg is not None and not isinstance(polling_cfg, dict):
1451
+ raise ConfigError("telegram_bot.polling must be a mapping if provided")
1452
+ if isinstance(polling_cfg, dict):
1453
+ if "timeout_seconds" in polling_cfg and not isinstance(
1454
+ polling_cfg.get("timeout_seconds"), int
1455
+ ):
1456
+ raise ConfigError("telegram_bot.polling.timeout_seconds must be an integer")
1457
+ timeout_seconds = polling_cfg.get("timeout_seconds")
1458
+ if isinstance(timeout_seconds, int) and timeout_seconds <= 0:
1459
+ raise ConfigError(
1460
+ "telegram_bot.polling.timeout_seconds must be greater than 0"
1461
+ )
1462
+ if "allowed_updates" in polling_cfg and not isinstance(
1463
+ polling_cfg.get("allowed_updates"), list
1464
+ ):
1465
+ raise ConfigError("telegram_bot.polling.allowed_updates must be a list")