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/model_search.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Finding model servers and their models (08-app.md §3 `POST /llm-servers/search`, Onboarding
|
|
2
|
+
step 1; KB-036). Runs in a worker task: an SSH search decrypts the machine's keys (INV-2).
|
|
3
|
+
|
|
4
|
+
Targets:
|
|
5
|
+
- this computer: OpenAI-compatible servers on 127.0.0.1 at the known ports and any added;
|
|
6
|
+
- an SSH machine: the same ports, first directly at the machine's address, then over SSH
|
|
7
|
+
channels on its loopback address (no listening port is opened); a server found only over
|
|
8
|
+
SSH is `via_ssh`;
|
|
9
|
+
- a URL: with or without `/v1`, https before http; a 401 asks for a key; known hosted
|
|
10
|
+
providers serving open models are named, and their price is asked for.
|
|
11
|
+
|
|
12
|
+
A port counts only if `GET /v1/models` answers with a model list. Models a server forwards to
|
|
13
|
+
a hosted service (Ollama cloud models) are marked not local."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import asdict, dataclass, field
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import paramiko
|
|
25
|
+
|
|
26
|
+
from app import ssh_chain
|
|
27
|
+
from app.addresses import private_address
|
|
28
|
+
from app.settings import Mode
|
|
29
|
+
|
|
30
|
+
KNOWN_PORTS = (11434, 1234, 8000, 8080, 30000) # Ollama, LM Studio, vLLM, llama.cpp, SGLang
|
|
31
|
+
TIMEOUT_S = 4.0
|
|
32
|
+
MAX_BODY = 4 * 1024 * 1024
|
|
33
|
+
# Hosted services that serve open-weights models through an OpenAI-compatible API.
|
|
34
|
+
PROVIDERS = {"openrouter.ai": "OpenRouter", "api.together.xyz": "Together",
|
|
35
|
+
"api.groq.com": "Groq", "api.deepinfra.com": "DeepInfra",
|
|
36
|
+
"api.fireworks.ai": "Fireworks", "api.mistral.ai": "Mistral",
|
|
37
|
+
"api.cerebras.ai": "Cerebras", "integrate.api.nvidia.com": "NVIDIA NIM"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Model:
|
|
42
|
+
id: str
|
|
43
|
+
context_length: int | None = None # as served, where the server says
|
|
44
|
+
local: bool = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Server:
|
|
49
|
+
endpoint: dict[str, Any] # {"type": "local"|"via_ssh"|"base_url", ...}
|
|
50
|
+
kind: str | None = None
|
|
51
|
+
provider: str | None = None # a known hosted provider
|
|
52
|
+
needs_key: bool = False
|
|
53
|
+
encrypted: bool = True
|
|
54
|
+
models: list[Model] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Result:
|
|
59
|
+
servers: list[Server] = field(default_factory=list)
|
|
60
|
+
problems: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
return asdict(self)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Fetch(Protocol):
|
|
67
|
+
def __call__(self, method: str, path: str, body: Any = None
|
|
68
|
+
) -> tuple[int, Any]: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _direct(base: str, key: str = "") -> Fetch:
|
|
72
|
+
"""HTTP to `base` (scheme://host:port), JSON in and out."""
|
|
73
|
+
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
74
|
+
|
|
75
|
+
def fetch(method: str, path: str, body: Any = None) -> tuple[int, Any]:
|
|
76
|
+
try:
|
|
77
|
+
r = httpx.request(method, base + path, json=body, headers=headers,
|
|
78
|
+
timeout=TIMEOUT_S, follow_redirects=False)
|
|
79
|
+
except (httpx.HTTPError, OSError):
|
|
80
|
+
return 0, None
|
|
81
|
+
try:
|
|
82
|
+
return r.status_code, r.json()
|
|
83
|
+
except ValueError:
|
|
84
|
+
return r.status_code, None
|
|
85
|
+
return fetch
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _over_ssh(transport: paramiko.Transport, port: int) -> Fetch:
|
|
89
|
+
"""HTTP/1.0 over a `direct-tcpip` channel to 127.0.0.1:`port` on the machine."""
|
|
90
|
+
def fetch(method: str, path: str, body: Any = None) -> tuple[int, Any]:
|
|
91
|
+
data = json.dumps(body).encode() if body is not None else b""
|
|
92
|
+
head = (f"{method} {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\n"
|
|
93
|
+
f"Content-Type: application/json\r\nContent-Length: {len(data)}\r\n\r\n")
|
|
94
|
+
try:
|
|
95
|
+
ch = transport.open_channel("direct-tcpip", ("127.0.0.1", port), ("127.0.0.1", 0),
|
|
96
|
+
timeout=TIMEOUT_S)
|
|
97
|
+
except (paramiko.SSHException, OSError, EOFError):
|
|
98
|
+
return 0, None
|
|
99
|
+
try:
|
|
100
|
+
ch.settimeout(TIMEOUT_S)
|
|
101
|
+
ch.sendall(head.encode() + data)
|
|
102
|
+
raw = b""
|
|
103
|
+
while len(raw) < MAX_BODY:
|
|
104
|
+
chunk = ch.recv(65536)
|
|
105
|
+
if not chunk:
|
|
106
|
+
break
|
|
107
|
+
raw += chunk
|
|
108
|
+
except (OSError, paramiko.SSHException, EOFError):
|
|
109
|
+
return 0, None
|
|
110
|
+
finally:
|
|
111
|
+
ch.close()
|
|
112
|
+
top, _, payload = raw.partition(b"\r\n\r\n")
|
|
113
|
+
parts = top.split(b" ", 2)
|
|
114
|
+
try:
|
|
115
|
+
status = int(parts[1])
|
|
116
|
+
except (IndexError, ValueError):
|
|
117
|
+
return 0, None
|
|
118
|
+
try:
|
|
119
|
+
return status, json.loads(payload)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return status, None
|
|
122
|
+
return fetch
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _models(fetch: Fetch) -> tuple[int, list[dict[str, Any]] | None]:
|
|
126
|
+
status, body = fetch("GET", "/v1/models")
|
|
127
|
+
if status == 200 and isinstance(body, dict) and isinstance(body.get("data"), list):
|
|
128
|
+
return status, [m for m in body["data"] if isinstance(m, dict) and m.get("id")]
|
|
129
|
+
return status, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def describe(fetch: Fetch, endpoint: dict[str, Any]) -> Server | None:
|
|
133
|
+
"""The server behind `fetch`, or None if it is not an OpenAI-compatible model server."""
|
|
134
|
+
status, listed = _models(fetch)
|
|
135
|
+
if status == 401:
|
|
136
|
+
return Server(endpoint=endpoint, needs_key=True)
|
|
137
|
+
if listed is None:
|
|
138
|
+
return None
|
|
139
|
+
server = Server(endpoint=endpoint)
|
|
140
|
+
version_status, _ = fetch("GET", "/api/version")
|
|
141
|
+
if version_status == 200: # Ollama
|
|
142
|
+
server.kind = "ollama"
|
|
143
|
+
_, ps = fetch("GET", "/api/ps")
|
|
144
|
+
loaded = {m.get("name"): m.get("context_length")
|
|
145
|
+
for m in (ps or {}).get("models", []) if isinstance(m, dict)}
|
|
146
|
+
for m in listed:
|
|
147
|
+
mid = str(m["id"])
|
|
148
|
+
cloud = mid.endswith(":cloud") or "-cloud" in mid
|
|
149
|
+
ctx = loaded.get(mid)
|
|
150
|
+
server.models.append(Model(mid, ctx if isinstance(ctx, int) else None, not cloud))
|
|
151
|
+
return server
|
|
152
|
+
for m in listed:
|
|
153
|
+
ctx = m.get("max_model_len")
|
|
154
|
+
if isinstance(ctx, int):
|
|
155
|
+
server.kind = "vllm"
|
|
156
|
+
server.models.append(Model(str(m["id"]), ctx if isinstance(ctx, int) else None))
|
|
157
|
+
if server.kind is None:
|
|
158
|
+
s, props = fetch("GET", "/props")
|
|
159
|
+
n_ctx = ((props or {}).get("default_generation_settings") or {}).get("n_ctx") \
|
|
160
|
+
if s == 200 and isinstance(props, dict) else None
|
|
161
|
+
if isinstance(n_ctx, int):
|
|
162
|
+
server.kind = "llama.cpp"
|
|
163
|
+
for model in server.models:
|
|
164
|
+
model.context_length = n_ctx
|
|
165
|
+
elif fetch("GET", "/api/v0/models")[0] == 200:
|
|
166
|
+
server.kind = "lmstudio"
|
|
167
|
+
return server
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def search_this_computer(ports: list[int]) -> Result:
|
|
171
|
+
result = Result()
|
|
172
|
+
for port in dict.fromkeys([*KNOWN_PORTS, *ports]):
|
|
173
|
+
found = describe(_direct(f"http://127.0.0.1:{port}"), {"type": "local", "port": port})
|
|
174
|
+
if found is not None:
|
|
175
|
+
result.servers.append(found)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def search_machine(machine_id: str, hops: list[dict[str, Any]], ports: list[int],
|
|
180
|
+
private_key: Callable[[str], str], mode: Mode) -> Result:
|
|
181
|
+
"""`hops` ends with the machine itself (its jump hosts first)."""
|
|
182
|
+
result = Result()
|
|
183
|
+
target = hops[-1]
|
|
184
|
+
host = str(target["host"])
|
|
185
|
+
seen: set[int] = set()
|
|
186
|
+
ports = list(dict.fromkeys([*KNOWN_PORTS, *ports]))
|
|
187
|
+
# Directly at the machine's address first; never a private address in hosted mode.
|
|
188
|
+
if not (mode == "hosted" and private_address(host)):
|
|
189
|
+
shown = f"[{host}]" if ":" in host else host
|
|
190
|
+
for port in ports:
|
|
191
|
+
base = f"http://{shown}:{port}"
|
|
192
|
+
found = describe(_direct(base), {"type": "base_url", "base_url": f"{base}/v1"})
|
|
193
|
+
if found is not None:
|
|
194
|
+
found.encrypted = private_address(host) # plain http: fine on a private net
|
|
195
|
+
result.servers.append(found)
|
|
196
|
+
seen.add(port)
|
|
197
|
+
try:
|
|
198
|
+
chain = ssh_chain.connect(hops, private_key)
|
|
199
|
+
except (ssh_chain.HopFailed, paramiko.SSHException, OSError, EOFError) as exc:
|
|
200
|
+
result.problems.append(f"over SSH: {exc}")
|
|
201
|
+
return result
|
|
202
|
+
try:
|
|
203
|
+
transport = chain.client.get_transport()
|
|
204
|
+
if transport is None:
|
|
205
|
+
result.problems.append(f"over SSH: {host} gave no transport")
|
|
206
|
+
return result
|
|
207
|
+
for port in ports:
|
|
208
|
+
if port in seen:
|
|
209
|
+
continue
|
|
210
|
+
found = describe(_over_ssh(transport, port),
|
|
211
|
+
{"type": "via_ssh", "machine": machine_id, "remote_port": port})
|
|
212
|
+
if found is not None:
|
|
213
|
+
result.servers.append(found)
|
|
214
|
+
finally:
|
|
215
|
+
chain.close()
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def normalize(url: str) -> list[str]:
|
|
220
|
+
"""Base URLs to try for what the user typed: with `/v1`, https before http."""
|
|
221
|
+
text = url.strip().rstrip("/")
|
|
222
|
+
if "://" not in text:
|
|
223
|
+
candidates = [f"https://{text}", f"http://{text}"]
|
|
224
|
+
else:
|
|
225
|
+
candidates = [text]
|
|
226
|
+
out = []
|
|
227
|
+
for c in candidates:
|
|
228
|
+
out.append(c if c.endswith("/v1") else f"{c}/v1")
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def search_url(url: str, key: str, mode: Mode) -> Result:
|
|
233
|
+
result = Result()
|
|
234
|
+
for base in normalize(url):
|
|
235
|
+
parts = urlsplit(base)
|
|
236
|
+
host = parts.hostname or ""
|
|
237
|
+
if not host or parts.scheme not in ("http", "https"):
|
|
238
|
+
result.problems.append(f"{url!r} is not a web address")
|
|
239
|
+
return result
|
|
240
|
+
if mode == "hosted" and private_address(host): # INV-11
|
|
241
|
+
result.problems.append(f"{host} is not a public address; hosted puremethod does "
|
|
242
|
+
"not call it. Use an SSH machine instead")
|
|
243
|
+
return result
|
|
244
|
+
root = base[: -len("/v1")]
|
|
245
|
+
found = describe(_direct(root, key), {"type": "base_url", "base_url": base})
|
|
246
|
+
if found is not None:
|
|
247
|
+
found.provider = PROVIDERS.get(host)
|
|
248
|
+
found.encrypted = parts.scheme == "https" or private_address(host)
|
|
249
|
+
result.servers.append(found)
|
|
250
|
+
return result
|
|
251
|
+
result.problems.append(f"no OpenAI-compatible model server answers at {url}")
|
|
252
|
+
return result
|
app/outputs.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Where delivered outputs are read from (08-app.md §3 Downloads, KB-021).
|
|
2
|
+
|
|
3
|
+
`LocalOutputs` is the self-hosted store: the bundle directory `loop` recorded in the run's
|
|
4
|
+
final `run.status`, which must lie inside the output root. Hosted mode adds an object-storage
|
|
5
|
+
store with the same methods."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from app.readonly import ReadOnlyLedger
|
|
17
|
+
|
|
18
|
+
PAPER = Path("paper") / "main.pdf"
|
|
19
|
+
STALE_S = 3600.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OutputStore(Protocol):
|
|
23
|
+
def paper(self, ledger_run_id: str, bundle_ref: str) -> Path | None: ...
|
|
24
|
+
def bundle_archive(self, ledger_run_id: str, bundle_ref: str, name: str) -> Path | None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bundle_ref(ledger: ReadOnlyLedger, ledger_run_id: str) -> str | None:
|
|
28
|
+
"""The bundle recorded by the last `run.status` with status `delivered`, if any."""
|
|
29
|
+
ref = None
|
|
30
|
+
for e in ledger.entries(ledger_run_id, types=["run.status"]):
|
|
31
|
+
if e.payload.get("status") == "delivered" and e.payload.get("bundle"):
|
|
32
|
+
ref = str(e.payload["bundle"])
|
|
33
|
+
return ref
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocalOutputs:
|
|
37
|
+
def __init__(self, output_root: Path, tmp_dir: Path, *,
|
|
38
|
+
clock: Callable[[], float] = time.time) -> None:
|
|
39
|
+
self._root = output_root.resolve()
|
|
40
|
+
self._tmp = tmp_dir
|
|
41
|
+
self._clock = clock
|
|
42
|
+
|
|
43
|
+
def _dir(self, ledger_run_id: str, bundle_ref: str) -> Path | None:
|
|
44
|
+
"""Inside this run's own output directory, which `loop` writes bundles under
|
|
45
|
+
(03-loop.md §5), never just anywhere under the shared root (review finding 8)."""
|
|
46
|
+
run_dir = (self._root / ledger_run_id).resolve()
|
|
47
|
+
path = Path(bundle_ref).resolve()
|
|
48
|
+
inside = run_dir.is_relative_to(self._root) and path.is_relative_to(run_dir)
|
|
49
|
+
if not inside or not path.is_dir():
|
|
50
|
+
return None
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
def paper(self, ledger_run_id: str, bundle_ref: str) -> Path | None:
|
|
54
|
+
d = self._dir(ledger_run_id, bundle_ref)
|
|
55
|
+
if d is None:
|
|
56
|
+
return None
|
|
57
|
+
pdf = (d / PAPER).resolve()
|
|
58
|
+
return pdf if pdf.is_relative_to(d) and pdf.is_file() else None
|
|
59
|
+
|
|
60
|
+
def bundle_archive(self, ledger_run_id: str, bundle_ref: str, name: str) -> Path | None:
|
|
61
|
+
"""A `.tar.gz` of the bundle in a temporary file; the caller deletes it. Symlinks in
|
|
62
|
+
the bundle are stored as links, never followed. Archives an interrupted download left
|
|
63
|
+
behind are removed after an hour (review finding 9)."""
|
|
64
|
+
d = self._dir(ledger_run_id, bundle_ref)
|
|
65
|
+
if d is None:
|
|
66
|
+
return None
|
|
67
|
+
self._tmp.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
for old in self._tmp.glob("bundle-*.tar.gz"):
|
|
69
|
+
try:
|
|
70
|
+
if self._clock() - old.stat().st_mtime > STALE_S:
|
|
71
|
+
old.unlink()
|
|
72
|
+
except OSError:
|
|
73
|
+
pass # another request is removing or sending it
|
|
74
|
+
fd, path = tempfile.mkstemp(prefix="bundle-", suffix=".tar.gz", dir=self._tmp)
|
|
75
|
+
with open(fd, "wb") as fh, tarfile.open(fileobj=fh, mode="w:gz") as tar:
|
|
76
|
+
tar.add(d, arcname=f"{name}-bundle", recursive=True)
|
|
77
|
+
return Path(path)
|
app/paths.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Self-hosted default locations (08-app.md §4 Storage, KB-025): data in
|
|
2
|
+
`<user data dir>/puremethod/data`, the master key in `<user config dir>/puremethod/master.key`.
|
|
3
|
+
The key file is never inside the data directory. `PM_DATA_DIR`, `PM_MASTER_KEY` and
|
|
4
|
+
`PM_MASTER_KEY_FILE` override these (app.settings)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import contextlib
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import platformdirs
|
|
18
|
+
|
|
19
|
+
APP = "puremethod"
|
|
20
|
+
KEY_BYTES = 32
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_data_dir() -> Path:
|
|
24
|
+
return Path(platformdirs.user_data_dir(APP, appauthor=False)) / "data"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_key_file() -> Path:
|
|
28
|
+
return Path(platformdirs.user_config_dir(APP, appauthor=False)) / "master.key"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class KeyFileExists(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_key_file(path: Path) -> None:
|
|
36
|
+
"""Writes a new random 32-byte key, base64, readable only by the current user (08 §5
|
|
37
|
+
step 3). The key is written and restricted under a temporary name, then moved into place
|
|
38
|
+
without overwriting, so a failure never leaves an unrestricted key at `path`, and an
|
|
39
|
+
existing key is never replaced (losing it makes stored credentials unreadable)."""
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
if path.exists():
|
|
42
|
+
raise KeyFileExists(str(path))
|
|
43
|
+
tmp = path.with_name(f".{path.name}.{secrets.token_hex(4)}.tmp")
|
|
44
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
45
|
+
try:
|
|
46
|
+
with os.fdopen(fd, "wb") as fh:
|
|
47
|
+
fh.write(base64.b64encode(os.urandom(KEY_BYTES)) + b"\n")
|
|
48
|
+
restrict_to_user(tmp)
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
os.rename(tmp, path) # fails if `path` exists
|
|
51
|
+
else:
|
|
52
|
+
os.link(tmp, path) # fails if `path` exists; rename would overwrite
|
|
53
|
+
except FileExistsError:
|
|
54
|
+
raise KeyFileExists(str(path)) from None
|
|
55
|
+
finally:
|
|
56
|
+
with contextlib.suppress(OSError):
|
|
57
|
+
tmp.unlink()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _system32(exe: str) -> str:
|
|
61
|
+
"""A Windows tool by its full path: PATH may hold another `whoami` (Git's, say)."""
|
|
62
|
+
return str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / exe)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _user_sid() -> str:
|
|
66
|
+
"""The current user's SID, from the process token (not from environment variables)."""
|
|
67
|
+
out = subprocess.run([_system32("whoami.exe"), "/user", "/fo", "csv", "/nh"], # noqa: S603
|
|
68
|
+
capture_output=True, text=True, check=True).stdout
|
|
69
|
+
sid = out.strip().rsplit(",", 1)[-1].strip('"')
|
|
70
|
+
if not sid.startswith("S-1-"):
|
|
71
|
+
raise OSError("cannot read the current user's SID")
|
|
72
|
+
return sid
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def restrict_to_user(path: Path) -> None:
|
|
76
|
+
"""POSIX: mode 0600. Windows: drop inherited entries and grant the current user alone,
|
|
77
|
+
since a file under AppData inherits SYSTEM and Administrators."""
|
|
78
|
+
if sys.platform != "win32":
|
|
79
|
+
os.chmod(path, 0o600)
|
|
80
|
+
return
|
|
81
|
+
# Fixed arguments as a list, no shell; the path is ours.
|
|
82
|
+
subprocess.run([_system32("icacls.exe"), str(path), "/inheritance:r", "/grant:r", # noqa: S603
|
|
83
|
+
f"*{_user_sid()}:F"], check=True, capture_output=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_restricted(path: Path) -> bool:
|
|
87
|
+
"""For `puremethod doctor`: is the key file readable by the current user only?"""
|
|
88
|
+
if sys.platform != "win32":
|
|
89
|
+
return stat.S_IMODE(path.stat().st_mode) & 0o077 == 0
|
|
90
|
+
out = subprocess.run([_system32("icacls.exe"), str(path)], capture_output=True, # noqa: S603
|
|
91
|
+
text=True, check=False).stdout
|
|
92
|
+
entries = [ln for ln in out.splitlines() if "(" in ln and ":" in ln]
|
|
93
|
+
return len(entries) == 1 and "(I)" not in out
|
app/readonly.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""What the API process may do with the ledger and `loop` (08-app.md INV-2, INV-3, INV-7).
|
|
2
|
+
|
|
3
|
+
`ReadOnlyLedger` exposes the read interface of `04-ledger.md` §3 only; any other attribute
|
|
4
|
+
raises. `status_loop` builds a `Loop` that can answer `status` but whose client factories
|
|
5
|
+
and credential lookup all refuse, so the API process never builds a client or decrypts."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, NoReturn
|
|
13
|
+
|
|
14
|
+
from ledger import ChainReport, Entry, Ledger, RunState, SpendSummary
|
|
15
|
+
from loop import Loop, RunStatus
|
|
16
|
+
|
|
17
|
+
READS = frozenset({"entries", "get_artifact", "state", "spend", "verify"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LedgerWriteRefused(PermissionError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReadOnlyLedger:
|
|
25
|
+
"""Also serializes reads: the API's request threads, event streams and the supervisor
|
|
26
|
+
share one ledger connection, and a SQLite connection is not safe for concurrent use."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, ledger: Ledger) -> None:
|
|
29
|
+
self._ledger = ledger
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def entries(self, run_id: str, types: Iterable[str] | None = None,
|
|
33
|
+
refs: Iterable[str] | None = None,
|
|
34
|
+
since_seq: int | None = None) -> Iterator[Entry]:
|
|
35
|
+
with self._lock:
|
|
36
|
+
got = list(self._ledger.entries(run_id, types=types, refs=refs, since_seq=since_seq))
|
|
37
|
+
return iter(got)
|
|
38
|
+
|
|
39
|
+
def get_artifact(self, run_id: str, digest: str) -> bytes:
|
|
40
|
+
with self._lock:
|
|
41
|
+
return self._ledger.get_artifact(run_id, digest)
|
|
42
|
+
|
|
43
|
+
def state(self, run_id: str) -> RunState:
|
|
44
|
+
with self._lock:
|
|
45
|
+
return self._ledger.state(run_id)
|
|
46
|
+
|
|
47
|
+
def spend(self, run_id: str) -> SpendSummary:
|
|
48
|
+
with self._lock:
|
|
49
|
+
return self._ledger.spend(run_id)
|
|
50
|
+
|
|
51
|
+
def verify(self, run_id: str) -> ChainReport:
|
|
52
|
+
with self._lock:
|
|
53
|
+
return self._ledger.verify(run_id)
|
|
54
|
+
|
|
55
|
+
def __getattr__(self, name: str) -> NoReturn:
|
|
56
|
+
raise LedgerWriteRefused(f"the API reads the ledger only (INV-3): {name}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _refuse(*_: Any, **__: Any) -> NoReturn:
|
|
60
|
+
raise LedgerWriteRefused("the API process builds no clients and reads no credentials")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def status_loop(ledger: ReadOnlyLedger, output_root: Path) -> Loop:
|
|
64
|
+
return Loop(ledger, _refuse, llm=_refuse, executor=_refuse, # type: ignore[arg-type]
|
|
65
|
+
purescholar=_refuse, puretrace=_refuse, credentials=_refuse,
|
|
66
|
+
output_root=output_root)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def status_of(loop: Loop, ledger_run_id: str) -> RunStatus:
|
|
70
|
+
return loop.status(ledger_run_id)
|