techtree 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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Obtaining the release's starter Skill. Spec section 10.5, decisions 0008.
|
|
2
|
+
|
|
3
|
+
The guided first run needs a Skill before it can prepare anything, and the
|
|
4
|
+
release names exactly which one: ``starter_skill_digest``. This module is how
|
|
5
|
+
those bytes reach a machine — read from a file a person names, or fetched from
|
|
6
|
+
a source a caller names — and how they are proved to be the right ones before
|
|
7
|
+
anything is done with them.
|
|
8
|
+
|
|
9
|
+
Four rules decide everything here.
|
|
10
|
+
|
|
11
|
+
*The release names the Skill; the caller only names where to look.* A source
|
|
12
|
+
is a hint about where bytes might be found. What makes them the starter Skill
|
|
13
|
+
is that they hash to the digest this build's ReleaseCore pins, and that check
|
|
14
|
+
is made against the release document, never against anything a caller passed
|
|
15
|
+
in. So a wrong URL, a stale download, or a helpful mirror serving something
|
|
16
|
+
else all end the same way: refused.
|
|
17
|
+
|
|
18
|
+
*A release that published its Skill needs nobody to name a source.* Spec
|
|
19
|
+
sections 4.1 and 10.5 make the starter Skill a public object at an exact URL,
|
|
20
|
+
and the release carries that URL as ``starter_skill_object_url``. So a caller
|
|
21
|
+
who names nothing gets the published object, and the identity check over what
|
|
22
|
+
arrives is the same one a hand-typed URL goes through — the release's address
|
|
23
|
+
buys the bytes no trust at all.
|
|
24
|
+
|
|
25
|
+
*An address that names its own bytes is checked against them first.* The
|
|
26
|
+
published object URL is a content address: it ends in the digest of the file it
|
|
27
|
+
serves. What arrives is hashed and compared to that before it is treated as a
|
|
28
|
+
Skill at all, which is the cheapest possible refusal of a wrong or tampered
|
|
29
|
+
response, and it happens before any scanning.
|
|
30
|
+
|
|
31
|
+
*The digest is the Skill's root digest, not a file hash.* Decisions document
|
|
32
|
+
0008 fixes this: the starter Skill is evaluated through the kernel, so it is
|
|
33
|
+
pinned by its ``SkillArtifact.root_digest`` — the ordered content-tree digest
|
|
34
|
+
the scanner computes. Materializing therefore *scans*, exactly as preparing a
|
|
35
|
+
participant's own Skill does, and compares the tree digest. A Skill that the
|
|
36
|
+
ordinary scanner or the ordinary policy refuses is refused here too. There is
|
|
37
|
+
no privileged path for a Skill because a release named it.
|
|
38
|
+
|
|
39
|
+
*The cache is Techtree's own.* Materialized Skills live under the Techtree
|
|
40
|
+
home, in a directory named by the digest they were verified against — never in
|
|
41
|
+
package resources, which are the build's and are read-only. A cache entry is
|
|
42
|
+
re-scanned and re-verified before it is reused, so a cached directory that
|
|
43
|
+
somebody edited is not a shortcut past the check.
|
|
44
|
+
|
|
45
|
+
What this module does not do is prepare a draft. It returns a path, and that
|
|
46
|
+
path goes through ``climb prepare`` like any other: scanner, policy, draft,
|
|
47
|
+
confirmation. Decisions 0008 and spec section 10.5 both put the starter Skill
|
|
48
|
+
inside the ordinary path rather than beside it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import urllib.error
|
|
54
|
+
import urllib.request
|
|
55
|
+
from collections.abc import Callable, Iterator
|
|
56
|
+
from contextlib import contextmanager
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
from ipaddress import ip_address
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import Final, Literal
|
|
61
|
+
from urllib.parse import urlsplit
|
|
62
|
+
|
|
63
|
+
from filelock import FileLock, Timeout
|
|
64
|
+
|
|
65
|
+
from techtree.canonical import sha256_digest_bytes
|
|
66
|
+
from techtree.constants import MAX_SKILL_TOTAL_BYTES
|
|
67
|
+
from techtree.errors import (
|
|
68
|
+
NotFoundError,
|
|
69
|
+
PrerequisiteError,
|
|
70
|
+
UsageError,
|
|
71
|
+
ValidationError,
|
|
72
|
+
VerificationError,
|
|
73
|
+
)
|
|
74
|
+
from techtree.fs import ensure_private_directory, remove_tree
|
|
75
|
+
from techtree.manifests.builder import skill_content_digest
|
|
76
|
+
from techtree.models.base import Digest
|
|
77
|
+
from techtree.models.skill import SKILL_ENTRY_FILE, SkillFile
|
|
78
|
+
from techtree.paths import TechtreePaths
|
|
79
|
+
from techtree.release.models import ReleaseCore, object_url_digest
|
|
80
|
+
from techtree.skills.policy import default_instruction_skill_policy
|
|
81
|
+
from techtree.skills.scanner import SkillScanResult, resolve_skill_root, scan_skill
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"STARTER_SKILL_DIGEST_MISMATCH",
|
|
85
|
+
"STARTER_SKILL_SOURCE_REFUSED",
|
|
86
|
+
"STARTER_SKILL_UNAVAILABLE",
|
|
87
|
+
"Downloader",
|
|
88
|
+
"MaterializedStarterSkill",
|
|
89
|
+
"StarterSkillService",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
#: Nothing here holds the pinned Skill and nowhere was named to get it from.
|
|
93
|
+
STARTER_SKILL_UNAVAILABLE: Final = "starter_skill_unavailable"
|
|
94
|
+
|
|
95
|
+
#: A source that will not be read at all — an unusable URL, an unreachable
|
|
96
|
+
#: one, or a file that is not there.
|
|
97
|
+
STARTER_SKILL_SOURCE_REFUSED: Final = "starter_skill_source_refused"
|
|
98
|
+
|
|
99
|
+
#: Bytes arrived and are not the Skill this release pins.
|
|
100
|
+
STARTER_SKILL_DIGEST_MISMATCH: Final = "starter_skill_digest_mismatch"
|
|
101
|
+
|
|
102
|
+
#: One materialization at a time per Techtree home, for the same reason an
|
|
103
|
+
#: engine install takes a lock: two of them would be writing one directory.
|
|
104
|
+
_CACHE_LOCK_FILENAME: Final = ".skills.lock"
|
|
105
|
+
_CACHE_LOCK_TIMEOUT_SECONDS: Final = 120.0
|
|
106
|
+
|
|
107
|
+
#: Where a download is staged before it has been proved. Named so that a
|
|
108
|
+
#: leftover from an interrupted materialization is recognizable.
|
|
109
|
+
_STAGING_PREFIX: Final = ".materializing-"
|
|
110
|
+
|
|
111
|
+
#: How long a fetch may take before it is a hang rather than a slow network.
|
|
112
|
+
DOWNLOAD_TIMEOUT_SECONDS: Final = 60.0
|
|
113
|
+
|
|
114
|
+
type Downloader = Callable[[str], bytes]
|
|
115
|
+
"""Returns the bytes one URL serves, or raises a typed refusal."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class MaterializedStarterSkill:
|
|
120
|
+
"""Where the pinned starter Skill is, and what proved it is that Skill."""
|
|
121
|
+
|
|
122
|
+
root: Path
|
|
123
|
+
entrypoint: Path
|
|
124
|
+
root_digest: Digest
|
|
125
|
+
file_count: int
|
|
126
|
+
total_bytes: int
|
|
127
|
+
origin: Literal["cache", "local_file", "download", "release"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class StarterSkillService:
|
|
131
|
+
"""Materializes the release's starter Skill into the Techtree home."""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
paths: TechtreePaths,
|
|
136
|
+
*,
|
|
137
|
+
download: Downloader | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._paths = paths
|
|
140
|
+
self._download = download or _download_document
|
|
141
|
+
|
|
142
|
+
def materialize(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
release: ReleaseCore,
|
|
146
|
+
local_file: Path | None = None,
|
|
147
|
+
url: str | None = None,
|
|
148
|
+
) -> MaterializedStarterSkill:
|
|
149
|
+
"""Return the pinned starter Skill, obtaining it if this home lacks it."""
|
|
150
|
+
pinned = release.starter_skill_digest
|
|
151
|
+
if local_file is not None and url is not None:
|
|
152
|
+
raise UsageError(
|
|
153
|
+
"name one source for the starter Skill, not two",
|
|
154
|
+
code=STARTER_SKILL_SOURCE_REFUSED,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
ensure_private_directory(self._paths.cache_dir)
|
|
158
|
+
ensure_private_directory(self._paths.skills_cache_dir())
|
|
159
|
+
destination = self._paths.skill_cache_dir(pinned)
|
|
160
|
+
|
|
161
|
+
with self._cache_lock():
|
|
162
|
+
origin: Literal["local_file", "download", "release"]
|
|
163
|
+
if local_file is not None:
|
|
164
|
+
origin, source = "local_file", str(local_file)
|
|
165
|
+
elif url is not None:
|
|
166
|
+
origin, source = "download", url
|
|
167
|
+
else:
|
|
168
|
+
cached = _verified_cache_entry(destination, pinned)
|
|
169
|
+
if cached is not None:
|
|
170
|
+
return _describe(cached, origin="cache")
|
|
171
|
+
origin, source = "release", release.starter_skill_object_url
|
|
172
|
+
|
|
173
|
+
staging = destination.parent / f"{_STAGING_PREFIX}{destination.name}"
|
|
174
|
+
remove_tree(staging)
|
|
175
|
+
try:
|
|
176
|
+
if local_file is not None:
|
|
177
|
+
_stage_local(local_file, staging)
|
|
178
|
+
else:
|
|
179
|
+
_require_fetchable(source)
|
|
180
|
+
_stage_document(_fetched(self._download, source), staging)
|
|
181
|
+
_verify_scan(staging, pinned, source=source)
|
|
182
|
+
remove_tree(destination)
|
|
183
|
+
staging.replace(destination)
|
|
184
|
+
except BaseException:
|
|
185
|
+
remove_tree(staging)
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
# Re-scanned where it now lives, so what is described is what a
|
|
189
|
+
# caller will hand to ``climb prepare`` and not what was staged.
|
|
190
|
+
published = _verified_cache_entry(destination, pinned)
|
|
191
|
+
|
|
192
|
+
if published is None: # pragma: no cover - defended, never expected
|
|
193
|
+
raise VerificationError(
|
|
194
|
+
"the starter Skill did not survive being written to the cache",
|
|
195
|
+
code=STARTER_SKILL_DIGEST_MISMATCH,
|
|
196
|
+
details={"expected": pinned, "cache": str(destination)},
|
|
197
|
+
)
|
|
198
|
+
return _describe(published, origin=origin)
|
|
199
|
+
|
|
200
|
+
@contextmanager
|
|
201
|
+
def _cache_lock(self) -> Iterator[None]:
|
|
202
|
+
"""Serialize materialization within one Techtree home."""
|
|
203
|
+
lock = FileLock(
|
|
204
|
+
str(self._paths.skills_cache_dir() / _CACHE_LOCK_FILENAME),
|
|
205
|
+
timeout=_CACHE_LOCK_TIMEOUT_SECONDS,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
lock.acquire()
|
|
209
|
+
except Timeout as error:
|
|
210
|
+
raise PrerequisiteError(
|
|
211
|
+
"another Techtree process is materializing a Skill",
|
|
212
|
+
code=STARTER_SKILL_UNAVAILABLE,
|
|
213
|
+
details={"path": str(self._paths.skills_cache_dir())},
|
|
214
|
+
retryable=True,
|
|
215
|
+
) from error
|
|
216
|
+
try:
|
|
217
|
+
yield
|
|
218
|
+
finally:
|
|
219
|
+
lock.release()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# The steps
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fetched(download: Downloader, url: str) -> bytes:
|
|
228
|
+
"""Return the bytes one address serves, checked against that address.
|
|
229
|
+
|
|
230
|
+
A content address promises which bytes it returns, and the release's
|
|
231
|
+
starter Skill address is one. Checking the response against the address it
|
|
232
|
+
came from costs one hash and refuses a wrong or tampered object before any
|
|
233
|
+
of it is treated as a Skill. An address that promises nothing — a mirror a
|
|
234
|
+
person typed — is checked by the tree digest alone, further down.
|
|
235
|
+
"""
|
|
236
|
+
data = download(url)
|
|
237
|
+
promised = _promised_digest(url)
|
|
238
|
+
if promised is None:
|
|
239
|
+
return data
|
|
240
|
+
|
|
241
|
+
served = sha256_digest_bytes(data)
|
|
242
|
+
if served != promised:
|
|
243
|
+
raise VerificationError(
|
|
244
|
+
f"{url} promises the bytes it serves are {promised}, and it served "
|
|
245
|
+
f"{served}",
|
|
246
|
+
code=STARTER_SKILL_DIGEST_MISMATCH,
|
|
247
|
+
details={"url": url, "expected": promised, "computed": served},
|
|
248
|
+
)
|
|
249
|
+
return data
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _promised_digest(url: str) -> Digest | None:
|
|
253
|
+
"""Return the digest an address is keyed by, when it is keyed by one."""
|
|
254
|
+
try:
|
|
255
|
+
return object_url_digest(url)
|
|
256
|
+
except ValueError:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _stage_local(source: Path, staging: Path) -> None:
|
|
261
|
+
"""Copy a Skill a person named on this machine into the staging tree."""
|
|
262
|
+
try:
|
|
263
|
+
root = resolve_skill_root(source)
|
|
264
|
+
except (NotFoundError, ValidationError) as error:
|
|
265
|
+
raise NotFoundError(
|
|
266
|
+
f"the starter Skill could not be read from {source}: {error.message}",
|
|
267
|
+
code=STARTER_SKILL_SOURCE_REFUSED,
|
|
268
|
+
details={"path": str(source)},
|
|
269
|
+
) from error
|
|
270
|
+
|
|
271
|
+
# Scanned before it is copied, so nothing the policy refuses is ever
|
|
272
|
+
# written into the Techtree home.
|
|
273
|
+
scan = _scan(root, source=str(source))
|
|
274
|
+
ensure_private_directory(staging)
|
|
275
|
+
for item in scan.files:
|
|
276
|
+
target = staging / item.relative_path
|
|
277
|
+
ensure_private_directory(target.parent)
|
|
278
|
+
target.write_bytes(item.source_path.read_bytes())
|
|
279
|
+
target.chmod(0o600)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _stage_document(data: bytes, staging: Path) -> None:
|
|
283
|
+
"""Write fetched bytes as the Skill's one entry file.
|
|
284
|
+
|
|
285
|
+
A fetched starter Skill is a single ``SKILL.md`` document: that is what
|
|
286
|
+
the v0.1 starter Skill is, and no release coordinate names an archive
|
|
287
|
+
format a tree could arrive in. A multi-file starter Skill would need one,
|
|
288
|
+
chosen in a decision rather than guessed at here.
|
|
289
|
+
"""
|
|
290
|
+
ensure_private_directory(staging)
|
|
291
|
+
entrypoint = staging / SKILL_ENTRY_FILE
|
|
292
|
+
entrypoint.write_bytes(data)
|
|
293
|
+
entrypoint.chmod(0o600)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _verify_scan(root: Path, expected: Digest, *, source: str) -> None:
|
|
297
|
+
"""Scan a staged Skill and refuse it unless it is the one that was pinned."""
|
|
298
|
+
computed = _root_digest(_scan(root, source=source))
|
|
299
|
+
if computed != expected:
|
|
300
|
+
raise VerificationError(
|
|
301
|
+
"what was obtained is not the starter Skill this release pins",
|
|
302
|
+
code=STARTER_SKILL_DIGEST_MISMATCH,
|
|
303
|
+
details={"expected": expected, "computed": computed, "source": source},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _verified_cache_entry(root: Path, expected: Digest) -> SkillScanResult | None:
|
|
308
|
+
"""Return a cached Skill that still is what its directory name claims."""
|
|
309
|
+
if not (root / SKILL_ENTRY_FILE).is_file():
|
|
310
|
+
return None
|
|
311
|
+
try:
|
|
312
|
+
scan = scan_skill(root, default_instruction_skill_policy())
|
|
313
|
+
except (NotFoundError, ValidationError):
|
|
314
|
+
return None
|
|
315
|
+
return scan if _root_digest(scan) == expected else None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _scan(root: Path, *, source: str) -> SkillScanResult:
|
|
319
|
+
"""Run the ordinary scanner and policy over a Skill, and report refusals."""
|
|
320
|
+
try:
|
|
321
|
+
return scan_skill(root, default_instruction_skill_policy())
|
|
322
|
+
except ValidationError as error:
|
|
323
|
+
raise ValidationError(
|
|
324
|
+
f"the starter Skill obtained from {source} is not a Skill Techtree "
|
|
325
|
+
f"would accept from anyone: {error.message}",
|
|
326
|
+
code=STARTER_SKILL_SOURCE_REFUSED,
|
|
327
|
+
details={"source": source},
|
|
328
|
+
) from error
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _root_digest(scan: SkillScanResult) -> Digest:
|
|
332
|
+
"""Return the content-tree digest decisions 0008 pins a starter Skill by."""
|
|
333
|
+
return skill_content_digest(
|
|
334
|
+
[
|
|
335
|
+
SkillFile(
|
|
336
|
+
path=item.relative_path.as_posix(),
|
|
337
|
+
media_type=item.media_type,
|
|
338
|
+
size=item.size,
|
|
339
|
+
digest=item.digest,
|
|
340
|
+
)
|
|
341
|
+
for item in scan.files
|
|
342
|
+
]
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _describe(
|
|
347
|
+
scan: SkillScanResult,
|
|
348
|
+
*,
|
|
349
|
+
origin: Literal["cache", "local_file", "download", "release"],
|
|
350
|
+
) -> MaterializedStarterSkill:
|
|
351
|
+
"""Say where the verified Skill is and how it got there."""
|
|
352
|
+
return MaterializedStarterSkill(
|
|
353
|
+
root=scan.root,
|
|
354
|
+
entrypoint=scan.root / SKILL_ENTRY_FILE,
|
|
355
|
+
root_digest=_root_digest(scan),
|
|
356
|
+
file_count=len(scan.files),
|
|
357
|
+
total_bytes=sum(item.size for item in scan.files),
|
|
358
|
+
origin=origin,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# Fetching
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _download_document(url: str) -> bytes:
|
|
368
|
+
"""Fetch one document over HTTP, bounded.
|
|
369
|
+
|
|
370
|
+
Transport is not what makes these bytes trustworthy — the release's digest
|
|
371
|
+
is, and it is checked afterwards on whatever arrives. This is only the
|
|
372
|
+
transport, and it is reached only after the service has decided the URL is
|
|
373
|
+
one Techtree fetches from at all.
|
|
374
|
+
"""
|
|
375
|
+
request = urllib.request.Request(
|
|
376
|
+
url, method="GET", headers={"Accept": "text/markdown, text/plain, */*"}
|
|
377
|
+
)
|
|
378
|
+
try:
|
|
379
|
+
with urllib.request.urlopen(
|
|
380
|
+
request, timeout=DOWNLOAD_TIMEOUT_SECONDS
|
|
381
|
+
) as response:
|
|
382
|
+
data: bytes = response.read(MAX_SKILL_TOTAL_BYTES + 1)
|
|
383
|
+
except (urllib.error.URLError, OSError, ValueError) as error:
|
|
384
|
+
raise PrerequisiteError(
|
|
385
|
+
f"the starter Skill could not be fetched from {url}: "
|
|
386
|
+
f"{getattr(error, 'reason', None) or error}",
|
|
387
|
+
code=STARTER_SKILL_SOURCE_REFUSED,
|
|
388
|
+
details={"url": url},
|
|
389
|
+
retryable=True,
|
|
390
|
+
) from error
|
|
391
|
+
|
|
392
|
+
if len(data) > MAX_SKILL_TOTAL_BYTES:
|
|
393
|
+
raise ValidationError(
|
|
394
|
+
f"{url} served more than the {MAX_SKILL_TOTAL_BYTES} bytes a Skill "
|
|
395
|
+
"may hold, so it is not the starter Skill",
|
|
396
|
+
code=STARTER_SKILL_SOURCE_REFUSED,
|
|
397
|
+
details={"url": url, "maximum_total_bytes": str(MAX_SKILL_TOTAL_BYTES)},
|
|
398
|
+
)
|
|
399
|
+
return data
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _require_fetchable(url: str) -> None:
|
|
403
|
+
"""Refuse a URL that is not an ordinary web address before anything opens it.
|
|
404
|
+
|
|
405
|
+
An ``https`` URL, or a plain ``http`` one only when it addresses this
|
|
406
|
+
machine, which is what a local fixture server is. Every other scheme is
|
|
407
|
+
refused rather than handed to a fetcher, because ``file:`` and friends
|
|
408
|
+
would turn a URL into a local read. The check is here rather than inside
|
|
409
|
+
the fetcher so that it holds for every fetcher, including a substituted
|
|
410
|
+
one.
|
|
411
|
+
"""
|
|
412
|
+
parts = urlsplit(url)
|
|
413
|
+
if parts.scheme == "https" and parts.hostname:
|
|
414
|
+
return
|
|
415
|
+
if parts.scheme == "http" and _is_loopback(parts.hostname):
|
|
416
|
+
return
|
|
417
|
+
raise UsageError(
|
|
418
|
+
"a starter Skill is fetched over https, or over http from this "
|
|
419
|
+
f"machine; {url!r} is neither",
|
|
420
|
+
code=STARTER_SKILL_SOURCE_REFUSED,
|
|
421
|
+
details={"url": url},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _is_loopback(hostname: str | None) -> bool:
|
|
426
|
+
"""Return whether a host names this machine and nothing else."""
|
|
427
|
+
if hostname is None:
|
|
428
|
+
return False
|
|
429
|
+
if hostname == "localhost":
|
|
430
|
+
return True
|
|
431
|
+
try:
|
|
432
|
+
return ip_address(hostname).is_loopback
|
|
433
|
+
except ValueError:
|
|
434
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The taskset subsystem: membership identity, locking, and validation."""
|