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.
- {dev_guru-0.2.0 → dev_guru-0.3.0}/CHANGELOG.md +16 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/PKG-INFO +3 -2
- {dev_guru-0.2.0 → dev_guru-0.3.0}/README.md +1 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/pyproject.toml +1 -1
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/cli.py +37 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/inprocess.py +50 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/negotiation.py +7 -3
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/port.py +25 -1
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/errors.py +11 -0
- dev_guru-0.3.0/src/dev_guru/gateway/__init__.py +5 -0
- dev_guru-0.3.0/src/dev_guru/gateway/server.py +366 -0
- dev_guru-0.3.0/src/dev_guru/gateway/static/index.html +12 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/version.py +1 -1
- dev_guru-0.3.0/tests/test_gateway.py +423 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_negotiation.py +3 -3
- {dev_guru-0.2.0 → dev_guru-0.3.0}/.gitignore +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/LICENSE +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/__init__.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/__main__.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/client/__init__.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/diagram/__init__.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/present/__init__.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/__init__.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/assets/explorer.css +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/assets/explorer.html +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/assets/explorer.js +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/model.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/pages.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/profiles.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/reports/render.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/sdk.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/src/dev_guru/service.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/conftest.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_cli.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_client.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_diagram.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_facade_features.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_packaging.py +0 -0
- {dev_guru-0.2.0 → dev_guru-0.3.0}/tests/test_present.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,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>
|
|
@@ -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.
|
|
15
|
-
("0.3.
|
|
14
|
+
("0.3.17", True),
|
|
15
|
+
("0.3.16", False),
|
|
16
16
|
("0.3.99", True),
|
|
17
|
-
("0.3.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|