ai-code-engineer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_code_engineer/__init__.py +2 -0
- ai_code_engineer/catalog.py +143 -0
- ai_code_engineer/chat.py +181 -0
- ai_code_engineer/cli.py +384 -0
- ai_code_engineer/config.py +405 -0
- ai_code_engineer/engine.py +1282 -0
- ai_code_engineer/errors.py +27 -0
- ai_code_engineer/git_integration.py +443 -0
- ai_code_engineer/gui.py +2646 -0
- ai_code_engineer/host.py +81 -0
- ai_code_engineer/ignore.py +269 -0
- ai_code_engineer/intent.py +222 -0
- ai_code_engineer/labels.py +871 -0
- ai_code_engineer/memory.py +91 -0
- ai_code_engineer/modes.py +156 -0
- ai_code_engineer/overrides.py +540 -0
- ai_code_engineer/planbook.py +192 -0
- ai_code_engineer/providers.py +404 -0
- ai_code_engineer/redaction.py +54 -0
- ai_code_engineer/repair.py +564 -0
- ai_code_engineer/report.py +352 -0
- ai_code_engineer/runner.py +854 -0
- ai_code_engineer/setup.py +386 -0
- ai_code_engineer/symbols.py +1286 -0
- ai_code_engineer/verification.py +218 -0
- ai_code_engineer/webapp/__init__.py +1 -0
- ai_code_engineer/webapp/__main__.py +45 -0
- ai_code_engineer/webapp/contract.py +36 -0
- ai_code_engineer/webapp/controller.py +3556 -0
- ai_code_engineer/webapp/fake.py +1141 -0
- ai_code_engineer/webapp/launch.py +108 -0
- ai_code_engineer/webapp/server.py +349 -0
- ai_code_engineer/webapp/static/app.css +780 -0
- ai_code_engineer/webapp/static/app.js +2118 -0
- ai_code_engineer/webapp/static/boot.js +19 -0
- ai_code_engineer/webapp/static/index.html +89 -0
- ai_code_engineer/webapp/static/tokens.css +173 -0
- ai_code_engineer/workspace.py +385 -0
- ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
- ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
- ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
- ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Start the local server and show it as an app window instead of a browser tab.
|
|
2
|
+
|
|
3
|
+
Edge and Chrome both take ``--app=<url>``, which opens a frameless top-level window with
|
|
4
|
+
no address bar, tabs or extensions. Pointing them at a private ``--user-data-dir`` is what
|
|
5
|
+
makes that work: an already-running browser would otherwise hand the URL to its own
|
|
6
|
+
window and exit immediately, so the app would close the moment it opened.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import webbrowser
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
MIN_WIDTH, MIN_HEIGHT = 1180, 720
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_browser() -> Path | None:
|
|
20
|
+
"""A Chromium browser that supports --app, or None when the machine has only Firefox."""
|
|
21
|
+
roots = [os.environ.get(key) for key in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA")]
|
|
22
|
+
candidates = [Path(root) / tail for root in roots if root
|
|
23
|
+
for tail in ("Microsoft/Edge/Application/msedge.exe",
|
|
24
|
+
"Google/Chrome/Application/chrome.exe",
|
|
25
|
+
"Chromium/Application/chrome.exe")]
|
|
26
|
+
candidates += [Path(rf"{drive}\Program Files\{vendor}\{exe}")
|
|
27
|
+
for drive in "CE" for vendor, exe in
|
|
28
|
+
(("Microsoft/Edge/Application", "msedge.exe"), ("Google/Chrome/Application", "chrome.exe"))]
|
|
29
|
+
return next((path for path in candidates if path.is_file()), None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def window_size() -> tuple[int, int]:
|
|
33
|
+
"""Fit the work area without a scrollbar; Tk is only used to measure the screen."""
|
|
34
|
+
try:
|
|
35
|
+
import tkinter
|
|
36
|
+
probe = tkinter.Tk()
|
|
37
|
+
probe.withdraw()
|
|
38
|
+
width, height = probe.winfo_screenwidth() - 120, probe.winfo_screenheight() - 140
|
|
39
|
+
probe.destroy()
|
|
40
|
+
except Exception: # noqa: BLE001 - headless is a valid answer too
|
|
41
|
+
width, height = 1440, 900
|
|
42
|
+
return max(MIN_WIDTH, min(width, 1800)), max(MIN_HEIGHT, min(height, 1200))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def open_window(url: str, profile: Path) -> subprocess.Popen | None:
|
|
46
|
+
browser = find_browser()
|
|
47
|
+
if browser is None:
|
|
48
|
+
return None
|
|
49
|
+
profile.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
arguments = [str(browser), f"--app={url}", f"--user-data-dir={profile}",
|
|
51
|
+
"--no-first-run", "--no-default-browser-check",
|
|
52
|
+
"--disable-features=Translate,MediaRouter"]
|
|
53
|
+
if len(list(profile.glob("Preferences"))) == 0:
|
|
54
|
+
# A fresh profile has no remembered bounds, so give it sensible ones. Once the
|
|
55
|
+
# profile exists the browser restores its own size and place, and forcing these
|
|
56
|
+
# flags would throw away wherever the user moved the window.
|
|
57
|
+
width, height = window_size()
|
|
58
|
+
arguments += [f"--window-size={width},{height}", "--window-position=60,50"]
|
|
59
|
+
return subprocess.Popen(arguments,
|
|
60
|
+
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run(app_dir: Path) -> int:
|
|
64
|
+
"""Serve the UI and block until its window closes.
|
|
65
|
+
|
|
66
|
+
Without a Chromium browser this still serves — in an ordinary tab, with the process held
|
|
67
|
+
open around it. The Tk window is not a fallback from here: it is `--tk`, or whatever
|
|
68
|
+
`desktop.pyw` starts when this function raises.
|
|
69
|
+
"""
|
|
70
|
+
from .controller import AgentController
|
|
71
|
+
from .server import serve
|
|
72
|
+
|
|
73
|
+
controller = AgentController(app_dir)
|
|
74
|
+
server, url, _token = serve(controller)
|
|
75
|
+
# The model list is what the window used to fetch a moment after it appeared: an
|
|
76
|
+
# offline Ollama is a normal state, so it must not block the window from opening.
|
|
77
|
+
controller.check_setup()
|
|
78
|
+
window = open_window(url, app_dir / ".agent-webview")
|
|
79
|
+
if window is None:
|
|
80
|
+
# No Chromium on this machine: the page is still a real UI, just in a normal tab.
|
|
81
|
+
webbrowser.open(url)
|
|
82
|
+
try:
|
|
83
|
+
if window is not None:
|
|
84
|
+
window.wait()
|
|
85
|
+
else:
|
|
86
|
+
import time
|
|
87
|
+
while True:
|
|
88
|
+
time.sleep(3600)
|
|
89
|
+
except KeyboardInterrupt:
|
|
90
|
+
pass
|
|
91
|
+
finally:
|
|
92
|
+
controller.close()
|
|
93
|
+
server.shutdown()
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_tk(app_dir: Path) -> int:
|
|
98
|
+
from ..gui import main as tk_main
|
|
99
|
+
tk_main(app_dir)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv: list[str] | None = None) -> int:
|
|
104
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
105
|
+
app_dir = Path(__file__).resolve().parents[3]
|
|
106
|
+
if "--tk" in argv:
|
|
107
|
+
return run_tk(app_dir)
|
|
108
|
+
return run(app_dir)
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Local-only HTTP server: static UI, a JSON action endpoint, and one SSE stream.
|
|
2
|
+
|
|
3
|
+
Binds to 127.0.0.1 on a random port and requires a per-launch token, because a browser
|
|
4
|
+
page on the same machine can otherwise be reached by any other process or web page.
|
|
5
|
+
|
|
6
|
+
The token alone was not enough. A page on any origin can *send* to a loopback port, and DNS
|
|
7
|
+
rebinding turns a hostile hostname into 127.0.0.1 inside the victim's browser — so every request is
|
|
8
|
+
also checked here for the Host it asked for and the Origin it came from, and every response carries a
|
|
9
|
+
Content-Security-Policy. Nothing in this module writes to a project.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import queue
|
|
15
|
+
import secrets
|
|
16
|
+
import threading
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ..errors import AgentError
|
|
22
|
+
from ..labels import friendly_error
|
|
23
|
+
from ..redaction import redact
|
|
24
|
+
|
|
25
|
+
STATIC = Path(__file__).resolve().parent / "static"
|
|
26
|
+
MAX_BODY = 1_000_000
|
|
27
|
+
DRAIN_CHUNK = 65_536
|
|
28
|
+
LOOPBACK = frozenset(("127.0.0.1", "localhost", "::1"))
|
|
29
|
+
|
|
30
|
+
# `script-src 'self'` with no 'unsafe-inline' is the line that matters, and index.html's boot script
|
|
31
|
+
# moved to its own file for it. Inline *styles* stay allowed: eight template strings set layout
|
|
32
|
+
# values inline (progress-bar widths, avatar colours), and a style cannot execute script.
|
|
33
|
+
CSP = ("default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
34
|
+
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
|
35
|
+
"frame-ancestors 'none'; base-uri 'none'; form-action 'none'; object-src 'none'")
|
|
36
|
+
SAFE_HEADERS = (
|
|
37
|
+
("content-security-policy", CSP),
|
|
38
|
+
("x-frame-options", "DENY"),
|
|
39
|
+
("x-content-type-options", "nosniff"),
|
|
40
|
+
# The launch URL carries the session token in its query string; a referrer would be told it.
|
|
41
|
+
("referrer-policy", "no-referrer"),
|
|
42
|
+
("cross-origin-opener-policy", "same-origin"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Hub:
|
|
47
|
+
"""Fan-out for the SSE stream; a slow client drops events instead of blocking the agent."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._clients: list[queue.Queue] = []
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
def subscribe(self) -> queue.Queue:
|
|
54
|
+
client: queue.Queue = queue.Queue(maxsize=256)
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._clients.append(client)
|
|
57
|
+
return client
|
|
58
|
+
|
|
59
|
+
def unsubscribe(self, client: queue.Queue) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
if client in self._clients:
|
|
62
|
+
self._clients.remove(client)
|
|
63
|
+
|
|
64
|
+
def publish(self, event: dict) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
clients = list(self._clients)
|
|
67
|
+
for client in clients:
|
|
68
|
+
try:
|
|
69
|
+
client.put_nowait(event)
|
|
70
|
+
except queue.Full:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Handler(BaseHTTPRequestHandler):
|
|
75
|
+
protocol_version = "HTTP/1.1"
|
|
76
|
+
server_version = "AICodeEngineer/1.0"
|
|
77
|
+
|
|
78
|
+
# Placeholders. The real four are set on a subclass per launch — see serve().
|
|
79
|
+
token: str = ""
|
|
80
|
+
controller = None
|
|
81
|
+
hub: Hub = Hub()
|
|
82
|
+
port: int = 0
|
|
83
|
+
|
|
84
|
+
def log_message(self, *_args): # silence per-request stderr noise
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# ---------------- plumbing ----------------
|
|
88
|
+
def _guard(self) -> tuple[str, int] | None:
|
|
89
|
+
"""Refuse a request that is not addressed to this server, before reading its body.
|
|
90
|
+
|
|
91
|
+
A browser will happily send a POST to 127.0.0.1 from a page on another site — the token is
|
|
92
|
+
what stops it being *answered*. `Host` is the other half: DNS rebinding makes a hostile
|
|
93
|
+
hostname resolve to loopback inside the victim's own browser, and then the page is
|
|
94
|
+
same-origin with this server and can read the answer. Checking that the request was aimed at
|
|
95
|
+
a loopback name on our own port closes that, and checking `Origin` refuses the page that only
|
|
96
|
+
wanted to write.
|
|
97
|
+
|
|
98
|
+
Returns the sentence and the status, or None when the request is addressed here. Each route
|
|
99
|
+
answers in its own way, because a POST still has its body in the socket.
|
|
100
|
+
"""
|
|
101
|
+
if not self._host_allowed(self.headers.get("host", "")):
|
|
102
|
+
return "This server answers only loopback requests addressed to itself.", 425
|
|
103
|
+
origin = self.headers.get("origin") or self.headers.get("referer") or ""
|
|
104
|
+
if origin and not self._origin_allowed(origin):
|
|
105
|
+
return "That request came from another origin.", 403
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def _origin_allowed(self, value: str) -> bool:
|
|
109
|
+
"""Where the page that asked lives — a stricter question than the one `Host` answers.
|
|
110
|
+
|
|
111
|
+
A client may send `Host: localhost` with no port, so the port check above has to allow a
|
|
112
|
+
missing port. An `Origin` never does: a browser always states one, and it always carries a
|
|
113
|
+
scheme. So `https://localhost` or `http://localhost` is a different page from this one —
|
|
114
|
+
this server is plain http on one random port, and a request from anywhere else is a page
|
|
115
|
+
that wants to write into the session while not being the page that holds it.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
parsed = urllib.parse.urlparse(value)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return False
|
|
121
|
+
if parsed.scheme != "http":
|
|
122
|
+
return False
|
|
123
|
+
if parsed.hostname not in LOOPBACK:
|
|
124
|
+
return False
|
|
125
|
+
return parsed.port == self.port
|
|
126
|
+
|
|
127
|
+
def _host_allowed(self, authority: str) -> bool:
|
|
128
|
+
"""A loopback name, on this port — no rebinding, no other service wearing our token."""
|
|
129
|
+
if not authority:
|
|
130
|
+
return False
|
|
131
|
+
try:
|
|
132
|
+
parsed = urllib.parse.urlparse("//" + authority)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return False
|
|
135
|
+
if parsed.hostname not in LOOPBACK:
|
|
136
|
+
return False
|
|
137
|
+
return parsed.port in (None, self.port)
|
|
138
|
+
|
|
139
|
+
def _safe(self) -> None:
|
|
140
|
+
"""The headers every response carries, so a new route cannot forget them."""
|
|
141
|
+
for name, value in SAFE_HEADERS:
|
|
142
|
+
self.send_header(name, value)
|
|
143
|
+
|
|
144
|
+
def _authorized(self, query: dict) -> bool:
|
|
145
|
+
if not self.token:
|
|
146
|
+
# serve() has not handed this handler a token. Two empty strings are equal, so with no
|
|
147
|
+
# guard an unconfigured server would let every unauthenticated request through.
|
|
148
|
+
return False
|
|
149
|
+
supplied = query.get("t", [""])[0] or self.headers.get("X-Auth-Token", "")
|
|
150
|
+
return secrets.compare_digest(supplied, self.token)
|
|
151
|
+
|
|
152
|
+
def _json(self, value, code=200):
|
|
153
|
+
body = json.dumps(value, ensure_ascii=False).encode("utf-8")
|
|
154
|
+
self.send_response(code)
|
|
155
|
+
self._safe()
|
|
156
|
+
self.send_header("content-type", "application/json; charset=utf-8")
|
|
157
|
+
self.send_header("content-length", str(len(body)))
|
|
158
|
+
self.send_header("cache-control", "no-store")
|
|
159
|
+
self.end_headers()
|
|
160
|
+
self.wfile.write(body)
|
|
161
|
+
|
|
162
|
+
def _text(self, text, code=400):
|
|
163
|
+
body = text.encode("utf-8")
|
|
164
|
+
self.send_response(code)
|
|
165
|
+
self._safe()
|
|
166
|
+
self.send_header("content-type", "text/plain; charset=utf-8")
|
|
167
|
+
self.send_header("content-length", str(len(body)))
|
|
168
|
+
self.end_headers()
|
|
169
|
+
self.wfile.write(body)
|
|
170
|
+
|
|
171
|
+
def _fail(self, exc: Exception, code: int = 500):
|
|
172
|
+
"""The answer for a request that went wrong: never the exception text as it stands.
|
|
173
|
+
|
|
174
|
+
An `AgentError` is a sentence this program wrote on purpose, so it is repeated — redacted and
|
|
175
|
+
capped, because some of them interpolate a provider's message or a folder path. Anything else
|
|
176
|
+
is somebody else's failure, and the browser gets one fixed line with a reference. The detail
|
|
177
|
+
goes to the Activity log, which is where a developer is looking anyway.
|
|
178
|
+
"""
|
|
179
|
+
reference = secrets.token_hex(3)
|
|
180
|
+
record = getattr(self.controller, "note_failure", None)
|
|
181
|
+
if record is not None:
|
|
182
|
+
record(reference, redact(f"{type(exc).__name__}: {exc}")[:600])
|
|
183
|
+
if isinstance(exc, AgentError):
|
|
184
|
+
return self._text(redact(friendly_error(exc))[:400], code)
|
|
185
|
+
return self._text(f"The window could not complete that request (id {reference}). "
|
|
186
|
+
"The reason is in the Activity log.", code)
|
|
187
|
+
|
|
188
|
+
def _refuse(self, text, code):
|
|
189
|
+
"""Answer a rejected POST without leaving its body in the socket.
|
|
190
|
+
|
|
191
|
+
A client that is still writing when the answer arrives gets its connection reset under
|
|
192
|
+
Windows, which turns a clean 403 into a broken pipe on the caller's side.
|
|
193
|
+
"""
|
|
194
|
+
self._drain(int(self.headers.get("content-length") or 0))
|
|
195
|
+
# Any part of an oversized body we did not read is not a request, so this connection ends
|
|
196
|
+
# after the answer instead of being parsed as one.
|
|
197
|
+
self.close_connection = True
|
|
198
|
+
return self._text(text, code)
|
|
199
|
+
|
|
200
|
+
def _query(self) -> dict:
|
|
201
|
+
return urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
202
|
+
|
|
203
|
+
def _drain(self, length: int) -> None:
|
|
204
|
+
"""Read a rejected body out of the socket so the client can finish its send.
|
|
205
|
+
|
|
206
|
+
Everything declared has to be consumed, or Windows aborts the connection under a client
|
|
207
|
+
that is still writing. The ceiling keeps a hostile content-length from turning that into
|
|
208
|
+
an unbounded read.
|
|
209
|
+
"""
|
|
210
|
+
remaining = min(length, MAX_BODY + 8 * DRAIN_CHUNK)
|
|
211
|
+
while remaining > 0:
|
|
212
|
+
block = self.rfile.read(min(DRAIN_CHUNK, remaining))
|
|
213
|
+
if not block:
|
|
214
|
+
break
|
|
215
|
+
remaining -= len(block)
|
|
216
|
+
|
|
217
|
+
def _body(self) -> dict:
|
|
218
|
+
length = int(self.headers.get("content-length") or 0)
|
|
219
|
+
if length > MAX_BODY:
|
|
220
|
+
# Answering 400 while a megabyte is still in flight makes Windows abort the socket
|
|
221
|
+
# from under the client, which is what made the oversized-body test flaky.
|
|
222
|
+
self._drain(length)
|
|
223
|
+
raise ValueError("Request body too large.")
|
|
224
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
225
|
+
try:
|
|
226
|
+
value = json.loads(raw.decode("utf-8") or "{}")
|
|
227
|
+
except ValueError:
|
|
228
|
+
raise ValueError("Body must be JSON.") from None
|
|
229
|
+
return value if isinstance(value, dict) else {}
|
|
230
|
+
|
|
231
|
+
# ---------------- routes ----------------
|
|
232
|
+
def do_GET(self): # noqa: N802
|
|
233
|
+
refused = self._guard()
|
|
234
|
+
if refused:
|
|
235
|
+
return self._text(*refused)
|
|
236
|
+
path = urllib.parse.urlparse(self.path).path
|
|
237
|
+
query = self._query()
|
|
238
|
+
if not path.startswith("/api/"):
|
|
239
|
+
# Bundled assets carry no user data, and a browser never sends a query string
|
|
240
|
+
# for a stylesheet link.
|
|
241
|
+
return self._static(path)
|
|
242
|
+
if not self._authorized(query):
|
|
243
|
+
return self._text("Not authorised for this UI session.", 403)
|
|
244
|
+
return self._get_api(path, query)
|
|
245
|
+
|
|
246
|
+
def do_POST(self): # noqa: N802
|
|
247
|
+
refused = self._guard()
|
|
248
|
+
if refused:
|
|
249
|
+
return self._refuse(*refused)
|
|
250
|
+
path = urllib.parse.urlparse(self.path).path
|
|
251
|
+
if not self._authorized(self._query()):
|
|
252
|
+
return self._refuse("Not authorised for this UI session.", 403)
|
|
253
|
+
if path not in {"/api/action", "/api/confirm"}:
|
|
254
|
+
return self._refuse("Unknown endpoint.", 404)
|
|
255
|
+
try:
|
|
256
|
+
body = self._body()
|
|
257
|
+
except ValueError as exc:
|
|
258
|
+
# Both texts this can raise are written in _body() — "Body must be JSON." and "Request
|
|
259
|
+
# body too large." — so repeating one says something useful and leaks nothing.
|
|
260
|
+
return self._text(str(exc), 400)
|
|
261
|
+
try:
|
|
262
|
+
if path == "/api/confirm":
|
|
263
|
+
self.controller.set_reply(str(body.get("id", "")), body)
|
|
264
|
+
return self._json({"ok": True})
|
|
265
|
+
result = self.controller.action(str(body.get("type", "")), body, self.hub.publish)
|
|
266
|
+
return self._json({"ok": True, "result": result, "state": self.controller.snapshot()})
|
|
267
|
+
except Exception as exc: # a UI must never take the server down
|
|
268
|
+
return self._fail(exc)
|
|
269
|
+
|
|
270
|
+
def _get_api(self, path: str, query: dict):
|
|
271
|
+
if path == "/api/bootstrap":
|
|
272
|
+
return self._json(self.controller.snapshot())
|
|
273
|
+
if path == "/api/fs":
|
|
274
|
+
try:
|
|
275
|
+
return self._json(self.controller.list_dir(query.get("path", [""])[0]))
|
|
276
|
+
except (AgentError, OSError, ValueError) as exc:
|
|
277
|
+
return self._fail(exc, 400)
|
|
278
|
+
if path == "/api/project":
|
|
279
|
+
# The drawer walks the folder to measure it, so it is asked for on open rather
|
|
280
|
+
# than carried in every snapshot.
|
|
281
|
+
try:
|
|
282
|
+
return self._json(self.controller.project_info(query.get("project", [""])[0]))
|
|
283
|
+
except (AgentError, OSError, ValueError) as exc:
|
|
284
|
+
return self._fail(exc, 400)
|
|
285
|
+
if path == "/api/events":
|
|
286
|
+
return self._stream()
|
|
287
|
+
return self._text("Unknown endpoint.", 404)
|
|
288
|
+
|
|
289
|
+
def _stream(self):
|
|
290
|
+
client = self.hub.subscribe()
|
|
291
|
+
self.send_response(200)
|
|
292
|
+
self._safe()
|
|
293
|
+
self.send_header("content-type", "text/event-stream; charset=utf-8")
|
|
294
|
+
self.send_header("cache-control", "no-store")
|
|
295
|
+
self.send_header("x-accel-buffering", "no")
|
|
296
|
+
self.end_headers()
|
|
297
|
+
try:
|
|
298
|
+
self.wfile.write(b"retry: 1500\n\n")
|
|
299
|
+
self.wfile.flush()
|
|
300
|
+
while True:
|
|
301
|
+
try:
|
|
302
|
+
event = client.get(timeout=15)
|
|
303
|
+
chunk = f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode("utf-8")
|
|
304
|
+
except queue.Empty:
|
|
305
|
+
chunk = b": keep-alive\n\n"
|
|
306
|
+
self.wfile.write(chunk)
|
|
307
|
+
self.wfile.flush()
|
|
308
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
309
|
+
pass
|
|
310
|
+
finally:
|
|
311
|
+
self.hub.unsubscribe(client)
|
|
312
|
+
|
|
313
|
+
def _static(self, path: str):
|
|
314
|
+
name = "index.html" if path in {"/", ""} else path.lstrip("/")
|
|
315
|
+
target = (STATIC / name).resolve()
|
|
316
|
+
if not target.is_file() or not target.is_relative_to(STATIC):
|
|
317
|
+
return self._text("Not found.", 404)
|
|
318
|
+
kinds = {".html": "text/html", ".css": "text/css", ".js": "text/javascript",
|
|
319
|
+
".svg": "image/svg+xml", ".png": "image/png", ".woff2": "font/woff2", ".json": "application/json"}
|
|
320
|
+
body = target.read_bytes()
|
|
321
|
+
self.send_response(200)
|
|
322
|
+
self._safe()
|
|
323
|
+
self.send_header("content-type", kinds.get(target.suffix, "application/octet-stream") + "; charset=utf-8")
|
|
324
|
+
self.send_header("content-length", str(len(body)))
|
|
325
|
+
self.send_header("cache-control", "no-cache")
|
|
326
|
+
self.end_headers()
|
|
327
|
+
self.wfile.write(body)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def serve(controller, host: str = "127.0.0.1", port: int = 0):
|
|
331
|
+
"""Start serving and return (server, url, token). Call server.shutdown() to stop."""
|
|
332
|
+
if host not in LOOPBACK:
|
|
333
|
+
raise AgentError("The UI binds to loopback only.")
|
|
334
|
+
token = secrets.token_urlsafe(16)
|
|
335
|
+
# One handler *class* per launch. `Handler` carries its session on the class because
|
|
336
|
+
# `BaseHTTPRequestHandler` is instantiated by the socket server with no room for constructor
|
|
337
|
+
# arguments, and writing the session on the shared class instead meant a second `serve()` in the
|
|
338
|
+
# same process took over the first one's authentication: the first window would answer the
|
|
339
|
+
# second's token and drive the second's controller. A subclass per server keeps two windows in
|
|
340
|
+
# one process — which the desktop launcher can be asked for — apart in all four fields.
|
|
341
|
+
bound = type("SessionHandler", (Handler,), {"token": token, "controller": controller, "hub": Hub()})
|
|
342
|
+
server = ThreadingHTTPServer((host, port), bound)
|
|
343
|
+
server.daemon_threads = True
|
|
344
|
+
actual = server.server_address[1]
|
|
345
|
+
# The Host check compares against the port this process actually owns, which is only knowable
|
|
346
|
+
# after binding: a caller that asked for port 0 gets a random one.
|
|
347
|
+
bound.port = actual
|
|
348
|
+
threading.Thread(target=server.serve_forever, name="ui-http", daemon=True).start()
|
|
349
|
+
return server, f"http://{host}:{actual}/?t={token}", token
|