@giamme/forge 0.8.0 → 0.10.0

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 (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
@@ -47,8 +47,7 @@ def supported():
47
47
 
48
48
 
49
49
  def state_root():
50
- """forge_fractal.state_root(), except that an empty XDG_STATE_HOME counts as unset (forge
51
- resolves '' to its working directory)."""
50
+ """forge_fractal.state_root(): an empty XDG_STATE_HOME counts as unset."""
52
51
  base = os.environ.get('XDG_STATE_HOME') or os.path.join(os.path.expanduser('~'), '.local', 'state')
53
52
  return os.path.join(os.path.realpath(os.path.expanduser(base)), 'forge', 'fractal')
54
53
 
@@ -11,7 +11,7 @@ import re
11
11
  import stat
12
12
  import urllib.parse
13
13
 
14
- from forge_ui import diffs, executions, findings, fractal
14
+ from forge_ui import diffs, executions, findings, fractal, transcripts
15
15
  from forge_ui.findings import verdict as _verdict
16
16
 
17
17
 
@@ -413,7 +413,9 @@ def _history(reader, name, escalation=False):
413
413
  continue
414
414
  entry = dict(at=at, attempt=attempt)
415
415
  if escalation:
416
- entry.update({'from': row[2], 'to': row[3]})
416
+ # the 5th column says who decided (policy, a human's retry --dwarf, a pin); older rows have 4
417
+ source = row[4].strip() if len(row) > 4 else ''
418
+ entry.update({'from': row[2], 'to': row[3], 'source': source or 'policy'})
417
419
  else:
418
420
  entry.update(status=row[2], infra=_nullable(row[3]) if len(row) > 3 else None)
419
421
  result.append(entry)
@@ -548,9 +550,20 @@ def _task_detail(reader, task, run_id, waves, branches):
548
550
  task['fail_first'] = _fail_first(reader, prefix + '/failfirst.json')
549
551
  shadows = _shadows(reader, ident, execs)
550
552
  task['shadow'] = [entry for entry, _, _ in shadows]
553
+ # What `forge control show` reads of the task. A change there (a write, or a delete: the dir's mtime) moves `updated`,
554
+ # so the page redraws and the Controls panel asks again.
555
+ reader.stat(prefix)
556
+ for name in _TASK_CONTROLS:
557
+ reader.stat(f'{prefix}/{name}')
551
558
  return execs + [item for _, items, _ in shadows for item in items]
552
559
 
553
560
 
561
+ # The control files (CONTRACT.md) nothing else here reads. `forge control` appends control.log and writes the task's
562
+ # files without touching the run dir, so they are stat'd for `updated` only.
563
+ _RUN_CONTROLS = ('control.log', 'retry_budget', 'infra_budget')
564
+ _TASK_CONTROLS = ('qa_threshold', 'retry_budget', 'retries.used', 'escalation.pin', 'escalation.stop', 'escalation.next')
565
+
566
+
554
567
  _FAIL_FIRST_RESULTS = ('proven', 'passes_without_change', 'inconclusive', 'error')
555
568
 
556
569
 
@@ -741,6 +754,8 @@ def detail(path, source='plan') -> dict:
741
754
  pool = []
742
755
  for task in tasks:
743
756
  pool += _task_detail(reader, task, result['run_id'], wave_map, branches)
757
+ for name in _RUN_CONTROLS:
758
+ reader.stat(name)
744
759
  run_execs = _run_execs(reader)
745
760
  items, integrations = executions.review_dirs(reader)
746
761
  published = _published(reader)
@@ -824,9 +839,8 @@ def task_detail(path, task_id, source='plan'):
824
839
  reviews=reviews, truncated=reader.truncated, warnings=reader.warnings)
825
840
 
826
841
 
827
- def execution_detail(path, name, source='plan'):
828
- """ExecutionDetail of one attempts/<role>-<uuid>/ dir of the run, or None."""
829
- reader = _Read(path)
842
+ def _execution(reader, name):
843
+ """(Execution, owner, owner dir) of the execution `name` of the run, or None."""
830
844
  kind = _kind(reader.path)
831
845
  if kind is None or not executions.NAME.fullmatch(name or ''):
832
846
  return None
@@ -856,8 +870,16 @@ def execution_detail(path, name, source='plan'):
856
870
  execs = _final_review(reader, items[index], index == 0, _published(reader))[1]
857
871
  owner, owner_dir = dict(kind='final_review', id=parts[0]), items[index][3]
858
872
  item = next((candidate for candidate in execs if candidate['id'] == name), None)
859
- if item is None:
873
+ return None if item is None else (item, owner, owner_dir)
874
+
875
+
876
+ def execution_detail(path, name, source='plan'):
877
+ """ExecutionDetail of one attempts/<role>-<uuid>/ dir of the run, or None."""
878
+ reader = _Read(path)
879
+ found = _execution(reader, name)
880
+ if found is None:
860
881
  return None
882
+ item, owner, owner_dir = found
861
883
  role, running = item['role'], item['state'] == 'running'
862
884
  resolved = executions.resolved_of(reader, f'{item.rel}/{role}.resolved')
863
885
  prompt = last = listed = None
@@ -880,6 +902,37 @@ def execution_detail(path, name, source='plan'):
880
902
  return result
881
903
 
882
904
 
905
+ def execution_transcript(path, name, source='plan'):
906
+ """ExecutionTranscript of a dwarf or qa execution: its agent's conversation, read from its `.log`; None
907
+ when there is no such execution or it is of another role."""
908
+ reader = _Read(path)
909
+ found = _execution(reader, name)
910
+ if found is None or found[0]['role'] not in ('dwarf', 'qa'):
911
+ return None
912
+ item, owner, owner_dir = found
913
+ role, running = item['role'], item['state'] == 'running'
914
+ # A finished execution's log is its copy; the owner dir's is the one being written, or the next one's.
915
+ rel = posixpath.join(owner_dir, role + '.log') if running else f'{item.rel}/{role}.log'
916
+ log = None
917
+ try:
918
+ real = Path(os.path.realpath(reader.path / rel))
919
+ if executions.inside(reader.path, str(real)) and stat.S_ISREG(real.stat().st_mode):
920
+ log = real
921
+ elif real.exists():
922
+ reader.warn(rel, 'outside the run dir or not a regular file')
923
+ except (OSError, ValueError):
924
+ pass
925
+ resolved = executions.resolved_of(reader, f'{item.rel}/{role}.resolved')
926
+ if resolved is None and running:
927
+ resolved = executions.parse_resolved(reader.text(posixpath.join(owner_dir, role + '.resolved'))) or None
928
+ model = item['model'] or {}
929
+ result = transcripts.transcript(log, model.get('harness'), running)
930
+ result.update(run=run_key(reader.path), execution=name, role=role, state=item['state'], attempt=item['attempt'],
931
+ spec=model.get('spec'), harness=model.get('harness'), repo=(resolved or {}).get('repo') or None,
932
+ owner=owner, warnings=reader.warnings + result['warnings'])
933
+ return result
934
+
935
+
883
936
  def final_review_detail(path, name, source='plan'):
884
937
  """FinalReviewDetail of final-review-<e>-<p> or integrate-<e>-<p>, or None."""
885
938
  reader = _Read(path)
@@ -1028,8 +1081,8 @@ def _diff_detail(reader, key, origin, sources, loaded, reason, reviews):
1028
1081
  found = []
1029
1082
  for review in reviews:
1030
1083
  text = reader.text(review['last']) if review['last'] else None
1031
- for item in findings.listing(text) or []:
1032
- found.append(dict(review={key: review[key] for key in ('kind', 'execution', 'attempt', 'spec')},
1084
+ for index, item in enumerate(findings.listing(text) or []):
1085
+ found.append(dict(id=_finding_id(review, index), review={key: review[key] for key in ('kind', 'execution', 'attempt', 'spec')},
1033
1086
  severity=item['severity'], status=item['status'], location=item['location'],
1034
1087
  summary=item['summary'], text=item['text']))
1035
1088
  return dict(run=key, source=origin, sources=sources, available=loaded is not None,
@@ -1141,6 +1194,50 @@ def final_review_diff(path, name, source='plan'):
1141
1194
  _NOT_INLINED if prompted else 'no review prompt yet', [review])
1142
1195
 
1143
1196
 
1197
+ # --- findings: every review's findings of a run, for the stage's marks ---------------------
1198
+
1199
+ def _finding_id(review, index):
1200
+ """A Finding's id: its review's execution (its kind when it has none) and its place in that reply."""
1201
+ return f"{review['execution'] or review['kind']}:{index}"
1202
+
1203
+
1204
+ def _findings_of(reader, review):
1205
+ listed = findings.listing(reader.text(review['last'])) if review['last'] else None
1206
+ return [dict(id=_finding_id(review, index), severity=item['severity'], status=item['status'],
1207
+ location=item['location'], summary=item['summary'])
1208
+ for index, item in enumerate(listed or [])]
1209
+
1210
+
1211
+ def run_findings(path, source='plan'):
1212
+ """RunFindings: each review's Findings, without their text, by task and by final review; None if no run."""
1213
+ reader = _Read(path)
1214
+ kind = _kind(reader.path)
1215
+ if kind is None:
1216
+ return None
1217
+ keys = ('kind', 'execution', 'attempt', 'spec', 'verdict')
1218
+ tasks = []
1219
+ for task_id in ['solo'] if kind == 'solo' else [task['id'] for task in _tasks(reader)]:
1220
+ ctx = _diff_task(reader, task_id)
1221
+ reviews = [dict({key: review[key] for key in keys}, findings=found)
1222
+ for review in (ctx['reviews'] if ctx else []) for found in [_findings_of(reader, review)] if found]
1223
+ if reviews:
1224
+ tasks.append(dict(id=task_id, reviews=reviews))
1225
+ finals = []
1226
+ if kind == 'plan':
1227
+ items, published = executions.review_dirs(reader)[0], _published(reader)
1228
+ for index, item in enumerate(items[:_MAX_REVIEWS]):
1229
+ summary = _final_review(reader, item, index == 0, published)[0]
1230
+ # the same review final_review_diff pins, so a Finding's id opens it there
1231
+ review = _diff_review('final', None, None, summary['spec'], summary['verdict'], item[3] + '/qa.last', [],
1232
+ summary['execution'])
1233
+ found = _findings_of(reader, review)
1234
+ if found:
1235
+ finals.append(dict(name=item[0], execution=summary['execution'], spec=summary['spec'],
1236
+ verdict=summary['verdict'], findings=found))
1237
+ return dict(run=run_key(reader.path), available=findings.available(), tasks=tasks, final_reviews=finals,
1238
+ truncated=reader.truncated, warnings=reader.warnings)
1239
+
1240
+
1144
1241
  # --- files: who planned, changed, drifted into or conflicted on each file --------------------
1145
1242
 
1146
1243
  _MAX_FILES, _MAX_TASKS, _MAX_OVERLAPS = 2000, 300, 5000
@@ -1298,6 +1395,16 @@ class Catalog:
1298
1395
  return result if _kind(path) else None
1299
1396
  return None
1300
1397
 
1398
+ def locate(self, key):
1399
+ """(path, kind) of the run with this key, kind 'plan' or 'solo'; None for an unknown key.
1400
+
1401
+ The only way a write finds a run dir: the client names a key, never a path."""
1402
+ for path, _source in discover(**self.options):
1403
+ if run_key(path) == key:
1404
+ kind = _kind(path)
1405
+ return (str(path), kind) if kind else None
1406
+ return None
1407
+
1301
1408
  def get(self, key):
1302
1409
  return self._find(key, detail)
1303
1410
 
@@ -1316,12 +1423,18 @@ class Catalog:
1316
1423
  def execution_diff(self, key, name):
1317
1424
  return self._find(key, execution_diff, name)
1318
1425
 
1426
+ def transcript(self, key, name):
1427
+ return self._find(key, execution_transcript, name)
1428
+
1319
1429
  def final_review_diff(self, key, name):
1320
1430
  return self._find(key, final_review_diff, name)
1321
1431
 
1322
1432
  def files(self, key):
1323
1433
  return self._find(key, files_detail)
1324
1434
 
1435
+ def findings(self, key):
1436
+ return self._find(key, run_findings)
1437
+
1325
1438
  def fractal(self, key, task_id):
1326
1439
  return self._find(key, fractal_detail, task_id)
1327
1440
 
@@ -1,26 +1,38 @@
1
- """Local HTTP server: the JSON API of docs/api.md and the static page. Read-only."""
1
+ """Local HTTP server: the JSON API of docs/api.md and the static page.
2
+
3
+ Reads are open to the local page. Writes (POST) pass the checks of `_write_response` and then run one
4
+ forge CLI call through control.py; the server itself never writes a run dir, a repo or config.
5
+ """
2
6
  from __future__ import annotations
3
7
 
4
8
  import base64
5
9
  from contextlib import contextmanager
6
10
  import hashlib
11
+ import hmac
7
12
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
13
  import json
9
14
  import os
10
15
  from pathlib import Path
11
16
  import re
17
+ import secrets
12
18
  import sys
13
19
  import threading
14
20
  import time
15
21
  import urllib.parse
16
22
 
17
- from forge_ui import __version__, build_id
23
+ from forge_ui import __version__, build_id, control
18
24
 
19
25
 
20
26
  STATIC_DIR = Path(__file__).resolve().parent / 'static'
21
27
  # The build of the code this process loaded. Files edited later must not change it: the launcher
22
28
  # compares /api/health with its own build_id() to find a server still running old code.
23
29
  BUILD = build_id()
30
+ # The forge command every write calls. A test points it at a fake that records its argv.
31
+ forge_cli = Path(__file__).resolve().parents[2] / 'forge'
32
+ MAX_BODY = 64 * 1024
33
+ DRAIN_WAIT = 1.0
34
+ DRAIN_MAX = 1024 * 1024 # an unread request body is read (to here) before a refusal, so the client sees the answer
35
+ TOKEN_HEADER = 'X-Forge-UI-Token'
24
36
 
25
37
  _RUN_PATH = re.compile(r'/api/runs/([0-9a-f]{12})')
26
38
  _TASK_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/tasks/([^/]+)')
@@ -28,16 +40,19 @@ _EXEC_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/executions/((?:task|dwarf|qa|
28
40
  _REVIEW_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/final-reviews/((?:final-review|integrate)-[0-9]+-[0-9]+)')
29
41
  _TASK_DIFF_PATH = re.compile(_TASK_PATH.pattern + r'/diff(?:/(current|prev|committed|existing))?')
30
42
  _EXEC_DIFF_PATH = re.compile(_EXEC_PATH.pattern + '/diff')
43
+ _TRANSCRIPT_PATH = re.compile(_EXEC_PATH.pattern + '/transcript')
31
44
  _REVIEW_DIFF_PATH = re.compile(_REVIEW_PATH.pattern + '/diff')
32
45
  _FILES_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/files')
46
+ _FINDINGS_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/findings')
33
47
  _EXPORT_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/export')
34
48
  _FRACTAL_PATH = re.compile(_TASK_PATH.pattern + r'/fractal(?:/nodes/([A-Za-z0-9][A-Za-z0-9_-]{0,100})(/diff)?)?')
35
- _STATIC_PATH = re.compile(r'/([A-Za-z0-9_.-]+\.(?:html|js|css|svg))')
49
+ _STATIC_PATH = re.compile(r'/([A-Za-z0-9_.-]+\.(?:html|js|css|svg|woff2))')
36
50
  _TYPES = {
37
51
  'html': 'text/html; charset=utf-8',
38
52
  'js': 'text/javascript; charset=utf-8',
39
53
  'css': 'text/css; charset=utf-8',
40
54
  'svg': 'image/svg+xml',
55
+ 'woff2': 'font/woff2',
41
56
  }
42
57
  _JSON = 'application/json; charset=utf-8'
43
58
  _CSP = ("default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; "
@@ -47,6 +62,8 @@ _CSP = ("default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self'
47
62
  _PAGE_CSP = ("default-src 'self'; style-src 'self'; script-src 'self' https://cdn.jsdelivr.net/npm/three@0.160.0/{}; "
48
63
  "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'")
49
64
  _IMPORTMAP = re.compile(r'<script type="importmap">(.*?)</script>', re.DOTALL | re.IGNORECASE)
65
+ _HEAD_TAG = re.compile(rb'<head(?:\s[^>]*)?>', re.IGNORECASE)
66
+ _DOCTYPE = re.compile(rb'<!doctype[^>]*>', re.IGNORECASE)
50
67
 
51
68
 
52
69
  def _log(message):
@@ -62,6 +79,15 @@ def page_csp(body):
62
79
  return _PAGE_CSP.format(hashes)
63
80
 
64
81
 
82
+ def with_token(body, token):
83
+ """index.html with `<meta name="forge-ui-token">` as the first thing in its <head>."""
84
+ meta = b'<meta name="forge-ui-token" content="%s">' % token.encode('ascii')
85
+ # Without a <head> tag the meta goes after the doctype (before it the page would render in quirks mode).
86
+ found = _HEAD_TAG.search(body) or _DOCTYPE.search(body)
87
+ end = found.end() if found else 0
88
+ return body[:end] + meta + body[end:]
89
+
90
+
65
91
  def _task_id(segment):
66
92
  """A task id from its URL segment, or None when it cannot name a task dir."""
67
93
  try:
@@ -106,9 +132,15 @@ def api_get(catalog, path):
106
132
  match = _REVIEW_DIFF_PATH.fullmatch(path)
107
133
  if match:
108
134
  return catalog.final_review_diff(*match.groups())
135
+ match = _TRANSCRIPT_PATH.fullmatch(path)
136
+ if match:
137
+ return catalog.transcript(*match.groups())
109
138
  match = _FILES_PATH.fullmatch(path)
110
139
  if match:
111
140
  return catalog.files(match.group(1))
141
+ match = _FINDINGS_PATH.fullmatch(path)
142
+ if match:
143
+ return catalog.findings(match.group(1))
112
144
  match = _FRACTAL_PATH.fullmatch(path)
113
145
  if match:
114
146
  key, task_id, node, diff = match.group(1), _task_id(match.group(2)), match.group(3), match.group(4)
@@ -131,6 +163,14 @@ class Handler(BaseHTTPRequestHandler):
131
163
  def do_HEAD(self):
132
164
  self._serve(head=True)
133
165
 
166
+ def do_POST(self):
167
+ with self._active():
168
+ self._send(*self._write_response())
169
+
170
+ def do_OPTIONS(self):
171
+ # No CORS: a preflight is never answered, so no other origin can learn that a write is allowed.
172
+ self._method_not_allowed()
173
+
134
174
  def __getattr__(self, name):
135
175
  # Every other method (POST, PUT, a made-up verb, ...) gets a 405, not the 501 of the base class.
136
176
  if name.startswith('do_'):
@@ -180,7 +220,10 @@ class Handler(BaseHTTPRequestHandler):
180
220
  if not self._host_allowed():
181
221
  self._send(*self._json(403, {'error': 'forbidden'}))
182
222
  return
183
- self._send(*self._json(405, {'error': 'method not allowed'}, {'Allow': 'GET, HEAD'}))
223
+ allow = 'GET, HEAD'
224
+ if not self.server.read_only and control.has_route('POST', self.path.partition('?')[0]):
225
+ allow += ', POST'
226
+ self._send(*self._json(405, {'error': 'method not allowed'}, {'Allow': allow}))
184
227
 
185
228
  @contextmanager
186
229
  def _active(self):
@@ -222,6 +265,7 @@ class Handler(BaseHTTPRequestHandler):
222
265
  'version': __version__,
223
266
  'build': BUILD,
224
267
  'pid': os.getpid(),
268
+ 'controls': not self.server.read_only,
225
269
  })
226
270
  catalog = self.server.catalog
227
271
  match = _EXPORT_PATH.fullmatch(path)
@@ -230,6 +274,9 @@ class Handler(BaseHTTPRequestHandler):
230
274
  value = api_get(catalog, path)
231
275
  if value is not NO_ROUTE:
232
276
  return self._found(value)
277
+ routed = control.dispatch(self.server, forge_cli, 'GET', path, None, query=self.query)
278
+ if routed:
279
+ return self._json(routed[0], routed[1])
233
280
  if path == '/':
234
281
  return self._static('index.html')
235
282
  match = _STATIC_PATH.fullmatch(path)
@@ -251,8 +298,88 @@ class Handler(BaseHTTPRequestHandler):
251
298
  if body is None:
252
299
  return self._json(404, {'error': 'not found'})
253
300
  kind = name.rsplit('.', 1)[1]
301
+ if name == 'index.html' and not self.server.read_only:
302
+ body = with_token(body, self.server.token)
254
303
  return 200, _TYPES[kind], body, {'Content-Security-Policy': page_csp(body)} if kind == 'html' else {}
255
304
 
305
+ # ------------------------------------------------------------------------------ writes
306
+
307
+ def _drain(self):
308
+ """Read (and drop) the request body that is already on its way, up to DRAIN_MAX, so a refusal is not
309
+ lost to a connection reset. A client that claims more than it sends costs DRAIN_WAIT, not the timeout."""
310
+ self.close_connection = True
311
+ try:
312
+ left = min(int(self.headers.get('Content-Length') or 0), DRAIN_MAX)
313
+ self.connection.settimeout(DRAIN_WAIT)
314
+ while left > 0:
315
+ chunk = self.rfile.read1(min(left, 65536))
316
+ if not chunk:
317
+ break
318
+ left -= len(chunk)
319
+ except (OSError, ValueError):
320
+ pass
321
+
322
+ def _refuse(self, status, error):
323
+ self._drain()
324
+ return self._json(status, {'error': error})
325
+
326
+ def _body(self):
327
+ """(object, None) for a valid JSON object body, else (None, refusal response)."""
328
+ if self.headers.get('Transfer-Encoding') is not None:
329
+ return None, self._refuse(400, 'bad request')
330
+ length = (self.headers.get('Content-Length') or '').strip()
331
+ if not length.isascii() or not length.isdigit():
332
+ return None, self._refuse(400, 'bad request')
333
+ if int(length) > MAX_BODY:
334
+ return None, self._refuse(413, 'payload too large')
335
+ try:
336
+ raw = self.rfile.read(int(length))
337
+ if len(raw) != int(length):
338
+ raise ValueError('short body')
339
+ body = json.loads(raw.decode('utf-8'))
340
+ except (OSError, ValueError, RecursionError): # not UTF-8, not JSON, or the client hung up
341
+ self.close_connection = True
342
+ return None, self._json(400, {'error': 'bad request'})
343
+ if not isinstance(body, dict):
344
+ return None, self._json(400, {'error': 'bad request'})
345
+ return body, None
346
+
347
+ def _write_response(self):
348
+ """The answer to a POST. A request passes every check, in this order, before any route sees it:
349
+ the Host, read-only mode (405), the token, the content type, the Origin and the body."""
350
+ server = self.server
351
+ path = self.path.partition('?')[0]
352
+ if not self._host_allowed():
353
+ return self._refuse(403, 'forbidden')
354
+ if server.read_only:
355
+ self._drain()
356
+ return self._json(405, {'error': 'method not allowed'}, {'Allow': 'GET, HEAD'})
357
+ # compare_digest takes bytes here: a non-ASCII header value must not raise.
358
+ sent = (self.headers.get(TOKEN_HEADER) or '').encode('utf-8', 'surrogateescape')
359
+ if not hmac.compare_digest(sent, server.token.encode('ascii')):
360
+ return self._refuse(403, 'forbidden')
361
+ kind = (self.headers.get('Content-Type') or '').partition(';')[0].strip().lower()
362
+ if kind != 'application/json':
363
+ return self._refuse(400, 'content type must be application/json')
364
+ origin = self.headers.get('Origin')
365
+ if origin is not None and origin != 'http://' + (self.headers.get('Host') or ''):
366
+ return self._refuse(403, 'forbidden')
367
+ body, refusal = self._body()
368
+ if refusal:
369
+ return refusal
370
+ try:
371
+ routed = control.dispatch(server, forge_cli, 'POST', path, body)
372
+ except Exception as exc: # one bad request must not kill the server
373
+ detail = ' '.join(str(exc).split())
374
+ _log(f'internal error on POST {path}: {type(exc).__name__}: {detail}')
375
+ return self._json(500, {'error': 'internal error'})
376
+ if routed is None:
377
+ return self._json(405, {'error': 'method not allowed'}, {'Allow': 'GET, HEAD'})
378
+ status, obj, route, key = routed
379
+ if not self._quiet():
380
+ _log(f'control {route} {key} -> {status}')
381
+ return self._json(status, obj)
382
+
256
383
  def _export(self, catalog, key):
257
384
  # One run as one HTML file, to download: never rendered from this origin (sandbox, no sources).
258
385
  from forge_ui import export
@@ -292,9 +419,12 @@ class Handler(BaseHTTPRequestHandler):
292
419
  pass # the client hung up (BrokenPipeError, ConnectionResetError, ...): nothing to tell it
293
420
 
294
421
 
295
- def make_server(catalog, host='127.0.0.1', port=0, static_dir=None, quiet=False):
422
+ def make_server(catalog, host='127.0.0.1', port=0, static_dir=None, quiet=False, read_only=False):
296
423
  server = ThreadingHTTPServer((host, port), Handler)
297
424
  server.catalog = catalog
425
+ server.read_only = read_only
426
+ server.token = secrets.token_urlsafe(32) # per process; only the page this process serves carries it
427
+ server.run_locks = control.RunLocks()
298
428
  server.static_dir = STATIC_DIR if static_dir is None else static_dir
299
429
  server.quiet = quiet
300
430
  server.last_request = time.monotonic() # stamped when a request arrives and when it ends