sable-sphynx 0.7.0.dev0__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.
- sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
- sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
- sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
- sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
- sphynx/__init__.py +469 -0
- sphynx/__init__.pyi +376 -0
- sphynx/_analytical_client.py +77 -0
- sphynx/_analytical_values.py +252 -0
- sphynx/_audit_client.py +173 -0
- sphynx/_comparison_adapters.py +113 -0
- sphynx/_componentized_full_rag.py +34 -0
- sphynx/_composed_pipeline.py +109 -0
- sphynx/_full_rag_grading.py +218 -0
- sphynx/_full_rag_trace.py +221 -0
- sphynx/_local_eval.py +268 -0
- sphynx/_pipeline_core.py +243 -0
- sphynx/_pr_client.py +237 -0
- sphynx/_rag_diagnostics_calibration.py +285 -0
- sphynx/_rag_diagnostics_catalog.py +39 -0
- sphynx/_rag_diagnostics_common.py +26 -0
- sphynx/_rag_diagnostics_contracts.py +186 -0
- sphynx/_rag_diagnostics_engine.py +243 -0
- sphynx/_rag_diagnostics_importers.py +242 -0
- sphynx/_rag_diagnostics_ranking.py +81 -0
- sphynx/_rag_diagnostics_semantic.py +325 -0
- sphynx/_rag_ranking.py +149 -0
- sphynx/_speech_benchmark_scoring.py +140 -0
- sphynx/_speech_benchmark_sources.py +275 -0
- sphynx/_speech_complete_scoring.py +218 -0
- sphynx/_speech_complete_source.py +118 -0
- sphynx/_speech_kido.py +294 -0
- sphynx/_speech_scoring.py +77 -0
- sphynx/_track_capture.py +213 -0
- sphynx/analytical_views.py +515 -0
- sphynx/assessment_contracts.py +378 -0
- sphynx/assessment_selection.py +57 -0
- sphynx/audit_access.py +245 -0
- sphynx/audit_contracts.py +204 -0
- sphynx/audit_requests.py +180 -0
- sphynx/authoring.py +188 -0
- sphynx/benchmarks.py +568 -0
- sphynx/candidates.py +62 -0
- sphynx/catalog.py +272 -0
- sphynx/chunking.py +372 -0
- sphynx/clickhouse_reader.py +169 -0
- sphynx/client.py +486 -0
- sphynx/comparison_core.py +149 -0
- sphynx/comparison_views.py +223 -0
- sphynx/components.py +1661 -0
- sphynx/contracts.py +514 -0
- sphynx/customer_rerank.py +299 -0
- sphynx/customer_retrieval.py +146 -0
- sphynx/data/benchmark-releases.v1.json +164 -0
- sphynx/data/public-benchmark-release.v1.json +354 -0
- sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
- sphynx/data/rag-paper-references.v1.json +176 -0
- sphynx/data/rag-paper-schemas.v1.json +1069 -0
- sphynx/data/rag-rerank-v1.json +507 -0
- sphynx/dataset_catalog_data.py +1214 -0
- sphynx/dataset_readiness.py +138 -0
- sphynx/dataset_v3.py +999 -0
- sphynx/demo.py +130 -0
- sphynx/demo_candidates/__init__.py +1 -0
- sphynx/demo_candidates/dense.py +11 -0
- sphynx/demo_candidates/lexical.py +21 -0
- sphynx/diagnostics.py +207 -0
- sphynx/documentation.py +737 -0
- sphynx/documentation_candidate.py +939 -0
- sphynx/evals.py +538 -0
- sphynx/evaluation_protocols.py +124 -0
- sphynx/evaluator_bindings.py +370 -0
- sphynx/evidence.py +352 -0
- sphynx/execution_capabilities.py +109 -0
- sphynx/fast_search.py +285 -0
- sphynx/full_rag.py +341 -0
- sphynx/hosted/__init__.py +18 -0
- sphynx/hosted/benchmark_intake.py +218 -0
- sphynx/hosted/contracts.py +361 -0
- sphynx/hosted/model_admission.py +130 -0
- sphynx/hosted/model_artifacts.py +298 -0
- sphynx/hosted/model_contracts.py +63 -0
- sphynx/hosted/model_gcs.py +181 -0
- sphynx/hosted/model_history.py +84 -0
- sphynx/hosted/model_session.py +233 -0
- sphynx/hosted/public_suite.py +74 -0
- sphynx/hosted/registry.py +97 -0
- sphynx/hosted/revisions.py +126 -0
- sphynx/hosted/sandbox.py +105 -0
- sphynx/hosted/submission.py +287 -0
- sphynx/hosted_workflow.py +451 -0
- sphynx/http_transport.py +86 -0
- sphynx/integrated_rag.py +352 -0
- sphynx/integrated_workflow.py +552 -0
- sphynx/integration_contracts.py +474 -0
- sphynx/invocation.py +791 -0
- sphynx/invocation_candidates.py +392 -0
- sphynx/invocation_catalog_data.py +40 -0
- sphynx/learned_catalog_data.py +30 -0
- sphynx/learned_rerank.py +267 -0
- sphynx/legacy_full_rag.py +67 -0
- sphynx/lifecycle.py +885 -0
- sphynx/lifecycle_workflow.py +490 -0
- sphynx/metric_views.py +474 -0
- sphynx/metrics.py +282 -0
- sphynx/model_adapters.py +438 -0
- sphynx/model_catalog_data.py +30 -0
- sphynx/official_benchmarks.py +385 -0
- sphynx/packs.py +258 -0
- sphynx/pipeline.py +313 -0
- sphynx/pipeline_workflow.py +551 -0
- sphynx/pr_evaluations.py +560 -0
- sphynx/progress.py +89 -0
- sphynx/public_bundle.py +173 -0
- sphynx/public_eval.py +62 -0
- sphynx/public_rerank.py +355 -0
- sphynx/py.typed +0 -0
- sphynx/pytest_plugin.py +62 -0
- sphynx/question_robustness.py +437 -0
- sphynx/rag.py +329 -0
- sphynx/rag_diagnostics.py +309 -0
- sphynx/rag_papers/__init__.py +29 -0
- sphynx/rag_papers/__main__.py +45 -0
- sphynx/rag_papers/candidates.py +19 -0
- sphynx/rag_papers/contracts.py +282 -0
- sphynx/rag_papers/datasets.py +291 -0
- sphynx/rag_papers/experiments.py +116 -0
- sphynx/rag_papers/judges.py +308 -0
- sphynx/rag_papers/registry.py +33 -0
- sphynx/rag_papers/runner.py +176 -0
- sphynx/rag_papers/sable.py +135 -0
- sphynx/rag_papers/schemas.py +12 -0
- sphynx/rag_papers/scoring.py +249 -0
- sphynx/rag_papers/semantic.py +288 -0
- sphynx/rag_papers/trec.py +80 -0
- sphynx/recorded_scores.py +352 -0
- sphynx/records.py +124 -0
- sphynx/reflex_traces.py +193 -0
- sphynx/report_rendering.py +71 -0
- sphynx/rerank_family_contracts.py +150 -0
- sphynx/rerank_workflow.py +424 -0
- sphynx/research_candidate.py +190 -0
- sphynx/results.py +852 -0
- sphynx/retrieval.py +750 -0
- sphynx/retrieval_candidates.py +295 -0
- sphynx/retrieval_catalog_data.py +19 -0
- sphynx/retrieval_recordings.py +125 -0
- sphynx/reviewer_audit.py +266 -0
- sphynx/routing_policies.py +375 -0
- sphynx/runner.py +544 -0
- sphynx/sable_rerank.py +489 -0
- sphynx/score_reliability.py +393 -0
- sphynx/speech_benchmarks.py +204 -0
- sphynx/speech_campaigns.py +378 -0
- sphynx/speech_complete.py +236 -0
- sphynx/speech_contracts.py +225 -0
- sphynx/speech_evidence.py +429 -0
- sphynx/speech_listening.py +115 -0
- sphynx/speech_multilingual.py +71 -0
- sphynx/speech_tts.py +479 -0
- sphynx/sql/001_sync_v1.sql +30 -0
- sphynx/stage_contracts.py +199 -0
- sphynx/statistical_catalog_data.py +53 -0
- sphynx/statistical_rerank.py +232 -0
- sphynx/sync.py +797 -0
- sphynx/workbench.py +378 -0
- sphynx/workbench_catalog_data.py +13 -0
- sphynx/workbench_tracks.py +78 -0
- sphynx/workflow_async.py +197 -0
- sphynx/workflow_contracts.py +594 -0
- sphynx/workflow_decisions.py +211 -0
- sphynx/workflow_defaults.py +16 -0
- sphynx/workflow_extensions.py +212 -0
- sphynx/workflow_registry.py +513 -0
- sphynx/workflows.py +792 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Operator-owned repository authorization; never supplied by a candidate PR."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .contracts import HostedError, fields, stable_id
|
|
10
|
+
|
|
11
|
+
REGISTRY_SCHEMA = "sphynx.hosted.registry.v1"
|
|
12
|
+
SUITE_ID = "rag.rerank.v1"
|
|
13
|
+
BASELINE_ID = "supplied-score:v1"
|
|
14
|
+
PUBLIC_SUITE_ID = "rag.rerank.public.v1"
|
|
15
|
+
REGISTERED_MAIN_BASELINE_ID = "registered-main-reflex-lexical:v1"
|
|
16
|
+
_REPOSITORY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def repository_name(value: object) -> str:
|
|
20
|
+
if not isinstance(value, str) or not _REPOSITORY.fullmatch(value):
|
|
21
|
+
raise HostedError("invalid_repository", "repository must be an owner/name pair")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RepositoryRegistration:
|
|
27
|
+
repository_id: int
|
|
28
|
+
full_name: str
|
|
29
|
+
team_id: str
|
|
30
|
+
allowed_suites: tuple[str, ...]
|
|
31
|
+
allowed_baselines: tuple[str, ...]
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if type(self.repository_id) is not int or self.repository_id <= 0:
|
|
35
|
+
raise HostedError("invalid_registry", "repository_id must be a positive GitHub repository ID")
|
|
36
|
+
repository_name(self.full_name)
|
|
37
|
+
stable_id(self.team_id, "team_id")
|
|
38
|
+
for values, supported in ((self.allowed_suites, {SUITE_ID, PUBLIC_SUITE_ID}),
|
|
39
|
+
(self.allowed_baselines, {BASELINE_ID, REGISTERED_MAIN_BASELINE_ID})):
|
|
40
|
+
if (not isinstance(values, tuple) or not values or
|
|
41
|
+
any(type(value) is not str or value not in supported for value in values)
|
|
42
|
+
or len(set(values)) != len(values)):
|
|
43
|
+
raise HostedError("invalid_registry", "suite and baseline grants must name explicit admitted IDs without duplicates")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class HostedRegistry:
|
|
48
|
+
repositories: tuple[RepositoryRegistration, ...]
|
|
49
|
+
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
if not isinstance(self.repositories, tuple) or not self.repositories:
|
|
52
|
+
raise HostedError("invalid_registry", "at least one explicit repository registration is required")
|
|
53
|
+
if any(not isinstance(repo, RepositoryRegistration) for repo in self.repositories):
|
|
54
|
+
raise HostedError("invalid_registry", "invalid repository registration")
|
|
55
|
+
ids = [repo.repository_id for repo in self.repositories]
|
|
56
|
+
names = [repo.full_name.casefold() for repo in self.repositories]
|
|
57
|
+
if len(set(ids)) != len(ids) or len(set(names)) != len(names):
|
|
58
|
+
raise HostedError("invalid_registry", "repository IDs and names must be unique")
|
|
59
|
+
|
|
60
|
+
def authorize(self, team_id: str, full_name: str, suite: str, baseline: str,
|
|
61
|
+
*, repository_id: int | None = None) -> RepositoryRegistration:
|
|
62
|
+
# Give one generic denial for unauthorized teams, repositories, and data.
|
|
63
|
+
match = next((repo for repo in self.repositories
|
|
64
|
+
if repo.full_name.casefold() == full_name.casefold() and repo.team_id == team_id), None)
|
|
65
|
+
if match is None or suite not in match.allowed_suites or baseline not in match.allowed_baselines:
|
|
66
|
+
raise HostedError("submission_not_authorized", "team, repository, suite, or baseline is not registered")
|
|
67
|
+
if repository_id is not None and match.repository_id != repository_id:
|
|
68
|
+
raise HostedError("repository_identity_mismatch", "GitHub repository ID differs from its registration")
|
|
69
|
+
return match
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_registry(path: str | Path) -> HostedRegistry:
|
|
73
|
+
try:
|
|
74
|
+
payload = Path(path).read_bytes()
|
|
75
|
+
if len(payload) > 262_144:
|
|
76
|
+
raise ValueError("size")
|
|
77
|
+
def unique(pairs):
|
|
78
|
+
result = {}
|
|
79
|
+
for key, value in pairs:
|
|
80
|
+
if key in result:
|
|
81
|
+
raise ValueError("duplicate")
|
|
82
|
+
result[key] = value
|
|
83
|
+
return result
|
|
84
|
+
raw = json.loads(payload, object_pairs_hook=unique)
|
|
85
|
+
except (OSError, ValueError, RecursionError):
|
|
86
|
+
raise HostedError("invalid_registry", "registry must be bounded JSON without duplicate fields") from None
|
|
87
|
+
fields(raw, {"schema_version", "repositories"}, "registry")
|
|
88
|
+
if raw["schema_version"] != REGISTRY_SCHEMA or not isinstance(raw["repositories"], list):
|
|
89
|
+
raise HostedError("invalid_registry", "unsupported registry schema")
|
|
90
|
+
registrations = []
|
|
91
|
+
for item in raw["repositories"]:
|
|
92
|
+
item = fields(item, {"repository_id", "full_name", "team_id", "allowed_suites", "allowed_baselines"}, "repository")
|
|
93
|
+
if not isinstance(item["allowed_suites"], list) or not isinstance(item["allowed_baselines"], list):
|
|
94
|
+
raise HostedError("invalid_registry", "suite and baseline permissions must be arrays")
|
|
95
|
+
registrations.append(RepositoryRegistration(item["repository_id"], item["full_name"], item["team_id"],
|
|
96
|
+
tuple(item["allowed_suites"]), tuple(item["allowed_baselines"])))
|
|
97
|
+
return HostedRegistry(tuple(registrations))
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Immutable source revision checks shared by PR and registered-main resolution.
|
|
2
|
+
|
|
3
|
+
Resolving a baseline source is deliberately separate from admitting a runnable
|
|
4
|
+
baseline. It does not fetch models, archive source, register an execution adapter,
|
|
5
|
+
or enable main-branch model execution in the current synthetic pilot.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Mapping, Protocol
|
|
13
|
+
|
|
14
|
+
from .contracts import HostedError
|
|
15
|
+
from .registry import RepositoryRegistration
|
|
16
|
+
|
|
17
|
+
_COMMIT_SHA = re.compile(r"[0-9a-fA-F]{40}", re.ASCII)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_commit_sha(value: object, *, field: str = "commit_sha") -> str:
|
|
21
|
+
"""Accept a complete Git SHA-1 object identity; never accept a moving ref."""
|
|
22
|
+
if not isinstance(value, str) or _COMMIT_SHA.fullmatch(value) is None:
|
|
23
|
+
raise HostedError("invalid_commit_sha", f"{field} must be a complete 40-character hexadecimal commit SHA")
|
|
24
|
+
return value.lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enforce_expected_sha(actual_sha: object, expected_sha: object | None, *,
|
|
28
|
+
field: str = "expected_head_sha",
|
|
29
|
+
mismatch_code: str = "expected_head_mismatch") -> str:
|
|
30
|
+
"""Actual identity is mandatory; a caller's matching constraint is optional."""
|
|
31
|
+
actual = require_commit_sha(actual_sha, field="resolved_commit_sha")
|
|
32
|
+
if expected_sha is not None and require_commit_sha(expected_sha, field=field) != actual:
|
|
33
|
+
raise HostedError(mismatch_code,
|
|
34
|
+
f"resolved revision differs from {field}; confirm the intended revision and submit a new experiment")
|
|
35
|
+
return actual
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RegisteredMainReader(Protocol):
|
|
39
|
+
def verify_repository_scope(self, full_name: str) -> Mapping[str, Any]: ...
|
|
40
|
+
def get_json(self, path: str) -> Mapping[str, Any]: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ResolvedMainRevision:
|
|
45
|
+
"""A source-only record; a future baseline manifest must also bind artifacts."""
|
|
46
|
+
repository: str
|
|
47
|
+
repository_id: int
|
|
48
|
+
team_id: str
|
|
49
|
+
head_sha: str
|
|
50
|
+
tree_sha: str
|
|
51
|
+
resolved_at_utc: str
|
|
52
|
+
expected_sha: str | None = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
if type(self.repository_id) is not int or self.repository_id <= 0:
|
|
56
|
+
raise HostedError("invalid_github_identity", "resolved repository ID is invalid")
|
|
57
|
+
# Use the same checks for restored records as for new resolutions.
|
|
58
|
+
from .registry import repository_name
|
|
59
|
+
repository_name(self.repository)
|
|
60
|
+
if not isinstance(self.team_id, str) or not self.team_id:
|
|
61
|
+
raise HostedError("invalid_recorded_head", "resolved team identity is missing")
|
|
62
|
+
object.__setattr__(self, "head_sha", enforce_expected_sha(
|
|
63
|
+
self.head_sha, self.expected_sha, field="baseline_expected_sha",
|
|
64
|
+
mismatch_code="expected_baseline_mismatch"))
|
|
65
|
+
object.__setattr__(self, "tree_sha", require_commit_sha(self.tree_sha, field="tree_sha"))
|
|
66
|
+
if self.expected_sha is not None:
|
|
67
|
+
object.__setattr__(self, "expected_sha", require_commit_sha(self.expected_sha, field="baseline_expected_sha"))
|
|
68
|
+
try:
|
|
69
|
+
stamp = datetime.fromisoformat(self.resolved_at_utc.replace("Z", "+00:00"))
|
|
70
|
+
if stamp.utcoffset() is None:
|
|
71
|
+
raise ValueError("timezone")
|
|
72
|
+
except (AttributeError, TypeError, ValueError):
|
|
73
|
+
raise HostedError("invalid_recorded_head", "resolved timestamp must include a timezone") from None
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
return {"schema_version": "sphynx.registered-main-revision.v1",
|
|
77
|
+
"repository": self.repository, "repository_id": self.repository_id,
|
|
78
|
+
"team_id": self.team_id, "ref": "refs/heads/main",
|
|
79
|
+
"head_sha": self.head_sha, "tree_sha": self.tree_sha,
|
|
80
|
+
"resolved_at_utc": self.resolved_at_utc, "expected_sha": self.expected_sha,
|
|
81
|
+
"scope": "source_revision_only", "execution_enabled": False}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, value: Mapping[str, Any]) -> "ResolvedMainRevision":
|
|
85
|
+
fields = {"schema_version", "repository", "repository_id", "team_id", "ref",
|
|
86
|
+
"head_sha", "tree_sha", "resolved_at_utc", "expected_sha", "scope", "execution_enabled"}
|
|
87
|
+
if (not isinstance(value, Mapping) or set(value) != fields or
|
|
88
|
+
value["schema_version"] != "sphynx.registered-main-revision.v1" or
|
|
89
|
+
value["ref"] != "refs/heads/main" or value["scope"] != "source_revision_only" or
|
|
90
|
+
value["execution_enabled"] is not False):
|
|
91
|
+
raise HostedError("invalid_recorded_head", "registered main revision has an unsupported shape")
|
|
92
|
+
return cls(**{key: value[key] for key in ("repository", "repository_id", "team_id", "head_sha",
|
|
93
|
+
"tree_sha", "resolved_at_utc", "expected_sha")})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_registered_main(reader: RegisteredMainReader, registration: RepositoryRegistration, *,
|
|
97
|
+
team_id: str, expected_sha: object | None = None) -> ResolvedMainRevision:
|
|
98
|
+
"""Resolve an operator-registered repository's main exactly once.
|
|
99
|
+
|
|
100
|
+
The caller must persist the returned record before archive/build/queue work.
|
|
101
|
+
Retries reuse that record instead of resolving main again. Baseline execution
|
|
102
|
+
additionally needs its own authorized definition, immutable model package,
|
|
103
|
+
and supported adapter; repository source permission alone grants none of them.
|
|
104
|
+
"""
|
|
105
|
+
if not isinstance(registration, RepositoryRegistration) or registration.team_id != team_id:
|
|
106
|
+
raise HostedError("submission_not_authorized", "repository is not registered for this team")
|
|
107
|
+
expected = None if expected_sha is None else require_commit_sha(expected_sha, field="baseline_expected_sha")
|
|
108
|
+
scope = reader.verify_repository_scope(registration.full_name)
|
|
109
|
+
if (not isinstance(scope, Mapping) or type(scope.get("repository_id")) is not int or
|
|
110
|
+
scope["repository_id"] != registration.repository_id or
|
|
111
|
+
not isinstance(scope.get("full_name"), str) or
|
|
112
|
+
scope["full_name"].casefold() != registration.full_name.casefold() or
|
|
113
|
+
scope.get("scoped_repository_count") != 1):
|
|
114
|
+
raise HostedError("repository_identity_mismatch", "GitHub scope differs from its repository registration")
|
|
115
|
+
branch = reader.get_json(f"repos/{registration.full_name}/branches/main")
|
|
116
|
+
if not isinstance(branch, Mapping) or branch.get("name") != "main" or not isinstance(branch.get("commit"), Mapping):
|
|
117
|
+
raise HostedError("invalid_github_identity", "GitHub did not return the registered main branch")
|
|
118
|
+
head = enforce_expected_sha(branch["commit"].get("sha"), expected, field="baseline_expected_sha",
|
|
119
|
+
mismatch_code="expected_baseline_mismatch")
|
|
120
|
+
commit = reader.get_json(f"repos/{registration.full_name}/git/commits/{head}")
|
|
121
|
+
if (not isinstance(commit, Mapping) or commit.get("sha") != head or
|
|
122
|
+
not isinstance(commit.get("tree"), Mapping)):
|
|
123
|
+
raise HostedError("invalid_github_identity", "GitHub commit differs from the frozen main revision")
|
|
124
|
+
tree = require_commit_sha(commit["tree"].get("sha"), field="tree_sha")
|
|
125
|
+
return ResolvedMainRevision(registration.full_name, registration.repository_id, team_id, head, tree,
|
|
126
|
+
datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), expected)
|
sphynx/hosted/sandbox.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Fail-closed Linux gVisor execution. No local/subprocess fallback for PR code.
|
|
2
|
+
|
|
3
|
+
The trusted host stages declared files, invokes Docker with a verified runsc
|
|
4
|
+
runtime, and captures bounded streams. The guest sees only source, two protocol
|
|
5
|
+
helper modules, and one label-free request. No SDK fixture/grader or credentials.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from .contracts import HostedError
|
|
18
|
+
|
|
19
|
+
MAX_STREAM = 1_048_576
|
|
20
|
+
POLICY = {'id': 'sphynx.gvisor.cpu.v1', 'cpus': 1, 'memory_bytes': 1_073_741_824,
|
|
21
|
+
'pids': 64, 'tmpfs_bytes': 67_108_864, 'case_timeout_seconds': 30,
|
|
22
|
+
'build_step_timeout_seconds': 30, 'network': 'none', 'user': '65532:65532',
|
|
23
|
+
'root_filesystem': 'read_only', 'fresh_process_per_case': True}
|
|
24
|
+
|
|
25
|
+
class GVisorSandbox:
|
|
26
|
+
def __init__(self, image: str, *, runsc_path='/usr/local/bin/runsc', expected_runsc_sha256: str):
|
|
27
|
+
if os.name != 'posix': raise HostedError('sandbox_unavailable', 'hosted PR execution requires a Linux gVisor worker')
|
|
28
|
+
if not isinstance(image, str) or not re.fullmatch(r'[a-zA-Z0-9./:_-]+@sha256:[0-9a-f]{64}', image):
|
|
29
|
+
raise HostedError('invalid_runtime_identity', 'the candidate image must use an immutable digest')
|
|
30
|
+
if not re.fullmatch('[0-9a-f]{64}', expected_runsc_sha256):
|
|
31
|
+
raise HostedError('invalid_runtime_identity', 'a trusted runsc binary digest is required')
|
|
32
|
+
self.image, self.runsc_path, self.runsc_hash = image, Path(runsc_path), expected_runsc_sha256
|
|
33
|
+
self._verified = False
|
|
34
|
+
def verify(self):
|
|
35
|
+
if self._verified: return self.identity()
|
|
36
|
+
if hashlib.sha256(self.runsc_path.read_bytes()).hexdigest() != self.runsc_hash:
|
|
37
|
+
raise HostedError('sandbox_identity_mismatch', 'runsc binary differs from the approved runtime')
|
|
38
|
+
result = subprocess.run(['docker', 'info', '--format', '{{json .Runtimes}}'], capture_output=True, timeout=20, check=True)
|
|
39
|
+
runtimes = json.loads(result.stdout)
|
|
40
|
+
configured = runtimes.get('runsc', {}).get('path')
|
|
41
|
+
if not isinstance(configured, str) or Path(configured).resolve() != self.runsc_path.resolve():
|
|
42
|
+
raise HostedError('sandbox_unavailable', 'Docker is not configured with the approved runsc runtime')
|
|
43
|
+
subprocess.run(['docker', 'image', 'inspect', self.image], capture_output=True, timeout=20, check=True)
|
|
44
|
+
self._verified = True
|
|
45
|
+
return self.identity()
|
|
46
|
+
def identity(self):
|
|
47
|
+
return {'image_digest': self.image, 'runsc_sha256': self.runsc_hash, 'resource_policy': dict(POLICY)}
|
|
48
|
+
def command(self, argv, source: Path, helper: Path, name: str):
|
|
49
|
+
return ['docker', 'run', '--rm', '--name', name, '--runtime=runsc', '--network=none',
|
|
50
|
+
'--read-only', '--cap-drop=ALL', '--security-opt=no-new-privileges:true',
|
|
51
|
+
'--cpus=1', '--memory=1g', '--memory-swap=1g', '--pids-limit=64', '--ulimit=nofile=128:128',
|
|
52
|
+
'--user=65532:65532', '--log-driver=none',
|
|
53
|
+
'--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=64m,uid=65532,gid=65532,mode=700',
|
|
54
|
+
'--mount', 'type=bind,source=' + str(source.resolve()) + ',target=/candidate,readonly',
|
|
55
|
+
'--mount', 'type=bind,source=' + str(helper.resolve()) + ',target=/helper,readonly',
|
|
56
|
+
'--workdir=/candidate', '--env=PYTHONPATH=/helper:/candidate', '--env=PYTHONPYCACHEPREFIX=/tmp/cache',
|
|
57
|
+
'--env=HOME=/tmp', '--env=PYTHONUTF8=1', '--env=PYTHONDONTWRITEBYTECODE=1',
|
|
58
|
+
'-i', self.image, *argv]
|
|
59
|
+
def execute(self, argv, source, helper, payload: bytes, *, timeout: float = 30):
|
|
60
|
+
self.verify()
|
|
61
|
+
name = 'sphynx-' + uuid.uuid4().hex
|
|
62
|
+
command = self.command(argv, source, helper, name)
|
|
63
|
+
start = time.monotonic()
|
|
64
|
+
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
65
|
+
env={key: value for key, value in os.environ.items() if key in ('PATH', 'LANG')})
|
|
66
|
+
streams = {'stdout': bytearray(), 'stderr': bytearray()}
|
|
67
|
+
exceeded = threading.Event()
|
|
68
|
+
def read(stream, label):
|
|
69
|
+
try:
|
|
70
|
+
while block := stream.read(8192):
|
|
71
|
+
remaining = MAX_STREAM - len(streams[label])
|
|
72
|
+
streams[label].extend(block[:remaining])
|
|
73
|
+
if len(block) > remaining: exceeded.set(); process.kill(); break
|
|
74
|
+
finally: stream.close()
|
|
75
|
+
threads = [threading.Thread(target=read, args=(process.stdout, 'stdout'), daemon=True),
|
|
76
|
+
threading.Thread(target=read, args=(process.stderr, 'stderr'), daemon=True)]
|
|
77
|
+
for thread in threads: thread.start()
|
|
78
|
+
def write_input():
|
|
79
|
+
try:
|
|
80
|
+
process.stdin.write(payload)
|
|
81
|
+
except (BrokenPipeError, OSError):
|
|
82
|
+
pass
|
|
83
|
+
finally:
|
|
84
|
+
try: process.stdin.close()
|
|
85
|
+
except OSError: pass
|
|
86
|
+
writer = threading.Thread(target=write_input, daemon=True)
|
|
87
|
+
writer.start()
|
|
88
|
+
status = 'ok'
|
|
89
|
+
try:
|
|
90
|
+
process.wait(timeout=timeout)
|
|
91
|
+
except subprocess.TimeoutExpired:
|
|
92
|
+
status = 'timeout'; process.kill(); process.wait(timeout=5)
|
|
93
|
+
finally:
|
|
94
|
+
ended = time.monotonic()
|
|
95
|
+
# CLI termination alone would leave the guest running. Remove it on the host.
|
|
96
|
+
subprocess.run(['docker', 'rm', '-f', name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
|
|
97
|
+
writer.join(5)
|
|
98
|
+
for thread in threads: thread.join(5)
|
|
99
|
+
if exceeded.is_set(): status = 'output_limit_exceeded'
|
|
100
|
+
elif status == 'ok' and process.returncode != 0:
|
|
101
|
+
if process.returncode == 125: raise HostedError('sandbox_launch_failed', 'the trusted sandbox could not start')
|
|
102
|
+
status = 'process_failed'
|
|
103
|
+
return {'status': status, 'stdout_base64': __import__('base64').b64encode(streams['stdout']).decode(),
|
|
104
|
+
'stderr_base64': __import__('base64').b64encode(streams['stderr']).decode(),
|
|
105
|
+
'exit_code': process.returncode, 'runner_latency_ms': (ended - start) * 1000}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Resolve one registered PR and freeze declared source files at its head SHA.
|
|
2
|
+
|
|
3
|
+
This is trusted submission preparation, not a build service or evaluation runner.
|
|
4
|
+
It never checks out a branch, follows a PR merge ref, or executes repository code.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Callable, Mapping, Protocol
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from ..contracts import INTERFACE_VERSION, canonical_json
|
|
17
|
+
from ..metrics import GRADER_RELEASE, METRIC_RELEASE
|
|
18
|
+
from ..rag import FIXTURE_SHA256
|
|
19
|
+
from .contracts import (CandidateConfig, HostedError, MAX_CONFIG_BYTES, MAX_FILE_BYTES,
|
|
20
|
+
MAX_SOURCE_BYTES, FAMILY_SUBMISSION_SCHEMA, SelectedCandidateRepository,
|
|
21
|
+
candidate_method_id, parse_candidate_repository)
|
|
22
|
+
from .registry import BASELINE_ID, SUITE_ID, HostedRegistry, repository_name
|
|
23
|
+
from .revisions import enforce_expected_sha, require_commit_sha
|
|
24
|
+
|
|
25
|
+
SUBMISSION_SCHEMA = "sphynx.hosted.prepared-submission.v1"
|
|
26
|
+
_SHA = re.compile(r"^[0-9a-f]{40}$")
|
|
27
|
+
_PR = re.compile(r"^/([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9][A-Za-z0-9_.-]*)/pull/([1-9][0-9]*)/?$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GitHubReader(Protocol):
|
|
31
|
+
"""Implemented by a trusted GitHub App transport in the hosted API later."""
|
|
32
|
+
def get_json(self, path: str) -> Mapping[str, Any]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pr_identity(url: object) -> tuple[str, str, int]:
|
|
36
|
+
if not isinstance(url, str) or len(url) > 512:
|
|
37
|
+
raise HostedError("invalid_pr_url", "use an HTTPS github.com repository/pull/number URL")
|
|
38
|
+
if any(character.isspace() or ord(character) < 32 for character in url):
|
|
39
|
+
raise HostedError("invalid_pr_url", "PR URL cannot contain whitespace or control characters")
|
|
40
|
+
try:
|
|
41
|
+
parsed = urlsplit(url)
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise HostedError("invalid_pr_url", "use an HTTPS github.com repository/pull/number URL") from None
|
|
44
|
+
match = _PR.fullmatch(parsed.path)
|
|
45
|
+
if parsed.scheme != "https" or parsed.netloc != "github.com" or parsed.query or parsed.fragment or match is None:
|
|
46
|
+
raise HostedError("invalid_pr_url", "use an HTTPS github.com repository/pull/number URL")
|
|
47
|
+
full_name = repository_name(match.group(1) + "/" + match.group(2))
|
|
48
|
+
number = int(match.group(3))
|
|
49
|
+
return f"https://github.com/{full_name}/pull/{number}", full_name, number
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _sha(value: object) -> str:
|
|
53
|
+
if not isinstance(value, str) or not _SHA.fullmatch(value):
|
|
54
|
+
raise HostedError("invalid_github_identity", "GitHub must return a complete commit or object SHA")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _mapping(value: object) -> Mapping[str, Any]:
|
|
59
|
+
if not isinstance(value, Mapping):
|
|
60
|
+
raise HostedError("invalid_github_response", "GitHub returned an unexpected object")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _repo(value: object) -> tuple[str, int]:
|
|
65
|
+
value = _mapping(value)
|
|
66
|
+
name = repository_name(value.get("full_name"))
|
|
67
|
+
repo_id = value.get("id")
|
|
68
|
+
if type(repo_id) is not int or repo_id <= 0:
|
|
69
|
+
raise HostedError("invalid_github_identity", "GitHub repository ID is missing")
|
|
70
|
+
return name, repo_id
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ResolvedPR:
|
|
75
|
+
pr_url: str
|
|
76
|
+
pr_number: int
|
|
77
|
+
base_repository: str
|
|
78
|
+
base_repository_id: int
|
|
79
|
+
head_repository: str
|
|
80
|
+
head_repository_id: int
|
|
81
|
+
head_sha: str
|
|
82
|
+
team_id: str
|
|
83
|
+
resolved_at_utc: str
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
return dict(vars(self))
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, value: Mapping[str, Any]) -> "ResolvedPR":
|
|
90
|
+
expected = {"pr_url", "pr_number", "base_repository", "base_repository_id",
|
|
91
|
+
"head_repository", "head_repository_id", "head_sha", "team_id", "resolved_at_utc"}
|
|
92
|
+
if not isinstance(value, Mapping) or set(value) != expected:
|
|
93
|
+
raise HostedError("invalid_recorded_head", "recorded PR head has an unsupported shape")
|
|
94
|
+
return cls(**value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve_pull_request(reader: GitHubReader, registry: HostedRegistry, *, team_id: str,
|
|
98
|
+
candidate_pr: str, suite: str = SUITE_ID, baseline: str = BASELINE_ID,
|
|
99
|
+
expected_head_sha: str | None = None) -> ResolvedPR:
|
|
100
|
+
if expected_head_sha is not None:
|
|
101
|
+
expected_head_sha = require_commit_sha(expected_head_sha, field='expected_head_sha')
|
|
102
|
+
pr_url, requested_repo, number = pr_identity(candidate_pr)
|
|
103
|
+
registry.authorize(team_id, requested_repo, suite, baseline)
|
|
104
|
+
pr = _mapping(reader.get_json(f"repos/{requested_repo}/pulls/{number}"))
|
|
105
|
+
if type(pr.get("number")) is not int or pr["number"] != number:
|
|
106
|
+
raise HostedError("invalid_github_identity", "GitHub PR number does not match the submission")
|
|
107
|
+
base_name, base_id = _repo(_mapping(pr.get("base")).get("repo"))
|
|
108
|
+
if base_name.casefold() != requested_repo.casefold():
|
|
109
|
+
raise HostedError("repository_identity_mismatch", "GitHub PR base differs from the requested repository")
|
|
110
|
+
registry.authorize(team_id, base_name, suite, baseline, repository_id=base_id)
|
|
111
|
+
head = _mapping(pr.get("head"))
|
|
112
|
+
if head.get("repo") is None:
|
|
113
|
+
raise HostedError("head_repository_unavailable", "PR head repository has been deleted or is inaccessible")
|
|
114
|
+
head_name, head_id = _repo(head["repo"])
|
|
115
|
+
registry.authorize(team_id, head_name, suite, baseline, repository_id=head_id)
|
|
116
|
+
head_sha = _sha(head.get("sha"))
|
|
117
|
+
enforce_expected_sha(head_sha, expected_head_sha)
|
|
118
|
+
return ResolvedPR(pr_url, number, base_name, base_id, head_name, head_id, head_sha,
|
|
119
|
+
team_id, datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class SourceFile:
|
|
124
|
+
path: str
|
|
125
|
+
content: bytes
|
|
126
|
+
git_blob_sha: str
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def digest(self) -> str:
|
|
130
|
+
return "sha256:" + hashlib.sha256(self.content).hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class FrozenSubmission:
|
|
135
|
+
pr_url: str
|
|
136
|
+
pr_number: int
|
|
137
|
+
base_repository: str
|
|
138
|
+
base_repository_id: int
|
|
139
|
+
head_repository: str
|
|
140
|
+
head_repository_id: int
|
|
141
|
+
head_sha: str
|
|
142
|
+
tree_sha: str
|
|
143
|
+
team_id: str
|
|
144
|
+
config: CandidateConfig
|
|
145
|
+
files: tuple[SourceFile, ...]
|
|
146
|
+
resolved_at_utc: str
|
|
147
|
+
repository_selection: SelectedCandidateRepository | None = None
|
|
148
|
+
model_runtime_json: str | None = None
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def source_bundle_digest(self) -> str:
|
|
152
|
+
identity = {
|
|
153
|
+
"head_repository_id": self.head_repository_id, "head_sha": self.head_sha,
|
|
154
|
+
"files": [{"path": source.path, "sha256": source.digest} for source in self.files],
|
|
155
|
+
}
|
|
156
|
+
return "sha256:" + hashlib.sha256(canonical_json(identity).encode("utf-8")).hexdigest()
|
|
157
|
+
|
|
158
|
+
def to_manifest(self) -> dict[str, Any]:
|
|
159
|
+
manifest = {
|
|
160
|
+
"schema_version": SUBMISSION_SCHEMA, "status": "prepared",
|
|
161
|
+
"hosted_execution_verified": False, "team_id": self.team_id,
|
|
162
|
+
"candidate_pr": self.pr_url, "pr_number": self.pr_number,
|
|
163
|
+
"base_repository": {"id": self.base_repository_id, "full_name": self.base_repository},
|
|
164
|
+
"head_repository": {"id": self.head_repository_id, "full_name": self.head_repository},
|
|
165
|
+
"head_sha": self.head_sha, "tree_sha": self.tree_sha,
|
|
166
|
+
"resolved_at_utc": self.resolved_at_utc,
|
|
167
|
+
"candidate_configuration": self.config.to_dict(),
|
|
168
|
+
"configuration_digest": self.config.digest,
|
|
169
|
+
"source_bundle_digest": self.source_bundle_digest,
|
|
170
|
+
"files": [{"path": source.path, "size_bytes": len(source.content),
|
|
171
|
+
"git_blob_sha": source.git_blob_sha, "sha256": source.digest} for source in self.files],
|
|
172
|
+
"suite": {"id": SUITE_ID, "dataset_digest": "sha256:" + FIXTURE_SHA256,
|
|
173
|
+
"case_count": 6, "interface_version": INTERFACE_VERSION,
|
|
174
|
+
"grader_release": GRADER_RELEASE, "metric_release": METRIC_RELEASE},
|
|
175
|
+
"baseline": {"id": BASELINE_ID, "score_source": "supplied_dense_scores",
|
|
176
|
+
"fresh_model_inference": False},
|
|
177
|
+
"requested_runtime_profile": self.config.runtime_profile,
|
|
178
|
+
"runtime_identity": None, "metrics": None, "report_location": None,
|
|
179
|
+
}
|
|
180
|
+
if self.repository_selection is not None and self.repository_selection.family is not None:
|
|
181
|
+
manifest.update(schema_version=FAMILY_SUBMISSION_SCHEMA,
|
|
182
|
+
candidate_method=self.repository_selection.candidate_method,
|
|
183
|
+
candidate_family=self.repository_selection.family_selection())
|
|
184
|
+
if self.model_runtime_json is not None:
|
|
185
|
+
from .model_contracts import MODEL_SUBMISSION_SCHEMA
|
|
186
|
+
import json
|
|
187
|
+
manifest.update(schema_version=MODEL_SUBMISSION_SCHEMA,
|
|
188
|
+
model_runtime=json.loads(self.model_runtime_json))
|
|
189
|
+
return manifest
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def prepare_submission(reader: GitHubReader, registry: HostedRegistry, *, team_id: str,
|
|
193
|
+
candidate_pr: str, suite: str = SUITE_ID, baseline: str = BASELINE_ID,
|
|
194
|
+
pinned: ResolvedPR | None = None,
|
|
195
|
+
on_resolved: Callable[[ResolvedPR], None] | None = None,
|
|
196
|
+
expected_head_sha: str | None = None,
|
|
197
|
+
candidate_method: str | None = None,
|
|
198
|
+
model_runtime=None) -> FrozenSubmission:
|
|
199
|
+
if candidate_method is not None:
|
|
200
|
+
candidate_method = candidate_method_id(candidate_method)
|
|
201
|
+
if expected_head_sha is not None:
|
|
202
|
+
expected_head_sha = require_commit_sha(expected_head_sha, field='expected_head_sha')
|
|
203
|
+
pr_url, requested_repo, number = pr_identity(candidate_pr)
|
|
204
|
+
registry.authorize(team_id, requested_repo, suite, baseline)
|
|
205
|
+
resolved = pinned or resolve_pull_request(reader, registry, team_id=team_id,
|
|
206
|
+
candidate_pr=candidate_pr, suite=suite, baseline=baseline,
|
|
207
|
+
expected_head_sha=expected_head_sha)
|
|
208
|
+
if (resolved.pr_url != pr_url or type(resolved.pr_number) is not int or resolved.pr_number != number
|
|
209
|
+
or resolved.base_repository.casefold() != requested_repo.casefold() or resolved.team_id != team_id):
|
|
210
|
+
raise HostedError("recorded_head_mismatch", "recorded head belongs to another submission")
|
|
211
|
+
for name, repo_id in ((resolved.base_repository, resolved.base_repository_id),
|
|
212
|
+
(resolved.head_repository, resolved.head_repository_id)):
|
|
213
|
+
if type(repo_id) is not int or repo_id <= 0:
|
|
214
|
+
raise HostedError("invalid_recorded_head", "recorded repository ID is invalid")
|
|
215
|
+
registry.authorize(team_id, repository_name(name), suite, baseline, repository_id=repo_id)
|
|
216
|
+
commit_sha = _sha(resolved.head_sha)
|
|
217
|
+
enforce_expected_sha(commit_sha, expected_head_sha)
|
|
218
|
+
if on_resolved is not None:
|
|
219
|
+
on_resolved(resolved) # Persist before source fetch; retries resume this exact head.
|
|
220
|
+
base_name, base_id = resolved.base_repository, resolved.base_repository_id
|
|
221
|
+
head_name, head_id = resolved.head_repository, resolved.head_repository_id
|
|
222
|
+
prefix = f"repos/{head_name}/git"
|
|
223
|
+
commit = _mapping(reader.get_json(f"{prefix}/commits/{commit_sha}"))
|
|
224
|
+
if commit.get("sha") != commit_sha:
|
|
225
|
+
raise HostedError("commit_identity_mismatch", "GitHub returned a different commit")
|
|
226
|
+
tree_sha = _sha(_mapping(commit.get("tree")).get("sha"))
|
|
227
|
+
trees: dict[str, Mapping[str, Any]] = {}
|
|
228
|
+
|
|
229
|
+
def read_file(path: str, maximum: int) -> SourceFile:
|
|
230
|
+
current_sha = tree_sha
|
|
231
|
+
components = path.split("/")
|
|
232
|
+
for index, component in enumerate(components):
|
|
233
|
+
if current_sha not in trees:
|
|
234
|
+
tree = _mapping(reader.get_json(f"{prefix}/trees/{current_sha}"))
|
|
235
|
+
entries = tree.get("tree")
|
|
236
|
+
if tree.get("sha") != current_sha or tree.get("truncated") or not isinstance(entries, list) or len(entries) > 10_000:
|
|
237
|
+
raise HostedError("invalid_source_tree", "source tree is truncated, oversized, or has a different identity")
|
|
238
|
+
trees[current_sha] = tree
|
|
239
|
+
matches = [item for item in trees[current_sha]["tree"]
|
|
240
|
+
if isinstance(item, Mapping) and item.get("path") == component]
|
|
241
|
+
if len(matches) != 1:
|
|
242
|
+
code = "candidate_config_missing" if path == "sphynx.toml" else "source_file_missing"
|
|
243
|
+
raise HostedError(code, "the pinned commit does not contain a declared regular file")
|
|
244
|
+
entry = matches[0]
|
|
245
|
+
current_sha = _sha(entry.get("sha"))
|
|
246
|
+
final = index == len(components) - 1
|
|
247
|
+
if not final:
|
|
248
|
+
if entry.get("type") != "tree" or entry.get("mode") != "040000":
|
|
249
|
+
raise HostedError("unsafe_source_entry", "source directory cannot be a symlink or submodule")
|
|
250
|
+
elif entry.get("type") != "blob" or entry.get("mode") not in ("100644", "100755"):
|
|
251
|
+
raise HostedError("unsafe_source_entry", "source files cannot be symlinks or submodules")
|
|
252
|
+
blob = _mapping(reader.get_json(f"{prefix}/blobs/{current_sha}"))
|
|
253
|
+
content, size = blob.get("content"), blob.get("size")
|
|
254
|
+
if blob.get("sha") != current_sha or blob.get("encoding") != "base64" or not isinstance(content, str):
|
|
255
|
+
raise HostedError("invalid_source_blob", "GitHub did not return the requested encoded blob")
|
|
256
|
+
if type(size) is not int or size < 0 or size > maximum or len(content) > maximum * 2 + 1024:
|
|
257
|
+
raise HostedError("source_size_limit", "source file exceeds the pilot size limit")
|
|
258
|
+
try:
|
|
259
|
+
data = base64.b64decode("".join(content.splitlines()), validate=True)
|
|
260
|
+
except (ValueError, UnicodeEncodeError):
|
|
261
|
+
raise HostedError("invalid_source_blob", "source blob is not valid base64") from None
|
|
262
|
+
actual_sha = hashlib.sha1(b"blob " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest()
|
|
263
|
+
if len(data) != size or actual_sha != current_sha:
|
|
264
|
+
raise HostedError("source_digest_mismatch", "source bytes do not match the pinned Git object")
|
|
265
|
+
return SourceFile(path, data, current_sha)
|
|
266
|
+
|
|
267
|
+
configuration_file = read_file("sphynx.toml", MAX_CONFIG_BYTES)
|
|
268
|
+
selection = parse_candidate_repository(configuration_file.content, candidate_method=candidate_method)
|
|
269
|
+
config = selection.config
|
|
270
|
+
model_runtime_json = None
|
|
271
|
+
if config.model_kind == "registered":
|
|
272
|
+
from .model_admission import ModelRuntimeAdmission, require_model_authorization
|
|
273
|
+
if model_runtime is None:
|
|
274
|
+
raise HostedError("models_not_admitted", "this candidate requires an operator-verified model runtime admission")
|
|
275
|
+
record = model_runtime.to_dict() if isinstance(model_runtime, ModelRuntimeAdmission) else model_runtime
|
|
276
|
+
admitted = require_model_authorization(record, team_id=team_id, base_repository_id=base_id,
|
|
277
|
+
head_repository_id=head_id, suite=suite, baseline=baseline)
|
|
278
|
+
model_runtime_json = canonical_json(admitted)
|
|
279
|
+
sources = [configuration_file]
|
|
280
|
+
for path in sorted(config.source_files):
|
|
281
|
+
sources.append(read_file(path, MAX_FILE_BYTES))
|
|
282
|
+
if sum(len(source.content) for source in sources) > MAX_SOURCE_BYTES:
|
|
283
|
+
raise HostedError("source_size_limit", "candidate bundle exceeds the pilot size limit")
|
|
284
|
+
return FrozenSubmission(pr_url, number, base_name, base_id, head_name, head_id, commit_sha,
|
|
285
|
+
tree_sha, team_id, config, tuple(sources),
|
|
286
|
+
resolved.resolved_at_utc, selection if selection.family is not None else None,
|
|
287
|
+
model_runtime_json)
|