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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. 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
@@ -0,0 +1,4 @@
1
+ """Run statuses as `loop.status` reports them (03-loop.md §3)."""
2
+
3
+ DRIVABLE = frozenset({"preflight", "running", "stopping", "writing"}) # `drive` advances these
4
+ TERMINAL = frozenset({"delivered", "failed"})
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()