graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2100 @@
|
|
|
1
|
+
"""Deadline-aware orchestration for isolated deep readiness probes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from importlib import machinery, metadata
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .doctor import DoctorCheck
|
|
22
|
+
from .incident_ledger import record_incident, repo_ledger_dir
|
|
23
|
+
from .llm import canonical_provider_name
|
|
24
|
+
from .llm_probe import SYSTEM_PROMPT as _LLM_SYSTEM_PROMPT
|
|
25
|
+
from .llm_probe import USER_PROMPT as _LLM_USER_PROMPT
|
|
26
|
+
from .llm_probe import WORKER_INPUT_LIMIT_BYTES as _LLM_WORKER_INPUT_LIMIT_BYTES
|
|
27
|
+
from .probe_process import (
|
|
28
|
+
INPUT_LIMIT_BYTES,
|
|
29
|
+
ProbeProcessError,
|
|
30
|
+
ProbeProcessResult,
|
|
31
|
+
run_bounded_process,
|
|
32
|
+
)
|
|
33
|
+
from .probe_workspace import ProbeWorkspaceLease, WorkspaceLeaseError
|
|
34
|
+
|
|
35
|
+
_CLEANUP_RESERVE_SECONDS = 1.0
|
|
36
|
+
_CORE_PROBE_SLOT_LOCK = threading.Lock()
|
|
37
|
+
_CORE_PROBE_SLOT_ACTIVE = False
|
|
38
|
+
_MCP_OUTPUT_LIMIT_BYTES = 1024 * 1024
|
|
39
|
+
_MCP_LINE_LIMIT = 64
|
|
40
|
+
_MCP_RESPONSE_LIMIT = 8
|
|
41
|
+
_MCP_NESTING_LIMIT = 32
|
|
42
|
+
_MCP_METADATA_ROOT_LIMIT = 64
|
|
43
|
+
_MCP_BUILDER_ARGUMENT_LIMIT_BYTES = 16 * 1024
|
|
44
|
+
_MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
45
|
+
_LLM_FAILURE_REMEDIATION = (
|
|
46
|
+
"Verify the provider endpoint.",
|
|
47
|
+
"Use a rotated session credential.",
|
|
48
|
+
"Verify the configured model.",
|
|
49
|
+
"Verify the provider timeout.",
|
|
50
|
+
)
|
|
51
|
+
_LLM_CATEGORIES = frozenset(
|
|
52
|
+
{"configuration", "authentication", "timeout", "connection", "provider_error"}
|
|
53
|
+
)
|
|
54
|
+
_LLM_OUTPUT_LIMIT_BYTES = 4096
|
|
55
|
+
_LLM_TIMEOUT_MAX_SECONDS = 60.0
|
|
56
|
+
_REQUIRED_MCP_TOOLS = frozenset(
|
|
57
|
+
{"graphite_query", "graphite_summary", "graphite_community", "graphite_refresh"}
|
|
58
|
+
)
|
|
59
|
+
_TYPESCRIPT_SCRIPT = (
|
|
60
|
+
"try{require.resolve('typescript')}catch(error){"
|
|
61
|
+
"if(error&&error.code==='MODULE_NOT_FOUND'){"
|
|
62
|
+
"process.stdout.write(JSON.stringify({missing_module:'typescript'}));process.exit(0)}"
|
|
63
|
+
"process.exit(4)}"
|
|
64
|
+
"process.stdout.write(JSON.stringify({detected:true}));"
|
|
65
|
+
)
|
|
66
|
+
_MCP_MANIFEST_BUILDER_BOOTSTRAP = """\
|
|
67
|
+
import os
|
|
68
|
+
import json
|
|
69
|
+
import pathlib
|
|
70
|
+
import sys
|
|
71
|
+
from importlib.machinery import PathFinder
|
|
72
|
+
|
|
73
|
+
sys.excepthook = lambda *_: os._exit(70)
|
|
74
|
+
def validate_binding(raw, require_directory):
|
|
75
|
+
if not isinstance(raw, dict) or set(raw) != {"canonical", "identity", "lexical"}:
|
|
76
|
+
raise SystemExit(70)
|
|
77
|
+
lexical = pathlib.Path(raw["lexical"])
|
|
78
|
+
canonical = pathlib.Path(raw["canonical"])
|
|
79
|
+
identity = raw["identity"]
|
|
80
|
+
if (
|
|
81
|
+
not isinstance(raw["lexical"], str)
|
|
82
|
+
or not isinstance(raw["canonical"], str)
|
|
83
|
+
or not lexical.is_absolute()
|
|
84
|
+
or not canonical.is_absolute()
|
|
85
|
+
or not isinstance(identity, list)
|
|
86
|
+
or len(identity) != 4
|
|
87
|
+
or any(not isinstance(item, int) or isinstance(item, bool) for item in identity)
|
|
88
|
+
):
|
|
89
|
+
raise SystemExit(70)
|
|
90
|
+
if lexical.resolve(strict=True) != canonical or canonical.resolve(strict=True) != canonical:
|
|
91
|
+
raise SystemExit(70)
|
|
92
|
+
stat = canonical.stat()
|
|
93
|
+
if [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns] != identity:
|
|
94
|
+
raise SystemExit(70)
|
|
95
|
+
if canonical.is_dir() != require_directory:
|
|
96
|
+
raise SystemExit(70)
|
|
97
|
+
return lexical, canonical
|
|
98
|
+
def overlaps(left, right):
|
|
99
|
+
try:
|
|
100
|
+
left.relative_to(right)
|
|
101
|
+
return True
|
|
102
|
+
except ValueError:
|
|
103
|
+
pass
|
|
104
|
+
try:
|
|
105
|
+
right.relative_to(left)
|
|
106
|
+
return True
|
|
107
|
+
except ValueError:
|
|
108
|
+
return False
|
|
109
|
+
def bind_path(raw, require_directory):
|
|
110
|
+
try:
|
|
111
|
+
raw = os.fspath(raw)
|
|
112
|
+
except TypeError:
|
|
113
|
+
raise SystemExit(70) from None
|
|
114
|
+
if not isinstance(raw, str):
|
|
115
|
+
raise SystemExit(70)
|
|
116
|
+
lexical = pathlib.Path(os.path.abspath(raw))
|
|
117
|
+
canonical = lexical.resolve(strict=True)
|
|
118
|
+
stat = canonical.stat()
|
|
119
|
+
binding = {
|
|
120
|
+
"lexical": str(lexical),
|
|
121
|
+
"canonical": str(canonical),
|
|
122
|
+
"identity": [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns],
|
|
123
|
+
}
|
|
124
|
+
validate_binding(binding, require_directory)
|
|
125
|
+
return binding, lexical, canonical
|
|
126
|
+
trusted_binding, trusted_lexical, trusted = bind_path(sys.argv[1], True)
|
|
127
|
+
selected_binding, _, selected = bind_path(sys.argv[2], True)
|
|
128
|
+
init_binding, init_lexical, expected_init = bind_path(
|
|
129
|
+
trusted_lexical / "graphite" / "__init__.py", False
|
|
130
|
+
)
|
|
131
|
+
doctor_binding, doctor_lexical, expected_doctor = bind_path(
|
|
132
|
+
trusted_lexical / "graphite" / "doctor_probes.py", False
|
|
133
|
+
)
|
|
134
|
+
mcp_binding, mcp_lexical, expected_mcp = bind_path(
|
|
135
|
+
trusted_lexical / "graphite" / "mcp.py", False
|
|
136
|
+
)
|
|
137
|
+
if (
|
|
138
|
+
init_lexical != trusted_lexical / "graphite" / "__init__.py"
|
|
139
|
+
or doctor_lexical != trusted_lexical / "graphite" / "doctor_probes.py"
|
|
140
|
+
or mcp_lexical != trusted_lexical / "graphite" / "mcp.py"
|
|
141
|
+
or expected_init.parent != trusted / "graphite"
|
|
142
|
+
or expected_doctor.parent != trusted / "graphite"
|
|
143
|
+
or expected_mcp.parent != trusted / "graphite"
|
|
144
|
+
):
|
|
145
|
+
raise SystemExit(70)
|
|
146
|
+
stdlib_path = list(sys.path)
|
|
147
|
+
graphite_spec = PathFinder.find_spec("graphite", [str(trusted)])
|
|
148
|
+
if (
|
|
149
|
+
graphite_spec is None
|
|
150
|
+
or graphite_spec.origin is None
|
|
151
|
+
or pathlib.Path(graphite_spec.origin).resolve(strict=True) != expected_init
|
|
152
|
+
or graphite_spec.submodule_search_locations is None
|
|
153
|
+
or [pathlib.Path(item).resolve(strict=True) for item in graphite_spec.submodule_search_locations]
|
|
154
|
+
!= [expected_init.parent]
|
|
155
|
+
):
|
|
156
|
+
raise SystemExit(70)
|
|
157
|
+
doctor_spec = PathFinder.find_spec(
|
|
158
|
+
"graphite.doctor_probes", list(graphite_spec.submodule_search_locations)
|
|
159
|
+
)
|
|
160
|
+
if (
|
|
161
|
+
doctor_spec is None
|
|
162
|
+
or doctor_spec.origin is None
|
|
163
|
+
or pathlib.Path(doctor_spec.origin).resolve(strict=True) != expected_doctor
|
|
164
|
+
):
|
|
165
|
+
raise SystemExit(70)
|
|
166
|
+
sys.path[:] = [str(trusted), *stdlib_path]
|
|
167
|
+
from graphite import doctor_probes
|
|
168
|
+
if pathlib.Path(doctor_probes.__file__).resolve(strict=True) != expected_doctor:
|
|
169
|
+
raise SystemExit(70)
|
|
170
|
+
cached_payload = sys.stdin.buffer.read()
|
|
171
|
+
raw_metadata_roots = json.loads(sys.argv[3])
|
|
172
|
+
if not isinstance(raw_metadata_roots, list) or len(raw_metadata_roots) > 64:
|
|
173
|
+
raise SystemExit(70)
|
|
174
|
+
metadata_roots = []
|
|
175
|
+
seen_roots = set()
|
|
176
|
+
for raw in raw_metadata_roots:
|
|
177
|
+
if not isinstance(raw, str) or not os.path.isabs(raw):
|
|
178
|
+
raise SystemExit(70)
|
|
179
|
+
candidate = pathlib.Path(os.path.abspath(raw))
|
|
180
|
+
try:
|
|
181
|
+
canonical_candidate = candidate.resolve(strict=True)
|
|
182
|
+
except OSError:
|
|
183
|
+
continue
|
|
184
|
+
if not canonical_candidate.is_dir():
|
|
185
|
+
continue
|
|
186
|
+
_, lexical, root = bind_path(raw, True)
|
|
187
|
+
if root == trusted:
|
|
188
|
+
continue
|
|
189
|
+
if overlaps(root, selected):
|
|
190
|
+
if any(doctor_probes.metadata.Distribution.discover(path=[str(lexical)])):
|
|
191
|
+
raise SystemExit(70)
|
|
192
|
+
continue
|
|
193
|
+
normalized = os.path.normcase(str(root))
|
|
194
|
+
if normalized in seen_roots:
|
|
195
|
+
raise SystemExit(70)
|
|
196
|
+
seen_roots.add(normalized)
|
|
197
|
+
metadata_roots.append(lexical)
|
|
198
|
+
if cached_payload:
|
|
199
|
+
manifest = json.loads(cached_payload.decode("utf-8"))
|
|
200
|
+
manifest = doctor_probes._validate_mcp_manifest(manifest, selected)
|
|
201
|
+
else:
|
|
202
|
+
manifest = doctor_probes._mcp_import_manifest(selected, tuple(metadata_roots))
|
|
203
|
+
envelope = {
|
|
204
|
+
"bindings": {
|
|
205
|
+
"doctor": doctor_binding,
|
|
206
|
+
"init": init_binding,
|
|
207
|
+
"mcp": mcp_binding,
|
|
208
|
+
"selected": selected_binding,
|
|
209
|
+
"trusted": trusted_binding,
|
|
210
|
+
},
|
|
211
|
+
"manifest": manifest,
|
|
212
|
+
}
|
|
213
|
+
sys.stdout.write(json.dumps(envelope, separators=(",", ":")))
|
|
214
|
+
"""
|
|
215
|
+
# Hard ceiling on what the child may write to stderr. Not belt-and-braces: the
|
|
216
|
+
# transcript is rejected when stdout plus stderr passes _MCP_OUTPUT_LIMIT_BYTES,
|
|
217
|
+
# and `run_bounded_process` fails the probe outright on the same bound, so an
|
|
218
|
+
# unbounded debug log would manufacture the failure it was added to explain.
|
|
219
|
+
_MCP_CHILD_LOG_BUDGET = 3000
|
|
220
|
+
# Route the MCP library's own log to stderr before handing off to the server.
|
|
221
|
+
#
|
|
222
|
+
# Issue #29's failure mode is `returncode=0`, empty stderr, and a transcript
|
|
223
|
+
# holding only the `initialize` reply -- the child never says why it stopped, so
|
|
224
|
+
# every mechanism proposed for it so far was inferred from adjacent evidence and
|
|
225
|
+
# four were refuted by measurement. The library already logs the facts that
|
|
226
|
+
# separate the survivors ("Dispatching request of type ...", "Response sent",
|
|
227
|
+
# "Request N cancelled - duplicate response suppressed"); nothing was listening.
|
|
228
|
+
#
|
|
229
|
+
# Kept out of the transport itself deliberately. Wrapping `sys.stdin.buffer` or
|
|
230
|
+
# `sys.stdout.buffer` to count bytes would perturb the very timing under
|
|
231
|
+
# observation; a log handler does not sit in the data path.
|
|
232
|
+
_MCP_CHILD_LOG_SETUP = f"""\
|
|
233
|
+
import logging as _logging
|
|
234
|
+
import sys as _sys
|
|
235
|
+
class _BoundedProbeLog(_logging.Handler):
|
|
236
|
+
def __init__(self, budget):
|
|
237
|
+
_logging.Handler.__init__(self)
|
|
238
|
+
self.budget = budget
|
|
239
|
+
def emit(self, record):
|
|
240
|
+
if self.budget <= 0:
|
|
241
|
+
return
|
|
242
|
+
try:
|
|
243
|
+
text = record.name.rpartition(".")[2] + ":" + record.getMessage()
|
|
244
|
+
line = text[:160].replace("\\r", " ").replace("\\n", " ") + "\\n"
|
|
245
|
+
if len(line) > self.budget:
|
|
246
|
+
line = line[: self.budget]
|
|
247
|
+
self.budget -= len(line)
|
|
248
|
+
_sys.stderr.write(line)
|
|
249
|
+
_sys.stderr.flush()
|
|
250
|
+
except Exception:
|
|
251
|
+
self.budget = 0
|
|
252
|
+
_probe_log = _logging.getLogger("mcp")
|
|
253
|
+
_probe_log.handlers[:] = [_BoundedProbeLog({_MCP_CHILD_LOG_BUDGET})]
|
|
254
|
+
_probe_log.setLevel(_logging.DEBUG)
|
|
255
|
+
_probe_log.propagate = False
|
|
256
|
+
"""
|
|
257
|
+
_MCP_BOOTSTRAP = """\
|
|
258
|
+
import json
|
|
259
|
+
import os
|
|
260
|
+
import pathlib
|
|
261
|
+
import runpy
|
|
262
|
+
import sys
|
|
263
|
+
from importlib import metadata
|
|
264
|
+
from importlib.machinery import PathFinder
|
|
265
|
+
|
|
266
|
+
sys.excepthook = lambda *_: os._exit(70)
|
|
267
|
+
def overlaps_selected(path):
|
|
268
|
+
try:
|
|
269
|
+
path.relative_to(selected)
|
|
270
|
+
return True
|
|
271
|
+
except ValueError:
|
|
272
|
+
pass
|
|
273
|
+
try:
|
|
274
|
+
selected.relative_to(path)
|
|
275
|
+
return True
|
|
276
|
+
except (ValueError, OSError):
|
|
277
|
+
return False
|
|
278
|
+
def validate_binding(raw, require_directory, reject_selected=True):
|
|
279
|
+
if not isinstance(raw, dict) or set(raw) != {"canonical", "identity", "lexical"}:
|
|
280
|
+
raise SystemExit(70)
|
|
281
|
+
if not isinstance(raw["lexical"], str) or not isinstance(raw["canonical"], str):
|
|
282
|
+
raise SystemExit(70)
|
|
283
|
+
lexical = pathlib.Path(raw["lexical"])
|
|
284
|
+
canonical = pathlib.Path(raw["canonical"])
|
|
285
|
+
identity = raw["identity"]
|
|
286
|
+
if (
|
|
287
|
+
not lexical.is_absolute()
|
|
288
|
+
or not canonical.is_absolute()
|
|
289
|
+
or not isinstance(identity, list)
|
|
290
|
+
or len(identity) != 4
|
|
291
|
+
or any(not isinstance(item, int) or isinstance(item, bool) for item in identity)
|
|
292
|
+
):
|
|
293
|
+
raise SystemExit(70)
|
|
294
|
+
resolved = lexical.resolve(strict=True)
|
|
295
|
+
if resolved != canonical or canonical.resolve(strict=True) != canonical:
|
|
296
|
+
raise SystemExit(70)
|
|
297
|
+
stat = canonical.stat()
|
|
298
|
+
if [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns] != identity:
|
|
299
|
+
raise SystemExit(70)
|
|
300
|
+
if require_directory != canonical.is_dir() or (reject_selected and overlaps_selected(canonical)):
|
|
301
|
+
raise SystemExit(70)
|
|
302
|
+
return lexical, canonical
|
|
303
|
+
selected_raw = json.loads(sys.argv[4])
|
|
304
|
+
if not isinstance(selected_raw, dict):
|
|
305
|
+
raise SystemExit(70)
|
|
306
|
+
selected = pathlib.Path(selected_raw.get("canonical", "."))
|
|
307
|
+
_, selected = validate_binding(selected_raw, True, False)
|
|
308
|
+
trusted_lexical, trusted = validate_binding(json.loads(sys.argv[1]), True, False)
|
|
309
|
+
init_lexical, expected_graphite_init = validate_binding(json.loads(sys.argv[2]), False, False)
|
|
310
|
+
mcp_lexical, expected_graphite_mcp = validate_binding(json.loads(sys.argv[3]), False, False)
|
|
311
|
+
if (
|
|
312
|
+
init_lexical != trusted_lexical / "graphite" / "__init__.py"
|
|
313
|
+
or mcp_lexical != trusted_lexical / "graphite" / "mcp.py"
|
|
314
|
+
or expected_graphite_init.parent != trusted / "graphite"
|
|
315
|
+
or expected_graphite_mcp.parent != trusted / "graphite"
|
|
316
|
+
):
|
|
317
|
+
raise SystemExit(70)
|
|
318
|
+
stdin_raw = sys.stdin.buffer.raw
|
|
319
|
+
def read_exact(count):
|
|
320
|
+
parts = []
|
|
321
|
+
while count > 0:
|
|
322
|
+
part = stdin_raw.read(count)
|
|
323
|
+
if not part:
|
|
324
|
+
raise SystemExit(70)
|
|
325
|
+
parts.append(part)
|
|
326
|
+
count -= len(part)
|
|
327
|
+
return b"".join(parts)
|
|
328
|
+
# Read the length header a byte at a time and the manifest by exact size, both
|
|
329
|
+
# straight off the raw stream. A buffered read would pull the protocol input in
|
|
330
|
+
# behind the manifest, where only this object could reach it -- the MCP server
|
|
331
|
+
# opens its own reader on fd 0 and would find EOF.
|
|
332
|
+
header = b""
|
|
333
|
+
while not header.endswith(b"\\n"):
|
|
334
|
+
byte = stdin_raw.read(1)
|
|
335
|
+
if not byte:
|
|
336
|
+
raise SystemExit(70)
|
|
337
|
+
header += byte
|
|
338
|
+
if len(header) > 24:
|
|
339
|
+
raise SystemExit(70)
|
|
340
|
+
declared_length = header[:-1]
|
|
341
|
+
if not declared_length.isdigit():
|
|
342
|
+
raise SystemExit(70)
|
|
343
|
+
manifest = json.loads(read_exact(int(declared_length)))
|
|
344
|
+
if not isinstance(manifest, dict) or set(manifest) != {"distributions", "files", "packages"}:
|
|
345
|
+
raise SystemExit(70)
|
|
346
|
+
raw_files = manifest["files"]
|
|
347
|
+
raw_packages = manifest["packages"]
|
|
348
|
+
raw_distributions = manifest["distributions"]
|
|
349
|
+
if not isinstance(raw_files, list) or not isinstance(raw_packages, dict) or not isinstance(raw_distributions, dict):
|
|
350
|
+
raise SystemExit(70)
|
|
351
|
+
allowed_files = set()
|
|
352
|
+
for raw_group in raw_files:
|
|
353
|
+
if not isinstance(raw_group, dict) or set(raw_group) != {"entries", "root"}:
|
|
354
|
+
raise SystemExit(70)
|
|
355
|
+
root_lexical, root = validate_binding(raw_group["root"], True)
|
|
356
|
+
entries = raw_group["entries"]
|
|
357
|
+
if not isinstance(entries, list):
|
|
358
|
+
raise SystemExit(70)
|
|
359
|
+
for entry in entries:
|
|
360
|
+
if (
|
|
361
|
+
not isinstance(entry, list)
|
|
362
|
+
or len(entry) != 5
|
|
363
|
+
or not isinstance(entry[0], str)
|
|
364
|
+
or any(not isinstance(item, int) or isinstance(item, bool) for item in entry[1:])
|
|
365
|
+
):
|
|
366
|
+
raise SystemExit(70)
|
|
367
|
+
relative = pathlib.PurePath(entry[0])
|
|
368
|
+
if relative.is_absolute() or not relative.parts or any(part in ("", ".", "..") for part in relative.parts):
|
|
369
|
+
raise SystemExit(70)
|
|
370
|
+
lexical = root_lexical.joinpath(*relative.parts)
|
|
371
|
+
canonical = root.joinpath(*relative.parts)
|
|
372
|
+
if lexical.resolve(strict=True) != canonical or canonical.resolve(strict=True) != canonical:
|
|
373
|
+
raise SystemExit(70)
|
|
374
|
+
stat = canonical.stat()
|
|
375
|
+
if not canonical.is_file() or [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns] != entry[1:]:
|
|
376
|
+
raise SystemExit(70)
|
|
377
|
+
allowed_files.add(canonical)
|
|
378
|
+
distributions = {}
|
|
379
|
+
for name, raw in raw_distributions.items():
|
|
380
|
+
if not isinstance(name, str):
|
|
381
|
+
raise SystemExit(70)
|
|
382
|
+
_, path = validate_binding(raw, True)
|
|
383
|
+
if not path.name.endswith(".dist-info"):
|
|
384
|
+
raise SystemExit(70)
|
|
385
|
+
distribution = metadata.PathDistribution(path)
|
|
386
|
+
declared = distribution.metadata.get("Name")
|
|
387
|
+
normalize = lambda value: "-".join(filter(None, __import__("re").split(r"[-_.]+", value.lower())))
|
|
388
|
+
if not isinstance(declared, str) or normalize(declared) != name:
|
|
389
|
+
raise SystemExit(70)
|
|
390
|
+
distributions[name] = distribution
|
|
391
|
+
packages = {}
|
|
392
|
+
for name, raw_entry in raw_packages.items():
|
|
393
|
+
if not isinstance(name, str) or not name.isidentifier():
|
|
394
|
+
raise SystemExit(70)
|
|
395
|
+
if not isinstance(raw_entry, dict) or set(raw_entry) != {"origin", "root", "search"}:
|
|
396
|
+
raise SystemExit(70)
|
|
397
|
+
entry = {}
|
|
398
|
+
_, entry["search"] = validate_binding(raw_entry["search"], True)
|
|
399
|
+
raw_origin = raw_entry["origin"]
|
|
400
|
+
if raw_origin is not None:
|
|
401
|
+
_, entry["origin"] = validate_binding(raw_origin, False)
|
|
402
|
+
else:
|
|
403
|
+
entry["origin"] = None
|
|
404
|
+
raw_root = raw_entry["root"]
|
|
405
|
+
if raw_root is not None:
|
|
406
|
+
_, entry["root"] = validate_binding(raw_root, True)
|
|
407
|
+
else:
|
|
408
|
+
entry["root"] = None
|
|
409
|
+
if (
|
|
410
|
+
(entry["origin"] is not None and entry["origin"] not in allowed_files)
|
|
411
|
+
or not entry["search"].is_dir()
|
|
412
|
+
or overlaps_selected(entry["search"])
|
|
413
|
+
or (entry["root"] is not None and overlaps_selected(entry["root"]))
|
|
414
|
+
):
|
|
415
|
+
raise SystemExit(70)
|
|
416
|
+
packages[name] = entry
|
|
417
|
+
|
|
418
|
+
class GuardedDistributionFinder:
|
|
419
|
+
@staticmethod
|
|
420
|
+
def find_distributions(context=metadata.DistributionFinder.Context()):
|
|
421
|
+
requested = context.name
|
|
422
|
+
if requested is None:
|
|
423
|
+
return iter(distributions.values())
|
|
424
|
+
normalized = "-".join(filter(None, __import__("re").split(r"[-_.]+", requested.lower())))
|
|
425
|
+
distribution = distributions.get(normalized)
|
|
426
|
+
return iter(()) if distribution is None else iter((distribution,))
|
|
427
|
+
|
|
428
|
+
@staticmethod
|
|
429
|
+
def find_spec(fullname, path=None, target=None):
|
|
430
|
+
del target
|
|
431
|
+
top_level = fullname.partition(".")[0]
|
|
432
|
+
entry = packages.get(top_level)
|
|
433
|
+
if entry is None:
|
|
434
|
+
return None
|
|
435
|
+
search = [str(entry["search"])] if fullname == top_level else path
|
|
436
|
+
if search is None:
|
|
437
|
+
raise ModuleNotFoundError(fullname)
|
|
438
|
+
spec = PathFinder.find_spec(fullname, search)
|
|
439
|
+
if spec is None or spec.origin in ("built-in", "frozen"):
|
|
440
|
+
raise ModuleNotFoundError(fullname)
|
|
441
|
+
locations = spec.submodule_search_locations
|
|
442
|
+
origin = None if spec.origin is None else pathlib.Path(spec.origin).resolve(strict=True)
|
|
443
|
+
if origin is not None and origin not in allowed_files:
|
|
444
|
+
raise ModuleNotFoundError(fullname)
|
|
445
|
+
if fullname == top_level and origin != entry["origin"]:
|
|
446
|
+
raise ModuleNotFoundError(fullname)
|
|
447
|
+
if locations is not None:
|
|
448
|
+
resolved = [pathlib.Path(item).resolve(strict=True) for item in locations]
|
|
449
|
+
if len(resolved) != 1:
|
|
450
|
+
raise ModuleNotFoundError(fullname)
|
|
451
|
+
package_root = entry["root"]
|
|
452
|
+
if package_root is None:
|
|
453
|
+
raise ModuleNotFoundError(fullname)
|
|
454
|
+
try:
|
|
455
|
+
resolved[0].relative_to(package_root)
|
|
456
|
+
except ValueError:
|
|
457
|
+
raise ModuleNotFoundError(fullname) from None
|
|
458
|
+
elif origin is None:
|
|
459
|
+
raise ModuleNotFoundError(fullname)
|
|
460
|
+
return spec
|
|
461
|
+
|
|
462
|
+
stdlib_path = list(sys.path)
|
|
463
|
+
sys.path[:] = [str(trusted), *stdlib_path]
|
|
464
|
+
sys.meta_path.insert(0, GuardedDistributionFinder)
|
|
465
|
+
graphite_spec = PathFinder.find_spec("graphite", [str(trusted)])
|
|
466
|
+
if (
|
|
467
|
+
graphite_spec is None
|
|
468
|
+
or graphite_spec.origin is None
|
|
469
|
+
or pathlib.Path(graphite_spec.origin).resolve(strict=True) != expected_graphite_init
|
|
470
|
+
or graphite_spec.submodule_search_locations is None
|
|
471
|
+
or [pathlib.Path(item).resolve(strict=True) for item in graphite_spec.submodule_search_locations]
|
|
472
|
+
!= [expected_graphite_init.parent]
|
|
473
|
+
):
|
|
474
|
+
raise SystemExit(70)
|
|
475
|
+
mcp_spec = PathFinder.find_spec("graphite.mcp", list(graphite_spec.submodule_search_locations))
|
|
476
|
+
if mcp_spec is None or mcp_spec.origin is None or pathlib.Path(mcp_spec.origin).resolve(strict=True) != expected_graphite_mcp:
|
|
477
|
+
raise SystemExit(70)
|
|
478
|
+
""" + _MCP_CHILD_LOG_SETUP + """\
|
|
479
|
+
runpy.run_module("graphite.mcp", run_name="__main__")
|
|
480
|
+
"""
|
|
481
|
+
# Machine-independent on purpose. This shipped naming one developer's
|
|
482
|
+
# `.codex_state` scratch directory, so on every other machine it pointed at a
|
|
483
|
+
# file that did not exist -- and it carried that user's name into the wheel.
|
|
484
|
+
# `require.resolve` asks node the same question the probe asks, needs nothing
|
|
485
|
+
# installed, and works wherever the reader happens to be.
|
|
486
|
+
_TYPESCRIPT_REMEDIATION = (
|
|
487
|
+
"Confirm the package resolves from the target project: "
|
|
488
|
+
"node -e \"require.resolve('typescript')\"",
|
|
489
|
+
"Then add typescript with the target project's existing package manager.",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _blocked(error_type: str, code: str) -> DoctorCheck:
|
|
494
|
+
return DoctorCheck(
|
|
495
|
+
"deep_core",
|
|
496
|
+
"Deterministic pipeline",
|
|
497
|
+
"blocked",
|
|
498
|
+
"The isolated deterministic pipeline probe failed safely.",
|
|
499
|
+
{"error_type": error_type, "code": code},
|
|
500
|
+
("Run the Graphite build, validate, and query commands locally and inspect their diagnostics.",),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _process_error_type(code: str) -> str:
|
|
505
|
+
if code in {"timeout", "output_limit"}:
|
|
506
|
+
return code
|
|
507
|
+
return "process"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _json_object(payload: bytes) -> dict[str, Any]:
|
|
511
|
+
try:
|
|
512
|
+
value = json.loads(payload.decode("utf-8"))
|
|
513
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
514
|
+
raise ProbeProcessError("unexpected") from None
|
|
515
|
+
if not isinstance(value, dict):
|
|
516
|
+
raise ProbeProcessError("unexpected")
|
|
517
|
+
return value
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _count(value: object, *, positive: bool = False) -> int | None:
|
|
521
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
522
|
+
return None
|
|
523
|
+
if value < (1 if positive else 0):
|
|
524
|
+
return None
|
|
525
|
+
return value
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _check_deadline(limit: float, clock: Callable[[], float]) -> None:
|
|
529
|
+
if clock() >= limit:
|
|
530
|
+
raise ProbeProcessError("timeout")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _cleanup_workspace_lease(
|
|
534
|
+
lease: Any,
|
|
535
|
+
deadline: float,
|
|
536
|
+
clock: Callable[[], float],
|
|
537
|
+
) -> str | None:
|
|
538
|
+
"""Let a bounded cleanup thread retain sole ownership of the live lease."""
|
|
539
|
+
failure: list[str] = []
|
|
540
|
+
|
|
541
|
+
def perform() -> None:
|
|
542
|
+
try:
|
|
543
|
+
lease.cleanup()
|
|
544
|
+
except WorkspaceLeaseError as exc:
|
|
545
|
+
failure.append("cleanup_blocked" if exc.code == "workspace_cleanup_blocked" else "cleanup_failed")
|
|
546
|
+
except Exception:
|
|
547
|
+
failure.append("cleanup_failed")
|
|
548
|
+
finally:
|
|
549
|
+
_release_core_probe_slot()
|
|
550
|
+
|
|
551
|
+
thread = threading.Thread(target=perform, daemon=True)
|
|
552
|
+
try:
|
|
553
|
+
thread.start()
|
|
554
|
+
except RuntimeError:
|
|
555
|
+
_release_core_probe_slot()
|
|
556
|
+
return "cleanup_failed"
|
|
557
|
+
thread.join(max(0.0, deadline - clock()))
|
|
558
|
+
if thread.is_alive():
|
|
559
|
+
return "cleanup_timeout"
|
|
560
|
+
return failure[0] if failure else None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _claim_core_probe_slot() -> bool:
|
|
564
|
+
global _CORE_PROBE_SLOT_ACTIVE
|
|
565
|
+
with _CORE_PROBE_SLOT_LOCK:
|
|
566
|
+
if _CORE_PROBE_SLOT_ACTIVE:
|
|
567
|
+
return False
|
|
568
|
+
_CORE_PROBE_SLOT_ACTIVE = True
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _release_core_probe_slot() -> None:
|
|
573
|
+
global _CORE_PROBE_SLOT_ACTIVE
|
|
574
|
+
with _CORE_PROBE_SLOT_LOCK:
|
|
575
|
+
_CORE_PROBE_SLOT_ACTIVE = False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _check_workspace(lease: Any, limit: float, clock: Callable[[], float]) -> None:
|
|
579
|
+
_check_deadline(limit, clock)
|
|
580
|
+
lease.validate()
|
|
581
|
+
_check_deadline(limit, clock)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def probe_core_pipeline(
|
|
585
|
+
selected_root: Path,
|
|
586
|
+
python_executable: str = sys.executable,
|
|
587
|
+
timeout_seconds: float = 30,
|
|
588
|
+
*,
|
|
589
|
+
_runner: Callable[..., ProbeProcessResult] = run_bounded_process,
|
|
590
|
+
_workspace_factory: Callable[[], Any] = ProbeWorkspaceLease.acquire,
|
|
591
|
+
_clock: Callable[[], float] = time.monotonic,
|
|
592
|
+
) -> DoctorCheck:
|
|
593
|
+
"""Exercise build/validate/query under one checked end-to-end deadline.
|
|
594
|
+
|
|
595
|
+
Subprocess transport is hard-cancelled. Filesystem calls cannot be safely
|
|
596
|
+
preempted, so their deadline is checked immediately before and after each
|
|
597
|
+
synchronous phase. Verified cleanup runs in a bounded daemon thread.
|
|
598
|
+
"""
|
|
599
|
+
started = _clock()
|
|
600
|
+
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
601
|
+
return _blocked("timeout", "invalid_timeout")
|
|
602
|
+
if not _claim_core_probe_slot():
|
|
603
|
+
return _blocked("cleanup", "cleanup_busy")
|
|
604
|
+
deadline = started + timeout_seconds
|
|
605
|
+
cleanup_reserve = min(_CLEANUP_RESERVE_SECONDS, max(0.05, timeout_seconds * 0.2))
|
|
606
|
+
phase_deadline = deadline - cleanup_reserve
|
|
607
|
+
lease: Any | None = None
|
|
608
|
+
candidate: DoctorCheck | None = None
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
_check_deadline(phase_deadline, _clock)
|
|
612
|
+
selected = selected_root.resolve(strict=True)
|
|
613
|
+
if not selected.is_dir():
|
|
614
|
+
return _blocked("invariant", "invalid_selected_root")
|
|
615
|
+
_check_deadline(phase_deadline, _clock)
|
|
616
|
+
|
|
617
|
+
temp_root = Path(tempfile.gettempdir()).resolve(strict=True)
|
|
618
|
+
_check_deadline(phase_deadline, _clock)
|
|
619
|
+
try:
|
|
620
|
+
temp_root.relative_to(selected)
|
|
621
|
+
except ValueError:
|
|
622
|
+
pass
|
|
623
|
+
else:
|
|
624
|
+
return _blocked("isolation", "selected_contains_temp")
|
|
625
|
+
|
|
626
|
+
_check_deadline(phase_deadline, _clock)
|
|
627
|
+
lease = _workspace_factory()
|
|
628
|
+
creation_exceeded_deadline = _clock() >= phase_deadline
|
|
629
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
630
|
+
work = Path(lease.path).resolve(strict=True)
|
|
631
|
+
try:
|
|
632
|
+
work.relative_to(temp_root)
|
|
633
|
+
except ValueError:
|
|
634
|
+
# Injected leases may use another explicitly verified canonical temp
|
|
635
|
+
# root; the lease remains the authority for its containment.
|
|
636
|
+
lease_root = Path(getattr(lease, "temp_root", temp_root)).resolve(strict=True)
|
|
637
|
+
try:
|
|
638
|
+
work.relative_to(lease_root)
|
|
639
|
+
except ValueError:
|
|
640
|
+
return _blocked("isolation", "unsafe_temp_path")
|
|
641
|
+
try:
|
|
642
|
+
work.relative_to(selected)
|
|
643
|
+
except ValueError:
|
|
644
|
+
pass
|
|
645
|
+
else:
|
|
646
|
+
return _blocked("isolation", "overlapping_temp_path")
|
|
647
|
+
try:
|
|
648
|
+
selected.relative_to(work)
|
|
649
|
+
except ValueError:
|
|
650
|
+
pass
|
|
651
|
+
else:
|
|
652
|
+
return _blocked("isolation", "overlapping_temp_path")
|
|
653
|
+
if creation_exceeded_deadline:
|
|
654
|
+
raise ProbeProcessError("timeout")
|
|
655
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
656
|
+
|
|
657
|
+
repo = work / "repo"
|
|
658
|
+
source = repo / "src"
|
|
659
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
660
|
+
source.mkdir(parents=True)
|
|
661
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
662
|
+
(source / "lib.py").write_text("def answer():\n return 42\n", encoding="utf-8")
|
|
663
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
664
|
+
(repo / "app.py").write_text("from src.lib import answer\nVALUE = answer()\n", encoding="utf-8")
|
|
665
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
666
|
+
|
|
667
|
+
out = work / "out"
|
|
668
|
+
cache = work / "cache"
|
|
669
|
+
graph = out / "graph.json"
|
|
670
|
+
commands = [
|
|
671
|
+
[python_executable, "-B", "-P", "-m", "graphite", "--output-dir", str(out), "--cache-dir", str(cache), "--llm", "none", "build", str(repo)],
|
|
672
|
+
[python_executable, "-B", "-P", "-m", "graphite", "validate", "--graph-json", str(graph), "--json"],
|
|
673
|
+
[python_executable, "-B", "-P", "-m", "graphite", "query", "stats", "--graph-json", str(graph)],
|
|
674
|
+
]
|
|
675
|
+
outputs: list[ProbeProcessResult] = []
|
|
676
|
+
validation_nodes: int | None = None
|
|
677
|
+
validation_edges: int | None = None
|
|
678
|
+
for index, command in enumerate(commands):
|
|
679
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
680
|
+
outputs.append(_runner(command, cwd=work, timeout_seconds=phase_deadline - _clock()))
|
|
681
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
682
|
+
if index == 1:
|
|
683
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
684
|
+
validation = _json_object(outputs[1].stdout)
|
|
685
|
+
_check_deadline(phase_deadline, _clock)
|
|
686
|
+
validation_nodes = _count(validation.get("node_count"), positive=True)
|
|
687
|
+
validation_edges = _count(validation.get("edge_count"))
|
|
688
|
+
if (
|
|
689
|
+
validation.get("ok") is not True
|
|
690
|
+
or validation.get("valid", True) is not True
|
|
691
|
+
or validation.get("error_count") != 0
|
|
692
|
+
or validation.get("errors") != []
|
|
693
|
+
or validation_nodes is None
|
|
694
|
+
or validation_edges is None
|
|
695
|
+
):
|
|
696
|
+
candidate = _blocked("validation", "validation_failed")
|
|
697
|
+
break
|
|
698
|
+
|
|
699
|
+
if candidate is None:
|
|
700
|
+
_check_workspace(lease, phase_deadline, _clock)
|
|
701
|
+
stats = _json_object(outputs[2].stdout)
|
|
702
|
+
_check_deadline(phase_deadline, _clock)
|
|
703
|
+
stats_nodes = _count(stats.get("node_count"), positive=True)
|
|
704
|
+
stats_edges = _count(stats.get("edge_count"))
|
|
705
|
+
if stats_nodes is None or stats_edges is None:
|
|
706
|
+
candidate = _blocked("response", "invalid_counts")
|
|
707
|
+
elif stats_nodes != validation_nodes or stats_edges != validation_edges:
|
|
708
|
+
candidate = _blocked("response", "count_mismatch")
|
|
709
|
+
else:
|
|
710
|
+
_check_deadline(phase_deadline, _clock)
|
|
711
|
+
candidate = DoctorCheck(
|
|
712
|
+
"deep_core",
|
|
713
|
+
"Deterministic pipeline",
|
|
714
|
+
"ready",
|
|
715
|
+
"The isolated deterministic build, validation, and query pipeline is ready.",
|
|
716
|
+
{
|
|
717
|
+
"node_count": stats_nodes,
|
|
718
|
+
"edge_count": stats_edges,
|
|
719
|
+
"duration_ms": max(0, round((_clock() - started) * 1000)),
|
|
720
|
+
"commands_completed": 3,
|
|
721
|
+
},
|
|
722
|
+
)
|
|
723
|
+
except ProbeProcessError as exc:
|
|
724
|
+
if exc.code == "unexpected":
|
|
725
|
+
candidate = _blocked("response", "malformed_output")
|
|
726
|
+
else:
|
|
727
|
+
candidate = _blocked(_process_error_type(exc.code), exc.code)
|
|
728
|
+
except WorkspaceLeaseError:
|
|
729
|
+
candidate = _blocked("isolation", "workspace_isolation_changed")
|
|
730
|
+
except (OSError, RuntimeError):
|
|
731
|
+
candidate = _blocked("isolation", "temporary_directory_failed")
|
|
732
|
+
except Exception:
|
|
733
|
+
candidate = _blocked("unexpected", "probe_failed")
|
|
734
|
+
finally:
|
|
735
|
+
if lease is not None:
|
|
736
|
+
cleanup_error = _cleanup_workspace_lease(lease, deadline, _clock)
|
|
737
|
+
if cleanup_error is not None:
|
|
738
|
+
candidate = _blocked("cleanup", cleanup_error)
|
|
739
|
+
else:
|
|
740
|
+
_release_core_probe_slot()
|
|
741
|
+
|
|
742
|
+
if candidate is None:
|
|
743
|
+
return _blocked("unexpected", "probe_failed")
|
|
744
|
+
if _clock() >= deadline and candidate.details.get("error_type") != "cleanup":
|
|
745
|
+
return _blocked("timeout", "timeout")
|
|
746
|
+
return candidate
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# Bounded well under the ledger's own 2048-char cap. The probe must not lean on
|
|
750
|
+
# that cap: an unbounded string still gets built, hashed and passed around
|
|
751
|
+
# before the ledger ever sees it, and a server can emit megabytes.
|
|
752
|
+
_PROBE_DIAGNOSTIC_STREAM_CHARS = 600
|
|
753
|
+
# stderr gets its own, larger allowance because it carries the child's log
|
|
754
|
+
# rather than a couple of JSON envelopes. Both together, plus the scalar fields
|
|
755
|
+
# and the repr quoting, stay under MAX_DETAIL_CHARS -- pinned by
|
|
756
|
+
# test_probe_diagnostics_detail_stays_within_the_ledger_cap, because raising
|
|
757
|
+
# either cap in isolation would push the joined detail past the ledger's own
|
|
758
|
+
# truncation and silently cost the tail this exists to keep.
|
|
759
|
+
_PROBE_DIAGNOSTIC_STDERR_CHARS = 700
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _stream_excerpt(raw: object, *, limit: int = _PROBE_DIAGNOSTIC_STREAM_CHARS, tail: bool = False) -> str:
|
|
763
|
+
"""Excerpt a captured stream, from the head by default or from the tail.
|
|
764
|
+
|
|
765
|
+
Which end to keep is not cosmetic. stdout holds the response transcript and
|
|
766
|
+
the responses under test are the first ones, so the head is what matters.
|
|
767
|
+
stderr holds the child's own log, where the useful entries are the LAST it
|
|
768
|
+
managed to emit before it stopped -- head-truncating it reports the startup
|
|
769
|
+
chatter and cuts off exactly at the failure.
|
|
770
|
+
"""
|
|
771
|
+
if isinstance(raw, bytes):
|
|
772
|
+
text = raw.decode("utf-8", "replace")
|
|
773
|
+
else:
|
|
774
|
+
text = str(raw or "")
|
|
775
|
+
if len(text) <= limit:
|
|
776
|
+
return text
|
|
777
|
+
dropped = len(text) - limit
|
|
778
|
+
if tail:
|
|
779
|
+
return f"[{dropped} more chars]...{text[-limit:]}"
|
|
780
|
+
return f"{text[:limit]}...[{dropped} more chars]"
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _record_probe_diagnostics(root: Path, failure: str, result: object = None) -> None:
|
|
784
|
+
"""Record what a failing probe knew, so the next occurrence explains itself.
|
|
785
|
+
|
|
786
|
+
`DoctorCheck.details` deliberately stays `{"code": ...}`: callers assert
|
|
787
|
+
exact equality on it, and a doctor report may be shared, whereas subprocess
|
|
788
|
+
stderr should not travel. The repo incident ledger is local and bounded, so
|
|
789
|
+
that is where the evidence goes.
|
|
790
|
+
|
|
791
|
+
Swallows everything. Diagnostics are a convenience; a probe that failed
|
|
792
|
+
because its own logging failed would be worse than the opacity this fixes.
|
|
793
|
+
"""
|
|
794
|
+
try:
|
|
795
|
+
detail = " | ".join(
|
|
796
|
+
(
|
|
797
|
+
f"returncode={getattr(result, 'returncode', '<none>')}",
|
|
798
|
+
# Transport-side evidence, and the only evidence there IS when
|
|
799
|
+
# the transport raised rather than returned (graphite#51).
|
|
800
|
+
# `run_bounded_process` never produces a result on that path, so
|
|
801
|
+
# every field below used to read `<none>` on precisely the
|
|
802
|
+
# failure this diagnostic exists to explain.
|
|
803
|
+
#
|
|
804
|
+
# Read `elapsed_s` against `budget_s`:
|
|
805
|
+
#
|
|
806
|
+
# elapsed <= budget -> the deadline fired on time; the child
|
|
807
|
+
# did not answer inside it. NOTE a normal
|
|
808
|
+
# timeout lands BELOW budget, because the
|
|
809
|
+
# runner reserves up to 40% of it for
|
|
810
|
+
# cleanup and enforces the earlier
|
|
811
|
+
# execution deadline.
|
|
812
|
+
# elapsed > budget -> our own deadline was late, i.e. THIS
|
|
813
|
+
# process was starved of CPU. That is the
|
|
814
|
+
# load hypothesis, and nothing in the log
|
|
815
|
+
# could express it before.
|
|
816
|
+
#
|
|
817
|
+
# `stdout_bytes` splits the first case: 0 means the child never
|
|
818
|
+
# produced a byte, non-zero means it was alive and progressing.
|
|
819
|
+
f"elapsed_s={getattr(result, 'elapsed_seconds', '<none>')}",
|
|
820
|
+
f"budget_s={getattr(result, 'budget_seconds', '<none>')}",
|
|
821
|
+
f"stdout_bytes={getattr(result, 'stdout_bytes', '<none>')}",
|
|
822
|
+
f"stderr_bytes={getattr(result, 'stderr_bytes', '<none>')}",
|
|
823
|
+
# Input-side evidence, and the point of recording it: a short
|
|
824
|
+
# response transcript is ambiguous on its own. `input_complete`
|
|
825
|
+
# False means the child stopped reading and therefore saw an
|
|
826
|
+
# early EOF -- it never got the whole request stream. True means
|
|
827
|
+
# it received everything and still answered less, which is a
|
|
828
|
+
# different bug in a different place. Without this the two are
|
|
829
|
+
# indistinguishable from the outside (graphite issue #29).
|
|
830
|
+
f"input_bytes={getattr(result, 'input_bytes', '<none>')}",
|
|
831
|
+
f"input_complete={getattr(result, 'input_complete', '<none>')}",
|
|
832
|
+
# Seconds the child outlived the close of its stdin. -1 means
|
|
833
|
+
# not measured, not instant.
|
|
834
|
+
#
|
|
835
|
+
# Read it as a liveness fact and nothing more. It was added to
|
|
836
|
+
# test whether the child died *of* the EOF, and it CANNOT answer
|
|
837
|
+
# that: the probe closes stdin the instant the payload is
|
|
838
|
+
# written, so the interval is dominated by binding validation
|
|
839
|
+
# and imports that finish before the server reads a byte, and a
|
|
840
|
+
# teardown race in the final milliseconds is invisible inside
|
|
841
|
+
# it. Measured -- a *passing* probe reports 5.14s against
|
|
842
|
+
# 2.08/2.34/4.69s on the #29 failures, so large-vs-small here
|
|
843
|
+
# discriminates nothing. The child's own log below is what
|
|
844
|
+
# separates those cases.
|
|
845
|
+
f"outlived_close_s={getattr(result, 'stdin_close_to_exit_seconds', '<none>')}",
|
|
846
|
+
f"stdout={_stream_excerpt(getattr(result, 'stdout', b''))!r}",
|
|
847
|
+
# Tail, not head: this is the child's own MCP log, and the
|
|
848
|
+
# entries that discriminate a dropped response from a lost one
|
|
849
|
+
# are the last it emitted.
|
|
850
|
+
f"stderr="
|
|
851
|
+
f"{_stream_excerpt(getattr(result, 'stderr', b''), limit=_PROBE_DIAGNOSTIC_STDERR_CHARS, tail=True)!r}",
|
|
852
|
+
)
|
|
853
|
+
)
|
|
854
|
+
record_incident(
|
|
855
|
+
repo_ledger_dir(root),
|
|
856
|
+
klass="doctor",
|
|
857
|
+
code=f"deep_mcp_{failure}",
|
|
858
|
+
subject="deep_mcp",
|
|
859
|
+
detail=detail,
|
|
860
|
+
)
|
|
861
|
+
# Also to stderr, and this is load-bearing rather than belt-and-braces.
|
|
862
|
+
# The ledger is per-repo on local disk; on a CI runner that disk is
|
|
863
|
+
# thrown away, so a ledger-only diagnostic is invisible in exactly the
|
|
864
|
+
# environment where this failure actually occurs. pytest shows captured
|
|
865
|
+
# stderr for a failing test, so this reaches the CI log.
|
|
866
|
+
print(f"[graphite-probe] deep_mcp {failure}: {detail}", file=sys.stderr)
|
|
867
|
+
except Exception:
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _degraded_probe(code: str, label: str, failure: str) -> DoctorCheck:
|
|
872
|
+
return DoctorCheck(
|
|
873
|
+
code,
|
|
874
|
+
label,
|
|
875
|
+
"degraded",
|
|
876
|
+
f"The {label} deep probe {failure}.",
|
|
877
|
+
{"code": failure},
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _paths_overlap(left: Path, right: Path) -> bool:
|
|
882
|
+
try:
|
|
883
|
+
left.relative_to(right)
|
|
884
|
+
return True
|
|
885
|
+
except ValueError:
|
|
886
|
+
pass
|
|
887
|
+
try:
|
|
888
|
+
right.relative_to(left)
|
|
889
|
+
return True
|
|
890
|
+
except ValueError:
|
|
891
|
+
return False
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _path_is_within(path: Path, root: Path) -> bool:
|
|
895
|
+
try:
|
|
896
|
+
path.relative_to(root)
|
|
897
|
+
return True
|
|
898
|
+
except ValueError:
|
|
899
|
+
return False
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _path_binding(path: Path, *, require_directory: bool) -> dict[str, object]:
|
|
903
|
+
# Best-effort race detection only: same-user replacement after the final
|
|
904
|
+
# check remains an operating-system trust boundary, not a complete TOCTOU fix.
|
|
905
|
+
lexical = Path(os.path.abspath(path))
|
|
906
|
+
canonical = lexical.resolve(strict=True)
|
|
907
|
+
if canonical.is_dir() != require_directory:
|
|
908
|
+
raise ValueError
|
|
909
|
+
stat = canonical.stat()
|
|
910
|
+
return {
|
|
911
|
+
"lexical": str(lexical),
|
|
912
|
+
"canonical": str(canonical),
|
|
913
|
+
"identity": [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns],
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _validate_path_binding(
|
|
918
|
+
binding: object,
|
|
919
|
+
*,
|
|
920
|
+
require_directory: bool,
|
|
921
|
+
) -> tuple[Path, Path]:
|
|
922
|
+
if not isinstance(binding, dict) or set(binding) != {"canonical", "identity", "lexical"}:
|
|
923
|
+
raise ValueError
|
|
924
|
+
raw_lexical = binding["lexical"]
|
|
925
|
+
raw_canonical = binding["canonical"]
|
|
926
|
+
identity = binding["identity"]
|
|
927
|
+
if (
|
|
928
|
+
not isinstance(raw_lexical, str)
|
|
929
|
+
or not isinstance(raw_canonical, str)
|
|
930
|
+
or not isinstance(identity, list)
|
|
931
|
+
or len(identity) != 4
|
|
932
|
+
or any(not isinstance(item, int) or isinstance(item, bool) for item in identity)
|
|
933
|
+
):
|
|
934
|
+
raise ValueError
|
|
935
|
+
lexical = Path(raw_lexical)
|
|
936
|
+
canonical = Path(raw_canonical)
|
|
937
|
+
if not lexical.is_absolute() or not canonical.is_absolute():
|
|
938
|
+
raise ValueError
|
|
939
|
+
if lexical.resolve(strict=True) != canonical or canonical.resolve(strict=True) != canonical:
|
|
940
|
+
raise ValueError
|
|
941
|
+
stat = canonical.stat()
|
|
942
|
+
if [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns] != identity:
|
|
943
|
+
raise ValueError
|
|
944
|
+
if canonical.is_dir() != require_directory:
|
|
945
|
+
raise ValueError
|
|
946
|
+
return lexical, canonical
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _compact_manifest_files(
|
|
950
|
+
distribution_paths: dict[str, dict[str, object]],
|
|
951
|
+
all_files: dict[Path, dict[str, object]],
|
|
952
|
+
) -> list[dict[str, object]]:
|
|
953
|
+
roots: dict[tuple[str, str], tuple[dict[str, object], Path, Path]] = {}
|
|
954
|
+
for binding in distribution_paths.values():
|
|
955
|
+
lexical, canonical = _validate_path_binding(binding, require_directory=True)
|
|
956
|
+
root_binding = _path_binding(lexical.parent, require_directory=True)
|
|
957
|
+
root_lexical, root_canonical = _validate_path_binding(
|
|
958
|
+
root_binding,
|
|
959
|
+
require_directory=True,
|
|
960
|
+
)
|
|
961
|
+
if canonical.parent != root_canonical:
|
|
962
|
+
raise ValueError
|
|
963
|
+
key = (os.path.normcase(str(root_lexical)), os.path.normcase(str(root_canonical)))
|
|
964
|
+
roots[key] = (root_binding, root_lexical, root_canonical)
|
|
965
|
+
|
|
966
|
+
grouped: dict[tuple[str, str], list[list[object]]] = {key: [] for key in roots}
|
|
967
|
+
file_bindings = list(all_files.values())
|
|
968
|
+
with ThreadPoolExecutor(max_workers=min(32, max(1, len(file_bindings)))) as executor:
|
|
969
|
+
validated_files = executor.map(
|
|
970
|
+
lambda binding: _validate_path_binding(binding, require_directory=False),
|
|
971
|
+
file_bindings,
|
|
972
|
+
)
|
|
973
|
+
bound_files = list(zip(file_bindings, validated_files, strict=True))
|
|
974
|
+
for binding, (lexical, canonical) in bound_files:
|
|
975
|
+
matches: list[tuple[tuple[str, str], Path]] = []
|
|
976
|
+
for key, (_, root_lexical, root_canonical) in roots.items():
|
|
977
|
+
try:
|
|
978
|
+
lexical_relative = lexical.relative_to(root_lexical)
|
|
979
|
+
canonical_relative = canonical.relative_to(root_canonical)
|
|
980
|
+
except ValueError:
|
|
981
|
+
continue
|
|
982
|
+
if lexical_relative == canonical_relative:
|
|
983
|
+
matches.append((key, lexical_relative))
|
|
984
|
+
if len(matches) != 1:
|
|
985
|
+
raise ValueError
|
|
986
|
+
key, relative = matches[0]
|
|
987
|
+
if relative.is_absolute() or not relative.parts or any(part in {"", ".", ".."} for part in relative.parts):
|
|
988
|
+
raise ValueError
|
|
989
|
+
identity = binding["identity"]
|
|
990
|
+
if not isinstance(identity, list) or len(identity) != 4:
|
|
991
|
+
raise ValueError
|
|
992
|
+
grouped[key].append([relative.as_posix(), *identity])
|
|
993
|
+
|
|
994
|
+
compact: list[dict[str, object]] = []
|
|
995
|
+
for key in sorted(grouped):
|
|
996
|
+
entries = sorted(grouped[key], key=lambda entry: os.path.normcase(str(entry[0])))
|
|
997
|
+
if entries:
|
|
998
|
+
compact.append({"root": roots[key][0], "entries": entries})
|
|
999
|
+
return compact
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _validate_manifest_file_groups(
|
|
1003
|
+
raw_groups: object,
|
|
1004
|
+
selected: Path,
|
|
1005
|
+
) -> set[Path]:
|
|
1006
|
+
if not isinstance(raw_groups, list):
|
|
1007
|
+
raise ValueError
|
|
1008
|
+
allowed_files: set[Path] = set()
|
|
1009
|
+
for raw_group in raw_groups:
|
|
1010
|
+
if not isinstance(raw_group, dict) or set(raw_group) != {"entries", "root"}:
|
|
1011
|
+
raise ValueError
|
|
1012
|
+
root_lexical, root = _validate_path_binding(
|
|
1013
|
+
raw_group["root"],
|
|
1014
|
+
require_directory=True,
|
|
1015
|
+
)
|
|
1016
|
+
if _paths_overlap(root, selected):
|
|
1017
|
+
raise ValueError
|
|
1018
|
+
entries = raw_group["entries"]
|
|
1019
|
+
if not isinstance(entries, list):
|
|
1020
|
+
raise ValueError
|
|
1021
|
+
for entry in entries:
|
|
1022
|
+
if (
|
|
1023
|
+
not isinstance(entry, list)
|
|
1024
|
+
or len(entry) != 5
|
|
1025
|
+
or not isinstance(entry[0], str)
|
|
1026
|
+
or any(not isinstance(item, int) or isinstance(item, bool) for item in entry[1:])
|
|
1027
|
+
):
|
|
1028
|
+
raise ValueError
|
|
1029
|
+
relative = Path(entry[0])
|
|
1030
|
+
if (
|
|
1031
|
+
relative.is_absolute()
|
|
1032
|
+
or not relative.parts
|
|
1033
|
+
or any(part in {"", ".", ".."} for part in relative.parts)
|
|
1034
|
+
):
|
|
1035
|
+
raise ValueError
|
|
1036
|
+
lexical = root_lexical.joinpath(*relative.parts)
|
|
1037
|
+
canonical = root.joinpath(*relative.parts)
|
|
1038
|
+
if lexical.resolve(strict=True) != canonical or canonical.resolve(strict=True) != canonical:
|
|
1039
|
+
raise ValueError
|
|
1040
|
+
stat = canonical.stat()
|
|
1041
|
+
if (
|
|
1042
|
+
not canonical.is_file()
|
|
1043
|
+
or [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns] != entry[1:]
|
|
1044
|
+
or _path_is_within(canonical, selected)
|
|
1045
|
+
):
|
|
1046
|
+
raise ValueError
|
|
1047
|
+
allowed_files.add(canonical)
|
|
1048
|
+
return allowed_files
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _normalized_distribution_name(value: str) -> str:
|
|
1052
|
+
return re.sub(r"[-_.]+", "-", value).lower()
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _marker_value(name: str) -> str:
|
|
1056
|
+
values = {
|
|
1057
|
+
"platform_python_implementation": platform.python_implementation(),
|
|
1058
|
+
"platform_system": platform.system(),
|
|
1059
|
+
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
1060
|
+
"python_full_version": platform.python_version(),
|
|
1061
|
+
"implementation_name": sys.implementation.name,
|
|
1062
|
+
"sys_platform": sys.platform,
|
|
1063
|
+
}
|
|
1064
|
+
if name not in values:
|
|
1065
|
+
raise ValueError
|
|
1066
|
+
return values[name]
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _version_tuple(value: str) -> tuple[int, ...]:
|
|
1070
|
+
"""Leading numeric release components, e.g. `3.14.0rc1` -> `(3, 14, 0)`.
|
|
1071
|
+
|
|
1072
|
+
Pre-release suffixes are dropped rather than raising: `python_full_version`
|
|
1073
|
+
reports them on release-candidate interpreters, and a ValueError from a
|
|
1074
|
+
non-extra marker aborts the entire distribution walk.
|
|
1075
|
+
"""
|
|
1076
|
+
parts: list[int] = []
|
|
1077
|
+
for part in value.split("."):
|
|
1078
|
+
digits = re.match(r"\d+", part)
|
|
1079
|
+
if digits is None:
|
|
1080
|
+
break
|
|
1081
|
+
parts.append(int(digits.group(0)))
|
|
1082
|
+
if digits.end() != len(part):
|
|
1083
|
+
break
|
|
1084
|
+
if not parts:
|
|
1085
|
+
raise ValueError
|
|
1086
|
+
return tuple(parts)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _marker_comparison_holds(condition: str) -> bool:
|
|
1090
|
+
match = re.fullmatch(
|
|
1091
|
+
r"(python_version|python_full_version|sys_platform|platform_system"
|
|
1092
|
+
r"|platform_python_implementation|implementation_name)"
|
|
1093
|
+
r"\s*(==|!=|<=|>=|<|>)\s*(['\"])([^'\"]+)\3",
|
|
1094
|
+
condition,
|
|
1095
|
+
)
|
|
1096
|
+
if match is None:
|
|
1097
|
+
raise ValueError
|
|
1098
|
+
actual = _marker_value(match.group(1))
|
|
1099
|
+
expected = match.group(4)
|
|
1100
|
+
operator = match.group(2)
|
|
1101
|
+
if match.group(1) in {"python_version", "python_full_version"}:
|
|
1102
|
+
if expected.endswith(".*"):
|
|
1103
|
+
# PEP 440 prefix match; only equality operators are defined for it.
|
|
1104
|
+
if operator not in {"==", "!="}:
|
|
1105
|
+
raise ValueError
|
|
1106
|
+
prefix = _version_tuple(expected[: -len(".*")])
|
|
1107
|
+
matches = _version_tuple(actual)[: len(prefix)] == prefix
|
|
1108
|
+
return matches if operator == "==" else not matches
|
|
1109
|
+
actual_value: object = _version_tuple(actual)
|
|
1110
|
+
expected_value: object = _version_tuple(expected)
|
|
1111
|
+
else:
|
|
1112
|
+
actual_value = actual
|
|
1113
|
+
expected_value = expected
|
|
1114
|
+
comparisons = {
|
|
1115
|
+
"==": actual_value == expected_value,
|
|
1116
|
+
"!=": actual_value != expected_value,
|
|
1117
|
+
"<": actual_value < expected_value,
|
|
1118
|
+
"<=": actual_value <= expected_value,
|
|
1119
|
+
">": actual_value > expected_value,
|
|
1120
|
+
">=": actual_value >= expected_value,
|
|
1121
|
+
}
|
|
1122
|
+
return comparisons[operator]
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _requirement_extras(raw_requirement: str) -> frozenset[str]:
|
|
1126
|
+
"""Extras a requirement requests of its target: `pyjwt[crypto]` -> {"crypto"}.
|
|
1127
|
+
|
|
1128
|
+
Only the portion before any marker is inspected, so an `extra ==` marker on
|
|
1129
|
+
the requirement itself is never mistaken for a bracket.
|
|
1130
|
+
"""
|
|
1131
|
+
head, _, _ = raw_requirement.partition(";")
|
|
1132
|
+
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*\s*\[([^\]]*)\]", head)
|
|
1133
|
+
if match is None:
|
|
1134
|
+
return frozenset()
|
|
1135
|
+
requested = set()
|
|
1136
|
+
for part in match.group(1).split(","):
|
|
1137
|
+
name = part.strip()
|
|
1138
|
+
if not name:
|
|
1139
|
+
continue
|
|
1140
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name):
|
|
1141
|
+
raise ValueError
|
|
1142
|
+
requested.add(_normalized_distribution_name(name))
|
|
1143
|
+
return frozenset(requested)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _extra_condition_holds(condition: str, active_extras: frozenset[str]) -> bool | None:
|
|
1147
|
+
"""Evaluate an `extra ==`/`!=` condition, or None if this is not one."""
|
|
1148
|
+
match = re.fullmatch(r"extra\s*(==|!=)\s*(['\"])([^'\"]+)\2", condition)
|
|
1149
|
+
if match is None:
|
|
1150
|
+
return None
|
|
1151
|
+
present = _normalized_distribution_name(match.group(3)) in active_extras
|
|
1152
|
+
return present if match.group(1) == "==" else not present
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _condition_holds(condition: str, active_extras: frozenset[str]) -> bool:
|
|
1156
|
+
# A sub-condition can arrive parenthesised, e.g. the `(sys_platform ==
|
|
1157
|
+
# "win32")` half of `(sys_platform == "win32") and extra == "crypto"`.
|
|
1158
|
+
# Unwrap balanced pairs; anything still holding a boolean operator is a
|
|
1159
|
+
# nested expression this parser deliberately does not support.
|
|
1160
|
+
while condition.startswith("(") and condition.endswith(")"):
|
|
1161
|
+
condition = condition[1:-1].strip()
|
|
1162
|
+
if re.search(r"\s+(and|or)\s+", condition):
|
|
1163
|
+
raise ValueError
|
|
1164
|
+
extra_result = _extra_condition_holds(condition, active_extras)
|
|
1165
|
+
if extra_result is not None:
|
|
1166
|
+
return extra_result
|
|
1167
|
+
if re.search(r"\bextra\b", condition):
|
|
1168
|
+
# An `extra` reference we cannot parse. Fail closed rather than guess.
|
|
1169
|
+
raise ValueError
|
|
1170
|
+
return _marker_comparison_holds(condition)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _requirement_applies(
|
|
1174
|
+
raw_requirement: str, active_extras: frozenset[str] = frozenset()
|
|
1175
|
+
) -> bool:
|
|
1176
|
+
"""Whether a requirement is live given the extras its dependent requested.
|
|
1177
|
+
|
|
1178
|
+
An `extra == "x"` marker holds only when a dependent actually asked for `x`
|
|
1179
|
+
(`pyjwt[crypto]`). With no extras requested this stays exactly as
|
|
1180
|
+
restrictive as the blanket rejection it replaced.
|
|
1181
|
+
"""
|
|
1182
|
+
_, separator, raw_marker = raw_requirement.partition(";")
|
|
1183
|
+
if not separator:
|
|
1184
|
+
return True
|
|
1185
|
+
marker = raw_marker.strip()
|
|
1186
|
+
while marker.startswith("(") and marker.endswith(")"):
|
|
1187
|
+
marker = marker[1:-1].strip()
|
|
1188
|
+
# PEP 508 precedence: `and` binds tighter than `or`. Nested parenthesised
|
|
1189
|
+
# sub-expressions stay unsupported and fail closed via ValueError.
|
|
1190
|
+
try:
|
|
1191
|
+
return any(
|
|
1192
|
+
all(
|
|
1193
|
+
_condition_holds(condition.strip(), active_extras)
|
|
1194
|
+
for condition in re.split(r"\s+and\s+", clause)
|
|
1195
|
+
)
|
|
1196
|
+
for clause in re.split(r"\s+or\s+", marker)
|
|
1197
|
+
)
|
|
1198
|
+
except ValueError:
|
|
1199
|
+
if re.search(r"\bextra\b", marker):
|
|
1200
|
+
# These never reached the parser while every `extra` marker was
|
|
1201
|
+
# rejected outright. Keep rejecting the ones we cannot evaluate,
|
|
1202
|
+
# rather than aborting the whole distribution walk.
|
|
1203
|
+
return False
|
|
1204
|
+
raise
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _mcp_distribution_closure(
|
|
1208
|
+
metadata_roots: tuple[Path, ...] | None = None,
|
|
1209
|
+
) -> dict[str, metadata.Distribution]:
|
|
1210
|
+
discovered: dict[str, list[metadata.Distribution]] | None = None
|
|
1211
|
+
if metadata_roots is not None:
|
|
1212
|
+
discovered = {}
|
|
1213
|
+
for distribution in metadata.Distribution.discover(
|
|
1214
|
+
path=[str(root) for root in metadata_roots]
|
|
1215
|
+
):
|
|
1216
|
+
declared_name = distribution.metadata.get("Name")
|
|
1217
|
+
if not isinstance(declared_name, str):
|
|
1218
|
+
raise ValueError
|
|
1219
|
+
normalized = _normalized_distribution_name(declared_name)
|
|
1220
|
+
discovered.setdefault(normalized, []).append(distribution)
|
|
1221
|
+
|
|
1222
|
+
# Each entry carries the extras its dependent requested, because a
|
|
1223
|
+
# distribution's requirement set is not fixed -- `pyjwt` alone and
|
|
1224
|
+
# `pyjwt[crypto]` pull in different things.
|
|
1225
|
+
pending: list[tuple[str, frozenset[str]]] = [
|
|
1226
|
+
("mcp", frozenset()),
|
|
1227
|
+
("networkx", frozenset()),
|
|
1228
|
+
]
|
|
1229
|
+
distributions: dict[str, metadata.Distribution] = {}
|
|
1230
|
+
walked_extras: dict[str, frozenset[str]] = {}
|
|
1231
|
+
while pending:
|
|
1232
|
+
requested, requested_extras = pending.pop()
|
|
1233
|
+
normalized = _normalized_distribution_name(requested)
|
|
1234
|
+
already = walked_extras.get(normalized)
|
|
1235
|
+
if already is not None and requested_extras <= already:
|
|
1236
|
+
continue
|
|
1237
|
+
active_extras = requested_extras if already is None else already | requested_extras
|
|
1238
|
+
walked_extras[normalized] = active_extras
|
|
1239
|
+
if normalized in distributions:
|
|
1240
|
+
# Seen before, but with fewer extras: re-walk its requirements
|
|
1241
|
+
# against the wider set without re-resolving the distribution.
|
|
1242
|
+
distribution = distributions[normalized]
|
|
1243
|
+
for requirement in distribution.requires or ():
|
|
1244
|
+
if not _requirement_applies(requirement, active_extras):
|
|
1245
|
+
continue
|
|
1246
|
+
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
|
1247
|
+
if match is None:
|
|
1248
|
+
raise ValueError
|
|
1249
|
+
pending.append((match.group(0), _requirement_extras(requirement)))
|
|
1250
|
+
continue
|
|
1251
|
+
if discovered is None:
|
|
1252
|
+
distribution = metadata.distribution(requested)
|
|
1253
|
+
else:
|
|
1254
|
+
candidates = discovered.get(normalized, [])
|
|
1255
|
+
if len(candidates) != 1:
|
|
1256
|
+
raise ValueError
|
|
1257
|
+
distribution = candidates[0]
|
|
1258
|
+
declared_name = distribution.metadata.get("Name")
|
|
1259
|
+
if not isinstance(declared_name, str) or _normalized_distribution_name(declared_name) != normalized:
|
|
1260
|
+
raise ValueError
|
|
1261
|
+
distributions[normalized] = distribution
|
|
1262
|
+
for requirement in distribution.requires or ():
|
|
1263
|
+
if not _requirement_applies(requirement, active_extras):
|
|
1264
|
+
continue
|
|
1265
|
+
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
|
1266
|
+
if match is None:
|
|
1267
|
+
raise ValueError
|
|
1268
|
+
pending.append((match.group(0), _requirement_extras(requirement)))
|
|
1269
|
+
return distributions
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
@lru_cache(maxsize=1)
|
|
1273
|
+
def _mcp_import_inventory(
|
|
1274
|
+
metadata_roots: tuple[Path, ...] | None = None,
|
|
1275
|
+
) -> tuple[
|
|
1276
|
+
dict[str, dict[str, object]],
|
|
1277
|
+
dict[str, frozenset[Path]],
|
|
1278
|
+
dict[str, dict[str, Path]],
|
|
1279
|
+
dict[str, frozenset[str]],
|
|
1280
|
+
dict[Path, dict[str, object]],
|
|
1281
|
+
]:
|
|
1282
|
+
"""Cache immutable installed-distribution records, never ambient import resolution."""
|
|
1283
|
+
distributions = _mcp_distribution_closure(metadata_roots)
|
|
1284
|
+
distribution_paths: dict[str, dict[str, object]] = {}
|
|
1285
|
+
for name, distribution in distributions.items():
|
|
1286
|
+
raw_path = getattr(distribution, "_path", None)
|
|
1287
|
+
if raw_path is None:
|
|
1288
|
+
raise ValueError
|
|
1289
|
+
binding = _path_binding(Path(raw_path), require_directory=True)
|
|
1290
|
+
path = Path(str(binding["canonical"]))
|
|
1291
|
+
if not path.is_dir() or not path.name.endswith(".dist-info"):
|
|
1292
|
+
raise ValueError
|
|
1293
|
+
distribution_paths[name] = binding
|
|
1294
|
+
suffixes = tuple(machinery.all_suffixes())
|
|
1295
|
+
files_by_distribution: dict[str, set[Path]] = {}
|
|
1296
|
+
top_directories: dict[str, dict[str, Path]] = {}
|
|
1297
|
+
ownership: dict[str, set[str]] = {}
|
|
1298
|
+
all_files: dict[Path, dict[str, object]] = {}
|
|
1299
|
+
for name, distribution in distributions.items():
|
|
1300
|
+
_, metadata_path = _validate_path_binding(
|
|
1301
|
+
distribution_paths[name],
|
|
1302
|
+
require_directory=True,
|
|
1303
|
+
)
|
|
1304
|
+
import_root = metadata_path.parent
|
|
1305
|
+
declared_files: set[Path] = set()
|
|
1306
|
+
declared_directories: dict[str, Path] = {}
|
|
1307
|
+
if distribution.files is None:
|
|
1308
|
+
raise ValueError
|
|
1309
|
+
raw_import_files: list[Path] = []
|
|
1310
|
+
# Every declared file names the same handful of top-level components,
|
|
1311
|
+
# and resolving one is a `_getfinalpathname` syscall -- the single
|
|
1312
|
+
# largest cost in this build. Resolve each distinct component once.
|
|
1313
|
+
examined_tops: set[str] = set()
|
|
1314
|
+
for declared in distribution.files:
|
|
1315
|
+
first = str(declared).replace("\\", "/").partition("/")[0]
|
|
1316
|
+
top_level = first.partition(".")[0] if first.endswith(suffixes) else first
|
|
1317
|
+
if top_level.isidentifier() and first == top_level and first not in examined_tops:
|
|
1318
|
+
examined_tops.add(first)
|
|
1319
|
+
try:
|
|
1320
|
+
top_path = Path(distribution.locate_file(first)).resolve(strict=True)
|
|
1321
|
+
except OSError:
|
|
1322
|
+
pass
|
|
1323
|
+
else:
|
|
1324
|
+
if top_path.is_dir():
|
|
1325
|
+
declared_directories[top_level] = top_path
|
|
1326
|
+
if not str(declared).endswith(suffixes):
|
|
1327
|
+
continue
|
|
1328
|
+
declared_parts = str(declared).replace("\\", "/").split("/")
|
|
1329
|
+
if "__pycache__" in declared_parts:
|
|
1330
|
+
continue
|
|
1331
|
+
raw_import_files.append(Path(distribution.locate_file(declared)))
|
|
1332
|
+
def bind_candidate(path: Path) -> dict[str, object] | None:
|
|
1333
|
+
try:
|
|
1334
|
+
return _path_binding(path, require_directory=False)
|
|
1335
|
+
except (OSError, ValueError):
|
|
1336
|
+
return None
|
|
1337
|
+
with ThreadPoolExecutor(max_workers=min(32, max(1, len(raw_import_files)))) as executor:
|
|
1338
|
+
bound_candidates = executor.map(bind_candidate, raw_import_files)
|
|
1339
|
+
candidate_bindings = list(bound_candidates)
|
|
1340
|
+
for binding in candidate_bindings:
|
|
1341
|
+
if binding is None:
|
|
1342
|
+
continue
|
|
1343
|
+
candidate = Path(str(binding["canonical"]))
|
|
1344
|
+
if not candidate.is_file() or not _path_is_within(candidate, import_root):
|
|
1345
|
+
continue
|
|
1346
|
+
declared_files.add(candidate)
|
|
1347
|
+
all_files[candidate] = binding
|
|
1348
|
+
if not declared_files:
|
|
1349
|
+
raise ValueError
|
|
1350
|
+
files_by_distribution[name] = declared_files
|
|
1351
|
+
top_directories[name] = declared_directories
|
|
1352
|
+
raw_top_level = distribution.read_text("top_level.txt") or ""
|
|
1353
|
+
top_levels: set[str] = set()
|
|
1354
|
+
for raw in raw_top_level.splitlines():
|
|
1355
|
+
normalized = raw.strip().replace("\\", "/")
|
|
1356
|
+
if normalized:
|
|
1357
|
+
top_levels.add(normalized.partition("/")[0])
|
|
1358
|
+
top_levels.add(normalized.rpartition("/")[2])
|
|
1359
|
+
for declared in distribution.files:
|
|
1360
|
+
first = str(declared).replace("\\", "/").partition("/")[0]
|
|
1361
|
+
if first.endswith(suffixes):
|
|
1362
|
+
first = first.partition(".")[0]
|
|
1363
|
+
top_levels.add(first)
|
|
1364
|
+
for top_level in top_levels:
|
|
1365
|
+
if top_level.isidentifier() and top_level != "__pycache__":
|
|
1366
|
+
ownership.setdefault(top_level, set()).add(name)
|
|
1367
|
+
|
|
1368
|
+
return (
|
|
1369
|
+
distribution_paths,
|
|
1370
|
+
{name: frozenset(files) for name, files in files_by_distribution.items()},
|
|
1371
|
+
top_directories,
|
|
1372
|
+
{name: frozenset(owners) for name, owners in ownership.items()},
|
|
1373
|
+
all_files,
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _mcp_import_manifest(
|
|
1378
|
+
selected_root: Path,
|
|
1379
|
+
metadata_roots: tuple[Path, ...] | None = None,
|
|
1380
|
+
) -> dict[str, object]:
|
|
1381
|
+
"""Describe only import files declared by the verified MCP dependency closure."""
|
|
1382
|
+
selected = selected_root.resolve(strict=True)
|
|
1383
|
+
if not selected.is_dir():
|
|
1384
|
+
raise OSError
|
|
1385
|
+
(
|
|
1386
|
+
distribution_paths,
|
|
1387
|
+
files_by_distribution,
|
|
1388
|
+
top_directories,
|
|
1389
|
+
ownership,
|
|
1390
|
+
all_files,
|
|
1391
|
+
) = _mcp_import_inventory(metadata_roots)
|
|
1392
|
+
for binding in distribution_paths.values():
|
|
1393
|
+
_, canonical = _validate_path_binding(binding, require_directory=True)
|
|
1394
|
+
if _paths_overlap(canonical, selected):
|
|
1395
|
+
raise ValueError
|
|
1396
|
+
|
|
1397
|
+
packages: dict[str, dict[str, str | None]] = {}
|
|
1398
|
+
for top_level, raw_owners in ownership.items():
|
|
1399
|
+
if len(raw_owners) != 1:
|
|
1400
|
+
raise ValueError
|
|
1401
|
+
owner = next(iter(raw_owners))
|
|
1402
|
+
search_path = sys.path if metadata_roots is None else [str(root) for root in metadata_roots]
|
|
1403
|
+
spec = machinery.PathFinder.find_spec(top_level, search_path)
|
|
1404
|
+
if spec is None or spec.origin in {"built-in", "frozen"}:
|
|
1405
|
+
continue
|
|
1406
|
+
raw_locations = spec.submodule_search_locations
|
|
1407
|
+
root: Path | None = None
|
|
1408
|
+
origin: Path | None = None
|
|
1409
|
+
if raw_locations is not None:
|
|
1410
|
+
locations = [Path(raw).resolve(strict=True) for raw in raw_locations]
|
|
1411
|
+
if len(locations) != 1 or not locations[0].is_dir():
|
|
1412
|
+
raise ValueError
|
|
1413
|
+
root = locations[0]
|
|
1414
|
+
search = root.parent
|
|
1415
|
+
if spec.origin is None:
|
|
1416
|
+
if top_directories[owner].get(top_level) != root:
|
|
1417
|
+
raise ValueError
|
|
1418
|
+
else:
|
|
1419
|
+
if spec.origin is None:
|
|
1420
|
+
raise ValueError
|
|
1421
|
+
origin = Path(spec.origin).resolve(strict=True)
|
|
1422
|
+
if origin not in files_by_distribution[owner]:
|
|
1423
|
+
raise ValueError
|
|
1424
|
+
search = origin.parent
|
|
1425
|
+
if spec.origin is not None:
|
|
1426
|
+
origin = Path(spec.origin).resolve(strict=True)
|
|
1427
|
+
if origin not in files_by_distribution[owner]:
|
|
1428
|
+
raise ValueError
|
|
1429
|
+
if _paths_overlap(search, selected) or (root is not None and _paths_overlap(root, selected)):
|
|
1430
|
+
raise ValueError
|
|
1431
|
+
packages[top_level] = {
|
|
1432
|
+
"origin": _path_binding(Path(spec.origin), require_directory=False)
|
|
1433
|
+
if spec.origin is not None
|
|
1434
|
+
else None,
|
|
1435
|
+
"root": _path_binding(Path(next(iter(raw_locations))), require_directory=True)
|
|
1436
|
+
if raw_locations is not None
|
|
1437
|
+
else None,
|
|
1438
|
+
"search": _path_binding(search, require_directory=True),
|
|
1439
|
+
}
|
|
1440
|
+
for required in ("mcp", "networkx"):
|
|
1441
|
+
if required not in packages:
|
|
1442
|
+
raise ValueError
|
|
1443
|
+
return {
|
|
1444
|
+
"distributions": distribution_paths,
|
|
1445
|
+
"files": _compact_manifest_files(distribution_paths, all_files),
|
|
1446
|
+
"packages": packages,
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _validate_mcp_manifest(manifest: object, selected_root: Path) -> dict[str, object]:
|
|
1451
|
+
if not isinstance(manifest, dict) or set(manifest) != {"distributions", "files", "packages"}:
|
|
1452
|
+
raise ValueError
|
|
1453
|
+
raw_distributions = manifest["distributions"]
|
|
1454
|
+
raw_files = manifest["files"]
|
|
1455
|
+
raw_packages = manifest["packages"]
|
|
1456
|
+
if not isinstance(raw_distributions, dict) or not isinstance(raw_packages, dict):
|
|
1457
|
+
raise ValueError
|
|
1458
|
+
selected = selected_root.resolve(strict=True)
|
|
1459
|
+
allowed_files = _validate_manifest_file_groups(raw_files, selected)
|
|
1460
|
+
for name, binding in raw_distributions.items():
|
|
1461
|
+
if not isinstance(name, str):
|
|
1462
|
+
raise ValueError
|
|
1463
|
+
_, canonical = _validate_path_binding(binding, require_directory=True)
|
|
1464
|
+
if not canonical.name.endswith(".dist-info") or _paths_overlap(canonical, selected):
|
|
1465
|
+
raise ValueError
|
|
1466
|
+
for name, entry in raw_packages.items():
|
|
1467
|
+
if not isinstance(name, str) or not name.isidentifier() or not isinstance(entry, dict):
|
|
1468
|
+
raise ValueError
|
|
1469
|
+
if set(entry) != {"origin", "root", "search"}:
|
|
1470
|
+
raise ValueError
|
|
1471
|
+
_, search = _validate_path_binding(entry["search"], require_directory=True)
|
|
1472
|
+
if _paths_overlap(search, selected):
|
|
1473
|
+
raise ValueError
|
|
1474
|
+
root: Path | None = None
|
|
1475
|
+
if entry["root"] is not None:
|
|
1476
|
+
_, root = _validate_path_binding(entry["root"], require_directory=True)
|
|
1477
|
+
if _paths_overlap(root, selected) or root.parent != search:
|
|
1478
|
+
raise ValueError
|
|
1479
|
+
if entry["origin"] is not None:
|
|
1480
|
+
_, origin = _validate_path_binding(entry["origin"], require_directory=False)
|
|
1481
|
+
if origin not in allowed_files:
|
|
1482
|
+
raise ValueError
|
|
1483
|
+
if root is not None and not _path_is_within(origin, root):
|
|
1484
|
+
raise ValueError
|
|
1485
|
+
elif root is None:
|
|
1486
|
+
raise ValueError
|
|
1487
|
+
if not {"mcp", "networkx"}.issubset(raw_packages):
|
|
1488
|
+
raise ValueError
|
|
1489
|
+
return manifest
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _raw_metadata_search_roots() -> list[str]:
|
|
1493
|
+
roots: list[str] = []
|
|
1494
|
+
seen: set[str] = set()
|
|
1495
|
+
for raw in sys.path:
|
|
1496
|
+
if not isinstance(raw, str) or not raw or not os.path.isabs(raw):
|
|
1497
|
+
continue
|
|
1498
|
+
candidate = os.path.abspath(raw)
|
|
1499
|
+
normalized = os.path.normcase(candidate)
|
|
1500
|
+
if normalized not in seen:
|
|
1501
|
+
seen.add(normalized)
|
|
1502
|
+
roots.append(candidate)
|
|
1503
|
+
return roots
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
_MCP_MANIFEST_CACHE_LOCK = threading.Lock()
|
|
1507
|
+
_MCP_MANIFEST_CACHE: tuple[tuple[str, ...], bytes] | None = None
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _validate_binding_shape(binding: object) -> dict[str, object]:
|
|
1511
|
+
if not isinstance(binding, dict) or set(binding) != {"canonical", "identity", "lexical"}:
|
|
1512
|
+
raise ValueError
|
|
1513
|
+
lexical = binding["lexical"]
|
|
1514
|
+
canonical = binding["canonical"]
|
|
1515
|
+
identity = binding["identity"]
|
|
1516
|
+
if (
|
|
1517
|
+
not isinstance(lexical, str)
|
|
1518
|
+
or not isinstance(canonical, str)
|
|
1519
|
+
or not os.path.isabs(lexical)
|
|
1520
|
+
or not os.path.isabs(canonical)
|
|
1521
|
+
or not isinstance(identity, list)
|
|
1522
|
+
or len(identity) != 4
|
|
1523
|
+
or any(not isinstance(item, int) or isinstance(item, bool) for item in identity)
|
|
1524
|
+
):
|
|
1525
|
+
raise ValueError
|
|
1526
|
+
return binding
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _parse_builder_envelope(
|
|
1530
|
+
payload: bytes,
|
|
1531
|
+
) -> tuple[dict[str, object], dict[str, dict[str, object]]]:
|
|
1532
|
+
envelope = _json_object(payload)
|
|
1533
|
+
if set(envelope) != {"bindings", "manifest"}:
|
|
1534
|
+
raise ValueError
|
|
1535
|
+
manifest = envelope["manifest"]
|
|
1536
|
+
bindings = envelope["bindings"]
|
|
1537
|
+
if (
|
|
1538
|
+
not isinstance(manifest, dict)
|
|
1539
|
+
or set(manifest) != {"distributions", "files", "packages"}
|
|
1540
|
+
or not isinstance(bindings, dict)
|
|
1541
|
+
or set(bindings) != {"doctor", "init", "mcp", "selected", "trusted"}
|
|
1542
|
+
):
|
|
1543
|
+
raise ValueError
|
|
1544
|
+
verified_bindings = {
|
|
1545
|
+
name: _validate_binding_shape(binding)
|
|
1546
|
+
for name, binding in bindings.items()
|
|
1547
|
+
}
|
|
1548
|
+
return manifest, verified_bindings
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _build_mcp_manifest_bounded(
|
|
1552
|
+
selected_raw: str,
|
|
1553
|
+
*,
|
|
1554
|
+
trusted_raw: str,
|
|
1555
|
+
metadata_roots: list[str],
|
|
1556
|
+
python_executable: str,
|
|
1557
|
+
timeout_seconds: float,
|
|
1558
|
+
builder_script: str,
|
|
1559
|
+
runner: Callable[..., ProbeProcessResult],
|
|
1560
|
+
) -> tuple[dict[str, object], dict[str, dict[str, object]]]:
|
|
1561
|
+
global _MCP_MANIFEST_CACHE
|
|
1562
|
+
if len(metadata_roots) > _MCP_METADATA_ROOT_LIMIT:
|
|
1563
|
+
raise ValueError
|
|
1564
|
+
metadata_payload = json.dumps(metadata_roots, separators=(",", ":"))
|
|
1565
|
+
argument_size = len(trusted_raw.encode("utf-8")) + len(selected_raw.encode("utf-8")) + len(
|
|
1566
|
+
metadata_payload.encode("utf-8")
|
|
1567
|
+
)
|
|
1568
|
+
if argument_size > _MCP_BUILDER_ARGUMENT_LIMIT_BYTES:
|
|
1569
|
+
raise ValueError
|
|
1570
|
+
use_cache = builder_script == _MCP_MANIFEST_BUILDER_BOOTSTRAP
|
|
1571
|
+
cache_key = (python_executable, trusted_raw, *metadata_roots)
|
|
1572
|
+
cached_payload = b""
|
|
1573
|
+
if use_cache:
|
|
1574
|
+
with _MCP_MANIFEST_CACHE_LOCK:
|
|
1575
|
+
cached = _MCP_MANIFEST_CACHE
|
|
1576
|
+
if cached is not None and cached[0] == cache_key:
|
|
1577
|
+
cached_payload = cached[1]
|
|
1578
|
+
try:
|
|
1579
|
+
result = runner(
|
|
1580
|
+
[
|
|
1581
|
+
python_executable,
|
|
1582
|
+
"-I",
|
|
1583
|
+
"-S",
|
|
1584
|
+
"-B",
|
|
1585
|
+
"-c",
|
|
1586
|
+
builder_script,
|
|
1587
|
+
trusted_raw,
|
|
1588
|
+
selected_raw,
|
|
1589
|
+
metadata_payload,
|
|
1590
|
+
],
|
|
1591
|
+
cwd=Path(os.path.abspath(os.path.dirname(sys.executable))),
|
|
1592
|
+
stdin=cached_payload,
|
|
1593
|
+
timeout_seconds=timeout_seconds,
|
|
1594
|
+
max_output_bytes=_MCP_OUTPUT_LIMIT_BYTES,
|
|
1595
|
+
)
|
|
1596
|
+
except ProbeProcessError as exc:
|
|
1597
|
+
if exc.code == "nonzero":
|
|
1598
|
+
if use_cache and cached_payload:
|
|
1599
|
+
with _MCP_MANIFEST_CACHE_LOCK:
|
|
1600
|
+
if _MCP_MANIFEST_CACHE == (cache_key, cached_payload):
|
|
1601
|
+
_MCP_MANIFEST_CACHE = None
|
|
1602
|
+
raise ValueError from None
|
|
1603
|
+
raise
|
|
1604
|
+
if len(result.stdout) + len(result.stderr) > _MCP_OUTPUT_LIMIT_BYTES:
|
|
1605
|
+
raise ProbeProcessError("output_limit")
|
|
1606
|
+
manifest, bindings = _parse_builder_envelope(result.stdout)
|
|
1607
|
+
if use_cache:
|
|
1608
|
+
manifest_payload = json.dumps(manifest, separators=(",", ":")).encode("utf-8")
|
|
1609
|
+
if len(manifest_payload) > _MCP_OUTPUT_LIMIT_BYTES:
|
|
1610
|
+
raise ProbeProcessError("output_limit")
|
|
1611
|
+
with _MCP_MANIFEST_CACHE_LOCK:
|
|
1612
|
+
_MCP_MANIFEST_CACHE = (cache_key, manifest_payload)
|
|
1613
|
+
return manifest, bindings
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def _validate_json_nesting(text: str) -> None:
|
|
1617
|
+
"""Reject excessive or mismatched JSON nesting without interpreting string data."""
|
|
1618
|
+
stack: list[str] = []
|
|
1619
|
+
in_string = False
|
|
1620
|
+
escaped = False
|
|
1621
|
+
pairs = {"}": "{", "]": "["}
|
|
1622
|
+
for character in text:
|
|
1623
|
+
if in_string:
|
|
1624
|
+
if escaped:
|
|
1625
|
+
escaped = False
|
|
1626
|
+
elif character == "\\":
|
|
1627
|
+
escaped = True
|
|
1628
|
+
elif character == '"':
|
|
1629
|
+
in_string = False
|
|
1630
|
+
continue
|
|
1631
|
+
if character == '"':
|
|
1632
|
+
in_string = True
|
|
1633
|
+
elif character in "[{":
|
|
1634
|
+
stack.append(character)
|
|
1635
|
+
if len(stack) > _MCP_NESTING_LIMIT:
|
|
1636
|
+
raise ValueError
|
|
1637
|
+
elif character in "]}":
|
|
1638
|
+
if not stack or stack.pop() != pairs[character]:
|
|
1639
|
+
raise ValueError
|
|
1640
|
+
if in_string or stack:
|
|
1641
|
+
raise ValueError
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _reject_json_constant(value: str) -> None:
|
|
1645
|
+
del value
|
|
1646
|
+
raise ValueError
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def _parse_finite_json_float(value: str) -> float:
|
|
1650
|
+
parsed = float(value)
|
|
1651
|
+
if not math.isfinite(parsed):
|
|
1652
|
+
raise ValueError
|
|
1653
|
+
return parsed
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _json_object_without_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
1657
|
+
value: dict[str, Any] = {}
|
|
1658
|
+
for key, item in pairs:
|
|
1659
|
+
if key in value:
|
|
1660
|
+
raise ValueError
|
|
1661
|
+
value[key] = item
|
|
1662
|
+
return value
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def _parse_mcp_responses(output: bytes, stderr: bytes) -> dict[int, dict[str, Any]]:
|
|
1666
|
+
if len(output) + len(stderr) > _MCP_OUTPUT_LIMIT_BYTES:
|
|
1667
|
+
raise ValueError
|
|
1668
|
+
raw_lines = output.splitlines()
|
|
1669
|
+
if not raw_lines or len(raw_lines) > _MCP_LINE_LIMIT:
|
|
1670
|
+
raise ValueError
|
|
1671
|
+
responses: dict[int, dict[str, Any]] = {}
|
|
1672
|
+
response_count = 0
|
|
1673
|
+
for raw_line in raw_lines:
|
|
1674
|
+
text = raw_line.decode("utf-8")
|
|
1675
|
+
_validate_json_nesting(text)
|
|
1676
|
+
envelope = json.loads(
|
|
1677
|
+
text,
|
|
1678
|
+
parse_constant=_reject_json_constant,
|
|
1679
|
+
parse_float=_parse_finite_json_float,
|
|
1680
|
+
object_pairs_hook=_json_object_without_duplicate_keys,
|
|
1681
|
+
)
|
|
1682
|
+
if not isinstance(envelope, dict) or envelope.get("jsonrpc") != "2.0":
|
|
1683
|
+
raise ValueError
|
|
1684
|
+
if "id" not in envelope:
|
|
1685
|
+
if not set(envelope).issubset({"jsonrpc", "method", "params"}):
|
|
1686
|
+
raise ValueError
|
|
1687
|
+
method = envelope.get("method")
|
|
1688
|
+
params = envelope.get("params", {})
|
|
1689
|
+
if not isinstance(method, str) or not method or not isinstance(params, (dict, list)):
|
|
1690
|
+
raise ValueError
|
|
1691
|
+
continue
|
|
1692
|
+
response_count += 1
|
|
1693
|
+
if response_count > _MCP_RESPONSE_LIMIT:
|
|
1694
|
+
raise ValueError
|
|
1695
|
+
if not set(envelope).issubset({"jsonrpc", "id", "result", "error"}):
|
|
1696
|
+
raise ValueError
|
|
1697
|
+
response_id = envelope["id"]
|
|
1698
|
+
if not isinstance(response_id, int) or isinstance(response_id, bool):
|
|
1699
|
+
raise ValueError
|
|
1700
|
+
if response_id not in {1, 2} or response_id in responses:
|
|
1701
|
+
raise ValueError
|
|
1702
|
+
if "result" not in envelope or "error" in envelope:
|
|
1703
|
+
raise ValueError
|
|
1704
|
+
responses[response_id] = envelope
|
|
1705
|
+
if set(responses) != {1, 2} or response_count != 2:
|
|
1706
|
+
raise ValueError
|
|
1707
|
+
return responses
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def _mcp_transcript_complete(stdout: bytes) -> bool:
|
|
1711
|
+
"""True once BOTH expected replies are on stdout, so stdin may be closed.
|
|
1712
|
+
|
|
1713
|
+
This is what makes the deferred close a fix for graphite#29 rather than a
|
|
1714
|
+
delay of it. `initialize` alone is precisely the transcript every observed
|
|
1715
|
+
failure captured -- the server answers it inline in its receive loop, then
|
|
1716
|
+
hands `tools/list` to a concurrent task that races the same loop tearing the
|
|
1717
|
+
write side down on EOF. Accepting one reply as "done" would close stdin at
|
|
1718
|
+
exactly the moment that race is lost.
|
|
1719
|
+
|
|
1720
|
+
Reuses the real parser rather than scanning for ids, so a half-written line
|
|
1721
|
+
is not mistaken for an answer: it raises, and raising means "not yet".
|
|
1722
|
+
"""
|
|
1723
|
+
try:
|
|
1724
|
+
responses = _parse_mcp_responses(stdout, b"")
|
|
1725
|
+
except Exception:
|
|
1726
|
+
return False
|
|
1727
|
+
return 1 in responses and 2 in responses
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def probe_mcp(
|
|
1731
|
+
root: Path,
|
|
1732
|
+
*,
|
|
1733
|
+
python_executable: str = sys.executable,
|
|
1734
|
+
timeout_seconds: float = 20.0,
|
|
1735
|
+
manifest_timeout_seconds: float | None = None,
|
|
1736
|
+
_runner: Callable[..., ProbeProcessResult] = run_bounded_process,
|
|
1737
|
+
_builder_script: str = _MCP_MANIFEST_BUILDER_BOOTSTRAP,
|
|
1738
|
+
_builder_runner: Callable[..., ProbeProcessResult] = run_bounded_process,
|
|
1739
|
+
) -> DoctorCheck:
|
|
1740
|
+
"""Initialize the MCP server and inspect its read-only tool inventory.
|
|
1741
|
+
|
|
1742
|
+
The manifest build and the server probe get **separate** allowances. They
|
|
1743
|
+
are different faults -- a slow local build is not an unresponsive child --
|
|
1744
|
+
and sharing one deadline meant a cold build (~8.7s over 1758 files) could
|
|
1745
|
+
spend the whole budget and report `timeout` without starting a child
|
|
1746
|
+
(graphite#39). Worst-case wall time is therefore the sum of the two.
|
|
1747
|
+
|
|
1748
|
+
`manifest_timeout_seconds` defaults to `timeout_seconds` so a caller with a
|
|
1749
|
+
single knob still bounds both phases: passing `timeout_seconds=0.05` must
|
|
1750
|
+
keep the builder on a 0.05s leash, not hand it a separate generous one.
|
|
1751
|
+
"""
|
|
1752
|
+
started = time.monotonic()
|
|
1753
|
+
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
1754
|
+
return _degraded_probe("deep_mcp", "MCP", "invalid_timeout")
|
|
1755
|
+
if manifest_timeout_seconds is None:
|
|
1756
|
+
manifest_timeout_seconds = timeout_seconds
|
|
1757
|
+
if not math.isfinite(manifest_timeout_seconds) or manifest_timeout_seconds <= 0:
|
|
1758
|
+
return _degraded_probe("deep_mcp", "MCP", "invalid_timeout")
|
|
1759
|
+
deadline = started + manifest_timeout_seconds
|
|
1760
|
+
requests = (
|
|
1761
|
+
{
|
|
1762
|
+
"jsonrpc": "2.0",
|
|
1763
|
+
"id": 1,
|
|
1764
|
+
"method": "initialize",
|
|
1765
|
+
"params": {
|
|
1766
|
+
"protocolVersion": _MCP_PROTOCOL_VERSION,
|
|
1767
|
+
"capabilities": {},
|
|
1768
|
+
"clientInfo": {"name": "graphite-doctor", "version": "1.0"},
|
|
1769
|
+
},
|
|
1770
|
+
},
|
|
1771
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
|
|
1772
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
1773
|
+
)
|
|
1774
|
+
protocol_input = b"".join(
|
|
1775
|
+
json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
1776
|
+
for request in requests
|
|
1777
|
+
)
|
|
1778
|
+
try:
|
|
1779
|
+
selected_raw = os.path.abspath(os.fspath(root))
|
|
1780
|
+
trusted_raw = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
|
1781
|
+
metadata_roots = _raw_metadata_search_roots()
|
|
1782
|
+
remaining = deadline - time.monotonic()
|
|
1783
|
+
if remaining <= 0:
|
|
1784
|
+
raise ProbeProcessError("timeout")
|
|
1785
|
+
manifest, bindings = _build_mcp_manifest_bounded(
|
|
1786
|
+
selected_raw,
|
|
1787
|
+
trusted_raw=trusted_raw,
|
|
1788
|
+
metadata_roots=metadata_roots,
|
|
1789
|
+
python_executable=python_executable,
|
|
1790
|
+
timeout_seconds=remaining,
|
|
1791
|
+
builder_script=_builder_script,
|
|
1792
|
+
runner=_builder_runner,
|
|
1793
|
+
)
|
|
1794
|
+
# Length-prefixed, not newline-terminated. The child has to find the
|
|
1795
|
+
# end of the manifest without consuming what follows it, and a
|
|
1796
|
+
# `readline()` scan cannot: BufferedReader pulls a chunk past the
|
|
1797
|
+
# newline into a buffer only that object can see, stranding the
|
|
1798
|
+
# protocol input where the MCP server's own fd-0 reader never finds it.
|
|
1799
|
+
manifest_bytes = json.dumps(manifest, separators=(",", ":")).encode("utf-8")
|
|
1800
|
+
stdin = b"%d\n" % len(manifest_bytes) + manifest_bytes + protocol_input
|
|
1801
|
+
# Fresh clock: the server gets its full allowance no matter how long
|
|
1802
|
+
# building the manifest took. See the note on separate budgets above.
|
|
1803
|
+
remaining = timeout_seconds
|
|
1804
|
+
selected_binding = bindings["selected"]
|
|
1805
|
+
selected = Path(str(selected_binding["canonical"]))
|
|
1806
|
+
result = _runner(
|
|
1807
|
+
[
|
|
1808
|
+
python_executable,
|
|
1809
|
+
"-I",
|
|
1810
|
+
"-S",
|
|
1811
|
+
"-B",
|
|
1812
|
+
"-c",
|
|
1813
|
+
_MCP_BOOTSTRAP,
|
|
1814
|
+
json.dumps(bindings["trusted"], separators=(",", ":")),
|
|
1815
|
+
json.dumps(bindings["init"], separators=(",", ":")),
|
|
1816
|
+
json.dumps(bindings["mcp"], separators=(",", ":")),
|
|
1817
|
+
json.dumps(selected_binding, separators=(",", ":")),
|
|
1818
|
+
],
|
|
1819
|
+
cwd=selected,
|
|
1820
|
+
stdin=stdin,
|
|
1821
|
+
timeout_seconds=remaining,
|
|
1822
|
+
max_output_bytes=_MCP_OUTPUT_LIMIT_BYTES,
|
|
1823
|
+
# Hold stdin open until both replies are in. The server treats EOF
|
|
1824
|
+
# as end-of-session, so closing it the moment the payload is
|
|
1825
|
+
# written lets teardown race a reply that is still in flight
|
|
1826
|
+
# (graphite#29).
|
|
1827
|
+
stdin_close_when=_mcp_transcript_complete,
|
|
1828
|
+
)
|
|
1829
|
+
except ProbeProcessError as exc:
|
|
1830
|
+
# The exception IS the result on this path (graphite#51). No
|
|
1831
|
+
# `ProbeProcessResult` exists -- run_bounded_process raises instead of
|
|
1832
|
+
# returning -- so passing nothing made every field render `<none>` on
|
|
1833
|
+
# the one failure the diagnostic exists to explain.
|
|
1834
|
+
#
|
|
1835
|
+
# The streams themselves deliberately stay inside the transport: the
|
|
1836
|
+
# error type is contractually free of process data, pinned by tests that
|
|
1837
|
+
# assert a child's output cannot reach `str(exc)`. What crosses is
|
|
1838
|
+
# COUNTS and TIMINGS -- numbers, safe for the same reason `os_error` is
|
|
1839
|
+
# carried as a number.
|
|
1840
|
+
_record_probe_diagnostics(root, exc.code, exc)
|
|
1841
|
+
return _degraded_probe("deep_mcp", "MCP", exc.code)
|
|
1842
|
+
except Exception:
|
|
1843
|
+
_record_probe_diagnostics(root, "probe_failed")
|
|
1844
|
+
return _degraded_probe("deep_mcp", "MCP", "probe_failed")
|
|
1845
|
+
|
|
1846
|
+
try:
|
|
1847
|
+
responses = _parse_mcp_responses(result.stdout, result.stderr)
|
|
1848
|
+
initialize = responses[1]["result"]
|
|
1849
|
+
tools_result = responses[2]["result"]
|
|
1850
|
+
if not isinstance(initialize, dict) or not isinstance(tools_result, dict):
|
|
1851
|
+
raise ValueError
|
|
1852
|
+
server_info = initialize.get("serverInfo")
|
|
1853
|
+
tools = tools_result.get("tools")
|
|
1854
|
+
if not isinstance(server_info, dict) or server_info.get("name") != "graphite":
|
|
1855
|
+
raise ValueError
|
|
1856
|
+
if not isinstance(tools, list):
|
|
1857
|
+
raise ValueError
|
|
1858
|
+
tool_names = {
|
|
1859
|
+
tool.get("name")
|
|
1860
|
+
for tool in tools
|
|
1861
|
+
if isinstance(tool, dict) and isinstance(tool.get("name"), str)
|
|
1862
|
+
}
|
|
1863
|
+
if not _REQUIRED_MCP_TOOLS.issubset(tool_names):
|
|
1864
|
+
raise ValueError
|
|
1865
|
+
except Exception:
|
|
1866
|
+
_record_probe_diagnostics(root, "invalid_response", result)
|
|
1867
|
+
return _degraded_probe("deep_mcp", "MCP", "invalid_response")
|
|
1868
|
+
return DoctorCheck(
|
|
1869
|
+
"deep_mcp",
|
|
1870
|
+
"MCP",
|
|
1871
|
+
"ready",
|
|
1872
|
+
"The MCP server initializes and exposes the required tools.",
|
|
1873
|
+
{"server_name": "graphite", "tool_count": len(tool_names)},
|
|
1874
|
+
)
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
def _resolve_external_node(root: Path) -> Path | None:
|
|
1878
|
+
"""Resolve executable Node from absolute PATH entries outside the selected project."""
|
|
1879
|
+
name = "node.exe" if os.name == "nt" else "node"
|
|
1880
|
+
try:
|
|
1881
|
+
selected = root.resolve()
|
|
1882
|
+
except OSError:
|
|
1883
|
+
return None
|
|
1884
|
+
for raw_directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
1885
|
+
directory = Path(raw_directory)
|
|
1886
|
+
if not raw_directory or not directory.is_absolute():
|
|
1887
|
+
continue
|
|
1888
|
+
try:
|
|
1889
|
+
candidate = (directory / name).resolve(strict=True)
|
|
1890
|
+
if not candidate.is_file() or (os.name != "nt" and not os.access(candidate, os.X_OK)):
|
|
1891
|
+
continue
|
|
1892
|
+
if not _paths_overlap(candidate.parent, selected):
|
|
1893
|
+
return candidate
|
|
1894
|
+
except OSError:
|
|
1895
|
+
continue
|
|
1896
|
+
return None
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def probe_typescript(
|
|
1900
|
+
root: Path,
|
|
1901
|
+
*,
|
|
1902
|
+
timeout_seconds: float = 10.0,
|
|
1903
|
+
_node_resolver: Callable[[Path], Path | None] = _resolve_external_node,
|
|
1904
|
+
_runner: Callable[..., ProbeProcessResult] = run_bounded_process,
|
|
1905
|
+
) -> DoctorCheck:
|
|
1906
|
+
"""Detect TypeScript statically without executing project-controlled JavaScript."""
|
|
1907
|
+
node = _node_resolver(root)
|
|
1908
|
+
if node is None:
|
|
1909
|
+
return DoctorCheck(
|
|
1910
|
+
"deep_typescript",
|
|
1911
|
+
"TypeScript",
|
|
1912
|
+
"optional",
|
|
1913
|
+
"Node.js is unavailable; the TypeScript deep probe is optional.",
|
|
1914
|
+
)
|
|
1915
|
+
try:
|
|
1916
|
+
result = _runner(
|
|
1917
|
+
[str(node), "-e", _TYPESCRIPT_SCRIPT],
|
|
1918
|
+
cwd=root,
|
|
1919
|
+
timeout_seconds=timeout_seconds,
|
|
1920
|
+
check=False,
|
|
1921
|
+
)
|
|
1922
|
+
except ProbeProcessError as exc:
|
|
1923
|
+
return _degraded_probe("deep_typescript", "TypeScript", exc.code)
|
|
1924
|
+
except Exception:
|
|
1925
|
+
return _degraded_probe("deep_typescript", "TypeScript", "probe_failed")
|
|
1926
|
+
if result.returncode != 0:
|
|
1927
|
+
return _degraded_probe("deep_typescript", "TypeScript", "invalid_result")
|
|
1928
|
+
try:
|
|
1929
|
+
payload = _json_object(result.stdout)
|
|
1930
|
+
except Exception:
|
|
1931
|
+
return _degraded_probe("deep_typescript", "TypeScript", "invalid_result")
|
|
1932
|
+
if payload == {"missing_module": "typescript"}:
|
|
1933
|
+
return DoctorCheck(
|
|
1934
|
+
"deep_typescript",
|
|
1935
|
+
"TypeScript",
|
|
1936
|
+
"optional",
|
|
1937
|
+
"The TypeScript compiler module is unavailable.",
|
|
1938
|
+
remediation=_TYPESCRIPT_REMEDIATION,
|
|
1939
|
+
)
|
|
1940
|
+
if set(payload) != {"detected"} or payload["detected"] is not True:
|
|
1941
|
+
return _degraded_probe("deep_typescript", "TypeScript", "invalid_result")
|
|
1942
|
+
return DoctorCheck(
|
|
1943
|
+
"deep_typescript",
|
|
1944
|
+
"TypeScript",
|
|
1945
|
+
"optional",
|
|
1946
|
+
"TypeScript was detected but intentionally not executed because project dependencies "
|
|
1947
|
+
"are outside the doctor trust boundary.",
|
|
1948
|
+
)
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
def _llm_failure(category: str = "provider_error") -> DoctorCheck:
|
|
1952
|
+
safe_category = category if category in _LLM_CATEGORIES else "provider_error"
|
|
1953
|
+
return DoctorCheck(
|
|
1954
|
+
"deep_llm",
|
|
1955
|
+
"LLM",
|
|
1956
|
+
"degraded",
|
|
1957
|
+
"synthetic connectivity probe failed",
|
|
1958
|
+
{"category": safe_category},
|
|
1959
|
+
_LLM_FAILURE_REMEDIATION,
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def _bounded_llm_text(value: object, *, limit: int) -> str | None:
|
|
1964
|
+
if value is None:
|
|
1965
|
+
return None
|
|
1966
|
+
if not isinstance(value, str) or len(value) > limit:
|
|
1967
|
+
raise ValueError
|
|
1968
|
+
return value
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
def _llm_worker_input(cfg: Config, timeout_seconds: float) -> bytes:
|
|
1972
|
+
mode = _bounded_llm_text(cfg.llm_mode, limit=16)
|
|
1973
|
+
provider = _bounded_llm_text(cfg.llm_provider, limit=128)
|
|
1974
|
+
if (
|
|
1975
|
+
mode is None
|
|
1976
|
+
or mode.strip().lower() not in {"auto", "local", "cloud"}
|
|
1977
|
+
or provider is None
|
|
1978
|
+
or not isinstance(cfg.seed, int)
|
|
1979
|
+
or isinstance(cfg.seed, bool)
|
|
1980
|
+
):
|
|
1981
|
+
raise ValueError
|
|
1982
|
+
payload = {
|
|
1983
|
+
"mode": mode,
|
|
1984
|
+
"provider": provider,
|
|
1985
|
+
"model": _bounded_llm_text(cfg.llm_model, limit=512),
|
|
1986
|
+
"base_url": _bounded_llm_text(cfg.llm_base_url, limit=2048),
|
|
1987
|
+
"api_key": _bounded_llm_text(cfg.llm_api_key, limit=4096),
|
|
1988
|
+
"timeout_seconds": timeout_seconds,
|
|
1989
|
+
"seed": cfg.seed,
|
|
1990
|
+
"system": _LLM_SYSTEM_PROMPT,
|
|
1991
|
+
"user": _LLM_USER_PROMPT,
|
|
1992
|
+
}
|
|
1993
|
+
encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
1994
|
+
if len(encoded) > INPUT_LIMIT_BYTES or len(encoded) > _LLM_WORKER_INPUT_LIMIT_BYTES:
|
|
1995
|
+
raise ValueError
|
|
1996
|
+
return encoded
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
def probe_llm(
|
|
2000
|
+
cfg: Config,
|
|
2001
|
+
*,
|
|
2002
|
+
_runner: Callable[..., ProbeProcessResult] = run_bounded_process,
|
|
2003
|
+
) -> DoctorCheck:
|
|
2004
|
+
"""Run one synthetic request in a native-contained, deadline-bounded worker."""
|
|
2005
|
+
if not isinstance(cfg.llm_mode, str):
|
|
2006
|
+
return _llm_failure("configuration")
|
|
2007
|
+
if cfg.llm_mode.strip().lower() == "none":
|
|
2008
|
+
return DoctorCheck(
|
|
2009
|
+
"deep_llm",
|
|
2010
|
+
"LLM",
|
|
2011
|
+
"optional",
|
|
2012
|
+
"disabled by configuration",
|
|
2013
|
+
)
|
|
2014
|
+
|
|
2015
|
+
try:
|
|
2016
|
+
configured_timeout = float(cfg.llm_timeout_seconds)
|
|
2017
|
+
if not math.isfinite(configured_timeout) or configured_timeout <= 0:
|
|
2018
|
+
return _llm_failure("configuration")
|
|
2019
|
+
timeout_seconds = max(0.1, min(configured_timeout, _LLM_TIMEOUT_MAX_SECONDS))
|
|
2020
|
+
stdin = _llm_worker_input(cfg, timeout_seconds)
|
|
2021
|
+
python = Path(sys.executable).resolve(strict=True)
|
|
2022
|
+
source = Path(__file__).resolve(strict=True)
|
|
2023
|
+
worker = source.with_name("llm_probe.py").resolve(strict=True)
|
|
2024
|
+
if worker.parent != source.parent or not worker.is_file() or not python.is_file():
|
|
2025
|
+
return _llm_failure("provider_error")
|
|
2026
|
+
result = _runner(
|
|
2027
|
+
[str(python), "-I", "-S", "-B", str(worker)],
|
|
2028
|
+
cwd=python.parent,
|
|
2029
|
+
stdin=stdin,
|
|
2030
|
+
timeout_seconds=timeout_seconds,
|
|
2031
|
+
max_output_bytes=_LLM_OUTPUT_LIMIT_BYTES,
|
|
2032
|
+
check=False,
|
|
2033
|
+
)
|
|
2034
|
+
except ProbeProcessError as exc:
|
|
2035
|
+
return _llm_failure("timeout" if exc.code == "timeout" else "provider_error")
|
|
2036
|
+
except (OSError, TypeError, ValueError):
|
|
2037
|
+
return _llm_failure("configuration")
|
|
2038
|
+
except Exception:
|
|
2039
|
+
return _llm_failure("provider_error")
|
|
2040
|
+
if result.returncode != 0:
|
|
2041
|
+
return _llm_failure("provider_error")
|
|
2042
|
+
try:
|
|
2043
|
+
payload = _json_object(result.stdout)
|
|
2044
|
+
except Exception:
|
|
2045
|
+
return _llm_failure("provider_error")
|
|
2046
|
+
if payload == {"status": "ready", "response_present": True}:
|
|
2047
|
+
pass
|
|
2048
|
+
elif (
|
|
2049
|
+
set(payload) == {"status", "category"}
|
|
2050
|
+
and payload.get("status") == "degraded"
|
|
2051
|
+
and payload.get("category") in _LLM_CATEGORIES
|
|
2052
|
+
):
|
|
2053
|
+
return _llm_failure(str(payload["category"]))
|
|
2054
|
+
else:
|
|
2055
|
+
return _llm_failure("provider_error")
|
|
2056
|
+
return DoctorCheck(
|
|
2057
|
+
"deep_llm",
|
|
2058
|
+
"LLM",
|
|
2059
|
+
"ready",
|
|
2060
|
+
"synthetic connectivity probe succeeded",
|
|
2061
|
+
{"provider": canonical_provider_name(cfg.llm_provider), "response_present": True},
|
|
2062
|
+
)
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
def run_deep_probes(
|
|
2066
|
+
root: Path,
|
|
2067
|
+
*,
|
|
2068
|
+
cfg: Config,
|
|
2069
|
+
include_llm: bool,
|
|
2070
|
+
) -> list[DoctorCheck]:
|
|
2071
|
+
"""Return the deep capabilities implemented in this release."""
|
|
2072
|
+
probes: tuple[tuple[Callable[[Path], DoctorCheck], Callable[[], DoctorCheck]], ...] = (
|
|
2073
|
+
(probe_core_pipeline, lambda: _blocked("unexpected", "probe_failed")),
|
|
2074
|
+
(probe_mcp, lambda: _degraded_probe("deep_mcp", "MCP", "probe_failed")),
|
|
2075
|
+
(
|
|
2076
|
+
probe_typescript,
|
|
2077
|
+
lambda: _degraded_probe("deep_typescript", "TypeScript", "probe_failed"),
|
|
2078
|
+
),
|
|
2079
|
+
)
|
|
2080
|
+
checks: list[DoctorCheck] = []
|
|
2081
|
+
for probe, fallback in probes:
|
|
2082
|
+
try:
|
|
2083
|
+
checks.append(probe(root))
|
|
2084
|
+
except Exception:
|
|
2085
|
+
checks.append(fallback())
|
|
2086
|
+
if not include_llm:
|
|
2087
|
+
checks.append(
|
|
2088
|
+
DoctorCheck(
|
|
2089
|
+
"deep_llm",
|
|
2090
|
+
"LLM",
|
|
2091
|
+
"optional",
|
|
2092
|
+
"not requested; use --deep --include-llm",
|
|
2093
|
+
)
|
|
2094
|
+
)
|
|
2095
|
+
return checks
|
|
2096
|
+
try:
|
|
2097
|
+
checks.append(probe_llm(cfg))
|
|
2098
|
+
except Exception:
|
|
2099
|
+
checks.append(_llm_failure())
|
|
2100
|
+
return checks
|