qaas-python 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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/mcp/env_control.py
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
"""The `env_control` MCP server — the environment an agent can actually name.
|
|
2
|
+
|
|
3
|
+
A defect envelope claims `reproduction.environment = {branch, fixture, flags}`.
|
|
4
|
+
That claim is only worth something if some component owns those three things and
|
|
5
|
+
can put them back. This server is that component: every tool here exists so a
|
|
6
|
+
later run (FORGE re-running a repro, PROOF verifying a fix) can stand the world
|
|
7
|
+
up in the same shape and get the same answer.
|
|
8
|
+
|
|
9
|
+
Two deliberate properties:
|
|
10
|
+
|
|
11
|
+
* **Nothing shells out through a shell.** Every subprocess is an argv list built
|
|
12
|
+
here from validated pieces, so a fixture name or a flag key coming out of a
|
|
13
|
+
model can never become a command.
|
|
14
|
+
* **Nothing hangs and nothing raises.** Docker can be absent, the daemon can be
|
|
15
|
+
down, the target app can be half-built. Each of those is a normal Tuesday, and
|
|
16
|
+
each returns an `err` naming the cause rather than a traceback or a stall.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import yaml
|
|
35
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
36
|
+
|
|
37
|
+
from qaas.mcp.context import ToolContext, err, ok
|
|
38
|
+
|
|
39
|
+
#: Fallbacks only. Every one of these has a field on the target profile
|
|
40
|
+
#: (`environment.compose_file`, `environment.seed_sql`) that had existed since
|
|
41
|
+
#: the schema was written and that nothing read -- so the "portable" seam was
|
|
42
|
+
#: fiction, and anyone pointing this at their own repository hit a hardcoded
|
|
43
|
+
#: `target-app/api/seed/fixtures.sql` on their first run. These now apply only
|
|
44
|
+
#: when the profile is silent.
|
|
45
|
+
DEFAULT_COMPOSE_FILENAME = "docker-compose.yml"
|
|
46
|
+
DEFAULT_SEED_DIR = "api/seed"
|
|
47
|
+
DEFAULT_FIXTURE_FILE = "fixtures.sql"
|
|
48
|
+
|
|
49
|
+
# Kept as the old names so nothing importing them breaks.
|
|
50
|
+
COMPOSE_FILENAME = DEFAULT_COMPOSE_FILENAME
|
|
51
|
+
SEED_DIR = DEFAULT_SEED_DIR
|
|
52
|
+
|
|
53
|
+
#: Lets a test point the server at a docker that is not there, and lets an
|
|
54
|
+
#: operator point it at a non-PATH install, without either one editing code.
|
|
55
|
+
DOCKER_BIN_ENV = "QAAS_DOCKER_BIN"
|
|
56
|
+
|
|
57
|
+
DEFAULT_UP_TIMEOUT_S = 180
|
|
58
|
+
POLL_INTERVAL_S = 2.0
|
|
59
|
+
SEED_TIMEOUT_S = 120
|
|
60
|
+
SHORT_TIMEOUT_S = 30
|
|
61
|
+
LOGIN_TIMEOUT_S = 15
|
|
62
|
+
|
|
63
|
+
#: Impersonation is a real login against the running API rather than a
|
|
64
|
+
#: hand-minted JWT, so the token an agent receives is exactly the token a
|
|
65
|
+
#: browser would receive — an auth bug in issuance is visible to the agent
|
|
66
|
+
#: instead of bypassed by it. That part was always right.
|
|
67
|
+
#:
|
|
68
|
+
#: What was wrong: the accounts and the password were hardcoded to the bundled
|
|
69
|
+
#: demo. `profile.auth.roles` has existed all along, and `Role.password()`
|
|
70
|
+
#: already reads from an environment variable precisely so that a committed
|
|
71
|
+
#: profile never carries a credential. Until this was wired up, pointing qaas at
|
|
72
|
+
#: a real application with `auth.mode: login` would POST the literal string
|
|
73
|
+
#: below at that application's login endpoint. These are fallbacks for the
|
|
74
|
+
#: bundled demo now, used only when the profile declares no roles.
|
|
75
|
+
FALLBACK_USERS: dict[str, str] = {
|
|
76
|
+
"admin": "admin@northwind.test",
|
|
77
|
+
"member": "member@northwind.test",
|
|
78
|
+
"viewer": "viewer@northwind.test",
|
|
79
|
+
}
|
|
80
|
+
FALLBACK_PASSWORD = "password123"
|
|
81
|
+
|
|
82
|
+
SEEDED_USERS = FALLBACK_USERS # old name, kept for importers
|
|
83
|
+
SEEDED_PASSWORD = FALLBACK_PASSWORD
|
|
84
|
+
|
|
85
|
+
_FIXTURE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
86
|
+
_FLAG_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
87
|
+
_DML_RE = re.compile(r"\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([A-Za-z_][A-Za-z0-9_.]*)", re.I)
|
|
88
|
+
_TAG_RE = re.compile(r"^(INSERT)\s+\d+\s+(\d+)$|^(UPDATE|DELETE)\s+(\d+)$")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------
|
|
92
|
+
# subprocess plumbing
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class _Proc:
|
|
98
|
+
"""The result of one subprocess run. Never an exception."""
|
|
99
|
+
|
|
100
|
+
argv: list[str]
|
|
101
|
+
code: int
|
|
102
|
+
out: str
|
|
103
|
+
err: str
|
|
104
|
+
timed_out: bool = False
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def okay(self) -> bool:
|
|
108
|
+
return self.code == 0 and not self.timed_out
|
|
109
|
+
|
|
110
|
+
def tail(self, limit: int = 1200) -> str:
|
|
111
|
+
blob = (self.err or self.out).strip()
|
|
112
|
+
return blob[-limit:] if blob else "(no output)"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _exec(argv: list[str], timeout: float, *, stdin: bytes | None = None, cwd: Path | None = None) -> _Proc:
|
|
116
|
+
"""Run argv with a hard deadline, capturing output. Kills on timeout."""
|
|
117
|
+
try:
|
|
118
|
+
proc = await asyncio.create_subprocess_exec(
|
|
119
|
+
*argv,
|
|
120
|
+
stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
|
|
121
|
+
stdout=subprocess.PIPE,
|
|
122
|
+
stderr=subprocess.PIPE,
|
|
123
|
+
cwd=str(cwd) if cwd else None,
|
|
124
|
+
)
|
|
125
|
+
except (OSError, ValueError) as exc: # binary vanished between check and exec
|
|
126
|
+
return _Proc(argv, 127, "", str(exc))
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
out, errout = await asyncio.wait_for(proc.communicate(stdin), timeout=timeout)
|
|
130
|
+
except (asyncio.TimeoutError, TimeoutError):
|
|
131
|
+
proc.kill()
|
|
132
|
+
try:
|
|
133
|
+
await proc.communicate()
|
|
134
|
+
except Exception: # noqa: BLE001 - the process is already gone; nothing to salvage
|
|
135
|
+
pass
|
|
136
|
+
return _Proc(argv, -1, "", f"timed out after {timeout:.0f}s", timed_out=True)
|
|
137
|
+
|
|
138
|
+
return _Proc(argv, proc.returncode or 0, out.decode(errors="replace"), errout.decode(errors="replace"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def docker_bin() -> str | None:
|
|
142
|
+
"""Path to the docker binary, or None if there is nothing usable."""
|
|
143
|
+
override = os.environ.get(DOCKER_BIN_ENV)
|
|
144
|
+
if override:
|
|
145
|
+
path = Path(override)
|
|
146
|
+
return override if path.is_file() and os.access(override, os.X_OK) else None
|
|
147
|
+
return shutil.which("docker")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------
|
|
151
|
+
# compose introspection
|
|
152
|
+
# --------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _environment(ctx: ToolContext):
|
|
156
|
+
"""The target's environment block, or None when no profile is loaded."""
|
|
157
|
+
profile = getattr(ctx.config, "profile", None)
|
|
158
|
+
return getattr(profile, "environment", None) if profile else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _compose_path(ctx: ToolContext) -> Path:
|
|
162
|
+
"""The compose file this target declares, falling back to the usual name."""
|
|
163
|
+
env = _environment(ctx)
|
|
164
|
+
declared = getattr(env, "compose_file", None) if env else None
|
|
165
|
+
return ctx.target_root / (declared or DEFAULT_COMPOSE_FILENAME)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _seed_dir(ctx: ToolContext) -> Path:
|
|
169
|
+
"""Where fixture SQL lives for this target.
|
|
170
|
+
|
|
171
|
+
`environment.seed_sql` names a file; its directory is the fixture
|
|
172
|
+
directory, which lets a project keep fixtures anywhere without this server
|
|
173
|
+
having an opinion about `api/seed`.
|
|
174
|
+
"""
|
|
175
|
+
env = _environment(ctx)
|
|
176
|
+
declared = getattr(env, "seed_sql", None) if env else None
|
|
177
|
+
if declared:
|
|
178
|
+
return (ctx.target_root / declared).parent.resolve()
|
|
179
|
+
return (ctx.target_root / DEFAULT_SEED_DIR).resolve()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _login_path(ctx: ToolContext) -> str:
|
|
183
|
+
"""The login path this target declares. `auth.login_endpoint` is written as
|
|
184
|
+
"POST /v1/auth/login", so the verb is stripped off."""
|
|
185
|
+
profile = getattr(ctx.config, "profile", None)
|
|
186
|
+
auth = getattr(profile, "auth", None) if profile else None
|
|
187
|
+
declared = getattr(auth, "login_endpoint", None) if auth else None
|
|
188
|
+
if not declared:
|
|
189
|
+
return "/v1/auth/login"
|
|
190
|
+
path = declared.split(None, 1)[-1].strip() if " " in declared else declared.strip()
|
|
191
|
+
return path if path.startswith("/") else f"/{path}"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _roles(ctx: ToolContext) -> dict[str, tuple[str, str | None]]:
|
|
195
|
+
"""role -> (username, password), from the profile where one declares them.
|
|
196
|
+
|
|
197
|
+
`profile.auth.roles` and `Role.password()` have existed since the schema was
|
|
198
|
+
written; nothing read them, so impersonation was welded to three demo
|
|
199
|
+
accounts and a literal password. Pointing qaas at a real application with
|
|
200
|
+
`auth.mode: login` would have POSTed that literal at its login endpoint.
|
|
201
|
+
|
|
202
|
+
Passwords come from the environment variable each role names, never from the
|
|
203
|
+
profile itself, because a profile is committed to a repository.
|
|
204
|
+
"""
|
|
205
|
+
profile = getattr(ctx.config, "profile", None)
|
|
206
|
+
auth = getattr(profile, "auth", None) if profile else None
|
|
207
|
+
declared = getattr(auth, "roles", None) or {}
|
|
208
|
+
if declared:
|
|
209
|
+
return {name: (role.username, role.password()) for name, role in declared.items()}
|
|
210
|
+
# No roles declared: the bundled demo, whose fixture password is public and
|
|
211
|
+
# whose accounts exist only inside a throwaway container.
|
|
212
|
+
return {name: (email, FALLBACK_PASSWORD) for name, email in FALLBACK_USERS.items()}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _default_fixture(ctx: ToolContext) -> str:
|
|
216
|
+
"""The filename `seed(fixture="default")` means for this target."""
|
|
217
|
+
env = _environment(ctx)
|
|
218
|
+
declared = getattr(env, "seed_sql", None) if env else None
|
|
219
|
+
return Path(declared).name if declared else DEFAULT_FIXTURE_FILE
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _load_compose(path: Path) -> dict[str, Any]:
|
|
223
|
+
try:
|
|
224
|
+
doc = yaml.safe_load(path.read_text()) or {}
|
|
225
|
+
except (OSError, yaml.YAMLError):
|
|
226
|
+
return {}
|
|
227
|
+
return doc if isinstance(doc, dict) else {}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _host_port(entry: Any) -> int | None:
|
|
231
|
+
"""First host port from a compose `ports` entry, in any of its spellings."""
|
|
232
|
+
if isinstance(entry, int):
|
|
233
|
+
return entry
|
|
234
|
+
if isinstance(entry, dict):
|
|
235
|
+
published = entry.get("published")
|
|
236
|
+
return int(published) if str(published).isdigit() else None
|
|
237
|
+
if isinstance(entry, str):
|
|
238
|
+
head = entry.split(":")[0].split("/")[0]
|
|
239
|
+
return int(head) if head.isdigit() else None
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _service_urls(compose: dict[str, Any]) -> dict[str, str]:
|
|
244
|
+
"""Reachable URL per service, derived from the published ports.
|
|
245
|
+
|
|
246
|
+
A service with POSTGRES_* environment gets a libpq URL rather than an http
|
|
247
|
+
one, because that is what a caller would actually connect with.
|
|
248
|
+
"""
|
|
249
|
+
urls: dict[str, str] = {}
|
|
250
|
+
for name, spec in (compose.get("services") or {}).items():
|
|
251
|
+
if not isinstance(spec, dict):
|
|
252
|
+
continue
|
|
253
|
+
ports = spec.get("ports") or []
|
|
254
|
+
port = next((p for p in (_host_port(e) for e in ports) if p), None)
|
|
255
|
+
if port is None:
|
|
256
|
+
continue
|
|
257
|
+
env = spec.get("environment") or {}
|
|
258
|
+
env = env if isinstance(env, dict) else {}
|
|
259
|
+
if "POSTGRES_DB" in env:
|
|
260
|
+
urls[name] = (
|
|
261
|
+
f"postgresql://{env.get('POSTGRES_USER', 'postgres')}:"
|
|
262
|
+
f"{env.get('POSTGRES_PASSWORD', '')}@localhost:{port}/{env.get('POSTGRES_DB')}"
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
urls[name] = f"http://localhost:{port}"
|
|
266
|
+
return urls
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _db_service(compose: dict[str, Any]) -> tuple[str, dict[str, str]] | None:
|
|
270
|
+
"""The postgres service and its credentials, if the compose file has one."""
|
|
271
|
+
for name, spec in (compose.get("services") or {}).items():
|
|
272
|
+
env = (spec or {}).get("environment") or {}
|
|
273
|
+
if isinstance(env, dict) and "POSTGRES_DB" in env:
|
|
274
|
+
return name, {str(k): str(v) for k, v in env.items()}
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_ps(raw: str) -> list[dict[str, Any]]:
|
|
279
|
+
"""`docker compose ps --format json` — array in some versions, JSONL in others."""
|
|
280
|
+
text = raw.strip()
|
|
281
|
+
if not text:
|
|
282
|
+
return []
|
|
283
|
+
try:
|
|
284
|
+
doc = json.loads(text)
|
|
285
|
+
return doc if isinstance(doc, list) else [doc]
|
|
286
|
+
except json.JSONDecodeError:
|
|
287
|
+
pass
|
|
288
|
+
rows: list[dict[str, Any]] = []
|
|
289
|
+
for line in text.splitlines():
|
|
290
|
+
line = line.strip()
|
|
291
|
+
if not line:
|
|
292
|
+
continue
|
|
293
|
+
try:
|
|
294
|
+
row = json.loads(line)
|
|
295
|
+
except json.JSONDecodeError:
|
|
296
|
+
continue
|
|
297
|
+
if isinstance(row, dict):
|
|
298
|
+
rows.append(row)
|
|
299
|
+
return rows
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _service_state(row: dict[str, Any]) -> tuple[str, str]:
|
|
303
|
+
state = str(row.get("State") or row.get("Status") or "unknown").lower()
|
|
304
|
+
health = str(row.get("Health") or "").lower()
|
|
305
|
+
return state, health
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _is_ready(state: str, health: str) -> bool:
|
|
309
|
+
# No healthcheck means compose reports an empty Health; running is the best
|
|
310
|
+
# signal available, and pretending otherwise would hang forever on `web`.
|
|
311
|
+
if not state.startswith("running") and not state.startswith("up"):
|
|
312
|
+
return False
|
|
313
|
+
return health in ("", "healthy")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --------------------------------------------------------------------------
|
|
317
|
+
# run-scoped state
|
|
318
|
+
# --------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _flags_path(ctx: ToolContext) -> Path:
|
|
322
|
+
"""The flags file for this run.
|
|
323
|
+
|
|
324
|
+
Kept inside the run directory rather than in the app tree so a run cannot
|
|
325
|
+
leave residue in the repository under test, and so two runs never fight over
|
|
326
|
+
one file. The target API does not read it yet — see the note in `set_flag`.
|
|
327
|
+
"""
|
|
328
|
+
return ctx.store.dir / "flags.json"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _state_path(ctx: ToolContext) -> Path:
|
|
332
|
+
return ctx.store.dir / "env-state.json"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _read_json(path: Path, default: dict[str, Any]) -> dict[str, Any]:
|
|
336
|
+
if not path.exists():
|
|
337
|
+
return dict(default)
|
|
338
|
+
try:
|
|
339
|
+
doc = json.loads(path.read_text())
|
|
340
|
+
except (OSError, json.JSONDecodeError):
|
|
341
|
+
return dict(default)
|
|
342
|
+
return doc if isinstance(doc, dict) else dict(default)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
346
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _read_flags(ctx: ToolContext) -> dict[str, Any]:
|
|
351
|
+
return _read_json(_flags_path(ctx), {"flags": {}, "clock": None})
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _read_state(ctx: ToolContext) -> dict[str, Any]:
|
|
355
|
+
return _read_json(_state_path(ctx), {"fixture": None, "branch": None, "services": []})
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _current_branch(target_root: Path) -> str | None:
|
|
359
|
+
"""Best-effort git branch. Absent git is not an error here."""
|
|
360
|
+
git = shutil.which("git")
|
|
361
|
+
if git is None:
|
|
362
|
+
return None
|
|
363
|
+
try:
|
|
364
|
+
proc = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
365
|
+
[git, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
366
|
+
cwd=str(target_root), capture_output=True, text=True, timeout=10,
|
|
367
|
+
)
|
|
368
|
+
except (OSError, subprocess.SubprocessError):
|
|
369
|
+
return None
|
|
370
|
+
return proc.stdout.strip() or None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# --------------------------------------------------------------------------
|
|
374
|
+
# tools
|
|
375
|
+
# --------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def build_tools(ctx: ToolContext) -> list:
|
|
379
|
+
"""The env_control tools, bound to one agent's run context.
|
|
380
|
+
|
|
381
|
+
Split from `build` so tests can call the handlers directly without standing
|
|
382
|
+
up an MCP transport.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
compose_file = _compose_path(ctx)
|
|
386
|
+
|
|
387
|
+
def preflight(*, needs_docker: bool = True) -> dict[str, Any] | None:
|
|
388
|
+
"""One gate for the two ways this server can be unusable."""
|
|
389
|
+
if not compose_file.exists():
|
|
390
|
+
return err(
|
|
391
|
+
f"No compose file at {compose_file}. This server drives the target app "
|
|
392
|
+
"through docker compose; without that file there is no environment to control."
|
|
393
|
+
)
|
|
394
|
+
if needs_docker and docker_bin() is None:
|
|
395
|
+
override = os.environ.get(DOCKER_BIN_ENV)
|
|
396
|
+
where = f"{DOCKER_BIN_ENV}={override}" if override else "PATH"
|
|
397
|
+
return err(
|
|
398
|
+
f"The `docker` binary was not found ({where}). Every environment tool here "
|
|
399
|
+
"bottoms out in `docker compose`, so nothing can be started, seeded or reset. "
|
|
400
|
+
"Install Docker or unset the override, then retry."
|
|
401
|
+
)
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
def compose_argv(*args: str) -> list[str]:
|
|
405
|
+
return [docker_bin() or "docker", "compose", "-f", str(compose_file), *args]
|
|
406
|
+
|
|
407
|
+
async def ps_rows(timeout: float = SHORT_TIMEOUT_S) -> tuple[list[dict[str, Any]], _Proc]:
|
|
408
|
+
proc = await _exec(compose_argv("ps", "--all", "--format", "json"), timeout, cwd=ctx.target_root)
|
|
409
|
+
return (_parse_ps(proc.out) if proc.okay else []), proc
|
|
410
|
+
|
|
411
|
+
async def wait_ready(services: list[str], deadline: float) -> tuple[dict[str, dict[str, str]], str | None]:
|
|
412
|
+
"""Poll compose until every named service is ready or the deadline passes."""
|
|
413
|
+
loop = asyncio.get_running_loop()
|
|
414
|
+
observed: dict[str, dict[str, str]] = {}
|
|
415
|
+
while True:
|
|
416
|
+
rows, proc = await ps_rows()
|
|
417
|
+
if not proc.okay:
|
|
418
|
+
return observed, f"`docker compose ps` failed: {proc.tail()}"
|
|
419
|
+
observed = {}
|
|
420
|
+
for row in rows:
|
|
421
|
+
name = str(row.get("Service") or row.get("Name") or "")
|
|
422
|
+
if not name:
|
|
423
|
+
continue
|
|
424
|
+
state, health = _service_state(row)
|
|
425
|
+
observed[name] = {"state": state, "health": health}
|
|
426
|
+
|
|
427
|
+
dead = [
|
|
428
|
+
n for n in services
|
|
429
|
+
if n in observed and observed[n]["state"].startswith(("exited", "dead"))
|
|
430
|
+
]
|
|
431
|
+
if dead:
|
|
432
|
+
return observed, f"service(s) exited during startup: {', '.join(sorted(dead))}"
|
|
433
|
+
|
|
434
|
+
pending = [
|
|
435
|
+
n for n in services
|
|
436
|
+
if n not in observed or not _is_ready(observed[n]["state"], observed[n]["health"])
|
|
437
|
+
]
|
|
438
|
+
if not pending:
|
|
439
|
+
return observed, None
|
|
440
|
+
if loop.time() >= deadline:
|
|
441
|
+
return observed, f"timed out waiting for: {', '.join(sorted(pending))}"
|
|
442
|
+
await asyncio.sleep(POLL_INTERVAL_S)
|
|
443
|
+
|
|
444
|
+
async def run_seed(fixture: str) -> dict[str, Any]:
|
|
445
|
+
"""Pipe a fixture file into psql inside the db container."""
|
|
446
|
+
compose = _load_compose(compose_file)
|
|
447
|
+
db = _db_service(compose)
|
|
448
|
+
if db is None:
|
|
449
|
+
return err(f"{compose_file} declares no postgres service, so there is nothing to seed.")
|
|
450
|
+
db_name, db_env = db
|
|
451
|
+
|
|
452
|
+
if not _FIXTURE_RE.match(fixture):
|
|
453
|
+
return err(
|
|
454
|
+
f"Fixture name '{fixture}' is not a bare filename. "
|
|
455
|
+
"Pass a name like 'default' or 'refunds', never a path."
|
|
456
|
+
)
|
|
457
|
+
filename = _default_fixture(ctx) if fixture == "default" else (
|
|
458
|
+
fixture if fixture.endswith(".sql") else f"{fixture}.sql"
|
|
459
|
+
)
|
|
460
|
+
seed_dir = _seed_dir(ctx)
|
|
461
|
+
path = (seed_dir / filename).resolve()
|
|
462
|
+
if not path.is_relative_to(seed_dir):
|
|
463
|
+
return err(f"Fixture '{fixture}' resolves outside {seed_dir}.")
|
|
464
|
+
if not path.exists():
|
|
465
|
+
available = sorted(p.stem for p in seed_dir.glob("*.sql")) if seed_dir.exists() else []
|
|
466
|
+
return err(
|
|
467
|
+
f"No fixture file at {path}. "
|
|
468
|
+
+ (f"Available: {', '.join(available)}." if available else "The seed directory is empty or absent.")
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
sql = path.read_bytes()
|
|
472
|
+
proc = await _exec(
|
|
473
|
+
compose_argv(
|
|
474
|
+
"exec", "-T", db_name,
|
|
475
|
+
"psql", "-U", db_env.get("POSTGRES_USER", "postgres"),
|
|
476
|
+
"-d", db_env.get("POSTGRES_DB", "postgres"),
|
|
477
|
+
"-v", "ON_ERROR_STOP=1",
|
|
478
|
+
),
|
|
479
|
+
SEED_TIMEOUT_S,
|
|
480
|
+
stdin=sql,
|
|
481
|
+
cwd=ctx.target_root,
|
|
482
|
+
)
|
|
483
|
+
if proc.timed_out:
|
|
484
|
+
return err(f"Seeding timed out after {SEED_TIMEOUT_S}s. Is the '{db_name}' service healthy?")
|
|
485
|
+
if not proc.okay:
|
|
486
|
+
return err(f"Seeding '{fixture}' failed: {proc.tail()}")
|
|
487
|
+
|
|
488
|
+
rows = _rows_affected(sql.decode(errors="replace"), proc.out)
|
|
489
|
+
state = _read_state(ctx)
|
|
490
|
+
state["fixture"] = fixture
|
|
491
|
+
_write_json(_state_path(ctx), state)
|
|
492
|
+
ctx.store.log("env", agent=ctx.agent.name, action="seed", fixture=fixture, rows=rows)
|
|
493
|
+
summary = ", ".join(f"{t}={n}" for t, n in sorted(rows.items())) or "no row counts reported by psql"
|
|
494
|
+
return ok(f"Seeded fixture '{fixture}' from {path.name}. Rows affected: {summary}.",
|
|
495
|
+
fixture=fixture, rows_affected=rows, path=str(path))
|
|
496
|
+
|
|
497
|
+
@tool(
|
|
498
|
+
"spin_up",
|
|
499
|
+
"Start the target app with docker compose and wait for its health checks. "
|
|
500
|
+
"Returns each service's status and URL. Call this before anything that touches the app.",
|
|
501
|
+
{
|
|
502
|
+
"type": "object",
|
|
503
|
+
"properties": {
|
|
504
|
+
"branch": {"type": "string", "description": "Branch this environment is meant to represent. Checked against the working tree, never checked out for you."},
|
|
505
|
+
"services": {"type": "array", "items": {"type": "string"}, "description": "Subset of services to start. Default: all of them."},
|
|
506
|
+
"timeout_s": {"type": "integer", "minimum": 10, "maximum": 900, "description": f"Overall budget. Default {DEFAULT_UP_TIMEOUT_S}s."},
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
async def spin_up(args: dict[str, Any]) -> dict[str, Any]:
|
|
511
|
+
gate = preflight()
|
|
512
|
+
if gate:
|
|
513
|
+
return gate
|
|
514
|
+
|
|
515
|
+
compose = _load_compose(compose_file)
|
|
516
|
+
declared = list((compose.get("services") or {}).keys())
|
|
517
|
+
if not declared:
|
|
518
|
+
return err(f"{compose_file} declares no services; it is empty or malformed.")
|
|
519
|
+
|
|
520
|
+
requested = args.get("services") or declared
|
|
521
|
+
unknown = [s for s in requested if s not in declared]
|
|
522
|
+
if unknown:
|
|
523
|
+
return err(f"Unknown service(s): {', '.join(unknown)}. This compose file has: {', '.join(declared)}.")
|
|
524
|
+
|
|
525
|
+
branch = args.get("branch")
|
|
526
|
+
if branch:
|
|
527
|
+
actual = _current_branch(ctx.target_root)
|
|
528
|
+
if actual and actual != branch:
|
|
529
|
+
return err(
|
|
530
|
+
f"You asked for branch '{branch}' but the working tree is on '{actual}'. "
|
|
531
|
+
"This server builds whatever is checked out; it will not move your tree. "
|
|
532
|
+
"Check the branch out first, or call spin_up without `branch` and accept "
|
|
533
|
+
f"'{actual}' as the environment identity."
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
timeout = float(args.get("timeout_s") or DEFAULT_UP_TIMEOUT_S)
|
|
537
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
538
|
+
|
|
539
|
+
up = await _exec(compose_argv("up", "-d", *requested), timeout, cwd=ctx.target_root)
|
|
540
|
+
if up.timed_out:
|
|
541
|
+
return err(
|
|
542
|
+
f"`docker compose up -d` did not finish within {timeout:.0f}s. "
|
|
543
|
+
"The image build is the usual culprit; build it once by hand "
|
|
544
|
+
"(`docker compose build`) and retry, or raise timeout_s."
|
|
545
|
+
)
|
|
546
|
+
if not up.okay:
|
|
547
|
+
return err(
|
|
548
|
+
"`docker compose up -d` failed. The target app may not be built yet. "
|
|
549
|
+
f"Compose said: {up.tail()}"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
observed, problem = await wait_ready(list(requested), deadline)
|
|
553
|
+
urls = _service_urls(compose)
|
|
554
|
+
report = {
|
|
555
|
+
name: {
|
|
556
|
+
"state": observed.get(name, {}).get("state", "missing"),
|
|
557
|
+
"health": observed.get(name, {}).get("health") or "n/a",
|
|
558
|
+
"url": urls.get(name),
|
|
559
|
+
}
|
|
560
|
+
for name in requested
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
state = _read_state(ctx)
|
|
564
|
+
state["services"] = list(requested)
|
|
565
|
+
state["branch"] = branch or _current_branch(ctx.target_root)
|
|
566
|
+
_write_json(_state_path(ctx), state)
|
|
567
|
+
ctx.store.log("env", agent=ctx.agent.name, action="spin_up", services=list(requested), problem=problem)
|
|
568
|
+
|
|
569
|
+
if problem:
|
|
570
|
+
logs = await _exec(compose_argv("logs", "--tail", "40", *requested), SHORT_TIMEOUT_S, cwd=ctx.target_root)
|
|
571
|
+
return err(
|
|
572
|
+
f"Environment did not come up cleanly: {problem}. "
|
|
573
|
+
f"Status: {json.dumps(report)}. Last log lines: {logs.tail(2000)}"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
lines = "; ".join(f"{n}: {v['state']}/{v['health']} {v['url'] or ''}".strip() for n, v in report.items())
|
|
577
|
+
return ok(f"Environment up ({lines}).", services=report, branch=state["branch"])
|
|
578
|
+
|
|
579
|
+
@tool(
|
|
580
|
+
"seed",
|
|
581
|
+
"Load a named fixture into the database. 'default' means fixtures.sql. "
|
|
582
|
+
"Reports rows affected per table where psql tells us.",
|
|
583
|
+
{
|
|
584
|
+
"type": "object",
|
|
585
|
+
"required": ["fixture"],
|
|
586
|
+
"properties": {"fixture": {"type": "string", "description": "Bare fixture name, e.g. 'default'."}},
|
|
587
|
+
},
|
|
588
|
+
)
|
|
589
|
+
async def seed(args: dict[str, Any]) -> dict[str, Any]:
|
|
590
|
+
gate = preflight()
|
|
591
|
+
if gate:
|
|
592
|
+
return gate
|
|
593
|
+
return await run_seed(str(args["fixture"]))
|
|
594
|
+
|
|
595
|
+
@tool(
|
|
596
|
+
"reset",
|
|
597
|
+
"Return the database to its post-seed state: recreate the db container (its data is tmpfs, "
|
|
598
|
+
"so this really is a wipe), then re-apply the fixture that was last seeded.",
|
|
599
|
+
{"type": "object", "properties": {"fixture": {"type": "string", "description": "Override the fixture to re-apply. Default: whatever seed() last loaded."}}},
|
|
600
|
+
)
|
|
601
|
+
async def reset(args: dict[str, Any]) -> dict[str, Any]:
|
|
602
|
+
gate = preflight()
|
|
603
|
+
if gate:
|
|
604
|
+
return gate
|
|
605
|
+
|
|
606
|
+
compose = _load_compose(compose_file)
|
|
607
|
+
db = _db_service(compose)
|
|
608
|
+
if db is None:
|
|
609
|
+
return err(f"{compose_file} declares no postgres service, so there is nothing to reset.")
|
|
610
|
+
db_name, _ = db
|
|
611
|
+
|
|
612
|
+
state = _read_state(ctx)
|
|
613
|
+
fixture = str(args.get("fixture") or state.get("fixture") or "default")
|
|
614
|
+
|
|
615
|
+
deadline = asyncio.get_running_loop().time() + DEFAULT_UP_TIMEOUT_S
|
|
616
|
+
down = await _exec(compose_argv("rm", "--stop", "--force", "--volumes", db_name), SHORT_TIMEOUT_S * 2, cwd=ctx.target_root)
|
|
617
|
+
if not down.okay and not down.timed_out:
|
|
618
|
+
return err(f"Could not remove the '{db_name}' container: {down.tail()}")
|
|
619
|
+
|
|
620
|
+
up = await _exec(compose_argv("up", "-d", db_name), SHORT_TIMEOUT_S * 4, cwd=ctx.target_root)
|
|
621
|
+
if not up.okay:
|
|
622
|
+
return err(f"Could not restart '{db_name}': {up.tail()}")
|
|
623
|
+
|
|
624
|
+
observed, problem = await wait_ready([db_name], deadline)
|
|
625
|
+
if problem:
|
|
626
|
+
return err(f"'{db_name}' did not become healthy after reset: {problem}. Status: {json.dumps(observed)}")
|
|
627
|
+
|
|
628
|
+
# The API holds a connection pool that the recreated database just
|
|
629
|
+
# invalidated. Restarting it is part of "back to post-seed state"; a
|
|
630
|
+
# stale pool would surface as spurious 500s and a bogus finding.
|
|
631
|
+
restarted = False
|
|
632
|
+
rows, _ = await ps_rows()
|
|
633
|
+
api_services = [
|
|
634
|
+
str(r.get("Service") or "")
|
|
635
|
+
for r in rows
|
|
636
|
+
if str(r.get("Service") or "") not in ("", db_name) and _service_state(r)[0].startswith(("running", "up"))
|
|
637
|
+
]
|
|
638
|
+
if api_services:
|
|
639
|
+
restart = await _exec(compose_argv("restart", *api_services), SHORT_TIMEOUT_S * 2, cwd=ctx.target_root)
|
|
640
|
+
restarted = restart.okay
|
|
641
|
+
_, problem = await wait_ready(api_services, asyncio.get_running_loop().time() + DEFAULT_UP_TIMEOUT_S)
|
|
642
|
+
if problem:
|
|
643
|
+
return err(f"Dependent services did not recover after the db reset: {problem}")
|
|
644
|
+
|
|
645
|
+
seeded = await run_seed(fixture)
|
|
646
|
+
if seeded.get("isError"):
|
|
647
|
+
return seeded
|
|
648
|
+
|
|
649
|
+
ctx.store.log("env", agent=ctx.agent.name, action="reset", fixture=fixture)
|
|
650
|
+
detail = seeded.get("structuredContent", {})
|
|
651
|
+
return ok(
|
|
652
|
+
f"Reset complete: '{db_name}' recreated, "
|
|
653
|
+
f"{'dependents restarted, ' if restarted else ''}fixture '{fixture}' re-applied.",
|
|
654
|
+
fixture=fixture,
|
|
655
|
+
rows_affected=detail.get("rows_affected", {}),
|
|
656
|
+
restarted=api_services if restarted else [],
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
@tool(
|
|
660
|
+
"set_flag",
|
|
661
|
+
"Set one feature flag for this run. Flags are part of the environment an envelope names, "
|
|
662
|
+
"so anything you toggle here belongs in reproduction.environment.flags.",
|
|
663
|
+
{
|
|
664
|
+
"type": "object",
|
|
665
|
+
"required": ["key", "value"],
|
|
666
|
+
"properties": {
|
|
667
|
+
"key": {"type": "string", "description": "Flag name, e.g. 'checkout.new_review_step'."},
|
|
668
|
+
"value": {"description": "Any JSON value: boolean, string, number."},
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
)
|
|
672
|
+
async def set_flag(args: dict[str, Any]) -> dict[str, Any]:
|
|
673
|
+
gate = preflight(needs_docker=False)
|
|
674
|
+
if gate:
|
|
675
|
+
return gate
|
|
676
|
+
key = str(args["key"])
|
|
677
|
+
if not _FLAG_KEY_RE.match(key):
|
|
678
|
+
return err(f"Flag key '{key}' is not a plain identifier (letters, digits, . _ -, max 64 chars).")
|
|
679
|
+
doc = _read_flags(ctx)
|
|
680
|
+
doc.setdefault("flags", {})[key] = args["value"]
|
|
681
|
+
_write_json(_flags_path(ctx), doc)
|
|
682
|
+
ctx.store.log("env", agent=ctx.agent.name, action="set_flag", key=key, value=args["value"])
|
|
683
|
+
return ok(
|
|
684
|
+
f"Flag '{key}' = {json.dumps(args['value'])} recorded in {_flags_path(ctx)}. "
|
|
685
|
+
"Note: the target API does not read this file yet, so treat the flag as run metadata "
|
|
686
|
+
"until the app is wired to it — do not claim behaviour changed because of it.",
|
|
687
|
+
flags=doc["flags"], path=str(_flags_path(ctx)),
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
@tool(
|
|
691
|
+
"get_flags",
|
|
692
|
+
"Read the flags and clock override in force for this run.",
|
|
693
|
+
{"type": "object", "properties": {}},
|
|
694
|
+
)
|
|
695
|
+
async def get_flags(args: dict[str, Any]) -> dict[str, Any]:
|
|
696
|
+
gate = preflight(needs_docker=False)
|
|
697
|
+
if gate:
|
|
698
|
+
return gate
|
|
699
|
+
doc = _read_flags(ctx)
|
|
700
|
+
return ok(
|
|
701
|
+
json.dumps(doc, indent=2, sort_keys=True),
|
|
702
|
+
flags=doc.get("flags", {}), clock=doc.get("clock"), path=str(_flags_path(ctx)),
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
@tool(
|
|
706
|
+
"set_clock",
|
|
707
|
+
"Record a clock override for this run, so a time-dependent repro names the time it assumed.",
|
|
708
|
+
{
|
|
709
|
+
"type": "object",
|
|
710
|
+
"required": ["iso_timestamp"],
|
|
711
|
+
"properties": {"iso_timestamp": {"type": "string", "description": "ISO-8601, e.g. '2026-01-31T23:59:59Z'."}},
|
|
712
|
+
},
|
|
713
|
+
)
|
|
714
|
+
async def set_clock(args: dict[str, Any]) -> dict[str, Any]:
|
|
715
|
+
gate = preflight(needs_docker=False)
|
|
716
|
+
if gate:
|
|
717
|
+
return gate
|
|
718
|
+
raw = str(args["iso_timestamp"]).strip()
|
|
719
|
+
try:
|
|
720
|
+
datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
721
|
+
except ValueError:
|
|
722
|
+
return err(f"'{raw}' is not an ISO-8601 timestamp. Use e.g. '2026-01-31T23:59:59Z'.")
|
|
723
|
+
doc = _read_flags(ctx)
|
|
724
|
+
doc["clock"] = raw
|
|
725
|
+
_write_json(_flags_path(ctx), doc)
|
|
726
|
+
ctx.store.log("env", agent=ctx.agent.name, action="set_clock", clock=raw)
|
|
727
|
+
return ok(
|
|
728
|
+
f"Clock override for this run recorded as {raw} in {_flags_path(ctx)}. "
|
|
729
|
+
"The target app does not consume the override yet; record it in the envelope environment "
|
|
730
|
+
"rather than asserting the app moved in time.",
|
|
731
|
+
clock=raw, path=str(_flags_path(ctx)),
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
@tool(
|
|
735
|
+
"impersonate",
|
|
736
|
+
"Get a real bearer token for a seeded user with the given role, by logging in against the "
|
|
737
|
+
"running API. Use the token in an Authorization header exactly as a browser would.",
|
|
738
|
+
{
|
|
739
|
+
"type": "object",
|
|
740
|
+
"required": ["role"],
|
|
741
|
+
"properties": {"role": {"type": "string", "description": "A role this target declares under auth.roles."}},
|
|
742
|
+
},
|
|
743
|
+
)
|
|
744
|
+
async def impersonate(args: dict[str, Any]) -> dict[str, Any]:
|
|
745
|
+
gate = preflight(needs_docker=False)
|
|
746
|
+
if gate:
|
|
747
|
+
return gate
|
|
748
|
+
role = str(args["role"]).lower()
|
|
749
|
+
roles = _roles(ctx)
|
|
750
|
+
entry = roles.get(role)
|
|
751
|
+
if entry is None:
|
|
752
|
+
return err(
|
|
753
|
+
f"No role '{role}' for this target. Declared roles: "
|
|
754
|
+
f"{', '.join(sorted(roles)) or '(none -- set auth.roles in the target profile)'}."
|
|
755
|
+
)
|
|
756
|
+
email, password = entry
|
|
757
|
+
if not password:
|
|
758
|
+
profile = getattr(ctx.config, "profile", None)
|
|
759
|
+
auth = getattr(profile, "auth", None) if profile else None
|
|
760
|
+
var = getattr(getattr(auth, "roles", {}).get(role, None), "password_env", "QAAS_PASSWORD")
|
|
761
|
+
return err(
|
|
762
|
+
f"Role '{role}' names environment variable {var} for its password and it is unset. "
|
|
763
|
+
"Export it and try again -- credentials are never read from the profile itself."
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
compose = _load_compose(compose_file)
|
|
767
|
+
urls = _service_urls(compose)
|
|
768
|
+
base = os.environ.get("QAAS_TARGET_BASE_URL") or next(
|
|
769
|
+
(u for n, u in urls.items() if u.startswith("http") and n != "web"), None
|
|
770
|
+
)
|
|
771
|
+
if base is None:
|
|
772
|
+
return err("No HTTP service with a published port in the compose file; nowhere to log in.")
|
|
773
|
+
|
|
774
|
+
# Field names come from the profile: not every API calls them
|
|
775
|
+
# "email" and "password".
|
|
776
|
+
profile = getattr(ctx.config, "profile", None)
|
|
777
|
+
auth = getattr(profile, "auth", None) if profile else None
|
|
778
|
+
user_field = getattr(auth, "username_field", None) or "email"
|
|
779
|
+
pass_field = getattr(auth, "password_field", None) or "password"
|
|
780
|
+
payload = json.dumps({user_field: email, pass_field: password}).encode()
|
|
781
|
+
request = urllib.request.Request(
|
|
782
|
+
f"{base.rstrip('/')}{_login_path(ctx)}", data=payload,
|
|
783
|
+
headers={"Content-Type": "application/json"}, method="POST",
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
def _post() -> tuple[int, str]:
|
|
787
|
+
try:
|
|
788
|
+
with urllib.request.urlopen(request, timeout=LOGIN_TIMEOUT_S) as resp: # noqa: S310 - fixed http scheme
|
|
789
|
+
return resp.status, resp.read().decode(errors="replace")
|
|
790
|
+
except urllib.error.HTTPError as exc:
|
|
791
|
+
return exc.code, exc.read().decode(errors="replace")
|
|
792
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
793
|
+
return 0, str(exc)
|
|
794
|
+
|
|
795
|
+
status, body = await asyncio.to_thread(_post)
|
|
796
|
+
if status == 0:
|
|
797
|
+
return err(
|
|
798
|
+
f"Could not reach the API at {base} ({body}). Call spin_up first; "
|
|
799
|
+
"if it is up, the api service is not healthy."
|
|
800
|
+
)
|
|
801
|
+
if status != 200:
|
|
802
|
+
return err(
|
|
803
|
+
f"Login as {email} returned HTTP {status}: {body[:400]}. "
|
|
804
|
+
"The database may not be seeded — call seed('default')."
|
|
805
|
+
)
|
|
806
|
+
try:
|
|
807
|
+
doc = json.loads(body)
|
|
808
|
+
except json.JSONDecodeError:
|
|
809
|
+
return err(f"Login returned HTTP 200 but not JSON: {body[:200]}")
|
|
810
|
+
token = doc.get("access_token")
|
|
811
|
+
if not token:
|
|
812
|
+
return err(f"Login response has no access_token: {body[:200]}")
|
|
813
|
+
|
|
814
|
+
ctx.store.log("env", agent=ctx.agent.name, action="impersonate", role=role, email=email)
|
|
815
|
+
return ok(
|
|
816
|
+
f"Signed in as {email} ({doc.get('role', role)}). Send header: Authorization: Bearer <token>.",
|
|
817
|
+
role=doc.get("role", role), email=email, token=token, base_url=base,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
@tool(
|
|
821
|
+
"status",
|
|
822
|
+
"What is running, which fixture is loaded, which flags are set. "
|
|
823
|
+
"Read this before assuming the environment is in the state you left it.",
|
|
824
|
+
{"type": "object", "properties": {}},
|
|
825
|
+
)
|
|
826
|
+
async def status(args: dict[str, Any]) -> dict[str, Any]:
|
|
827
|
+
gate = preflight()
|
|
828
|
+
if gate:
|
|
829
|
+
return gate
|
|
830
|
+
compose = _load_compose(compose_file)
|
|
831
|
+
urls = _service_urls(compose)
|
|
832
|
+
rows, proc = await ps_rows()
|
|
833
|
+
if not proc.okay:
|
|
834
|
+
return err(
|
|
835
|
+
"`docker compose ps` failed, so the environment state is unknown: "
|
|
836
|
+
f"{proc.tail()}"
|
|
837
|
+
)
|
|
838
|
+
services = {}
|
|
839
|
+
for row in rows:
|
|
840
|
+
name = str(row.get("Service") or row.get("Name") or "")
|
|
841
|
+
if not name:
|
|
842
|
+
continue
|
|
843
|
+
state, health = _service_state(row)
|
|
844
|
+
services[name] = {"state": state, "health": health or "n/a", "url": urls.get(name)}
|
|
845
|
+
|
|
846
|
+
env_state = _read_state(ctx)
|
|
847
|
+
flags = _read_flags(ctx)
|
|
848
|
+
payload = {
|
|
849
|
+
"services": services,
|
|
850
|
+
"fixture": env_state.get("fixture"),
|
|
851
|
+
"branch": env_state.get("branch") or _current_branch(ctx.target_root),
|
|
852
|
+
"flags": flags.get("flags", {}),
|
|
853
|
+
"clock": flags.get("clock"),
|
|
854
|
+
}
|
|
855
|
+
running = [n for n, v in services.items() if v["state"].startswith(("running", "up"))]
|
|
856
|
+
headline = f"{len(running)} service(s) running: {', '.join(sorted(running))}." if running else "Nothing is running."
|
|
857
|
+
return ok(f"{headline} Fixture: {payload['fixture'] or 'none loaded'}.", **payload)
|
|
858
|
+
|
|
859
|
+
@tool(
|
|
860
|
+
"tear_down",
|
|
861
|
+
"Stop the target app and delete its volumes. Call when you are done; leaving it up costs "
|
|
862
|
+
"the next run a confusing dirty state.",
|
|
863
|
+
{"type": "object", "properties": {}},
|
|
864
|
+
)
|
|
865
|
+
async def tear_down(args: dict[str, Any]) -> dict[str, Any]:
|
|
866
|
+
gate = preflight()
|
|
867
|
+
if gate:
|
|
868
|
+
return gate
|
|
869
|
+
proc = await _exec(compose_argv("down", "-v"), SHORT_TIMEOUT_S * 4, cwd=ctx.target_root)
|
|
870
|
+
if proc.timed_out:
|
|
871
|
+
return err(f"`docker compose down -v` timed out after {SHORT_TIMEOUT_S * 4}s; containers may still be up.")
|
|
872
|
+
if not proc.okay:
|
|
873
|
+
return err(f"`docker compose down -v` failed: {proc.tail()}")
|
|
874
|
+
state = _read_state(ctx)
|
|
875
|
+
state.update({"services": [], "fixture": None})
|
|
876
|
+
_write_json(_state_path(ctx), state)
|
|
877
|
+
ctx.store.log("env", agent=ctx.agent.name, action="tear_down")
|
|
878
|
+
return ok("Environment torn down and volumes removed.")
|
|
879
|
+
|
|
880
|
+
return [spin_up, seed, reset, set_flag, get_flags, set_clock, impersonate, status, tear_down]
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _rows_affected(sql: str, psql_output: str) -> dict[str, int]:
|
|
884
|
+
"""Pair psql's command tags with the DML statements that produced them.
|
|
885
|
+
|
|
886
|
+
psql reports `INSERT 0 12` without naming the table, so the only way to
|
|
887
|
+
attribute counts is positionally: the nth tag belongs to the nth DML
|
|
888
|
+
statement. If the two sequences disagree in length we report the prefix we
|
|
889
|
+
can trust rather than guessing — a wrong row count is worse than none.
|
|
890
|
+
"""
|
|
891
|
+
targets = [m.group(2) for m in _DML_RE.finditer(sql)]
|
|
892
|
+
counts: list[int] = []
|
|
893
|
+
for line in psql_output.splitlines():
|
|
894
|
+
match = _TAG_RE.match(line.strip())
|
|
895
|
+
if match:
|
|
896
|
+
counts.append(int(match.group(2) or match.group(4) or 0))
|
|
897
|
+
rows: dict[str, int] = {}
|
|
898
|
+
for table, count in zip(targets, counts):
|
|
899
|
+
rows[table] = rows.get(table, 0) + count
|
|
900
|
+
return rows
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def build(ctx: ToolContext):
|
|
904
|
+
"""Construct the env_control MCP server bound to one agent's run context."""
|
|
905
|
+
return create_sdk_mcp_server(name="env_control", version="1.0.0", tools=build_tools(ctx))
|