puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
app/ssh_machines.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""SSH machines (08-app.md §4, KB-036): hosts the engine reaches over SSH, used by `ssh`
|
|
2
|
+
executors and, later, by `via_ssh` model endpoints.
|
|
3
|
+
|
|
4
|
+
A machine is the source of truth for its connection. Each `ssh` executor keeps a resolved
|
|
5
|
+
copy (address, user, port, key, host key and the jump chain) next to the machine's id, so a
|
|
6
|
+
run's executor snapshot stays self-contained. Every change to a machine re-resolves the
|
|
7
|
+
executors that use it, in the same transaction (`TenantRepo.save_machine`)."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
14
|
+
|
|
15
|
+
from app.settings import Mode
|
|
16
|
+
from app.ssh_hosts import parse_host_key
|
|
17
|
+
|
|
18
|
+
MAX_JUMPS = 4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MachineConfig(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
23
|
+
|
|
24
|
+
host: Annotated[str, Field(min_length=1, max_length=253)]
|
|
25
|
+
user: Annotated[str, Field(min_length=1, max_length=64)]
|
|
26
|
+
key_credential: Annotated[str, Field(min_length=1)]
|
|
27
|
+
port: Annotated[int, Field(ge=1, le=65535)] = 22
|
|
28
|
+
host_key: str | None = None # the confirmed "<type> <base64>" (INV-10)
|
|
29
|
+
known_hosts: str | None = None # self-hosted only; machines migrated from before KB-035
|
|
30
|
+
jumps: Annotated[list[str], Field(max_length=MAX_JUMPS)] = [] # machine ids, in order
|
|
31
|
+
|
|
32
|
+
@field_validator("host_key")
|
|
33
|
+
@classmethod
|
|
34
|
+
def _valid_host_key(cls, v: str | None) -> str | None:
|
|
35
|
+
if v is not None:
|
|
36
|
+
parse_host_key(v) # raises SSHSetupError, a ValueError
|
|
37
|
+
return v
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
CONNECTION = ("host", "user", "key_credential", "port", "host_key", "known_hosts")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate(config: dict[str, Any], mode: Mode) -> MachineConfig:
|
|
44
|
+
cfg = MachineConfig.model_validate(config)
|
|
45
|
+
if mode == "hosted" and cfg.known_hosts is not None:
|
|
46
|
+
raise ValueError("hosted mode does not accept known_hosts paths; confirm the host key")
|
|
47
|
+
return cfg
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def from_executor(config: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
"""A machine config from an `ssh` executor's inline fields (08 §4: inline fields create
|
|
52
|
+
a machine; existing executors are migrated this way)."""
|
|
53
|
+
out = {k: config[k] for k in CONNECTION if config.get(k) is not None}
|
|
54
|
+
out.setdefault("port", 22)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def chain(machines: dict[str, dict[str, Any]], machine_id: str) -> list[dict[str, Any]]:
|
|
59
|
+
"""The hops before `machine_id`, flattened in connection order, each a connection dict
|
|
60
|
+
with a confirmed host key. A hop's own jumps come first. Cycles and long chains are
|
|
61
|
+
refused."""
|
|
62
|
+
hops: list[dict[str, Any]] = []
|
|
63
|
+
|
|
64
|
+
def walk(mid: str, seen: tuple[str, ...]) -> None:
|
|
65
|
+
if mid in seen:
|
|
66
|
+
raise ValueError("the jump hosts form a loop")
|
|
67
|
+
cfg = machines.get(mid)
|
|
68
|
+
if cfg is None:
|
|
69
|
+
raise ValueError(f"no SSH machine {mid!r}")
|
|
70
|
+
for jid in cfg.get("jumps", []):
|
|
71
|
+
walk(jid, (*seen, mid))
|
|
72
|
+
jump = machines[jid]
|
|
73
|
+
if not jump.get("host_key"):
|
|
74
|
+
raise ValueError(f"jump host {jump['host']} has no confirmed host key")
|
|
75
|
+
hops.append({k: jump[k] for k in ("host", "user", "key_credential", "port",
|
|
76
|
+
"host_key")})
|
|
77
|
+
|
|
78
|
+
walk(machine_id, ())
|
|
79
|
+
if len(hops) > MAX_JUMPS:
|
|
80
|
+
raise ValueError(f"at most {MAX_JUMPS} jump hosts")
|
|
81
|
+
return hops
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve(machines: dict[str, dict[str, Any]], machine_id: str) -> dict[str, Any]:
|
|
85
|
+
"""The connection fields an `ssh` executor stores for `machine_id`."""
|
|
86
|
+
cfg = machines[machine_id]
|
|
87
|
+
out: dict[str, Any] = {k: cfg.get(k) for k in CONNECTION}
|
|
88
|
+
out["port"] = cfg.get("port", 22)
|
|
89
|
+
out["jumps"] = chain(machines, machine_id)
|
|
90
|
+
out["machine"] = machine_id
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def users(machines: dict[str, tuple[str, dict[str, Any]]],
|
|
95
|
+
executors: list[tuple[str, dict[str, Any]]], machine_id: str) -> list[str]:
|
|
96
|
+
"""What uses a machine, by name: executors on it, and machines that jump through it.
|
|
97
|
+
`machines` maps id to (name, config); `executors` lists (name, config)."""
|
|
98
|
+
names = [f"executor {name}" for name, cfg in executors if cfg.get("machine") == machine_id]
|
|
99
|
+
names += [f"SSH machine {name}" for name, cfg in machines.values()
|
|
100
|
+
if machine_id in cfg.get("jumps", [])]
|
|
101
|
+
return names
|
app/statuses.py
ADDED
app/supervisor.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Keeps a worker process for every run or check that needs one (08-app.md §5 step 2).
|
|
2
|
+
|
|
3
|
+
Self-hosted mode runs this as a thread inside the server. A worker that exits with an error
|
|
4
|
+
is retried with backoff, so a broken run cannot spin. Leases, not this class, guarantee one
|
|
5
|
+
driver per run: a second worker for the same run exits at once."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from app import scan
|
|
18
|
+
from app.db import Database
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("app.supervisor")
|
|
21
|
+
|
|
22
|
+
BACKOFF_S = (5.0, 30.0, 120.0, 600.0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Proc(Protocol):
|
|
26
|
+
def poll(self) -> int | None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def spawn_worker(work: scan.Work) -> Proc:
|
|
30
|
+
# Arguments are ids this app generated, passed as a list: no shell.
|
|
31
|
+
return subprocess.Popen([sys.executable, "-m", "app.worker", work.kind, # noqa: S603
|
|
32
|
+
work.tenant_id, work.id])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Supervisor:
|
|
36
|
+
def __init__(self, db: Database, status_of: Callable[[str], str | None], *,
|
|
37
|
+
spawn: Callable[[scan.Work], Proc] = spawn_worker,
|
|
38
|
+
clock: Callable[[], float] = time.time, interval_s: float = 2.0) -> None:
|
|
39
|
+
self._db, self._status_of, self._spawn = db, status_of, spawn
|
|
40
|
+
self._clock, self._interval = clock, interval_s
|
|
41
|
+
self._procs: dict[str, Proc] = {}
|
|
42
|
+
self._failures: dict[str, int] = {}
|
|
43
|
+
self._not_before: dict[str, float] = {}
|
|
44
|
+
self._stop = threading.Event()
|
|
45
|
+
self._thread: threading.Thread | None = None
|
|
46
|
+
|
|
47
|
+
def tick(self) -> list[scan.Work]:
|
|
48
|
+
now = self._clock()
|
|
49
|
+
for key, proc in list(self._procs.items()):
|
|
50
|
+
code = proc.poll()
|
|
51
|
+
if code is None:
|
|
52
|
+
continue
|
|
53
|
+
del self._procs[key]
|
|
54
|
+
if code in (0, 2): # finished, or another worker holds the lease
|
|
55
|
+
self._failures.pop(key, None)
|
|
56
|
+
self._not_before.pop(key, None)
|
|
57
|
+
else:
|
|
58
|
+
n = self._failures.get(key, 0)
|
|
59
|
+
self._failures[key] = n + 1
|
|
60
|
+
self._not_before[key] = now + BACKOFF_S[min(n, len(BACKOFF_S) - 1)]
|
|
61
|
+
log.warning("worker %s exited with %s; retry in %.0f s", key, code,
|
|
62
|
+
self._not_before[key] - now)
|
|
63
|
+
started = []
|
|
64
|
+
for w in scan.work(self._db, self._status_of, now):
|
|
65
|
+
key = w.lease_key
|
|
66
|
+
if key in self._procs or self._not_before.get(key, 0.0) > now:
|
|
67
|
+
continue
|
|
68
|
+
self._procs[key] = self._spawn(w)
|
|
69
|
+
started.append(w)
|
|
70
|
+
return started
|
|
71
|
+
|
|
72
|
+
def _run(self) -> None:
|
|
73
|
+
while not self._stop.is_set():
|
|
74
|
+
try:
|
|
75
|
+
self.tick()
|
|
76
|
+
except Exception as exc: # keep supervising; the next tick retries
|
|
77
|
+
log.error("supervisor tick failed: %s", type(exc).__name__)
|
|
78
|
+
self._stop.wait(self._interval)
|
|
79
|
+
|
|
80
|
+
def start(self) -> None:
|
|
81
|
+
self._thread = threading.Thread(target=self._run, name="supervisor", daemon=True)
|
|
82
|
+
self._thread.start()
|
|
83
|
+
|
|
84
|
+
def stop(self) -> None:
|
|
85
|
+
self._stop.set()
|
|
86
|
+
if self._thread is not None:
|
|
87
|
+
self._thread.join(timeout=10)
|
app/tunnels.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""SSH tunnels to model servers on SSH machines (07-llm.md §4 `via_ssh`, §5 step 9; 08-app.md
|
|
2
|
+
INV-8, INV-10; KB-036).
|
|
3
|
+
|
|
4
|
+
The worker process that makes the calls opens the tunnel: a listening socket on 127.0.0.1
|
|
5
|
+
(or a bundled network's gateway address) at a free port, whose connections are carried over
|
|
6
|
+
SSH, through the machine's jump chain, to `127.0.0.1:<remote port>` on the machine. The model
|
|
7
|
+
server can stay bound to the machine's loopback. A dropped SSH connection is reopened on the
|
|
8
|
+
next request; `llm` sees a transient error meanwhile and retries (§5 step 5)."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import select
|
|
15
|
+
import socket
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import paramiko
|
|
21
|
+
|
|
22
|
+
from app import ssh_chain
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Tunnel:
|
|
26
|
+
def __init__(self, hops: list[dict[str, Any]], remote_port: int,
|
|
27
|
+
private_key: Callable[[str], str], bind: str = "127.0.0.1") -> None:
|
|
28
|
+
if not hops:
|
|
29
|
+
raise ValueError("a tunnel needs the machine to reach")
|
|
30
|
+
self._hops = hops
|
|
31
|
+
self._remote_port = remote_port
|
|
32
|
+
self._private_key = private_key
|
|
33
|
+
self._bind = bind
|
|
34
|
+
self._chain: ssh_chain.Chain | None = None
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self._sock: socket.socket | None = None
|
|
37
|
+
self._closed = threading.Event()
|
|
38
|
+
self.port = 0
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def machine(self) -> str:
|
|
42
|
+
return str(self._hops[-1]["host"])
|
|
43
|
+
|
|
44
|
+
def open(self) -> int:
|
|
45
|
+
"""Listens; connects over SSH on the first request. Returns the local port."""
|
|
46
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
47
|
+
sock.bind((self._bind, 0))
|
|
48
|
+
sock.listen(16)
|
|
49
|
+
self._sock, self.port = sock, sock.getsockname()[1]
|
|
50
|
+
threading.Thread(target=self._accept, name=f"tunnel-{self.port}", daemon=True).start()
|
|
51
|
+
return self.port
|
|
52
|
+
|
|
53
|
+
def url(self) -> str:
|
|
54
|
+
return f"http://{self._bind}:{self.port}/v1"
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
self._closed.set()
|
|
58
|
+
if self._sock is not None:
|
|
59
|
+
with contextlib.suppress(OSError):
|
|
60
|
+
self._sock.close()
|
|
61
|
+
with self._lock:
|
|
62
|
+
if self._chain is not None:
|
|
63
|
+
self._chain.close()
|
|
64
|
+
self._chain = None
|
|
65
|
+
|
|
66
|
+
def _connected(self) -> paramiko.Transport:
|
|
67
|
+
with self._lock:
|
|
68
|
+
transport = self._chain.client.get_transport() if self._chain else None
|
|
69
|
+
if transport is None or not transport.is_active():
|
|
70
|
+
if self._chain is not None:
|
|
71
|
+
self._chain.close()
|
|
72
|
+
self._chain = ssh_chain.connect(self._hops, self._private_key)
|
|
73
|
+
transport = self._chain.client.get_transport()
|
|
74
|
+
if transport is None:
|
|
75
|
+
raise ssh_chain.HopFailed(f"{self.machine}: no SSH transport")
|
|
76
|
+
transport.set_keepalive(30)
|
|
77
|
+
return transport
|
|
78
|
+
|
|
79
|
+
def _accept(self) -> None:
|
|
80
|
+
assert self._sock is not None # noqa: S101 (set by open before this thread starts)
|
|
81
|
+
while not self._closed.is_set():
|
|
82
|
+
try:
|
|
83
|
+
client, _ = self._sock.accept()
|
|
84
|
+
except OSError:
|
|
85
|
+
return
|
|
86
|
+
threading.Thread(target=self._serve, args=(client,), daemon=True).start()
|
|
87
|
+
|
|
88
|
+
def _serve(self, client: socket.socket) -> None:
|
|
89
|
+
try:
|
|
90
|
+
channel = self._connected().open_channel(
|
|
91
|
+
"direct-tcpip", ("127.0.0.1", self._remote_port), client.getpeername())
|
|
92
|
+
except (paramiko.SSHException, OSError, EOFError) as exc:
|
|
93
|
+
# Answered as a 503 naming the machine or hop: `llm` retries it as transient, and
|
|
94
|
+
# a persistent failure pauses the run with this reason (07 §5 step 9).
|
|
95
|
+
reason = str(exc) if isinstance(exc, ssh_chain.HopFailed) else (
|
|
96
|
+
f"{self.machine}: no model server answers on port {self._remote_port} "
|
|
97
|
+
f"({type(exc).__name__})")
|
|
98
|
+
_refuse(client, f"SSH tunnel to {self.machine} failed: {reason}")
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
_pump(client, channel)
|
|
102
|
+
finally:
|
|
103
|
+
channel.close()
|
|
104
|
+
client.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Relay:
|
|
108
|
+
"""A plain TCP relay from `bind` to 127.0.0.1:`port` on this machine: lets the bundled
|
|
109
|
+
PureScholar container reach a model that listens only on the host's loopback where
|
|
110
|
+
Docker cannot (Docker Engine on Linux). It listens only on the bundled network's gateway
|
|
111
|
+
address (08-app.md INV-8, KB-036)."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, port: int, bind: str) -> None:
|
|
114
|
+
self._target, self._bind = port, bind
|
|
115
|
+
self._sock: socket.socket | None = None
|
|
116
|
+
self._closed = threading.Event()
|
|
117
|
+
self.port = 0
|
|
118
|
+
|
|
119
|
+
def open(self) -> int:
|
|
120
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
121
|
+
sock.bind((self._bind, 0))
|
|
122
|
+
sock.listen(16)
|
|
123
|
+
self._sock, self.port = sock, sock.getsockname()[1]
|
|
124
|
+
threading.Thread(target=self._accept, name=f"relay-{self.port}", daemon=True).start()
|
|
125
|
+
return self.port
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
self._closed.set()
|
|
129
|
+
if self._sock is not None:
|
|
130
|
+
with contextlib.suppress(OSError):
|
|
131
|
+
self._sock.close()
|
|
132
|
+
|
|
133
|
+
def _accept(self) -> None:
|
|
134
|
+
assert self._sock is not None # noqa: S101 (set by open before this thread starts)
|
|
135
|
+
while not self._closed.is_set():
|
|
136
|
+
try:
|
|
137
|
+
client, _ = self._sock.accept()
|
|
138
|
+
except OSError:
|
|
139
|
+
return
|
|
140
|
+
threading.Thread(target=self._serve, args=(client,), daemon=True).start()
|
|
141
|
+
|
|
142
|
+
def _serve(self, client: socket.socket) -> None:
|
|
143
|
+
try:
|
|
144
|
+
upstream = socket.create_connection(("127.0.0.1", self._target), timeout=10)
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
_refuse(client, f"no model server answers on this computer's port {self._target} "
|
|
147
|
+
f"({type(exc).__name__})")
|
|
148
|
+
return
|
|
149
|
+
try:
|
|
150
|
+
upstream.settimeout(None)
|
|
151
|
+
_pump(client, upstream)
|
|
152
|
+
finally:
|
|
153
|
+
upstream.close()
|
|
154
|
+
client.close()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _refuse(client: socket.socket, message: str) -> None:
|
|
158
|
+
body = json.dumps({"error": {"message": message, "type": "tunnel_unavailable"}}).encode()
|
|
159
|
+
try:
|
|
160
|
+
client.settimeout(5)
|
|
161
|
+
with contextlib.suppress(OSError):
|
|
162
|
+
client.recv(65536) # the request line and headers; their content does not matter
|
|
163
|
+
client.sendall(b"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\n"
|
|
164
|
+
b"Connection: close\r\nContent-Length: " + str(len(body)).encode()
|
|
165
|
+
+ b"\r\n\r\n" + body)
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
finally:
|
|
169
|
+
client.close()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _pump(a: socket.socket, b: Any) -> None:
|
|
173
|
+
while True:
|
|
174
|
+
ready, _, _ = select.select([a, b], [], [], 60)
|
|
175
|
+
if not ready:
|
|
176
|
+
continue
|
|
177
|
+
for src, dst in ((a, b), (b, a)):
|
|
178
|
+
if src in ready:
|
|
179
|
+
try:
|
|
180
|
+
data = src.recv(65536)
|
|
181
|
+
except OSError:
|
|
182
|
+
return
|
|
183
|
+
if not data:
|
|
184
|
+
return
|
|
185
|
+
dst.sendall(data)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def open_tunnels(profile: dict[str, dict[str, Any]], private_key: Callable[[str], str],
|
|
189
|
+
bind: str = "127.0.0.1") -> tuple[dict[str, dict[str, Any]], list[Tunnel]]:
|
|
190
|
+
"""For each `via_ssh` role (and fallback), a tunnel and the role pointed at it. Roles on
|
|
191
|
+
the same machine and port share one tunnel. Tunnels connect on first use, so this only
|
|
192
|
+
fails if a local port cannot be bound; every tunnel opened so far is closed then."""
|
|
193
|
+
opened: dict[tuple[str, int, int], Tunnel] = {}
|
|
194
|
+
tunnels: list[Tunnel] = []
|
|
195
|
+
|
|
196
|
+
def point(spec: dict[str, Any]) -> dict[str, Any]:
|
|
197
|
+
ssh = spec.get("ssh")
|
|
198
|
+
if not isinstance(ssh, dict):
|
|
199
|
+
return spec
|
|
200
|
+
hops, port = list(ssh["hops"]), int(ssh["remote_port"])
|
|
201
|
+
key = (str(hops[-1]["host"]), int(hops[-1].get("port", 22)), port)
|
|
202
|
+
if key not in opened:
|
|
203
|
+
t = Tunnel(hops, port, private_key, bind)
|
|
204
|
+
t.open()
|
|
205
|
+
opened[key] = t
|
|
206
|
+
tunnels.append(t)
|
|
207
|
+
out = {k: v for k, v in spec.items() if k != "ssh"}
|
|
208
|
+
out["base_url"] = opened[key].url()
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
result: dict[str, dict[str, Any]] = {}
|
|
213
|
+
for role, spec in profile.items():
|
|
214
|
+
spec = point(spec)
|
|
215
|
+
if isinstance(spec.get("fallback"), dict):
|
|
216
|
+
spec = {**spec, "fallback": point(spec["fallback"])}
|
|
217
|
+
result[role] = spec
|
|
218
|
+
except BaseException:
|
|
219
|
+
for t in tunnels:
|
|
220
|
+
t.close()
|
|
221
|
+
raise
|
|
222
|
+
return result, tunnels
|
app/unseal.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The only credential decryption in `app`. Worker processes only (INV-2, KB-021): import-linter
|
|
2
|
+
forbids `app.api` from importing this module, and `calls` lets tests check the API process
|
|
3
|
+
never decrypts."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from cryptography.exceptions import InvalidTag
|
|
8
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
9
|
+
|
|
10
|
+
from app.keys import aad
|
|
11
|
+
|
|
12
|
+
calls = 0 # decryptions in this process
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CredentialUnreadable(Exception):
|
|
16
|
+
"""Wrong master key, or a ciphertext that is not this row's. Never carries the value."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def unseal(master_key: bytes, tenant_id: str, credential_id: str, nonce: bytes,
|
|
20
|
+
ciphertext: bytes) -> str:
|
|
21
|
+
global calls
|
|
22
|
+
calls += 1
|
|
23
|
+
try:
|
|
24
|
+
plain = AESGCM(master_key).decrypt(nonce, ciphertext, aad(tenant_id, credential_id))
|
|
25
|
+
except InvalidTag:
|
|
26
|
+
raise CredentialUnreadable(credential_id) from None
|
|
27
|
+
return plain.decode()
|