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/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)