misterdev 0.2.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 (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. misterdev-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,281 @@
1
+ """Cost-aware, data-driven model selection.
2
+
3
+ Turns the static complexity->tier->model routing into a policy that picks a
4
+ model to maximize quality-per-dollar under an unconditional quality floor: the
5
+ validation gates. A cheaper model that writes worse code simply fails the gate
6
+ and the policy escalates, so model choice can only change cost/latency, never
7
+ shipped quality.
8
+
9
+ The policy is deterministic (UCB scoring from the ledger, not random draws) and
10
+ disabled by default; with it off, ``select`` returns None and the executor
11
+ keeps its existing static routing.
12
+ """
13
+
14
+ from typing import List, Optional
15
+
16
+ from misterdev.config import get_setting
17
+ from misterdev.core.economics.model_ledger import ModelLedger
18
+ from misterdev.logging_setup import setup_logger
19
+
20
+ logger = setup_logger(__name__)
21
+
22
+
23
+ class ModelSelector:
24
+ """Picks a model id per attempt from the escalation ladder and ledger."""
25
+
26
+ def __init__(
27
+ self, config: dict, ledger: ModelLedger, free_models: Optional[List[str]] = None
28
+ ):
29
+ self.ledger = ledger
30
+ # dynamic_selection is False (off), True (on), or "auto" (self-
31
+ # activating). A non-"auto" string is treated as off, never truthy.
32
+ raw = get_setting(config, "llm", "dynamic_selection")
33
+ if isinstance(raw, str):
34
+ self.auto = raw.strip().lower() == "auto"
35
+ self.enabled = self.auto
36
+ else:
37
+ self.auto = False
38
+ self.enabled = bool(raw)
39
+ self.escalation: List[str] = list(
40
+ get_setting(config, "llm", "escalation") or []
41
+ )
42
+ self.models = dict(get_setting(config, "llm", "models") or {})
43
+ self.min_obs = get_setting(config, "llm", "min_observations")
44
+ self.first_try_floor = get_setting(config, "llm", "first_try_floor")
45
+ self.incompetence_floor = get_setting(config, "llm", "incompetence_floor")
46
+ self.max_latency = get_setting(config, "llm", "max_attempt_latency_seconds")
47
+ self.posture = get_setting(config, "llm", "selection_posture")
48
+ self.maturity_threshold = get_setting(config, "llm", "maturity_threshold")
49
+ # Cells already announced as graduated to cheap-first (one log per cell).
50
+ self._graduated: set = set()
51
+ free = list(free_models or [])
52
+ if self.escalation and free:
53
+ # Harvested free models join the configured cheapest tier as extra
54
+ # candidates, so they are only ever tried on early (non-final)
55
+ # attempts and under the same proven-first-try gate as any cheap
56
+ # model. They go first so an exploration tie among unseen candidates
57
+ # (equal +inf UCB) breaks toward the zero-cost option.
58
+ cheapest = self.escalation[0]
59
+ existing = self._tier_models(cheapest)
60
+ self.models[cheapest] = [m for m in free if m not in existing] + existing
61
+ elif not self.escalation and free:
62
+ # No ladder configured: self-assemble one from the harvested free
63
+ # models (cheap) and the default model (strong). This is what makes
64
+ # free models work out of the box with no escalation/models config.
65
+ default_model = get_setting(config, "llm", "model")
66
+ self.models = {**self.models, "_free": free}
67
+ if default_model:
68
+ self.models["_default"] = [default_model]
69
+ self.escalation = ["_free", "_default"]
70
+
71
+ def _tier_models(self, tier: str) -> List[str]:
72
+ v = self.models.get(tier)
73
+ if v is None:
74
+ return []
75
+ if isinstance(v, str):
76
+ return [v]
77
+ return [m for m in v if isinstance(m, str)]
78
+
79
+ def _rungs(self) -> List[str]:
80
+ """Escalation tiers that actually resolve to at least one model."""
81
+ return [t for t in self.escalation if self._tier_models(t)]
82
+
83
+ def _cost(self, model: str, category: str, complexity: str) -> float:
84
+ """Mean successful-attempt cost, or a neutral 1.0 when unknown.
85
+
86
+ Unknown cost stays neutral so an unseen model is ranked purely on its
87
+ (optimistic) quality score rather than being favored for a $0 estimate.
88
+ """
89
+ avg = self.ledger.stat(model, category, complexity).avg_cost
90
+ return avg if avg > 0 else 1.0
91
+
92
+ # Empirical-Bayes prior weight: how many pseudo-observations of the model's
93
+ # GLOBAL first-try rate to fold into a cold cell. Small, so cell-specific data
94
+ # overrides it within a few real attempts — the prior only warm-starts.
95
+ _PRIOR_WEIGHT = 3.0
96
+
97
+ def _proven(self, model: str, category: str, complexity: str) -> bool:
98
+ """True when a model has earned trust for a first attempt on this cell.
99
+
100
+ A cold cell borrows strength from the model's GLOBAL first-try rate
101
+ (shrinkage): a model reliable everywhere else clears the floor here with
102
+ fewer local observations, while a globally-weak model does not. As the
103
+ cell accumulates real attempts, the blended rate converges to the cell's
104
+ own — the prior fades. A model with no history anywhere stays unproven.
105
+ """
106
+ s = self.ledger.stat(model, category, complexity)
107
+ g_att, g_rate = self.ledger.global_first_try(model, category, complexity)
108
+ prior = self._PRIOR_WEIGHT if g_att >= self.min_obs else 0.0
109
+ effective_obs = s.first_try_attempts + prior
110
+ if effective_obs < self.min_obs:
111
+ return False
112
+ blended = (s.first_try_successes + prior * g_rate) / effective_obs
113
+ return blended >= self.first_try_floor
114
+
115
+ def _incompetent(self, model: str, category: str, complexity: str) -> bool:
116
+ """Proven UNABLE to do this kind of task: enough attempts to judge and a
117
+ success rate below the floor. Distinct from unproven (too few attempts).
118
+ Trying such a model only burns a failed attempt and forces escalation."""
119
+ if not self.incompetence_floor:
120
+ return False
121
+ s = self.ledger.stat(model, category, complexity)
122
+ return s.attempts >= self.min_obs and s.success_rate < self.incompetence_floor
123
+
124
+ def _too_slow(self, model: str, category: str, complexity: str) -> bool:
125
+ """Proven too slow for the per-task wall-clock budget (ledger avg latency
126
+ above the configured ceiling). Unseen models (avg 0) are never flagged."""
127
+ if not self.max_latency:
128
+ return False
129
+ return (
130
+ self.ledger.stat(model, category, complexity).avg_latency > self.max_latency
131
+ )
132
+
133
+ # Free (`:free`) endpoints are reserved for the easiest tasks: they are slow
134
+ # (2.5-5 min/call observed) and unreliable, so on anything heavier than a
135
+ # small task they cost more wall-clock and failed attempts than they save.
136
+ _FREE_OK_COMPLEXITY = ("trivial", "small")
137
+
138
+ def _pick(
139
+ self,
140
+ models: List[str],
141
+ category: str,
142
+ complexity: str,
143
+ explore: bool,
144
+ final: bool = False,
145
+ ) -> Optional[str]:
146
+ """Best model by quality-per-dollar.
147
+
148
+ explore=True uses the optimistic UCB score (unseen models score +inf, so
149
+ they get tried); explore=False uses the conservative Wilson lower bound
150
+ (unseen models score 0, so only proven models win). Free endpoints are
151
+ dropped for non-easy tasks (see ``_FREE_OK_COMPLEXITY``), and models the
152
+ ledger has proven incompetent (or, on non-final attempts, too slow) are
153
+ dropped; if that empties the tier, returns None so the caller climbs to
154
+ the next (paid) rung. Incompetence still filters on the final attempt — a
155
+ model that cannot do the task is never the right last resort.
156
+ """
157
+ if complexity not in self._FREE_OK_COMPLEXITY:
158
+ models = [m for m in models if ":free" not in m]
159
+ models = [m for m in models if not self._incompetent(m, category, complexity)]
160
+ if not final:
161
+ models = [m for m in models if not self._too_slow(m, category, complexity)]
162
+ if not models:
163
+ return None
164
+ total = self.ledger.total_observations(category, complexity)
165
+
166
+ def value(model: str) -> float:
167
+ if explore:
168
+ score = self.ledger.selection_score(model, category, complexity, total)
169
+ else:
170
+ score = self.ledger.stat(model, category, complexity).quality_score()
171
+ return score / self._cost(model, category, complexity)
172
+
173
+ return max(models, key=value)
174
+
175
+ def _mature(self, category: str, complexity: str) -> bool:
176
+ """True once a cell has enough data to stop exploring (auto mode)."""
177
+ return (
178
+ self.ledger.total_observations(category, complexity)
179
+ >= self.maturity_threshold
180
+ )
181
+
182
+ def _explore_on_first(self, category: str, complexity: str) -> bool:
183
+ if self.auto:
184
+ # Self-regulating: explore cheap/free models on easy tasks while the
185
+ # cell is immature, then settle into conservative cheap-first.
186
+ # Only trivial/small tasks are explored on the FIRST attempt: on a
187
+ # large repo, unproven free models returned no usable edits on medium
188
+ # refactor tasks (the emathy run burned first attempts on null
189
+ # responses). A cheap model can still earn medium first-attempt use
190
+ # once PROVEN (the conservative branch picks a proven cheap model).
191
+ if self._mature(category, complexity):
192
+ return False
193
+ return complexity in ("trivial", "small")
194
+ if self.posture == "aggressive":
195
+ return True
196
+ if self.posture == "balanced":
197
+ return complexity in ("trivial", "small", "medium")
198
+ return False # conservative
199
+
200
+ def select(
201
+ self, category: str, complexity: str, attempt: int, max_attempts: int
202
+ ) -> Optional[str]:
203
+ """Choose a model id for this attempt, or None to use the default.
204
+
205
+ Returns None when disabled or when no escalation ladder is configured,
206
+ leaving the executor's existing static routing in charge.
207
+ """
208
+ if not self.enabled:
209
+ return None
210
+ rungs = self._rungs()
211
+ if not rungs:
212
+ return None
213
+ last = len(rungs) - 1
214
+
215
+ # The final attempt always uses the strongest tier: the quality floor's
216
+ # safety net, so every prior attempt can afford to try something cheaper.
217
+ # final=True lifts the latency guard here (a slow-but-capable last resort
218
+ # beats giving up), but incompetent models are still excluded.
219
+ if attempt >= max_attempts - 1:
220
+ return self._pick(
221
+ self._tier_models(rungs[last]), category, complexity, True, final=True
222
+ )
223
+
224
+ if attempt == 0 and not self._explore_on_first(category, complexity):
225
+ # Conservative first attempt: cheapest rung with a proven model, else
226
+ # the strongest tier. Never gamble the first impression on an
227
+ # unproven cheap model.
228
+ for tier in rungs:
229
+ proven = [
230
+ m
231
+ for m in self._tier_models(tier)
232
+ if self._proven(m, category, complexity)
233
+ ]
234
+ if proven:
235
+ chosen = self._pick(proven, category, complexity, False)
236
+ self._announce_graduation(category, complexity, chosen)
237
+ return chosen
238
+ return self._pick(
239
+ self._tier_models(rungs[last]), category, complexity, True
240
+ )
241
+
242
+ # Climbing path: one rung up per attempt, capped at the strongest tier.
243
+ # If the target rung is emptied by the free/incompetence/latency filters,
244
+ # climb to the next (stronger) rung rather than fall back to the client
245
+ # default — a filtered tier should escalate, not disable selection.
246
+ target = min(attempt, last)
247
+ return self._pick_climbing(rungs, target, category, complexity)
248
+
249
+ def _pick_climbing(
250
+ self, rungs: List[str], start: int, category: str, complexity: str
251
+ ) -> Optional[str]:
252
+ """Pick from rung ``start``, climbing to each stronger rung in turn until
253
+ one yields a model the filters didn't drop; None only when every rung
254
+ from ``start`` up is empty."""
255
+ for i in range(start, len(rungs)):
256
+ chosen = self._pick(self._tier_models(rungs[i]), category, complexity, True)
257
+ if chosen:
258
+ return chosen
259
+ return None
260
+
261
+ def _announce_graduation(self, category: str, complexity: str, model: str) -> None:
262
+ """Log the first time a cell settles on a proven cheap model (auto)."""
263
+ if not self.auto:
264
+ return
265
+ cell = (category, complexity)
266
+ if cell not in self._graduated:
267
+ self._graduated.add(cell)
268
+ logger.info(
269
+ f"[auto] {category}/{complexity} now using proven cheap model "
270
+ f"{model!r} on first attempt"
271
+ )
272
+
273
+ def is_ready(self, category: str, complexity: str) -> bool:
274
+ """Whether this cell would use a proven cheap model on a first attempt."""
275
+ if not self.enabled:
276
+ return False
277
+ return any(
278
+ self._proven(m, category, complexity)
279
+ for tier in self._rungs()[:-1]
280
+ for m in self._tier_models(tier)
281
+ )
File without changes
@@ -0,0 +1,50 @@
1
+ """Shared bounded best-effort runner for the optional gates.
2
+
3
+ Several optional gates — goal check, adversarial critic, runtime/web/vision/
4
+ mutation smoke, LSP diagnostics, and the MCP substrate — must be strictly
5
+ non-blocking: a slow or hung model, server, or subprocess can NEVER stall a
6
+ build. They all shared one shape: run the work in a daemon thread, join with a
7
+ hard timeout, and on timeout abandon the thread (it dies with the process) and
8
+ return a SKIP sentinel. This centralizes that shape so the call sites don't each
9
+ re-implement it.
10
+ """
11
+
12
+ import threading
13
+ from typing import Callable, TypeVar
14
+
15
+ from misterdev.logging_setup import setup_logger
16
+
17
+ logger = setup_logger(__name__)
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ def run_bounded(
23
+ fn: Callable[[], T], timeout: float, default: T, what: str = "operation"
24
+ ) -> T:
25
+ """Run ``fn()`` in a daemon thread, returning its result or ``default``.
26
+
27
+ Returns ``default`` when ``fn`` does not finish within ``timeout`` seconds
28
+ (the thread is abandoned and dies with the process) or when it raises (logged
29
+ at debug — a backstop, since ``fn`` is expected to handle its own expected
30
+ failures and return an appropriate value itself). ``what`` names the
31
+ operation for log context. The caller is guaranteed control back within
32
+ ``timeout`` seconds and is never handed an exception.
33
+ """
34
+ box = {"result": default}
35
+
36
+ def _worker() -> None:
37
+ try:
38
+ box["result"] = fn()
39
+ except Exception as e: # backstop: fn should catch its own failures
40
+ logger.debug(f"{what} failed in bounded runner: {e}")
41
+ box["result"] = default
42
+
43
+ worker = threading.Thread(target=_worker, daemon=True)
44
+ worker.start()
45
+ worker.join(timeout)
46
+ if worker.is_alive():
47
+ logger.warning(f"{what} timed out after {timeout}s; skipping (never blocks).")
48
+ return default
49
+
50
+ return box["result"]
@@ -0,0 +1,221 @@
1
+ """Engine-agnostic OCI container execution substrate (opt-in).
2
+
3
+ Runs gate commands (build/lint/test/golden) inside a throwaway container so a
4
+ build is exercised against a pinned, reproducible toolchain instead of whatever
5
+ happens to be installed on the host. Strictly best-effort and opt-in: it is used
6
+ only when ``environment.type`` is ``docker``/``container`` AND a usable OCI
7
+ engine is actually present. With no engine reachable (no daemon, CLI missing)
8
+ the orchestrator falls back to executing commands locally exactly as before, so
9
+ the feature can never hard-fail a build or require a daemon to exist.
10
+
11
+ Design mirrors :mod:`misterdev.core.context.lsp`: detection probes are
12
+ bounded by a short timeout (a hung/absent engine can never block), every failure
13
+ is logged at debug and degrades to a safe no-op, and the runtime knob is off by
14
+ default.
15
+
16
+ Git operations (branch/commit/revert) always stay on the host; only the gate
17
+ commands are routed through the container, executed against the repository
18
+ bind-mounted at the same path so produced paths line up with the host tree.
19
+ """
20
+
21
+ import os
22
+ import shlex
23
+ import subprocess
24
+ from pathlib import Path
25
+ from typing import List, Optional, Tuple
26
+
27
+ from misterdev.logging_setup import setup_logger
28
+
29
+ logger = setup_logger(__name__)
30
+
31
+ # Engines we know how to drive, in preference order: rootless/daemonless first
32
+ # (podman, nerdctl), then docker, then the colima-managed docker socket. All
33
+ # four speak the same ``<engine> run`` CLI surface we rely on.
34
+ _PREFERRED_ENGINES: Tuple[str, ...] = ("podman", "docker", "nerdctl", "colima")
35
+
36
+ # How long an engine availability probe may take before we treat the engine as
37
+ # unavailable. A short bound so a wedged daemon can never stall detection.
38
+ _PROBE_TIMEOUT = 8
39
+
40
+ # Default image per detected project language. Conservative, widely-mirrored
41
+ # tags; a project may override with ``environment.image``.
42
+ _LANGUAGE_IMAGES = {
43
+ "python": "python:3.12-slim",
44
+ "rust": "rust:slim",
45
+ "node": "node:20-slim",
46
+ "javascript": "node:20-slim",
47
+ "typescript": "node:20-slim",
48
+ "go": "golang:1.22",
49
+ "ruby": "ruby:3.3-slim",
50
+ "java": "eclipse-temurin:21",
51
+ }
52
+
53
+ _DEFAULT_IMAGE = "debian:stable-slim"
54
+
55
+
56
+ def _probe_engine(engine: str) -> bool:
57
+ """True if ``engine`` is installed and its backend responds within the probe
58
+ timeout. Never raises: any failure (missing binary, unreachable daemon,
59
+ timeout) is reported as unavailable."""
60
+ if engine == "colima":
61
+ # colima is a VM manager, not a runtime CLI; a running colima exposes a
62
+ # docker engine. Treat it as available only when its status is running.
63
+ cmd = ["colima", "status"]
64
+ else:
65
+ # `info` requires a working backend (daemon for docker; none for
66
+ # podman/nerdctl rootless), so it distinguishes "CLI present" from
67
+ # "engine actually usable".
68
+ cmd = [engine, "info"]
69
+ try:
70
+ proc = subprocess.run(
71
+ cmd,
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=_PROBE_TIMEOUT,
75
+ )
76
+ return proc.returncode == 0
77
+ except (OSError, subprocess.SubprocessError) as e:
78
+ logger.debug(f"Container engine probe failed for {engine}: {e}")
79
+ return False
80
+
81
+
82
+ def detect_engine(preferred: Optional[str] = None) -> Optional[str]:
83
+ """Return the name of the first usable OCI engine, or ``None`` if none.
84
+
85
+ ``preferred`` (from ``environment.engine``) is tried first when given; the
86
+ standard rootless-first order follows. ``colima`` resolves to ``docker`` as
87
+ the run CLI since colima manages a docker-compatible daemon.
88
+ """
89
+ order: List[str] = []
90
+ if preferred:
91
+ order.append(preferred)
92
+ order.extend(e for e in _PREFERRED_ENGINES if e not in order)
93
+ for engine in order:
94
+ if _probe_engine(engine):
95
+ # colima provides a docker daemon; the actual run CLI is docker.
96
+ return "docker" if engine == "colima" else engine
97
+ return None
98
+
99
+
100
+ def image_for_language(language: Optional[str]) -> str:
101
+ """Pick a default base image for ``language``; falls back to a generic image
102
+ when the language is unknown so the container can still run shell gates."""
103
+ return _LANGUAGE_IMAGES.get((language or "").lower(), _DEFAULT_IMAGE)
104
+
105
+
106
+ class ContainerEngine:
107
+ """Thin wrapper over a detected OCI engine for running gate commands.
108
+
109
+ One instance per build. Commands run in a fresh ``--rm`` container with the
110
+ repository bind-mounted at ``mount_path`` and the process mapped to the
111
+ host uid/gid so artifacts are not left root-owned. The container is not kept
112
+ running between commands (each gate is a self-contained ``run``), which
113
+ keeps lifecycle trivial and leak-free while still pinning the toolchain.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ engine: str,
119
+ image: str,
120
+ host_path: Path,
121
+ mount_path: str = "/workspace",
122
+ network: Optional[str] = None,
123
+ memory: Optional[str] = None,
124
+ cpus: Optional[str] = None,
125
+ pids_limit: Optional[int] = None,
126
+ cap_drop: Optional[List[str]] = None,
127
+ security_opt: Optional[List[str]] = None,
128
+ ):
129
+ self.engine = engine
130
+ self.image = image
131
+ self.host_path = Path(host_path).resolve()
132
+ self.mount_path = mount_path
133
+ # Egress control: "none" runs the container with no network (governance.
134
+ # network); any other value (or None) leaves the engine default, so the
135
+ # off path is byte-identical to before. This constrains CONTAINERIZED
136
+ # execution only — host execution and git stay on the host network.
137
+ self.network = network
138
+ # Optional resource caps (environment.memory / .cpus / .pids_limit). Each
139
+ # is emitted only when set, so an unconfigured engine produces the exact
140
+ # same argv as before. They bound a runaway gate (fork bomb, memory hog)
141
+ # in the throwaway container without affecting host execution.
142
+ self.memory = memory
143
+ self.cpus = cpus
144
+ self.pids_limit = pids_limit
145
+ # Optional sandbox hardening for running model-generated code with less
146
+ # trust: cap_drop (e.g. ["ALL"]) drops Linux capabilities; security_opt
147
+ # (e.g. ["no-new-privileges", "seccomp=/path/profile.json"]) passes
148
+ # --security-opt. Both emitted only when set; a bare string is accepted
149
+ # and wrapped, so ``cap_drop: ALL`` in YAML works too.
150
+ self.cap_drop = [cap_drop] if isinstance(cap_drop, str) else (cap_drop or [])
151
+ self.security_opt = (
152
+ [security_opt] if isinstance(security_opt, str) else (security_opt or [])
153
+ )
154
+
155
+ def is_available(self) -> bool:
156
+ return bool(self.engine)
157
+
158
+ def _user_args(self) -> List[str]:
159
+ """``--user uid:gid`` so host-bind-mounted writes are owned by the
160
+ invoking user, not root. Skipped on platforms without uid/gid
161
+ (Windows), where the flag is meaningless."""
162
+ getuid = getattr(os, "getuid", None)
163
+ getgid = getattr(os, "getgid", None)
164
+ if getuid is None or getgid is None:
165
+ return []
166
+ return ["--user", f"{getuid()}:{getgid()}"]
167
+
168
+ def wrap_command(self, command: str, timeout: int) -> List[str]:
169
+ """Build the argv that runs ``command`` inside a throwaway container.
170
+
171
+ The user command is passed verbatim to ``sh -c`` inside the container,
172
+ so shell features (``&&``, pipes, globs) behave as on the host. The repo
173
+ is the working directory via the bind mount.
174
+ """
175
+ argv = [
176
+ self.engine,
177
+ "run",
178
+ "--rm",
179
+ "-v",
180
+ f"{self.host_path}:{self.mount_path}",
181
+ "-w",
182
+ self.mount_path,
183
+ ]
184
+ if self.network == "none":
185
+ argv.extend(["--network", "none"])
186
+ if self.memory:
187
+ argv.extend(["--memory", str(self.memory)])
188
+ if self.cpus:
189
+ argv.extend(["--cpus", str(self.cpus)])
190
+ if self.pids_limit:
191
+ argv.extend(["--pids-limit", str(self.pids_limit)])
192
+ for cap in self.cap_drop:
193
+ argv.extend(["--cap-drop", str(cap)])
194
+ for opt in self.security_opt:
195
+ argv.extend(["--security-opt", str(opt)])
196
+ argv.extend(self._user_args())
197
+ argv.extend([self.image, "sh", "-c", command])
198
+ return argv
199
+
200
+ def run(self, command: str, timeout: int = 180) -> Tuple[bool, str]:
201
+ """Execute ``command`` inside the container. Returns ``(ok, output)``
202
+ with the same contract as the local runner, so callers are agnostic to
203
+ where the command ran. Never raises: engine/timeout failures return
204
+ ``(False, message)``."""
205
+ argv = self.wrap_command(command, timeout)
206
+ logger.debug(f"Container exec ({self.engine}): {shlex.join(argv)}")
207
+ try:
208
+ proc = subprocess.run(
209
+ argv,
210
+ capture_output=True,
211
+ text=True,
212
+ timeout=timeout,
213
+ )
214
+ output = proc.stdout
215
+ if proc.stderr:
216
+ output += "\n" + proc.stderr
217
+ return proc.returncode == 0, output
218
+ except subprocess.TimeoutExpired:
219
+ return False, f"Container command timed out after {timeout}s: {command}"
220
+ except (OSError, subprocess.SubprocessError) as e:
221
+ return False, f"Container command failed: {e}"