@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.
- package/CHANGELOG.md +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
package/ui/forge_ui/fractal.py
CHANGED
|
@@ -47,8 +47,7 @@ def supported():
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def state_root():
|
|
50
|
-
"""forge_fractal.state_root()
|
|
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
|
|
package/ui/forge_ui/reader.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
828
|
-
"""
|
|
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
|
|
package/ui/forge_ui/server.py
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
|
-
"""Local HTTP server: the JSON API of docs/api.md and the static page.
|
|
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
|
-
|
|
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
|
|
Binary file
|