dev-guru 0.2.0__tar.gz → 0.3.0__tar.gz

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 (40) hide show
  1. {dev_guru-0.2.0 → dev_guru-0.3.0}/CHANGELOG.md +16 -0
  2. {dev_guru-0.2.0 → dev_guru-0.3.0}/PKG-INFO +3 -2
  3. {dev_guru-0.2.0 → dev_guru-0.3.0}/README.md +1 -0
  4. {dev_guru-0.2.0 → dev_guru-0.3.0}/pyproject.toml +1 -1
  5. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/cli.py +37 -0
  6. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/inprocess.py +50 -0
  7. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/negotiation.py +7 -3
  8. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/port.py +25 -1
  9. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/errors.py +11 -0
  10. dev_guru-0.3.0/src/dev_guru/gateway/__init__.py +5 -0
  11. dev_guru-0.3.0/src/dev_guru/gateway/server.py +366 -0
  12. dev_guru-0.3.0/src/dev_guru/gateway/static/index.html +12 -0
  13. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/version.py +1 -1
  14. dev_guru-0.3.0/tests/test_gateway.py +423 -0
  15. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_negotiation.py +3 -3
  16. {dev_guru-0.2.0 → dev_guru-0.3.0}/.gitignore +0 -0
  17. {dev_guru-0.2.0 → dev_guru-0.3.0}/LICENSE +0 -0
  18. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/__init__.py +0 -0
  19. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/__main__.py +0 -0
  20. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/__init__.py +0 -0
  21. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/diagram/__init__.py +0 -0
  22. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/present/__init__.py +0 -0
  23. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/__init__.py +0 -0
  24. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/assets/explorer.css +0 -0
  25. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/assets/explorer.html +0 -0
  26. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/assets/explorer.js +0 -0
  27. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/model.py +0 -0
  28. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/pages.py +0 -0
  29. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/profiles.py +0 -0
  30. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/render.py +0 -0
  31. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/sdk.py +0 -0
  32. {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/service.py +0 -0
  33. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/conftest.py +0 -0
  34. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_cli.py +0 -0
  35. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_client.py +0 -0
  36. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_diagram.py +0 -0
  37. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_facade_features.py +0 -0
  38. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_packaging.py +0 -0
  39. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_present.py +0 -0
  40. {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_reports.py +0 -0
@@ -10,6 +10,22 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — [SemVer](ht
10
10
 
11
11
  ---
12
12
 
13
+ ## [0.3.0] — 2026-11-03
14
+
15
+ ### Added
16
+
17
+ - **`dev serve`, a local gateway to Ananke.** It listens on loopback only. A single-use link signs in one browser, and
18
+ changes also need a same-origin `Origin`, a CSRF token and a JSON body. Workspaces are named by opaque id, and no route
19
+ takes a command or path to run. It starts and cancels runs, reads events and artifacts, previews workflows and serves
20
+ the code graph. There's no web interface yet. Guide: [The local gateway](docs/guides/gateway.md).
21
+ - The client port gains run, event, artifact, plan and code graph operations, so the gateway reaches Ananke only through it.
22
+
23
+ ### Changed
24
+
25
+ - Needs Ananke 0.3.17 or newer, for its facade that's safe across threads.
26
+
27
+ ---
28
+
13
29
  ## [0.2.0] — 2026-11-03
14
30
 
15
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dev-guru
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Dev Guru: understand, improve, verify and learn from your software. A local-first developer product on the Ananke Plexus harness: Python SDK and CLI first, offline HTML reports, later a local web UI.
5
5
  Project-URL: Homepage, https://github.com/Neo-Pragnya/Dev-Guru
6
6
  Project-URL: Repository, https://github.com/Neo-Pragnya/Dev-Guru
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Topic :: Software Development :: Quality Assurance
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.11
25
- Requires-Dist: ananke-plexus<0.4,>=0.3.13
25
+ Requires-Dist: ananke-plexus<0.4,>=0.3.17
26
26
  Requires-Dist: markdown-it-py<5,>=3
27
27
  Requires-Dist: pydantic<3,>=2.11
28
28
  Requires-Dist: rich>=13
@@ -90,6 +90,7 @@ dev present talk.md # Markdown slides → one offline, accessib
90
90
  dev diagram docs/arch.md # an animated SVG of what a document states, every edge cited
91
91
  dev explore . --profile public # a report safe to publish: no source, docstrings or Git revision
92
92
  dev sources # capabilities from your folders, private Git and Dex Trove
93
+ dev serve # a local, authenticated gateway to Ananke for browsers
93
94
  dev learning review NAME # review what Ananke learned before it becomes reusable
94
95
  ```
95
96
 
@@ -47,6 +47,7 @@ dev present talk.md # Markdown slides → one offline, accessib
47
47
  dev diagram docs/arch.md # an animated SVG of what a document states, every edge cited
48
48
  dev explore . --profile public # a report safe to publish: no source, docstrings or Git revision
49
49
  dev sources # capabilities from your folders, private Git and Dex Trove
50
+ dev serve # a local, authenticated gateway to Ananke for browsers
50
51
  dev learning review NAME # review what Ananke learned before it becomes reusable
51
52
  ```
52
53
 
@@ -28,7 +28,7 @@ classifiers = [
28
28
  # Dev Guru is a client of Ananke: the harness owns analysis, execution and evidence (docs/architecture.md).
29
29
  # Keep this range in step with SUPPORTED_ANANKE in src/dev_guru/client/negotiation.py (tests/test_packaging.py).
30
30
  dependencies = [
31
- "ananke-plexus>=0.3.13,<0.4",
31
+ "ananke-plexus>=0.3.17,<0.4",
32
32
  "markdown-it-py>=3,<5",
33
33
  "pydantic>=2.11,<3",
34
34
  "rich>=13",
@@ -286,6 +286,43 @@ def learning_review(
286
286
  webbrowser.open(out.resolve().as_uri())
287
287
 
288
288
 
289
+ @app.command()
290
+ def serve(
291
+ workspaces: Annotated[
292
+ list[Path] | None, typer.Argument(help="Projects the browser may reach (default: here)")
293
+ ] = None,
294
+ port: Annotated[int, typer.Option(help="Local port (default: any free one)")] = 0,
295
+ open_browser: Annotated[bool, typer.Option("--open/--no-open", help="Open the browser")] = True,
296
+ transport: TransportOpt = "in-process",
297
+ ) -> None:
298
+ """Start the local web gateway. It only listens on this computer, and only the printed link gets in."""
299
+ from dev_guru.gateway import Gateway
300
+
301
+ try:
302
+ client = connect(transport)
303
+ client.require_compatible() # type: ignore[attr-defined]
304
+ gateway = Gateway(workspaces or [Path.cwd()], client, port=port)
305
+ except errors.GuruError as exc:
306
+ _fail(exc, False, 2)
307
+ except OSError as exc:
308
+ _fail(
309
+ errors.GuruError(errors.INVALID_INPUT, f"can't listen on port {port}: {exc}"), False, 2
310
+ )
311
+ # printed plainly: rich would wrap a long link, and this link is the way in
312
+ print(
313
+ f"Dev Guru is listening on this computer only. Open this link, which works once:\n{gateway.login_url}",
314
+ flush=True,
315
+ )
316
+ if open_browser:
317
+ webbrowser.open(gateway.login_url)
318
+ try:
319
+ gateway.serve_forever()
320
+ except KeyboardInterrupt:
321
+ pass
322
+ finally:
323
+ gateway.shutdown()
324
+
325
+
289
326
  @app.command()
290
327
  def diagram(
291
328
  document: Annotated[Path, typer.Argument(help="An approved Markdown document")],
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections import Counter
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
7
8
  from typing import Any
8
9
 
@@ -87,6 +88,55 @@ class InProcessAnanke:
87
88
  raise errors.GuruError(errors.HARNESS_INCOMPATIBLE, exc.message) from exc
88
89
  return facade
89
90
 
91
+ def _call(self, workspace: Path, work: Callable[[Any], Any]) -> Any:
92
+ """Run one facade call, turning what Ananke refuses into a typed error that keeps its code."""
93
+ from ananke.plexus.app import AppError
94
+
95
+ facade = self._facade(self._root(workspace))
96
+ try:
97
+ return work(facade)
98
+ except AppError as exc:
99
+ raise errors.HarnessRefused(exc.code, exc.message, retryable=exc.retryable) from exc
100
+
101
+ def harness_capabilities(self, workspace: Path) -> dict[str, Any]:
102
+ return dict(self._call(workspace, lambda f: f.capabilities()))
103
+
104
+ def code_view(self, workspace: Path, **filters: Any) -> dict[str, Any]:
105
+ return dict(self._call(workspace, lambda f: f.code_view(**filters)))
106
+
107
+ def prepare_plan(self, workspace: Path, draft: dict[str, Any]) -> dict[str, Any]:
108
+ return dict(self._call(workspace, lambda f: f.preview_workflow(draft)))
109
+
110
+ def submit_run(
111
+ self, workspace: Path, kind: str, parameters: dict[str, Any], idempotency_key: str
112
+ ) -> dict[str, Any]:
113
+ return dict(
114
+ self._call(
115
+ workspace,
116
+ lambda f: f.submit_run(
117
+ f.prepare_run(kind, parameters), idempotency_key, start=False
118
+ ),
119
+ )
120
+ )
121
+
122
+ def drive_run(self, workspace: Path, run_id: str) -> dict[str, Any]:
123
+ return dict(self._call(workspace, lambda f: f.drive(run_id)))
124
+
125
+ def get_run(self, workspace: Path, run_id: str) -> dict[str, Any]:
126
+ return dict(self._call(workspace, lambda f: f.get_run(run_id)))
127
+
128
+ def run_events(self, workspace: Path, run_id: str, after: int = 0) -> dict[str, Any]:
129
+ return dict(self._call(workspace, lambda f: f.stream_events(run_id, after)))
130
+
131
+ def cancel_run(self, workspace: Path, run_id: str) -> dict[str, Any]:
132
+ return dict(self._call(workspace, lambda f: f.cancel_run(run_id)))
133
+
134
+ def run_artifacts(self, workspace: Path, run_id: str) -> list[dict[str, Any]]:
135
+ return list(self._call(workspace, lambda f: f.list_artifacts(run_id)))
136
+
137
+ def read_artifact(self, workspace: Path, run_id: str, name: str) -> bytes:
138
+ return bytes(self._call(workspace, lambda f: f.read_artifact(run_id, name)))
139
+
90
140
  def graph_data(self, workspace: Path) -> GraphData:
91
141
  """Run Ananke's graph build through the app facade and read the snapshot artifact it produced."""
92
142
  info = self.require_compatible()
@@ -11,10 +11,8 @@ import importlib
11
11
  import re
12
12
  from importlib import metadata
13
13
 
14
- SUPPORTED_ANANKE = ">=0.3.13,<0.4" # keep in step with pyproject.toml (tests/test_packaging.py)
15
- _MIN = (0, 3, 13)
14
+ SUPPORTED_ANANKE = ">=0.3.17,<0.4" # keep in step with pyproject.toml (tests/test_packaging.py)
16
15
  APP_PROTOCOL = "ananke-app/1" # the app facade protocol this Dev Guru speaks
17
- _BELOW = (0, 4, 0)
18
16
 
19
17
  # Capability id -> (module, attribute path). Required ones are what this release of Dev Guru calls.
20
18
  CAPABILITIES: dict[str, tuple[str, str]] = {
@@ -36,6 +34,12 @@ def parse_version(text: str) -> tuple[int, int, int] | None:
36
34
  return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None
37
35
 
38
36
 
37
+ _MIN, _BELOW = (
38
+ tuple(int(part) for part in bound.split("."))
39
+ for bound in re.fullmatch(r">=([\d.]+),<([\d.]+)", SUPPORTED_ANANKE).groups() # type: ignore[union-attr]
40
+ )
41
+
42
+
39
43
  def supported(version: str) -> bool:
40
44
  parsed = parse_version(version)
41
45
  return parsed is not None and _MIN <= parsed < _BELOW
@@ -9,7 +9,7 @@ silently swapped for another (docs/concepts/ananke-client.md).
9
9
  from __future__ import annotations
10
10
 
11
11
  from pathlib import Path
12
- from typing import Protocol, runtime_checkable
12
+ from typing import Any, Protocol, runtime_checkable
13
13
 
14
14
  from pydantic import BaseModel, Field
15
15
 
@@ -125,6 +125,30 @@ class AnankeClient(Protocol):
125
125
 
126
126
  def learning_candidate(self, workspace: Path, name: str) -> LearningCandidate: ...
127
127
 
128
+ def harness_capabilities(self, workspace: Path) -> dict[str, Any]: ...
129
+
130
+ def code_view(self, workspace: Path, **filters: Any) -> dict[str, Any]: ...
131
+
132
+ def prepare_plan(self, workspace: Path, draft: dict[str, Any]) -> dict[str, Any]: ...
133
+
134
+ def submit_run(
135
+ self, workspace: Path, kind: str, parameters: dict[str, Any], idempotency_key: str
136
+ ) -> dict[str, Any]:
137
+ """Record a run without starting it. The same key and request return the same run."""
138
+ ...
139
+
140
+ def drive_run(self, workspace: Path, run_id: str) -> dict[str, Any]: ...
141
+
142
+ def get_run(self, workspace: Path, run_id: str) -> dict[str, Any]: ...
143
+
144
+ def run_events(self, workspace: Path, run_id: str, after: int = 0) -> dict[str, Any]: ...
145
+
146
+ def cancel_run(self, workspace: Path, run_id: str) -> dict[str, Any]: ...
147
+
148
+ def run_artifacts(self, workspace: Path, run_id: str) -> list[dict[str, Any]]: ...
149
+
150
+ def read_artifact(self, workspace: Path, run_id: str, name: str) -> bytes: ...
151
+
128
152
 
129
153
  __all__ = [
130
154
  "AnankeClient",
@@ -10,6 +10,7 @@ UNSUPPORTED_TRANSPORT = "UNSUPPORTED_TRANSPORT"
10
10
  WORKSPACE_INVALID = "WORKSPACE_INVALID"
11
11
  HARNESS_FAILED = "HARNESS_FAILED"
12
12
  INVALID_INPUT = "INVALID_INPUT"
13
+ HARNESS_REFUSED = "HARNESS_REFUSED"
13
14
  CODES = frozenset(
14
15
  {
15
16
  HARNESS_MISSING,
@@ -19,6 +20,7 @@ CODES = frozenset(
19
20
  WORKSPACE_INVALID,
20
21
  HARNESS_FAILED,
21
22
  INVALID_INPUT,
23
+ HARNESS_REFUSED,
22
24
  }
23
25
  )
24
26
 
@@ -44,3 +46,12 @@ class GuruError(Exception):
44
46
  "hint": self.hint,
45
47
  "run_id": self.run_id,
46
48
  }
49
+
50
+
51
+ class HarnessRefused(GuruError):
52
+ """Ananke refused a request. ``harness_code`` is its own stable code, kept so a client can branch on it."""
53
+
54
+ def __init__(self, harness_code: str, message: str, *, retryable: bool = False) -> None:
55
+ super().__init__(HARNESS_REFUSED, message)
56
+ self.harness_code = harness_code
57
+ self.retryable = retryable
@@ -0,0 +1,5 @@
1
+ """The local web gateway: authenticates one browser session and forwards to the Ananke client port."""
2
+
3
+ from dev_guru.gateway.server import Gateway
4
+
5
+ __all__ = ["Gateway"]
@@ -0,0 +1,366 @@
1
+ """A loopback HTTP gateway from one browser session to the Ananke client port.
2
+
3
+ It holds no domain logic: Ananke compiles, authorizes, runs and records. The gateway's job is to make sure the caller
4
+ is this user's browser session, because any web page the user visits can send requests to a local port:
5
+
6
+ - it binds to the loopback address only, with no option to change that;
7
+ - ``Host`` must name this server, which stops DNS rebinding;
8
+ - a single-use token from the terminal is exchanged for a session cookie, then scrubbed from the URL;
9
+ - requests that change anything also need a same-origin ``Origin``, a CSRF token and a JSON body;
10
+ - there are no CORS headers, so other origins can't read a response;
11
+ - browsers name a workspace by opaque id, so no request can reach a path outside the roots given at start;
12
+ - no route accepts a command or a path to run.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import re
21
+ import secrets
22
+ import threading
23
+ from concurrent.futures import ThreadPoolExecutor
24
+ from http.cookies import CookieError, SimpleCookie
25
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from urllib.parse import parse_qs, urlsplit
29
+
30
+ from dev_guru import errors
31
+ from dev_guru.client.port import AnankeClient
32
+ from dev_guru.version import __version__
33
+
34
+ HOST = "127.0.0.1"
35
+ MAX_BODY_BYTES = 2_000_000
36
+ RUN_WORKERS = 2
37
+ CSRF_HEADER = "X-Dev-Guru-CSRF"
38
+ _RUN_ID = r"run-[0-9a-f]+"
39
+ _ARTIFACT = r"[A-Za-z0-9_][A-Za-z0-9_.-]*"
40
+ # The status for what Ananke refuses; anything retryable is 503, and anything else here is 400.
41
+ _REFUSAL_STATUS = {
42
+ "RUN_NOT_FOUND": 404,
43
+ "ARTIFACT_NOT_FOUND": 404,
44
+ "SOURCE_NOT_FOUND": 404,
45
+ "IDEMPOTENCY_CONFLICT": 409,
46
+ "STALE_PLAN": 409,
47
+ "DIGEST_MISMATCH": 409,
48
+ }
49
+
50
+
51
+ class _Reject(Exception):
52
+ def __init__(self, status: int, code: str, message: str) -> None:
53
+ super().__init__(message)
54
+ self.status, self.code, self.message = status, code, message
55
+
56
+
57
+ class Gateway:
58
+ def __init__(self, roots: list[Path], client: AnankeClient, *, port: int = 0) -> None:
59
+ if not roots:
60
+ raise errors.GuruError(errors.INVALID_INPUT, "the gateway needs at least one workspace")
61
+ self.client = client
62
+ self.roots: dict[str, Path] = {}
63
+ for root in roots:
64
+ resolved = root.expanduser().resolve()
65
+ if not resolved.is_dir():
66
+ raise errors.GuruError(errors.WORKSPACE_INVALID, f"{resolved} is not a directory")
67
+ self.roots[hashlib.sha256(str(resolved).encode()).hexdigest()[:12]] = resolved
68
+ self._session = secrets.token_urlsafe(32)
69
+ self._login_token: str | None = secrets.token_urlsafe(32)
70
+ self._login_lock = threading.Lock()
71
+ self.csrf = hmac.new(self._session.encode(), b"csrf", hashlib.sha256).hexdigest()
72
+ self._server = ThreadingHTTPServer((HOST, port), _handler_for(self))
73
+ self._server.daemon_threads = True
74
+ self.port = int(self._server.server_address[1])
75
+ self.cookie_name = f"dev_guru_session_{self.port}" # cookies aren't separated by port
76
+ self.origins = {f"http://{HOST}:{self.port}", f"http://localhost:{self.port}"}
77
+ self.hosts = {f"{HOST}:{self.port}", f"localhost:{self.port}"}
78
+ self.runs = ThreadPoolExecutor(max_workers=RUN_WORKERS, thread_name_prefix="dev-guru-run")
79
+
80
+ @property
81
+ def login_url(self) -> str:
82
+ return f"http://{HOST}:{self.port}/login?token={self._login_token}"
83
+
84
+ def redeem(self, token: str) -> bool:
85
+ """The login token works once. A link left in a history or a terminal log opens nothing later."""
86
+ with self._login_lock:
87
+ if self._login_token and hmac.compare_digest(token, self._login_token):
88
+ self._login_token = None
89
+ return True
90
+ return False
91
+
92
+ def is_session(self, value: str) -> bool:
93
+ return hmac.compare_digest(value, self._session)
94
+
95
+ def session_cookie(self) -> str:
96
+ return f"{self.cookie_name}={self._session}; HttpOnly; SameSite=Strict; Path=/"
97
+
98
+ def serve_forever(self) -> None:
99
+ self._server.serve_forever()
100
+
101
+ def shutdown(self) -> None:
102
+ self._server.shutdown()
103
+ self._server.server_close()
104
+ self.runs.shutdown(wait=True)
105
+
106
+ def workspace(self, workspace_id: object) -> Path:
107
+ root = self.roots.get(str(workspace_id))
108
+ if root is None:
109
+ raise _Reject(404, "WORKSPACE_NOT_FOUND", "no such workspace: see /api/v1/workspaces")
110
+ return root
111
+
112
+ def drive(self, root: Path, run_id: str) -> None:
113
+ try:
114
+ self.client.drive_run(root, run_id)
115
+ except (
116
+ Exception
117
+ ): # the harness records failures on the run itself; this thread has nowhere to report
118
+ return
119
+
120
+
121
+ def _handler_for(gateway: Gateway) -> type[BaseHTTPRequestHandler]:
122
+ class Handler(_Handler):
123
+ pass
124
+
125
+ Handler.gateway = gateway
126
+ return Handler
127
+
128
+
129
+ class _Handler(BaseHTTPRequestHandler):
130
+ gateway: Gateway
131
+ server_version = "DevGuruGateway"
132
+ sys_version = ""
133
+ protocol_version = "HTTP/1.1"
134
+
135
+ def log_message(self, format: str, *args: Any) -> None:
136
+ """Silent: a request line can carry the login token."""
137
+
138
+ # ------------------------------------------------------------------ responses
139
+ def _security_headers(self) -> None:
140
+ self.send_header("X-Content-Type-Options", "nosniff")
141
+ self.send_header("Referrer-Policy", "no-referrer")
142
+ self.send_header("Cache-Control", "no-store")
143
+ self.send_header("Cross-Origin-Resource-Policy", "same-origin")
144
+ self.send_header(
145
+ "Content-Security-Policy",
146
+ "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
147
+ )
148
+
149
+ def _send(self, status: int, body: bytes, media_type: str, *extra: tuple[str, str]) -> None:
150
+ self.send_response(status)
151
+ self.send_header("Content-Type", media_type)
152
+ self.send_header("Content-Length", str(len(body)))
153
+ self._security_headers()
154
+ for name, value in extra:
155
+ self.send_header(name, value)
156
+ self.end_headers()
157
+ self.wfile.write(body)
158
+
159
+ def _json(self, payload: object, status: int = 200) -> None:
160
+ self._send(status, json.dumps(payload).encode(), "application/json")
161
+
162
+ def _error(self, status: int, code: str, message: str, *, retryable: bool = False) -> None:
163
+ self.close_connection = True # an unread body mustn't be parsed as the next request
164
+ self._json(
165
+ {"ok": False, "error": {"code": code, "message": message, "retryable": retryable}},
166
+ status,
167
+ )
168
+
169
+ # ------------------------------------------------------------------ checks
170
+ def _check_host(self) -> None:
171
+ if self.headers.get("Host", "") not in self.gateway.hosts:
172
+ raise _Reject(403, "HOST_REJECTED", "this request isn't addressed to this gateway")
173
+
174
+ def _check_origin(self, *, required: bool) -> None:
175
+ origin = self.headers.get("Origin")
176
+ if origin is None:
177
+ if required:
178
+ raise _Reject(403, "ORIGIN_REJECTED", "a change needs a same-origin Origin header")
179
+ elif origin not in self.gateway.origins:
180
+ raise _Reject(403, "ORIGIN_REJECTED", "requests from other origins aren't accepted")
181
+ if self.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
182
+ raise _Reject(403, "ORIGIN_REJECTED", "requests from other sites aren't accepted")
183
+
184
+ def _has_session(self) -> bool:
185
+ try:
186
+ cookies = SimpleCookie(self.headers.get("Cookie", ""))
187
+ except CookieError:
188
+ return False
189
+ morsel = cookies.get(self.gateway.cookie_name)
190
+ return morsel is not None and self.gateway.is_session(morsel.value)
191
+
192
+ def _check_session(self) -> None:
193
+ if not self._has_session():
194
+ raise _Reject(401, "NOT_AUTHENTICATED", "open the link `dev serve` printed")
195
+
196
+ def _check_csrf(self) -> None:
197
+ sent = self.headers.get(CSRF_HEADER, "")
198
+ if not hmac.compare_digest(sent, self.gateway.csrf):
199
+ raise _Reject(403, "CSRF_REJECTED", f"a change needs the {CSRF_HEADER} header")
200
+
201
+ def _read_json(self) -> dict[str, Any]:
202
+ if self.headers.get_content_type() != "application/json":
203
+ raise _Reject(415, "UNSUPPORTED_MEDIA_TYPE", "send application/json")
204
+ try:
205
+ length = int(self.headers.get("Content-Length", ""))
206
+ except ValueError:
207
+ raise _Reject(411, "LENGTH_REQUIRED", "send a Content-Length") from None
208
+ if not 0 <= length <= MAX_BODY_BYTES:
209
+ raise _Reject(413, "TOO_LARGE", f"a request may be at most {MAX_BODY_BYTES} bytes")
210
+ try:
211
+ body = json.loads(self.rfile.read(length) or b"{}")
212
+ except ValueError:
213
+ raise _Reject(400, "INVALID_JSON", "the body isn't valid JSON") from None
214
+ if not isinstance(body, dict):
215
+ raise _Reject(400, "INVALID_JSON", "the body must be a JSON object")
216
+ return body
217
+
218
+ # ------------------------------------------------------------------ dispatch
219
+ def _guarded(self, work: Any) -> None:
220
+ try:
221
+ self._check_host()
222
+ work()
223
+ except _Reject as exc:
224
+ self._error(exc.status, exc.code, exc.message)
225
+ except errors.HarnessRefused as exc:
226
+ status = 503 if exc.retryable else _REFUSAL_STATUS.get(exc.harness_code, 400)
227
+ self._error(status, exc.harness_code, exc.message, retryable=exc.retryable)
228
+ except errors.GuruError as exc:
229
+ self._error(503, exc.code, exc.message)
230
+ except (BrokenPipeError, ConnectionResetError):
231
+ self.close_connection = True
232
+ except Exception: # never hand internals to a browser
233
+ self._error(500, "INTERNAL_ERROR", "the gateway hit an unexpected error")
234
+
235
+ def do_GET(self) -> None:
236
+ self._guarded(self._get)
237
+
238
+ def do_POST(self) -> None:
239
+ self._guarded(self._post)
240
+
241
+ def _refuse_method(self) -> None:
242
+ self._guarded(lambda: self._error(405, "METHOD_NOT_ALLOWED", "use GET or POST"))
243
+
244
+ do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_HEAD = do_TRACE = _refuse_method
245
+
246
+ def _get(self) -> None:
247
+ url = urlsplit(self.path)
248
+ query = {k: v[-1] for k, v in parse_qs(url.query).items()}
249
+ if url.path == "/login":
250
+ self._login(query.get("token", ""))
251
+ return
252
+ self._check_origin(required=False)
253
+ self._check_session()
254
+ if url.path == "/":
255
+ page = (Path(__file__).parent / "static" / "index.html").read_bytes()
256
+ self._send(200, page, "text/html; charset=utf-8")
257
+ return
258
+ self._api_get(url.path, query)
259
+
260
+ def _login(self, token: str) -> None:
261
+ if not self.gateway.redeem(token):
262
+ raise _Reject(401, "NOT_AUTHENTICATED", "this link was already used or isn't valid")
263
+ self.send_response(303)
264
+ self.send_header("Location", "/")
265
+ self.send_header("Set-Cookie", self.gateway.session_cookie())
266
+ self.send_header("Content-Length", "0")
267
+ self._security_headers()
268
+ self.end_headers()
269
+
270
+ @staticmethod
271
+ def _int(query: dict[str, str], name: str, default: int) -> int:
272
+ try:
273
+ return int(query.get(name, default))
274
+ except ValueError:
275
+ raise _Reject(400, "INVALID_REQUEST", f"{name} must be a whole number") from None
276
+
277
+ def _api_get(self, path: str, query: dict[str, str]) -> None:
278
+ gateway, client = self.gateway, self.gateway.client
279
+ if path == "/api/v1/session":
280
+ self._json({"csrf_header": CSRF_HEADER, "csrf_token": gateway.csrf})
281
+ elif path == "/api/v1/health":
282
+ info = client.info()
283
+ self._json(
284
+ {"ok": info.compatible, "gateway": __version__, "harness": info.model_dump()}
285
+ )
286
+ elif path == "/api/v1/workspaces":
287
+ rows = [{"id": k, "name": v.name, "path": str(v)} for k, v in gateway.roots.items()]
288
+ self._json({"workspaces": rows})
289
+ else:
290
+ root = gateway.workspace(query.get("workspace"))
291
+ self._api_get_in(path, query, root)
292
+
293
+ def _api_get_in(self, path: str, query: dict[str, str], root: Path) -> None:
294
+ client = self.gateway.client
295
+ if path == "/api/v1/capabilities":
296
+ self._json(client.harness_capabilities(root))
297
+ elif path == "/api/v1/sources":
298
+ found = client.discover_capabilities(root, query.get("q", ""))
299
+ self._json({"capabilities": [c.model_dump() for c in found]})
300
+ elif path == "/api/v1/code":
301
+ self._json(
302
+ client.code_view(
303
+ root,
304
+ path_prefix=query.get("path", ""),
305
+ query=query.get("q", ""),
306
+ kinds=[k for k in query.get("kinds", "").split(",") if k],
307
+ budget=self._int(query, "budget", 500),
308
+ collapse=query.get("collapse", "none"),
309
+ depth=self._int(query, "depth", 1),
310
+ )
311
+ )
312
+ elif match := re.fullmatch(rf"/api/v1/runs/({_RUN_ID})", path):
313
+ self._json(client.get_run(root, match[1]))
314
+ elif match := re.fullmatch(rf"/api/v1/runs/({_RUN_ID})/events", path):
315
+ self._json(client.run_events(root, match[1], self._int(query, "after", 0)))
316
+ elif match := re.fullmatch(rf"/api/v1/runs/({_RUN_ID})/artifacts", path):
317
+ self._json({"artifacts": client.run_artifacts(root, match[1])})
318
+ elif match := re.fullmatch(rf"/api/v1/runs/({_RUN_ID})/artifacts/({_ARTIFACT})", path):
319
+ data = client.read_artifact(root, match[1], match[2])
320
+ # always a download, and sandboxed if opened anyway: an artifact can hold anything
321
+ self._send(
322
+ 200,
323
+ data,
324
+ "application/octet-stream",
325
+ ("Content-Disposition", f'attachment; filename="{match[2]}"'),
326
+ ("Content-Security-Policy", "sandbox"),
327
+ )
328
+ else:
329
+ raise _Reject(404, "NOT_FOUND", "no such route")
330
+
331
+ def _post(self) -> None:
332
+ self._check_origin(required=True)
333
+ self._check_session()
334
+ self._check_csrf()
335
+ body = self._read_json()
336
+ path = urlsplit(self.path).path
337
+ root = self.gateway.workspace(body.get("workspace"))
338
+ client = self.gateway.client
339
+ if path == "/api/v1/plans/prepare":
340
+ self._json(client.prepare_plan(root, self._object(body, "draft")))
341
+ elif path == "/api/v1/runs":
342
+ run = client.submit_run(
343
+ root,
344
+ str(body.get("kind", "")),
345
+ self._object(body, "parameters", default={}),
346
+ str(body.get("idempotency_key", "")),
347
+ )
348
+ if run["state"] == "queued":
349
+ self.gateway.runs.submit(self.gateway.drive, root, run["run_id"])
350
+ self._json(run, 202)
351
+ elif match := re.fullmatch(rf"/api/v1/runs/({_RUN_ID})/cancel", path):
352
+ self._json(client.cancel_run(root, match[1]))
353
+ else:
354
+ raise _Reject(404, "NOT_FOUND", "no such route")
355
+
356
+ @staticmethod
357
+ def _object(
358
+ body: dict[str, Any], name: str, default: dict[str, Any] | None = None
359
+ ) -> dict[str, Any]:
360
+ value = body.get(name, default)
361
+ if not isinstance(value, dict):
362
+ raise _Reject(400, "INVALID_REQUEST", f"{name} must be a JSON object")
363
+ return value
364
+
365
+
366
+ __all__ = ["CSRF_HEADER", "HOST", "MAX_BODY_BYTES", "Gateway"]
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>Dev Guru</title>
6
+ <body>
7
+ <main>
8
+ <h1>Dev Guru</h1>
9
+ <p>The local gateway is running. The web interface isn't part of this version yet; the API is at <code>/api/v1/</code>.</p>
10
+ </main>
11
+ </body>
12
+ </html>
@@ -1,3 +1,3 @@
1
1
  """The package version, in a module that imports nothing (so any module can use it without an import cycle)."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,423 @@
1
+ """The gateway over real HTTP on a real socket, driving the real Ananke harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from collections.abc import Iterator
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ import pytest
16
+
17
+ from dev_guru import errors
18
+ from dev_guru.client import connect
19
+ from dev_guru.gateway import Gateway
20
+ from dev_guru.gateway.server import CSRF_HEADER, HOST, MAX_BODY_BYTES
21
+
22
+
23
+ @pytest.fixture
24
+ def repo(tmp_path: Path) -> Path:
25
+ root = tmp_path / "repo"
26
+ root.mkdir()
27
+ (root / "a.py").write_text("def one():\n return 1\n")
28
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
29
+ subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
30
+ subprocess.run(
31
+ [
32
+ "git",
33
+ "-C",
34
+ str(root),
35
+ "-c",
36
+ "user.name=t",
37
+ "-c",
38
+ "user.email=t@example.com",
39
+ "commit",
40
+ "-qm",
41
+ "i",
42
+ ],
43
+ check=True,
44
+ )
45
+ return root
46
+
47
+
48
+ @pytest.fixture
49
+ def gateway(repo: Path) -> Iterator[Gateway]:
50
+ g = Gateway([repo], connect("in-process"))
51
+ thread = threading.Thread(target=g.serve_forever, daemon=True)
52
+ thread.start()
53
+ yield g
54
+ g.shutdown()
55
+
56
+
57
+ class Browser:
58
+ """Sends exactly the headers a browser would, so each test can take one away."""
59
+
60
+ def __init__(self, gateway: Gateway) -> None:
61
+ self.g = gateway
62
+ self.origin = f"http://{HOST}:{gateway.port}"
63
+ self.cookie = ""
64
+ self.workspace = next(iter(gateway.roots))
65
+
66
+ def raw(
67
+ self,
68
+ method: str,
69
+ path: str,
70
+ *,
71
+ headers: dict[str, str] | None = None,
72
+ body: bytes | None = None,
73
+ ) -> tuple[int, dict[str, str], bytes]:
74
+ conn = http.client.HTTPConnection(HOST, self.g.port, timeout=30)
75
+ conn.putrequest(method, path, skip_host=True, skip_accept_encoding=True)
76
+ sent = {"Host": f"{HOST}:{self.g.port}", **(headers or {})}
77
+ if self.cookie and "Cookie" not in sent:
78
+ sent["Cookie"] = self.cookie
79
+ if body is not None:
80
+ sent["Content-Length"] = str(len(body))
81
+ for name, value in sent.items():
82
+ if value:
83
+ conn.putheader(name, value)
84
+ conn.endheaders(body)
85
+ response = conn.getresponse()
86
+ data = response.read()
87
+ result = (response.status, {k.lower(): v for k, v in response.getheaders()}, data)
88
+ conn.close()
89
+ return result
90
+
91
+ def login(self) -> Browser:
92
+ split = urlsplit(self.g.login_url)
93
+ status, headers, _ = self.raw("GET", f"{split.path}?{split.query}")
94
+ assert status == 303 and headers["location"] == "/"
95
+ self.cookie = headers["set-cookie"].split(";")[0]
96
+ return self
97
+
98
+ def get(self, path: str) -> tuple[int, Any]:
99
+ sep = "&" if "?" in path else "?"
100
+ status, _, data = self.raw("GET", f"{path}{sep}workspace={self.workspace}")
101
+ return status, json.loads(data)
102
+
103
+ def post(self, path: str, payload: dict[str, Any], **headers: str) -> tuple[int, Any]:
104
+ sent = {
105
+ "Origin": self.origin,
106
+ "Content-Type": "application/json",
107
+ CSRF_HEADER: self.g.csrf,
108
+ **{k.replace("_", "-"): v for k, v in headers.items()},
109
+ }
110
+ body = json.dumps({"workspace": self.workspace, **payload}).encode()
111
+ status, _, data = self.raw("POST", path, headers=sent, body=body)
112
+ return status, json.loads(data)
113
+
114
+ def finish(self, run_id: str) -> dict[str, Any]:
115
+ for _ in range(300):
116
+ status, run = self.get(f"/api/v1/runs/{run_id}")
117
+ assert status == 200
118
+ if run["state"] in ("succeeded", "failed", "cancelled"):
119
+ return run
120
+ time.sleep(0.05)
121
+ raise AssertionError("the run didn't finish")
122
+
123
+
124
+ @pytest.fixture
125
+ def browser(gateway: Gateway) -> Browser:
126
+ return Browser(gateway).login()
127
+
128
+
129
+ def code(response: tuple[int, Any]) -> str:
130
+ return str(response[1]["error"]["code"])
131
+
132
+
133
+ # ------------------------------------------------------------------ who gets in
134
+ def test_it_listens_on_loopback_only(gateway: Gateway) -> None:
135
+ assert gateway._server.server_address[0] == "127.0.0.1"
136
+
137
+
138
+ def test_the_login_link_works_once_and_leaves_the_url(gateway: Gateway) -> None:
139
+ b = Browser(gateway)
140
+ split = urlsplit(gateway.login_url)
141
+ target = f"{split.path}?{split.query}"
142
+ assert b.raw("GET", target)[0] == 303
143
+ assert b.raw("GET", target)[0] == 401
144
+
145
+
146
+ def test_the_session_cookie_is_strict(gateway: Gateway) -> None:
147
+ b = Browser(gateway)
148
+ split = urlsplit(gateway.login_url)
149
+ cookie = b.raw("GET", f"{split.path}?{split.query}")[1]["set-cookie"]
150
+ assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
151
+ assert f"dev_guru_session_{gateway.port}=" in cookie
152
+
153
+
154
+ @pytest.mark.parametrize("path", ["/", "/api/v1/health", "/api/v1/session", "/api/v1/workspaces"])
155
+ def test_nothing_is_served_without_a_session(gateway: Gateway, path: str) -> None:
156
+ assert Browser(gateway).raw("GET", path)[0] == 401
157
+
158
+
159
+ def test_a_wrong_token_and_a_wrong_cookie_get_nothing(gateway: Gateway) -> None:
160
+ b = Browser(gateway)
161
+ assert b.raw("GET", "/login?token=guess")[0] == 401
162
+ b.cookie = f"{gateway.cookie_name}=guess"
163
+ assert b.raw("GET", "/api/v1/health")[0] == 401
164
+ b.cookie = "not a ; valid = cookie;;"
165
+ assert b.raw("GET", "/api/v1/health")[0] == 401
166
+
167
+
168
+ @pytest.mark.parametrize(
169
+ "host", ["evil.example", "evil.example:80", "127.0.0.1", "localhost:1", ""]
170
+ )
171
+ def test_a_rebound_host_is_refused_even_with_a_session(browser: Browser, host: str) -> None:
172
+ status, _, data = browser.raw("GET", "/api/v1/health", headers={"Host": host})
173
+ assert status == 403 and json.loads(data)["error"]["code"] == "HOST_REJECTED"
174
+
175
+
176
+ def test_localhost_is_accepted_as_the_same_server(browser: Browser, gateway: Gateway) -> None:
177
+ host = f"localhost:{gateway.port}"
178
+ assert browser.raw("GET", "/api/v1/health", headers={"Host": host})[0] == 200
179
+
180
+
181
+ # ------------------------------------------------------------------ a page on another site
182
+ def test_a_change_needs_origin_csrf_and_json(browser: Browser) -> None:
183
+ payload = {"kind": "graph.build", "idempotency_key": "k"}
184
+ path = "/api/v1/runs"
185
+ assert code(browser.post(path, payload, Origin="https://evil.example")) == "ORIGIN_REJECTED"
186
+ assert code(browser.post(path, payload, Origin="")) == "ORIGIN_REJECTED"
187
+ assert code(browser.post(path, payload, Sec_Fetch_Site="cross-site")) == "ORIGIN_REJECTED"
188
+ assert (
189
+ code(browser.post(path, payload, **{CSRF_HEADER.replace("-", "_"): ""})) == "CSRF_REJECTED"
190
+ )
191
+ assert (
192
+ code(browser.post(path, payload, **{CSRF_HEADER.replace("-", "_"): "guess"}))
193
+ == "CSRF_REJECTED"
194
+ )
195
+ assert code(browser.post(path, payload, Content_Type="text/plain")) == "UNSUPPORTED_MEDIA_TYPE"
196
+ assert (
197
+ code(browser.post(path, payload, Content_Type="application/x-www-form-urlencoded"))
198
+ == "UNSUPPORTED_MEDIA_TYPE"
199
+ )
200
+ assert not (Path(browser.g.roots[browser.workspace]) / ".ananke" / "app" / "runs").exists()
201
+
202
+
203
+ def test_a_read_from_another_origin_is_refused_and_never_gets_cors(browser: Browser) -> None:
204
+ status, headers, _ = browser.raw(
205
+ "GET", "/api/v1/health", headers={"Origin": "https://evil.example"}
206
+ )
207
+ assert status == 403 and "access-control-allow-origin" not in headers
208
+ status, headers, _ = browser.raw("OPTIONS", "/api/v1/runs", headers={"Origin": browser.origin})
209
+ assert status == 405 and "access-control-allow-origin" not in headers
210
+ status, headers, _ = browser.raw("GET", "/api/v1/health", headers={"Origin": browser.origin})
211
+ assert status == 200 and "access-control-allow-origin" not in headers
212
+
213
+
214
+ def test_responses_carry_security_headers(browser: Browser) -> None:
215
+ _, headers, _ = browser.raw("GET", "/")
216
+ assert headers["x-content-type-options"] == "nosniff"
217
+ assert headers["referrer-policy"] == "no-referrer"
218
+ assert "frame-ancestors 'none'" in headers["content-security-policy"]
219
+
220
+
221
+ @pytest.mark.parametrize(
222
+ ("body", "expected", "status"),
223
+ [(b"not json", "INVALID_JSON", 400), (b"[1, 2]", "INVALID_JSON", 400)],
224
+ )
225
+ def test_a_bad_body_is_refused(browser: Browser, body: bytes, expected: str, status: int) -> None:
226
+ sent = {
227
+ "Origin": browser.origin,
228
+ "Content-Type": "application/json",
229
+ CSRF_HEADER: browser.g.csrf,
230
+ }
231
+ got, _, data = browser.raw("POST", "/api/v1/runs", headers=sent, body=body)
232
+ assert got == status and json.loads(data)["error"]["code"] == expected
233
+
234
+
235
+ def test_an_oversized_body_is_refused_without_reading_it(browser: Browser) -> None:
236
+ sent = {
237
+ "Origin": browser.origin,
238
+ "Content-Type": "application/json",
239
+ CSRF_HEADER: browser.g.csrf,
240
+ "Content-Length": str(MAX_BODY_BYTES + 1),
241
+ }
242
+ got, _, data = browser.raw("POST", "/api/v1/runs", headers=sent)
243
+ assert got == 413 and json.loads(data)["error"]["code"] == "TOO_LARGE"
244
+
245
+
246
+ # ------------------------------------------------------------------ scope
247
+ def test_a_workspace_is_an_id_and_a_path_reaches_nothing(browser: Browser, tmp_path: Path) -> None:
248
+ status, listed = browser.get("/api/v1/workspaces")
249
+ assert status == 200 and [w["id"] for w in listed["workspaces"]] == [browser.workspace]
250
+ for attempt in (str(tmp_path), "..", "../..", "/etc", "nope"):
251
+ browser.workspace = attempt
252
+ assert code(browser.get("/api/v1/code")) == "WORKSPACE_NOT_FOUND"
253
+ assert (
254
+ code(browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "x"}))
255
+ == "WORKSPACE_NOT_FOUND"
256
+ )
257
+
258
+
259
+ @pytest.mark.parametrize(
260
+ "path",
261
+ [
262
+ "/api/v1/runs/..%2F..%2Fsecrets",
263
+ "/api/v1/runs/run-abc/artifacts/..%2F..%2Frun.json",
264
+ "/api/v1/runs/run-abc/artifacts/.env",
265
+ "/api/v1/shell",
266
+ "/api/v1/exec",
267
+ "/static/../../etc/passwd",
268
+ ],
269
+ )
270
+ def test_odd_paths_reach_nothing(browser: Browser, path: str) -> None:
271
+ assert browser.get(path)[0] == 404
272
+
273
+
274
+ def test_no_route_runs_a_command(browser: Browser) -> None:
275
+ status, run = browser.post(
276
+ "/api/v1/runs",
277
+ {"kind": "shell", "parameters": {"command": "touch pwned"}, "idempotency_key": "x"},
278
+ )
279
+ assert status == 400 and run["error"]["code"] == "UNKNOWN_RUN_KIND"
280
+ assert not (browser.g.roots[browser.workspace] / "pwned").exists()
281
+
282
+
283
+ def test_an_unknown_workspace_at_start_is_refused(tmp_path: Path) -> None:
284
+ with pytest.raises(errors.GuruError):
285
+ Gateway([tmp_path / "missing"], connect("in-process"))
286
+ with pytest.raises(errors.GuruError):
287
+ Gateway([], connect("in-process"))
288
+
289
+
290
+ # ------------------------------------------------------------------ the real harness behind it
291
+ def test_a_run_goes_through_the_harness_with_events_and_artifacts(browser: Browser) -> None:
292
+ status, run = browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "one"})
293
+ assert status == 202 and run["state"] in ("queued", "running", "succeeded")
294
+ done = browser.finish(run["run_id"])
295
+ assert done["state"] == "succeeded"
296
+
297
+ status, seen = browser.get(f"/api/v1/runs/{run['run_id']}/events")
298
+ types = [e["type"] for e in seen["events"]]
299
+ assert types[0] == "run.queued" and types[-1] == "run.succeeded"
300
+ status, later = browser.get(
301
+ f"/api/v1/runs/{run['run_id']}/events?after={seen['events'][-1]['seq']}"
302
+ )
303
+ assert later["events"] == []
304
+
305
+ _, listed = browser.get(f"/api/v1/runs/{run['run_id']}/artifacts")
306
+ assert [a["name"] for a in listed["artifacts"]] == ["graph-snapshot.json"]
307
+ status, headers, data = browser.raw(
308
+ "GET",
309
+ f"/api/v1/runs/{run['run_id']}/artifacts/graph-snapshot.json?workspace={browser.workspace}",
310
+ )
311
+ assert status == 200 and json.loads(data)["nodes"]
312
+ assert headers["content-disposition"].startswith("attachment")
313
+ assert headers["content-type"] == "application/octet-stream"
314
+ assert "sandbox" in headers["content-security-policy"]
315
+
316
+ status, view = browser.get("/api/v1/code?collapse=file&budget=5")
317
+ assert status == 200 and view["collapse"] == "file"
318
+
319
+
320
+ def test_a_reloaded_tab_and_a_second_tab_see_the_same_run(
321
+ browser: Browser, gateway: Gateway
322
+ ) -> None:
323
+ _, run = browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "same"})
324
+ _, again = browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "same"})
325
+ assert again["run_id"] == run["run_id"]
326
+ assert browser.finish(run["run_id"])["state"] == "succeeded"
327
+ second = Browser(gateway)
328
+ second.cookie = browser.cookie
329
+ assert second.finish(run["run_id"])["state"] == "succeeded"
330
+
331
+
332
+ def test_what_ananke_refuses_keeps_its_code_and_a_fitting_status(browser: Browser) -> None:
333
+ assert code(browser.get("/api/v1/runs/run-0123")) == "RUN_NOT_FOUND"
334
+ assert browser.get("/api/v1/runs/run-0123")[0] == 404
335
+ status, body = browser.get("/api/v1/code")
336
+ assert status == 503 and body["error"]["code"] == "GRAPH_MISSING" and body["error"]["retryable"]
337
+ assert code(browser.get("/api/v1/code?budget=x")) == "INVALID_REQUEST"
338
+ browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "k"})
339
+ status, conflict = browser.post(
340
+ "/api/v1/runs",
341
+ {"kind": "graph.impact", "parameters": {"changed_files": ["a.py"]}, "idempotency_key": "k"},
342
+ )
343
+ assert status == 409 and conflict["error"]["code"] == "IDEMPOTENCY_CONFLICT"
344
+
345
+
346
+ def test_a_cancelled_run_stays_cancelled(browser: Browser, monkeypatch: pytest.MonkeyPatch) -> None:
347
+ release = threading.Event()
348
+ original = browser.g.drive
349
+ monkeypatch.setattr(
350
+ browser.g, "drive", lambda root, run_id: (release.wait(10), original(root, run_id))
351
+ )
352
+ _, run = browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "c"})
353
+ status, cancelled = browser.post(f"/api/v1/runs/{run['run_id']}/cancel", {})
354
+ assert status == 200 and cancelled["state"] == "cancelled"
355
+ release.set()
356
+ time.sleep(0.2)
357
+ assert browser.get(f"/api/v1/runs/{run['run_id']}")[1]["state"] == "cancelled"
358
+
359
+
360
+ def test_a_workflow_previews_and_runs_through_the_gateway(browser: Browser) -> None:
361
+ root = browser.g.roots[browser.workspace]
362
+ skill = root / "skills" / "demo"
363
+ skill.mkdir(parents=True)
364
+ (skill / "SKILL.md").write_text(
365
+ "---\nname: demo\ndescription: A demo skill for the gateway test.\nversion: 1.0.0\n---\n# Demo\nSay ping.\n"
366
+ )
367
+ from ananke.plexus.registry.registry import Registry
368
+
369
+ Registry.for_project(root, create=True).learn(str(skill), namespace="local")
370
+ subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
371
+ subprocess.run(
372
+ [
373
+ "git",
374
+ "-C",
375
+ str(root),
376
+ "-c",
377
+ "user.name=t",
378
+ "-c",
379
+ "user.email=t@example.com",
380
+ "commit",
381
+ "-qm",
382
+ "s",
383
+ ],
384
+ check=True,
385
+ )
386
+ draft = {
387
+ "schema_version": "1.0.0",
388
+ "workflow_id": "demo/one",
389
+ "nodes": [
390
+ {
391
+ "id": "a",
392
+ "capability_ref": "local/demo@1.0.0",
393
+ "kind": "skill",
394
+ "inputs": [],
395
+ "outputs": [],
396
+ }
397
+ ],
398
+ "edges": [],
399
+ }
400
+ status, preview = browser.post("/api/v1/plans/prepare", {"draft": draft})
401
+ assert status == 200 and preview["ok"]
402
+ _, run = browser.post(
403
+ "/api/v1/runs",
404
+ {
405
+ "kind": "workflow.run",
406
+ "parameters": {"draft": draft, "backend": "fake", "plan_hash": preview["plan_hash"]},
407
+ "idempotency_key": "w",
408
+ },
409
+ )
410
+ done = browser.finish(run["run_id"])
411
+ assert done["state"] == "succeeded" and done["complete"] is False
412
+ assert {w["code"] for w in done["warnings"]} == {"SIMULATED_BACKEND"}
413
+
414
+
415
+ def test_an_unexpected_error_tells_the_browser_nothing_internal(
416
+ browser: Browser, monkeypatch: pytest.MonkeyPatch
417
+ ) -> None:
418
+ def boom(*_: object, **__: object) -> None:
419
+ raise RuntimeError("secret path /home/me/.env")
420
+
421
+ monkeypatch.setattr(browser.g.client, "get_run", boom)
422
+ status, _, data = browser.raw("GET", f"/api/v1/runs/run-1?workspace={browser.workspace}")
423
+ assert status == 500 and b"secret" not in data and b"INTERNAL_ERROR" in data
@@ -11,10 +11,10 @@ from dev_guru.client import inprocess, negotiation
11
11
  @pytest.mark.parametrize(
12
12
  ("version", "ok"),
13
13
  [
14
- ("0.3.13", True),
15
- ("0.3.12", False),
14
+ ("0.3.17", True),
15
+ ("0.3.16", False),
16
16
  ("0.3.99", True),
17
- ("0.3.11", False),
17
+ ("0.3.13", False),
18
18
  ("0.4.0", False),
19
19
  ("1.0.0", False),
20
20
  ("junk", False),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes