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/scan.py ADDED
@@ -0,0 +1,65 @@
1
+ """The one cross-tenant query: what needs a worker (08-app.md §5 step 2). Returns ids only.
2
+ Supervisor side; `app.api` may not import it (import-linter)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+
10
+ from app import leases
11
+ from app.db import Database
12
+ from app.statuses import DRIVABLE
13
+
14
+ log = logging.getLogger("app.scan")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Work:
19
+ kind: str # run | preflight | credcheck | llmsearch | profilecheck | literature
20
+ tenant_id: str
21
+ id: str
22
+
23
+ @property
24
+ def lease_key(self) -> str:
25
+ return f"{self.kind}:{self.id}"
26
+
27
+
28
+ def work(db: Database, status_of: Callable[[str], str | None], now: float) -> list[Work]:
29
+ """Runs that are queued, interrupted at start, have pending commands, or are drivable;
30
+ pending executor preflights and credential checks. Anything whose lease is held is
31
+ skipped."""
32
+ with db.tx() as c:
33
+ runs = c.execute(
34
+ "SELECT r.tenant_id, r.id, r.state, r.ledger_run_id, r.worker_error, "
35
+ "EXISTS (SELECT 1 FROM commands m WHERE m.run_id = r.id AND m.state = 'pending') "
36
+ "FROM runs r WHERE r.state <> 'start_failed' ORDER BY r.created_at, r.id")
37
+ checks = c.execute("SELECT tenant_id, id FROM preflight_checks WHERE state = 'pending' "
38
+ "ORDER BY created_at, id")
39
+ creds = c.execute("SELECT tenant_id, id FROM credential_checks WHERE state = 'pending' "
40
+ "ORDER BY created_at, id")
41
+ searches = c.execute("SELECT tenant_id, id FROM llm_searches WHERE state = 'pending' "
42
+ "ORDER BY created_at, id")
43
+ profile_checks = c.execute("SELECT tenant_id, id FROM profile_checks "
44
+ "WHERE state = 'pending' ORDER BY created_at, id")
45
+ # `starting` too: a start whose worker died is taken up again once its lease is free.
46
+ bundled = c.execute("SELECT tenant_id FROM bundled_services "
47
+ "WHERE state IN ('pending', 'starting') ORDER BY updated_at, tenant_id")
48
+ out: list[Work] = []
49
+ for tenant_id, run_id, state, ledger_run_id, worker_error, pending in runs:
50
+ if worker_error:
51
+ continue # a new command clears it (TenantRepo.add_command)
52
+ wanted = state in ("queued", "starting") or bool(pending)
53
+ if not wanted and ledger_run_id is not None:
54
+ try:
55
+ wanted = status_of(ledger_run_id) in DRIVABLE
56
+ except Exception as exc: # one unreadable run must not stop scheduling the rest
57
+ log.warning("cannot read the status of run %s: %s", run_id, type(exc).__name__)
58
+ if wanted:
59
+ out.append(Work("run", tenant_id, run_id))
60
+ out += [Work("preflight", t, i) for t, i in checks]
61
+ out += [Work("credcheck", t, i) for t, i in creds]
62
+ out += [Work("llmsearch", t, i) for t, i in searches]
63
+ out += [Work("profilecheck", t, i) for t, i in profile_checks]
64
+ out += [Work("literature", t, t) for (t,) in bundled]
65
+ return [w for w in out if not leases.held(db, w.lease_key, now)]
app/server.py ADDED
@@ -0,0 +1,24 @@
1
+ """Builds what the API process holds (08-app.md §5 step 8): the app database, the ledger
2
+ behind `ReadOnlyLedger`, a status-only `Loop`, sign-in, the output store and the link key."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from app.accounts import LocalTokenAuth, Sessions, SigninCodes
7
+ from app.api.common import Ctx
8
+ from app.db import Database
9
+ from app.keys import link_key
10
+ from app.outputs import LocalOutputs
11
+ from app.readonly import ReadOnlyLedger, status_loop
12
+ from app.settings import Settings
13
+ from ledger import Ledger
14
+
15
+
16
+ def build_ctx(settings: Settings, db: Database | None = None) -> Ctx:
17
+ settings.ensure_dirs()
18
+ db = db or Database(settings.app_db)
19
+ ledger = ReadOnlyLedger(Ledger(settings.ledger_url, settings.artifact_root))
20
+ return Ctx(settings=settings, db=db, ledger=ledger,
21
+ status=status_loop(ledger, settings.output_root), auth=LocalTokenAuth(db),
22
+ sessions=Sessions(db),
23
+ outputs=LocalOutputs(settings.output_root, settings.tmp_dir),
24
+ link_key=link_key(settings.master_key), codes=SigninCodes(db))
app/settings.py ADDED
@@ -0,0 +1,125 @@
1
+ """Deployment settings, read from `PM_*` environment variables (08-app.md §5 steps 3, 8, 9)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import os
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from app import paths
14
+
15
+ KEY_BYTES = paths.KEY_BYTES
16
+ Mode = Literal["self-hosted", "hosted"]
17
+ # An installed wheel carries the built UI inside the package (08-app.md §5 step 11); a source
18
+ # checkout uses the static export that `pnpm build` in implementation/web writes.
19
+ _PACKAGED_WEB_DIR = Path(__file__).resolve().parent / "_web"
20
+ DEFAULT_WEB_DIR = (_PACKAGED_WEB_DIR if (_PACKAGED_WEB_DIR / "index.html").is_file()
21
+ else Path(__file__).resolve().parents[2] / "web" / "out")
22
+
23
+
24
+ class SettingsError(Exception):
25
+ """The deployment cannot start as configured."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Settings:
30
+ mode: Mode
31
+ data_dir: Path
32
+ master_key: bytes = field(repr=False)
33
+ host: str = "127.0.0.1"
34
+ port: int = 8400
35
+ purescholar_url: str | None = None
36
+ puretrace_url: str | None = None
37
+ tectonic_image: str | None = None
38
+ purescholar_image: str | None = None # development and tests only (08 §5 step 12)
39
+ web_dir: Path | None = None # the built web UI (static export); served at `/` if present
40
+
41
+ @property
42
+ def app_db(self) -> Path:
43
+ return self.data_dir / "app.db"
44
+
45
+ @property
46
+ def ledger_url(self) -> str:
47
+ return f"sqlite:///{self.data_dir / 'ledger.db'}"
48
+
49
+ @property
50
+ def artifact_root(self) -> Path:
51
+ return self.data_dir / "artifacts"
52
+
53
+ @property
54
+ def output_root(self) -> Path:
55
+ return self.data_dir / "outputs"
56
+
57
+ @property
58
+ def executor_root(self) -> Path:
59
+ return self.data_dir / "executors"
60
+
61
+ @property
62
+ def tmp_dir(self) -> Path:
63
+ return self.data_dir / "tmp"
64
+
65
+ def ensure_dirs(self) -> None:
66
+ # 0700 on POSIX: the app database holds token hashes and credential ciphertexts.
67
+ self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
68
+ for d in (self.artifact_root, self.output_root, self.executor_root, self.tmp_dir):
69
+ d.mkdir(parents=True, exist_ok=True)
70
+
71
+ @classmethod
72
+ def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
73
+ env = os.environ if env is None else env
74
+ mode = env.get("PM_MODE", "self-hosted")
75
+ if mode not in ("self-hosted", "hosted"):
76
+ raise SettingsError("PM_MODE is self-hosted or hosted")
77
+ data_dir = data_dir_from(env)
78
+ return cls(mode=mode, # type: ignore[arg-type]
79
+ data_dir=data_dir, master_key=load_master_key(env, data_dir),
80
+ host=env.get("PM_HOST", "127.0.0.1"), port=int(env.get("PM_PORT", "8400")),
81
+ purescholar_url=env.get("PM_PURESCHOLAR_URL") or None,
82
+ puretrace_url=env.get("PM_PURETRACE_URL") or None,
83
+ tectonic_image=env.get("PM_TECTONIC_IMAGE") or None,
84
+ purescholar_image=env.get("PM_PURESCHOLAR_IMAGE") or None,
85
+ web_dir=Path(env["PM_WEB_DIR"]).resolve() if env.get("PM_WEB_DIR")
86
+ else DEFAULT_WEB_DIR)
87
+
88
+
89
+ def data_dir_from(env: Mapping[str, str]) -> Path:
90
+ return (Path(env["PM_DATA_DIR"]) if env.get("PM_DATA_DIR")
91
+ else paths.default_data_dir()).resolve()
92
+
93
+
94
+ def key_file(env: Mapping[str, str]) -> Path:
95
+ """The key file `serve` reads when `PM_MASTER_KEY` is not set, and the launcher creates."""
96
+ return Path(env["PM_MASTER_KEY_FILE"]).resolve() if env.get("PM_MASTER_KEY_FILE") \
97
+ else paths.default_key_file().resolve()
98
+
99
+
100
+ def load_master_key(env: Mapping[str, str], data_dir: Path) -> bytes:
101
+ """32 bytes from `PM_MASTER_KEY` (base64), or from a key file outside the data directory:
102
+ `PM_MASTER_KEY_FILE`, else the default location (KB-025). No key, no start (KB-021);
103
+ only the launcher creates the file. Errors never echo key material."""
104
+ raw: bytes
105
+ if env.get("PM_MASTER_KEY"):
106
+ try:
107
+ raw = base64.b64decode(env["PM_MASTER_KEY"], validate=True)
108
+ except (binascii.Error, ValueError):
109
+ raise SettingsError("PM_MASTER_KEY is not valid base64") from None
110
+ elif env.get("PM_MASTER_KEY_FILE") or key_file(env).is_file():
111
+ path = key_file(env)
112
+ if path.is_relative_to(data_dir):
113
+ raise SettingsError("the master key file must be outside the data directory")
114
+ try:
115
+ raw = base64.b64decode(path.read_bytes().strip(), validate=True)
116
+ except OSError:
117
+ raise SettingsError(f"cannot read the master key file {path}") from None
118
+ except (binascii.Error, ValueError):
119
+ raise SettingsError("the master key file does not hold valid base64") from None
120
+ else:
121
+ raise SettingsError("no master key: run `puremethod`, which creates one, or set "
122
+ "PM_MASTER_KEY or PM_MASTER_KEY_FILE")
123
+ if len(raw) != KEY_BYTES:
124
+ raise SettingsError(f"the master key must be {KEY_BYTES} bytes")
125
+ return raw
app/setup.py ADDED
@@ -0,0 +1,117 @@
1
+ """Setup status (08-app.md §5 steps 13 and 14, KB-029), computed from configuration already
2
+ stored: credential checks, executor preflights and the service settings. There is no separate
3
+ setup state. Reads metadata only; nothing here decrypts (INV-2)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from app.repo import TenantRepo
12
+
13
+ PURESCHOLAR_CREDENTIAL = "purescholar"
14
+ PURETRACE_CREDENTIAL = "puretrace"
15
+ LOCAL_NOT_YET = "the bundled PureScholar is not available in this version; use a remote one"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ServiceStatus:
20
+ state: str # stopped | starting | ready | failed
21
+ reason: str = ""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Setup:
26
+ model_key: bool
27
+ literature: ServiceStatus
28
+ executor: bool
29
+ missing: list[str] = field(default_factory=list)
30
+
31
+ @property
32
+ def complete(self) -> bool:
33
+ return not self.missing
34
+
35
+ def as_dict(self) -> dict[str, Any]:
36
+ return {"model_key": self.model_key, "executor": self.executor,
37
+ "literature": {"state": self.literature.state,
38
+ "reason": self.literature.reason},
39
+ "missing": self.missing, "complete": self.complete}
40
+
41
+
42
+ LocalStatus = Callable[[TenantRepo], ServiceStatus]
43
+
44
+
45
+ def local_not_yet(repo: TenantRepo) -> ServiceStatus:
46
+ return ServiceStatus("failed", LOCAL_NOT_YET)
47
+
48
+
49
+ def _passed(report: dict[str, Any] | None) -> bool:
50
+ return bool(report and report.get("ok"))
51
+
52
+
53
+ def literature_status(repo: TenantRepo, local: LocalStatus = local_not_yet) -> ServiceStatus:
54
+ setting = repo.service_setting("literature")
55
+ if setting is None:
56
+ return ServiceStatus("stopped", "no literature service is configured")
57
+ if setting.get("mode") == "local":
58
+ return local(repo)
59
+ url = setting.get("url")
60
+ cred = next((c for c in repo.list_credentials() if c.name == PURESCHOLAR_CREDENTIAL), None)
61
+ if cred is None:
62
+ return ServiceStatus("failed", f"no {PURESCHOLAR_CREDENTIAL!r} credential is stored")
63
+ check = repo.latest_credential_check(cred.id)
64
+ if check is None or check.request.get("url") != url:
65
+ return ServiceStatus("failed", "the PureScholar key has not been checked against "
66
+ "this URL; check it again")
67
+ if check.state == "pending":
68
+ return ServiceStatus("starting", "checking the PureScholar key")
69
+ if _passed(check.report):
70
+ return ServiceStatus("ready")
71
+ problems = (check.report or {}).get("problems") or ["the check failed"]
72
+ return ServiceStatus("failed", "; ".join(str(p) for p in problems))
73
+
74
+
75
+ def setup_status(repo: TenantRepo, local: LocalStatus = local_not_yet) -> Setup:
76
+ # 08 §5 step 14 (KB-037): a passed credential check, or a passed profile check (a model
77
+ # that needs no key has no credential to check).
78
+ model_key = any(
79
+ c.type == "llm-key" and _passed(getattr(repo.latest_credential_check(c.id),
80
+ "report", None))
81
+ for c in repo.list_credentials()) or any(
82
+ repo.profile_checked(p) for p in repo.list_profiles())
83
+ executor = any(_passed(getattr(repo.latest_preflight(x.id), "report", None))
84
+ for x in repo.list_executors())
85
+ literature = literature_status(repo, local)
86
+ missing = []
87
+ if not model_key:
88
+ missing.append("model: choose a model and check it")
89
+ if literature.state != "ready":
90
+ missing.append(f"literature service: {literature.reason or literature.state}")
91
+ if not executor:
92
+ missing.append("executor: add one and run its check")
93
+ return Setup(model_key, literature, executor, missing)
94
+
95
+
96
+ LOCAL = "local" # a run's PureScholar is the bundled one (08 §5 step 12)
97
+
98
+
99
+ def run_services(repo: TenantRepo, purescholar_fallback: str | None,
100
+ puretrace_fallback: str | None) -> dict[str, str | None]:
101
+ """What a new run records about its services, like its executor: later setting changes
102
+ never move a run to another service (review of slice 3, Auditor MEDIUM-2)."""
103
+ lit = repo.service_setting("literature")
104
+ ps = LOCAL if lit and lit.get("mode") == "local" else \
105
+ service_url(repo, "literature", purescholar_fallback)
106
+ return {"purescholar": ps, "puretrace": service_url(repo, "puretrace", puretrace_fallback)}
107
+
108
+
109
+ def service_url(repo: TenantRepo, service: str, fallback: str | None) -> str | None:
110
+ """The URL a run uses (08 §4, KB-029): the tenant's remote setting, else the `PM_*`
111
+ fallback. Local mode's URL comes from the bundled containers (§5 step 12)."""
112
+ setting = repo.service_setting(service)
113
+ if setting and setting.get("mode") == "local":
114
+ return None # the bundled PureScholar's address comes from app.bundled, never a fallback
115
+ if setting and setting.get("url"):
116
+ return str(setting["url"])
117
+ return fallback
app/ssh_chain.py ADDED
@@ -0,0 +1,102 @@
1
+ """SSH connections through jump hosts, for `app`'s own SSH work (KB-036): the Docker probe
2
+ of onboarding step 3 and, with `via_ssh` model endpoints, tunnels. `executor` has its own
3
+ connection code for jobs; `app` does not reach into it (05-executor.md §3).
4
+
5
+ Every hop trusts exactly its confirmed host key and refuses any other (INV-10). Failures
6
+ name the hop."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import paramiko
16
+
17
+ from app.ssh_hosts import load_private, parse_host_key
18
+
19
+ TIMEOUT_S = 15.0
20
+
21
+
22
+ class HopFailed(OSError):
23
+ """A hop could not be reached or refused us. The message names the hop, never key data."""
24
+
25
+
26
+ @dataclass
27
+ class Chain:
28
+ """Open connections, first hop first; `client` is the target's."""
29
+ clients: list[paramiko.SSHClient]
30
+
31
+ @property
32
+ def client(self) -> paramiko.SSHClient:
33
+ return self.clients[-1]
34
+
35
+ def close(self) -> None:
36
+ for c in reversed(self.clients):
37
+ c.close()
38
+
39
+
40
+ def _trusting(host: str, port: int, host_key: str) -> paramiko.SSHClient:
41
+ hk = parse_host_key(host_key)
42
+ client = paramiko.SSHClient()
43
+ name = host if port == 22 else f"[{host}]:{port}"
44
+ client.get_host_keys().add(name, hk.type, paramiko.PKey.from_type_string(
45
+ hk.type, base64.b64decode(hk.key)))
46
+ client.set_missing_host_key_policy(paramiko.RejectPolicy())
47
+ return client
48
+
49
+
50
+ def _reason(exc: Exception, host: str, port: int, user: str) -> str:
51
+ if isinstance(exc, paramiko.BadHostKeyException):
52
+ return (f"{host}'s host key does not match the confirmed one; the server may have been "
53
+ "replaced or impersonated")
54
+ if isinstance(exc, paramiko.AuthenticationException):
55
+ return f"{host} refused the SSH key for user {user!r}"
56
+ if isinstance(exc, ValueError):
57
+ return f"the SSH key for {host} is missing or is not a supported private key"
58
+ return f"cannot reach {host}:{port} ({type(exc).__name__})"
59
+
60
+
61
+ def connect(hops: list[dict[str, Any]], private_key: Callable[[str], str],
62
+ timeout: float = TIMEOUT_S) -> Chain:
63
+ """Connects through `hops` in order; the last is the target. Each hop is a dict with
64
+ host, user, port, key_credential and host_key. `private_key(name)` returns a key's text."""
65
+ clients: list[paramiko.SSHClient] = []
66
+ sock: Any = None
67
+ try:
68
+ for i, hop in enumerate(hops):
69
+ host, port, user = str(hop["host"]), int(hop.get("port", 22)), str(hop["user"])
70
+ if not hop.get("host_key"):
71
+ raise HopFailed(f"{host} has no confirmed host key")
72
+ client = _trusting(host, port, str(hop["host_key"]))
73
+ try:
74
+ client.connect(host, port=port, username=user, sock=sock,
75
+ pkey=load_private(private_key(str(hop["key_credential"]))),
76
+ timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
77
+ allow_agent=False, look_for_keys=False)
78
+ except (paramiko.SSHException, OSError, EOFError, ValueError) as exc:
79
+ client.close()
80
+ raise HopFailed(_reason(exc, host, port, user)) from None
81
+ clients.append(client)
82
+ if i + 1 < len(hops):
83
+ nxt = hops[i + 1]
84
+ transport = client.get_transport()
85
+ try:
86
+ if transport is None:
87
+ raise paramiko.SSHException("no transport")
88
+ sock = transport.open_channel(
89
+ "direct-tcpip", (str(nxt["host"]), int(nxt.get("port", 22))),
90
+ ("127.0.0.1", 0), timeout=timeout)
91
+ except (paramiko.SSHException, OSError) as exc:
92
+ raise HopFailed(f"{host} cannot reach {nxt['host']}:{nxt.get('port', 22)} "
93
+ f"({type(exc).__name__})") from None
94
+ except BaseException:
95
+ Chain(clients).close()
96
+ raise
97
+ return Chain(clients)
98
+
99
+
100
+ def hops_of(config: dict[str, Any]) -> list[dict[str, Any]]:
101
+ """The connection order for an `ssh` executor config: its jumps, then the machine."""
102
+ return [*config.get("jumps", []), config]
app/ssh_hosts.py ADDED
@@ -0,0 +1,244 @@
1
+ """SSH conveniences for adding an `ssh` executor (08-app.md §3 Onboarding step 3, KB-035).
2
+
3
+ Self-hosted only, except `fetch_host_key`: the user's `~/.ssh/config` hosts, their key files,
4
+ and `known_hosts`. Host keys are confirmed by fingerprint before an executor stores one
5
+ (INV-10). Private keys are returned only to be sealed as credentials: never logged, never in
6
+ a response.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import io
14
+ import socket
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ import paramiko
21
+ from cryptography.hazmat.primitives import serialization
22
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
23
+
24
+ FETCH_TIMEOUT_S = 10.0
25
+ KnownState = Literal["known", "unknown", "changed"]
26
+
27
+
28
+ class SSHSetupError(ValueError):
29
+ """A reason the user can act on. Never carries key material."""
30
+
31
+
32
+ def ssh_dir() -> Path:
33
+ """The user's `~/.ssh`. Tests point it elsewhere."""
34
+ return Path.home() / ".ssh"
35
+
36
+
37
+ # ---- ~/.ssh/config ---------------------------------------------------------------------------
38
+
39
+ @dataclass(frozen=True)
40
+ class SSHHost:
41
+ name: str
42
+ address: str
43
+ user: str | None
44
+ port: int
45
+ has_key_file: bool
46
+ proxy_jump: tuple[str, ...] = () # hops in order, as written: names or [user@]host[:port]
47
+
48
+
49
+ def _config() -> paramiko.SSHConfig:
50
+ path = ssh_dir() / "config"
51
+ return paramiko.SSHConfig.from_path(str(path)) if path.is_file() else paramiko.SSHConfig()
52
+
53
+
54
+ def _key_files(entry: paramiko.SSHConfigDict) -> list[Path]:
55
+ return [Path(p).expanduser() for p in entry.get("identityfile", [])]
56
+
57
+
58
+ def list_hosts() -> list[SSHHost]:
59
+ """Named hosts from `~/.ssh/config`; wildcard patterns are not hosts."""
60
+ cfg = _config()
61
+ out = []
62
+ for name in sorted(cfg.get_hostnames()):
63
+ if any(ch in name for ch in "*?!"):
64
+ continue
65
+ entry = cfg.lookup(name)
66
+ jump = str(entry.get("proxyjump", "")).strip()
67
+ hops = tuple(h.strip() for h in jump.split(",") if h.strip()) \
68
+ if jump.lower() not in ("", "none") else ()
69
+ out.append(SSHHost(name=name, address=str(entry.get("hostname", name)),
70
+ user=entry.get("user"), port=int(entry.get("port", 22)),
71
+ has_key_file=any(p.is_file() for p in _key_files(entry)),
72
+ proxy_jump=hops))
73
+ return out
74
+
75
+
76
+ def parse_hop(text: str) -> tuple[str | None, str, int]:
77
+ """`[user@]host[:port]` from a `ProxyJump` list: (user, host, port)."""
78
+ user, _, rest = text.rpartition("@")
79
+ host, port = rest, 22
80
+ if rest.startswith("["): # [v6]:port
81
+ host, _, tail = rest[1:].partition("]")
82
+ port = int(tail[1:]) if tail.startswith(":") else 22
83
+ elif rest.count(":") == 1:
84
+ host, _, p = rest.partition(":")
85
+ port = int(p)
86
+ return (user or None), host, port
87
+
88
+
89
+ def read_key_for(name: str) -> tuple[str, str]:
90
+ """The private key file `~/.ssh/config` names for host `name`, read once:
91
+ (private key text, public key line). Passphrase-protected keys are refused."""
92
+ if name not in {h.name for h in list_hosts()}:
93
+ raise SSHSetupError(f"no host named {name!r} in ~/.ssh/config")
94
+ files = [p for p in _key_files(_config().lookup(name)) if p.is_file()]
95
+ if not files:
96
+ raise SSHSetupError(f"~/.ssh/config names no existing key file for {name!r}")
97
+ text = files[0].read_text(encoding="utf-8")
98
+ return text, public_line(load_private(text))
99
+
100
+
101
+ # ---- keys ------------------------------------------------------------------------------------
102
+
103
+ def load_private(text: str) -> paramiko.PKey:
104
+ for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
105
+ try:
106
+ return cls.from_private_key(io.StringIO(text))
107
+ except paramiko.PasswordRequiredException:
108
+ raise SSHSetupError("the key is protected by a passphrase; puremethod cannot use "
109
+ "it. Generate a key here instead, or use one without a "
110
+ "passphrase") from None
111
+ except (paramiko.SSHException, ValueError):
112
+ continue
113
+ raise SSHSetupError("the file is not a supported private key (Ed25519, ECDSA or RSA)")
114
+
115
+
116
+ def public_line(key: paramiko.PKey) -> str:
117
+ return f"{key.get_name()} {key.get_base64()} puremethod"
118
+
119
+
120
+ def generate() -> tuple[str, str]:
121
+ """A new Ed25519 key pair: (OpenSSH private key text, public key line)."""
122
+ private = Ed25519PrivateKey.generate()
123
+ text = private.private_bytes(serialization.Encoding.PEM,
124
+ serialization.PrivateFormat.OpenSSH,
125
+ serialization.NoEncryption()).decode()
126
+ pub = private.public_key().public_bytes(serialization.Encoding.OpenSSH,
127
+ serialization.PublicFormat.OpenSSH).decode()
128
+ return text, f"{pub} puremethod"
129
+
130
+
131
+ # ---- host keys (INV-10) ----------------------------------------------------------------------
132
+
133
+ @dataclass(frozen=True)
134
+ class HostKey:
135
+ type: str
136
+ key: str # base64
137
+ fingerprint: str # "SHA256:<base64>", as `ssh` prints it
138
+
139
+ @property
140
+ def line(self) -> str:
141
+ return f"{self.type} {self.key}"
142
+
143
+
144
+ def fingerprint(key_b64: str) -> str:
145
+ digest = hashlib.sha256(base64.b64decode(key_b64)).digest()
146
+ return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")
147
+
148
+
149
+ def parse_host_key(line: str) -> HostKey:
150
+ """Validates a stored `<type> <base64>` host key."""
151
+ parts = line.split()
152
+ if len(parts) != 2:
153
+ raise SSHSetupError("a host key is '<type> <base64>'")
154
+ try:
155
+ paramiko.PKey.from_type_string(parts[0], base64.b64decode(parts[1], validate=True))
156
+ except (ValueError, paramiko.SSHException):
157
+ raise SSHSetupError("the host key is not a valid SSH public key") from None
158
+ return HostKey(parts[0], parts[1], fingerprint(parts[1]))
159
+
160
+
161
+ def fetch_host_key(host: str, port: int, timeout: float = FETCH_TIMEOUT_S) -> HostKey:
162
+ """The key `host` presents, without logging in."""
163
+ try:
164
+ sock = socket.create_connection((host, port), timeout=timeout)
165
+ except OSError as exc:
166
+ raise SSHSetupError(f"cannot reach {host}:{port} ({type(exc).__name__})") from None
167
+ t = paramiko.Transport(sock)
168
+ try:
169
+ t.start_client(timeout=timeout)
170
+ k = t.get_remote_server_key()
171
+ except (paramiko.SSHException, OSError, EOFError) as exc:
172
+ raise SSHSetupError(f"{host}:{port} did not answer as an SSH server "
173
+ f"({type(exc).__name__})") from None
174
+ finally:
175
+ t.close()
176
+ return HostKey(k.get_name(), k.get_base64(), fingerprint(k.get_base64()))
177
+
178
+
179
+ def known_state(host: str, port: int, key: HostKey) -> KnownState:
180
+ """Whether the user's `~/.ssh/known_hosts` trusts this key for this host."""
181
+ path = ssh_dir() / "known_hosts"
182
+ if not path.is_file():
183
+ return "unknown"
184
+ try:
185
+ hk = paramiko.HostKeys(str(path))
186
+ except (OSError, paramiko.SSHException, ValueError):
187
+ return "unknown"
188
+ name = host if port == 22 else f"[{host}]:{port}"
189
+ entry = hk.lookup(name)
190
+ if not entry:
191
+ return "unknown"
192
+ if key.type in entry:
193
+ return "known" if entry[key.type].get_base64() == key.key else "changed"
194
+ return "unknown" # trusted under another key type; this one is confirmed by fingerprint
195
+
196
+
197
+ def known_key(host: str, port: int) -> HostKey | None:
198
+ """The key `~/.ssh/known_hosts` trusts for this host, if any (ed25519 first). For a host
199
+ the engine cannot reach directly, such as one behind a jump host, a matching entry is the
200
+ confirmation (INV-10, KB-036)."""
201
+ path = ssh_dir() / "known_hosts"
202
+ if not path.is_file():
203
+ return None
204
+ try:
205
+ entry = paramiko.HostKeys(str(path)).lookup(host if port == 22 else f"[{host}]:{port}")
206
+ except (OSError, paramiko.SSHException, ValueError):
207
+ return None
208
+ if not entry:
209
+ return None
210
+ for kind in ("ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "ssh-rsa",
211
+ *entry.keys()):
212
+ if kind in entry:
213
+ b64 = entry[kind].get_base64()
214
+ return HostKey(type=kind, key=b64, fingerprint=fingerprint(b64))
215
+ return None
216
+
217
+
218
+ # ---- Docker on the host (onboarding step 3) ----------------------------------------------------
219
+
220
+ def docker_answers(host: str, port: int, user: str, private_key: str, host_key: str,
221
+ timeout: float = FETCH_TIMEOUT_S) -> bool:
222
+ """Whether `docker version` answers for `user` on the host, over the confirmed host key."""
223
+ hop: dict[str, object] = {"host": host, "port": port, "user": user, "key_credential": "target",
224
+ "host_key": host_key}
225
+ return docker_answers_via([hop], lambda _: private_key, timeout)
226
+
227
+
228
+ def docker_answers_via(hops: list[dict[str, object]], private_key: Callable[[str], str],
229
+ timeout: float = FETCH_TIMEOUT_S) -> bool:
230
+ """`docker_answers` through jump hosts: `hops` ends with the target (KB-036)."""
231
+ from app import ssh_chain # imports this module, so not at the top
232
+
233
+ try:
234
+ chain = ssh_chain.connect(list(hops), private_key, timeout)
235
+ except (paramiko.SSHException, OSError, EOFError, SSHSetupError):
236
+ return False
237
+ try:
238
+ _, out, _ = chain.client.exec_command("docker version --format '{{.Server.Version}}'",
239
+ timeout=timeout)
240
+ return out.channel.recv_exit_status() == 0 and bool(out.read().strip())
241
+ except (paramiko.SSHException, OSError, EOFError):
242
+ return False
243
+ finally:
244
+ chain.close()