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,1586 @@
|
|
|
1
|
+
"""Consent and policy orchestration for optional project-local TypeScript.
|
|
2
|
+
|
|
3
|
+
This module's detector is deliberately pure and filesystem-driven. Process
|
|
4
|
+
execution, consent, validation, installation, and verification belong to the
|
|
5
|
+
activation orchestration layer.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import stat
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ._cleanup_worker import _windows_directory_identity
|
|
26
|
+
from .config import Config
|
|
27
|
+
from .dependency_install import (
|
|
28
|
+
ACTIVATION_MAX_FILES,
|
|
29
|
+
MAX_CONTROL_FILE_BYTES,
|
|
30
|
+
FileSnapshot,
|
|
31
|
+
Manager,
|
|
32
|
+
Runner,
|
|
33
|
+
StepResult,
|
|
34
|
+
TRUSTED_REGISTRY,
|
|
35
|
+
TrustedFile,
|
|
36
|
+
_trusted_identity_launcher,
|
|
37
|
+
adapter_for,
|
|
38
|
+
command_for,
|
|
39
|
+
control_files_use_trusted_sources,
|
|
40
|
+
probe_local_typescript,
|
|
41
|
+
revalidate_trusted_file,
|
|
42
|
+
resolve_trusted_executable,
|
|
43
|
+
resolve_trusted_file,
|
|
44
|
+
resolve_windows_npm_prefix,
|
|
45
|
+
run_install,
|
|
46
|
+
run_manager_version,
|
|
47
|
+
run_validator,
|
|
48
|
+
snapshot_control_file,
|
|
49
|
+
)
|
|
50
|
+
from .probe_process import (
|
|
51
|
+
ProbeProcessError,
|
|
52
|
+
run_bounded_process,
|
|
53
|
+
sanitized_probe_environment,
|
|
54
|
+
)
|
|
55
|
+
from .ingest import SKIP_DIRS
|
|
56
|
+
from .probe_workspace import (
|
|
57
|
+
_close_raw_handle,
|
|
58
|
+
_open_directory_handle,
|
|
59
|
+
_windows_handle_final_path,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ActivationOutcome(StrEnum):
|
|
64
|
+
INSTALLED = "installed"
|
|
65
|
+
ALREADY_AVAILABLE = "already_available"
|
|
66
|
+
NOT_APPLICABLE = "not_applicable"
|
|
67
|
+
DECLINED = "declined"
|
|
68
|
+
GUIDANCE_ONLY = "guidance_only"
|
|
69
|
+
VALIDATION_FAILED = "validation_failed"
|
|
70
|
+
INSTALLATION_FAILED = "installation_failed"
|
|
71
|
+
VERIFICATION_FAILED = "verification_failed"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
FATAL_OUTCOMES = frozenset(
|
|
75
|
+
{
|
|
76
|
+
ActivationOutcome.VALIDATION_FAILED,
|
|
77
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
78
|
+
ActivationOutcome.VERIFICATION_FAILED,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ActivationResult:
|
|
85
|
+
outcome: ActivationOutcome
|
|
86
|
+
manager: Manager | None
|
|
87
|
+
reason: str
|
|
88
|
+
manifest: str | None = None
|
|
89
|
+
lockfile: str | None = None
|
|
90
|
+
changed_files: tuple[str, ...] = ()
|
|
91
|
+
attempted: bool = False
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def fatal(self) -> bool:
|
|
95
|
+
return self.outcome in FATAL_OUTCOMES
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, object]:
|
|
98
|
+
return {
|
|
99
|
+
"outcome": self.outcome.value,
|
|
100
|
+
"manager": self.manager.value if self.manager else None,
|
|
101
|
+
"reason": self.reason,
|
|
102
|
+
"manifest": self.manifest,
|
|
103
|
+
"lockfile": self.lockfile,
|
|
104
|
+
"changed_files": list(self.changed_files),
|
|
105
|
+
"attempted": self.attempted,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ActivationDetection:
|
|
111
|
+
result: ActivationResult | None
|
|
112
|
+
manager: Manager | None
|
|
113
|
+
manifest: str | None
|
|
114
|
+
lockfile: str | None
|
|
115
|
+
manifest_snapshot: FileSnapshot | None
|
|
116
|
+
lockfile_snapshot: FileSnapshot | None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class ActivationRequest:
|
|
121
|
+
root: Path
|
|
122
|
+
cfg: Config
|
|
123
|
+
stdin_is_tty: bool
|
|
124
|
+
stdout_is_tty: bool
|
|
125
|
+
assume_yes: bool
|
|
126
|
+
json_mode: bool
|
|
127
|
+
timeout_seconds: float = 120.0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class _TemporaryDirectoryLease:
|
|
132
|
+
name: str
|
|
133
|
+
identity: tuple[int, int]
|
|
134
|
+
|
|
135
|
+
def __fspath__(self) -> str:
|
|
136
|
+
return self.name
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Fixed worker protocol disposition; mirrored by
|
|
140
|
+
# ``_cleanup_worker.EXIT_POSIX_ROOT_RETAINED`` without importing package code
|
|
141
|
+
# into the isolated worker process.
|
|
142
|
+
_POSIX_CLEANUP_ROOT_RETAINED_EXIT = 75
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _new_temporary_directory() -> _TemporaryDirectoryLease:
|
|
146
|
+
temporary_parent = Path(tempfile.gettempdir()).resolve(strict=True)
|
|
147
|
+
path = Path(
|
|
148
|
+
tempfile.mkdtemp(prefix="graphite-typescript-", dir=temporary_parent)
|
|
149
|
+
).absolute()
|
|
150
|
+
details = path.lstat()
|
|
151
|
+
if not stat.S_ISDIR(details.st_mode) or _is_reparse(details):
|
|
152
|
+
raise OSError("temporary_directory_invalid")
|
|
153
|
+
return _TemporaryDirectoryLease(
|
|
154
|
+
str(path), _temporary_directory_identity(path, details)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _temporary_directory_identity(
|
|
159
|
+
path: Path, details: os.stat_result | None = None
|
|
160
|
+
) -> tuple[int, int]:
|
|
161
|
+
if os.name == "nt":
|
|
162
|
+
return _windows_directory_identity(path)
|
|
163
|
+
selected = details if details is not None else path.lstat()
|
|
164
|
+
return selected.st_dev, selected.st_ino
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _rename_no_replace(source: Path, destination: Path) -> bool:
|
|
168
|
+
"""Atomically rename one path without clobbering an existing recovery path."""
|
|
169
|
+
try:
|
|
170
|
+
if os.name == "nt":
|
|
171
|
+
os.rename(source, destination)
|
|
172
|
+
return True
|
|
173
|
+
import ctypes
|
|
174
|
+
|
|
175
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
176
|
+
source_bytes = os.fsencode(source)
|
|
177
|
+
destination_bytes = os.fsencode(destination)
|
|
178
|
+
renameat2 = getattr(libc, "renameat2", None)
|
|
179
|
+
if renameat2 is not None:
|
|
180
|
+
renameat2.argtypes = [
|
|
181
|
+
ctypes.c_int,
|
|
182
|
+
ctypes.c_char_p,
|
|
183
|
+
ctypes.c_int,
|
|
184
|
+
ctypes.c_char_p,
|
|
185
|
+
ctypes.c_uint,
|
|
186
|
+
]
|
|
187
|
+
renameat2.restype = ctypes.c_int
|
|
188
|
+
return renameat2(-100, source_bytes, -100, destination_bytes, 1) == 0
|
|
189
|
+
renamex_np = getattr(libc, "renamex_np", None)
|
|
190
|
+
if renamex_np is not None:
|
|
191
|
+
renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
|
|
192
|
+
renamex_np.restype = ctypes.c_int
|
|
193
|
+
return renamex_np(source_bytes, destination_bytes, 4) == 0
|
|
194
|
+
except (OSError, TypeError, ValueError):
|
|
195
|
+
return False
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _trusted_running_interpreter(root: Path) -> TrustedFile | None:
|
|
200
|
+
"""Pin the running interpreter, preferring the name it was invoked as.
|
|
201
|
+
|
|
202
|
+
A POSIX virtual environment's ``bin/python`` is a symlink, and Python
|
|
203
|
+
locates a virtual environment from the executable it was *invoked as* -- so
|
|
204
|
+
canonicalising that name first launches the base installation under a
|
|
205
|
+
different ``sys.prefix``, which is not the runtime that decided to run this
|
|
206
|
+
worker. Validate the symlink route instead, and keep the given name.
|
|
207
|
+
|
|
208
|
+
Three steps, narrowing deliberately:
|
|
209
|
+
|
|
210
|
+
1. The full symlink route, path-trusted end to end. Best evidence, so first.
|
|
211
|
+
2. Failing that, the target's identity and content, keeping the name -- see
|
|
212
|
+
`_trusted_identity_launcher`. A hosted CI runner ships its whole Python
|
|
213
|
+
toolchain mode 0777, so step 1 refuses all seven components there while
|
|
214
|
+
`_trusted_file` accepts the very same binary; without this step that gap
|
|
215
|
+
silently became "launch a different interpreter".
|
|
216
|
+
3. Only then the canonical target, which drops the name. Reached when the
|
|
217
|
+
launcher is one the selected repository controls -- a virtual environment
|
|
218
|
+
inside it, say -- where refusing the name is the entire point.
|
|
219
|
+
|
|
220
|
+
A previous version of this docstring claimed the canonical fallback was "no
|
|
221
|
+
weaker than what this did". That was wrong, and measuring is what showed it:
|
|
222
|
+
`_trusted_posix_launcher` enforces path trust while `_trusted_file` enforces
|
|
223
|
+
identity and content, so falling from the first to the second is a change of
|
|
224
|
+
threat model, not a narrowing of one. Step 2 exists so that trade is made
|
|
225
|
+
once, on purpose, for the interpreter already executing this process.
|
|
226
|
+
"""
|
|
227
|
+
executable = Path(sys.executable)
|
|
228
|
+
reference = resolve_trusted_file(executable, root, executable=True, follow_launcher=True)
|
|
229
|
+
if reference is not None:
|
|
230
|
+
return reference
|
|
231
|
+
reference = _trusted_identity_launcher(executable, root)
|
|
232
|
+
if reference is not None:
|
|
233
|
+
return reference
|
|
234
|
+
try:
|
|
235
|
+
canonical = executable.resolve(strict=True)
|
|
236
|
+
except (OSError, RuntimeError):
|
|
237
|
+
return None
|
|
238
|
+
return resolve_trusted_file(canonical, root, executable=True)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _cleanup_isolated_home(
|
|
242
|
+
lease: _TemporaryDirectoryLease,
|
|
243
|
+
root: Path,
|
|
244
|
+
timeout: float,
|
|
245
|
+
) -> StepResult:
|
|
246
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
247
|
+
return StepResult(False, "cleanup_timeout")
|
|
248
|
+
validated_lease = _validated_isolated_lease(lease, root)
|
|
249
|
+
if validated_lease is None:
|
|
250
|
+
return StepResult(False, "cleanup_failed")
|
|
251
|
+
isolated_home = Path(validated_lease.name)
|
|
252
|
+
cleanup_target = isolated_home.with_name(
|
|
253
|
+
f".{isolated_home.name}.graphite-cleanup-recovery-"
|
|
254
|
+
f"{lease.identity[0]:x}-{lease.identity[1]:x}"
|
|
255
|
+
)
|
|
256
|
+
if not _rename_no_replace(isolated_home, cleanup_target):
|
|
257
|
+
return StepResult(False, "cleanup_failed")
|
|
258
|
+
|
|
259
|
+
def fail_after_quarantine(reason: str = "cleanup_failed") -> StepResult:
|
|
260
|
+
if _path_is_lexically_present(cleanup_target):
|
|
261
|
+
_rename_no_replace(cleanup_target, isolated_home)
|
|
262
|
+
return StepResult(False, reason)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
details = cleanup_target.lstat()
|
|
266
|
+
if (
|
|
267
|
+
_temporary_directory_identity(cleanup_target, details) != lease.identity
|
|
268
|
+
or not stat.S_ISDIR(details.st_mode)
|
|
269
|
+
or stat.S_ISLNK(details.st_mode)
|
|
270
|
+
or _is_reparse(details)
|
|
271
|
+
or cleanup_target.resolve(strict=True) != cleanup_target
|
|
272
|
+
):
|
|
273
|
+
return fail_after_quarantine()
|
|
274
|
+
except (OSError, RuntimeError, ValueError):
|
|
275
|
+
return fail_after_quarantine()
|
|
276
|
+
worker = Path(__file__).with_name("_cleanup_worker.py")
|
|
277
|
+
worker_reference = resolve_trusted_file(worker, root, executable=False)
|
|
278
|
+
python_reference = _trusted_running_interpreter(root)
|
|
279
|
+
if worker_reference is None or python_reference is None:
|
|
280
|
+
return fail_after_quarantine()
|
|
281
|
+
if not revalidate_trusted_file(
|
|
282
|
+
worker_reference, root, executable=False
|
|
283
|
+
) or not revalidate_trusted_file(python_reference, root, executable=True):
|
|
284
|
+
return fail_after_quarantine()
|
|
285
|
+
try:
|
|
286
|
+
result = run_bounded_process(
|
|
287
|
+
[
|
|
288
|
+
str(python_reference.command_path),
|
|
289
|
+
"-I",
|
|
290
|
+
str(worker_reference.path),
|
|
291
|
+
str(cleanup_target),
|
|
292
|
+
str(lease.identity[0]),
|
|
293
|
+
str(lease.identity[1]),
|
|
294
|
+
],
|
|
295
|
+
cwd=cleanup_target.parent,
|
|
296
|
+
stdin=None,
|
|
297
|
+
timeout_seconds=timeout,
|
|
298
|
+
max_output_bytes=4096,
|
|
299
|
+
check=False,
|
|
300
|
+
environment=sanitized_probe_environment(),
|
|
301
|
+
)
|
|
302
|
+
except ProbeProcessError as error:
|
|
303
|
+
reason = "cleanup_timeout" if error.code == "timeout" else "cleanup_failed"
|
|
304
|
+
return fail_after_quarantine(reason)
|
|
305
|
+
except Exception:
|
|
306
|
+
return fail_after_quarantine()
|
|
307
|
+
# POSIX has no primitive that unlinks an open directory by descriptor.
|
|
308
|
+
# Exit 75 means the trusted worker emptied the held Graphite-created lease
|
|
309
|
+
# but intentionally retained its root name, which we leave untouched for
|
|
310
|
+
# OS temporary-directory reclamation. In particular, do not inspect and
|
|
311
|
+
# then remove that name: a same-UID actor could have replaced it meanwhile.
|
|
312
|
+
if (
|
|
313
|
+
os.name != "nt"
|
|
314
|
+
and result.returncode == _POSIX_CLEANUP_ROOT_RETAINED_EXIT
|
|
315
|
+
):
|
|
316
|
+
return StepResult(True, "cleanup_root_retained")
|
|
317
|
+
if result.returncode != 0 or _path_is_lexically_present(cleanup_target):
|
|
318
|
+
return fail_after_quarantine()
|
|
319
|
+
return StepResult(True, "cleaned")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass
|
|
323
|
+
class ActivationDependencies:
|
|
324
|
+
prompt: Callable[[str], str] = input
|
|
325
|
+
environ: Mapping[str, str] = field(default_factory=lambda: os.environ)
|
|
326
|
+
monotonic: Callable[[], float] = time.monotonic
|
|
327
|
+
runner: Runner = run_bounded_process
|
|
328
|
+
temporary_directory: Callable[[], Any] = _new_temporary_directory
|
|
329
|
+
cleanup: Callable[[_TemporaryDirectoryLease, Path, float], StepResult] = (
|
|
330
|
+
_cleanup_isolated_home
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
_ACTIVATION_LOCKS_GUARD = threading.Lock()
|
|
335
|
+
_ACTIVATION_LOCKS: dict[str, tuple[threading.Lock, int]] = {}
|
|
336
|
+
_ACTIVE_CLEANUP_ROOTS: set[str] = set()
|
|
337
|
+
_CLEANUP_MAX_SECONDS = 1.0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _acquire_activation_lock(root: Path) -> tuple[str, threading.Lock] | None:
|
|
341
|
+
key = os.path.normcase(str(root))
|
|
342
|
+
with _ACTIVATION_LOCKS_GUARD:
|
|
343
|
+
if key in _ACTIVE_CLEANUP_ROOTS:
|
|
344
|
+
return None
|
|
345
|
+
lock, users = _ACTIVATION_LOCKS.get(key, (threading.Lock(), 0))
|
|
346
|
+
_ACTIVATION_LOCKS[key] = (lock, users + 1)
|
|
347
|
+
if lock.acquire(blocking=False):
|
|
348
|
+
return key, lock
|
|
349
|
+
current_lock, current_users = _ACTIVATION_LOCKS[key]
|
|
350
|
+
if current_users == 1:
|
|
351
|
+
del _ACTIVATION_LOCKS[key]
|
|
352
|
+
else:
|
|
353
|
+
_ACTIVATION_LOCKS[key] = (current_lock, current_users - 1)
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _release_activation_lock(key: str, lock: threading.Lock) -> None:
|
|
358
|
+
lock.release()
|
|
359
|
+
with _ACTIVATION_LOCKS_GUARD:
|
|
360
|
+
current_lock, users = _ACTIVATION_LOCKS[key]
|
|
361
|
+
if users == 1:
|
|
362
|
+
del _ACTIVATION_LOCKS[key]
|
|
363
|
+
else:
|
|
364
|
+
_ACTIVATION_LOCKS[key] = (current_lock, users - 1)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class _DeadlineExpired(Exception):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class _DependencyFailure(Exception):
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass(frozen=True)
|
|
376
|
+
class _Deadline:
|
|
377
|
+
expires_at: float
|
|
378
|
+
monotonic: Callable[[], float]
|
|
379
|
+
|
|
380
|
+
def remaining(self) -> float:
|
|
381
|
+
try:
|
|
382
|
+
remaining = self.expires_at - self.monotonic()
|
|
383
|
+
if not math.isfinite(remaining):
|
|
384
|
+
raise ValueError("clock_invalid")
|
|
385
|
+
except Exception:
|
|
386
|
+
raise _DependencyFailure from None
|
|
387
|
+
if remaining <= 0:
|
|
388
|
+
raise _DeadlineExpired
|
|
389
|
+
return remaining
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _mark_cleanup_active(root: Path) -> None:
|
|
393
|
+
key = os.path.normcase(str(root))
|
|
394
|
+
with _ACTIVATION_LOCKS_GUARD:
|
|
395
|
+
_ACTIVE_CLEANUP_ROOTS.add(key)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _clear_cleanup_active(root: Path) -> None:
|
|
399
|
+
key = os.path.normcase(str(root))
|
|
400
|
+
with _ACTIVATION_LOCKS_GUARD:
|
|
401
|
+
_ACTIVE_CLEANUP_ROOTS.discard(key)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _bounded_temporary_cleanup(
|
|
405
|
+
lease: _TemporaryDirectoryLease,
|
|
406
|
+
deadline: _Deadline,
|
|
407
|
+
root: Path,
|
|
408
|
+
cleanup: Callable[[_TemporaryDirectoryLease, Path, float], StepResult],
|
|
409
|
+
) -> tuple[str, bool]:
|
|
410
|
+
"""Run terminable cleanup while lock acquisition observes its ownership."""
|
|
411
|
+
expired_before_cleanup = False
|
|
412
|
+
dependency_failed = False
|
|
413
|
+
try:
|
|
414
|
+
remaining = deadline.remaining()
|
|
415
|
+
except _DeadlineExpired:
|
|
416
|
+
remaining = _CLEANUP_MAX_SECONDS
|
|
417
|
+
expired_before_cleanup = True
|
|
418
|
+
except _DependencyFailure:
|
|
419
|
+
remaining = _CLEANUP_MAX_SECONDS
|
|
420
|
+
dependency_failed = True
|
|
421
|
+
cleanup_timeout = min(_CLEANUP_MAX_SECONDS, max(0.001, remaining))
|
|
422
|
+
_mark_cleanup_active(root)
|
|
423
|
+
try:
|
|
424
|
+
cleanup_result = cleanup(lease, root, cleanup_timeout)
|
|
425
|
+
except Exception:
|
|
426
|
+
cleanup_result = StepResult(False, "cleanup_failed")
|
|
427
|
+
finally:
|
|
428
|
+
_clear_cleanup_active(root)
|
|
429
|
+
if dependency_failed:
|
|
430
|
+
return "dependency_failed", expired_before_cleanup
|
|
431
|
+
if not isinstance(cleanup_result, StepResult):
|
|
432
|
+
return "cleanup_failed", expired_before_cleanup
|
|
433
|
+
if not cleanup_result.ok:
|
|
434
|
+
reason = (
|
|
435
|
+
cleanup_result.reason
|
|
436
|
+
if cleanup_result.reason in {"cleanup_failed", "cleanup_timeout"}
|
|
437
|
+
else "cleanup_failed"
|
|
438
|
+
)
|
|
439
|
+
return reason, expired_before_cleanup
|
|
440
|
+
try:
|
|
441
|
+
deadline.remaining()
|
|
442
|
+
except _DeadlineExpired:
|
|
443
|
+
return "cleanup_timeout", expired_before_cleanup
|
|
444
|
+
except _DependencyFailure:
|
|
445
|
+
return "dependency_failed", expired_before_cleanup
|
|
446
|
+
return "cleaned", expired_before_cleanup
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
_PACKAGE_MANAGER_RE = re.compile(r"(npm|pnpm|yarn|bun)@(\S+)")
|
|
450
|
+
_STABLE_STAT_FIELDS = ("st_dev", "st_ino", "st_size", "st_mtime_ns")
|
|
451
|
+
_EVIDENCE_SCAN_SECONDS = 1.0
|
|
452
|
+
_EVIDENCE_MAX_DEPTH = 128
|
|
453
|
+
_SUPPORTED_LOCKFILES = tuple(
|
|
454
|
+
(lockfile, manager)
|
|
455
|
+
for manager in Manager
|
|
456
|
+
for lockfile in adapter_for(manager).lockfiles
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class _EvidenceScanOutcome(StrEnum):
|
|
461
|
+
FOUND = "found"
|
|
462
|
+
ABSENT = "absent"
|
|
463
|
+
LIMITED = "limited"
|
|
464
|
+
UNSAFE = "unsafe"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@dataclass(frozen=True)
|
|
468
|
+
class _ScanDirectory:
|
|
469
|
+
path: Path
|
|
470
|
+
handle: int
|
|
471
|
+
identity: tuple[int, int]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@dataclass
|
|
475
|
+
class _EvidenceScanState:
|
|
476
|
+
entry_limit: int
|
|
477
|
+
deadline: float
|
|
478
|
+
visited: int = 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _is_reparse(details: os.stat_result) -> bool:
|
|
482
|
+
attributes = getattr(details, "st_file_attributes", 0)
|
|
483
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
484
|
+
return bool(attributes & reparse_flag)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _is_contained(path: Path, root: Path) -> bool:
|
|
488
|
+
try:
|
|
489
|
+
path.relative_to(root)
|
|
490
|
+
except ValueError:
|
|
491
|
+
return False
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _path_is_lexically_present(path: Path) -> bool:
|
|
496
|
+
try:
|
|
497
|
+
return os.path.lexists(path)
|
|
498
|
+
except (OSError, ValueError):
|
|
499
|
+
return True
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _root_file_state(
|
|
503
|
+
root: Path,
|
|
504
|
+
relative_path: str,
|
|
505
|
+
*,
|
|
506
|
+
maximum_size: int | None = None,
|
|
507
|
+
) -> str:
|
|
508
|
+
path = root / relative_path
|
|
509
|
+
if not _path_is_lexically_present(path):
|
|
510
|
+
return "missing"
|
|
511
|
+
try:
|
|
512
|
+
details = path.lstat()
|
|
513
|
+
if stat.S_ISLNK(details.st_mode) or _is_reparse(details):
|
|
514
|
+
return "unsafe"
|
|
515
|
+
if not stat.S_ISREG(details.st_mode):
|
|
516
|
+
return "unsafe"
|
|
517
|
+
if maximum_size is not None and details.st_size > maximum_size:
|
|
518
|
+
return "oversized"
|
|
519
|
+
canonical = path.resolve(strict=True)
|
|
520
|
+
except (OSError, RuntimeError):
|
|
521
|
+
return "unsafe"
|
|
522
|
+
if canonical != path or not _is_contained(canonical, root):
|
|
523
|
+
return "unsafe"
|
|
524
|
+
return "safe"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _evidence_entry_limit(value: int | None) -> int | None:
|
|
528
|
+
if isinstance(value, bool) or (value is not None and not isinstance(value, int)):
|
|
529
|
+
return None
|
|
530
|
+
if value is not None and value < 0:
|
|
531
|
+
return None
|
|
532
|
+
return min(value or ACTIVATION_MAX_FILES, ACTIVATION_MAX_FILES)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _configured_exclusions(root: Path, cfg: Config) -> tuple[tuple[str, ...], ...]:
|
|
536
|
+
exclusions: set[tuple[str, ...]] = set()
|
|
537
|
+
for configured in (cfg.output_dir, cfg.cache_dir):
|
|
538
|
+
candidate = configured if configured.is_absolute() else root / configured
|
|
539
|
+
try:
|
|
540
|
+
relative = candidate.resolve(strict=False).relative_to(root)
|
|
541
|
+
except (OSError, RuntimeError, ValueError):
|
|
542
|
+
continue
|
|
543
|
+
if relative != Path("."):
|
|
544
|
+
exclusions.add(tuple(os.path.normcase(part) for part in relative.parts))
|
|
545
|
+
return tuple(sorted(exclusions))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _is_excluded_path(
|
|
549
|
+
parts: tuple[str, ...],
|
|
550
|
+
exclusions: tuple[tuple[str, ...], ...],
|
|
551
|
+
) -> bool:
|
|
552
|
+
normalized = tuple(os.path.normcase(part) for part in parts)
|
|
553
|
+
return any(
|
|
554
|
+
len(normalized) >= len(exclusion)
|
|
555
|
+
and normalized[: len(exclusion)] == exclusion
|
|
556
|
+
for exclusion in exclusions
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _windows_identity(raw_identity: tuple[int, int, int]) -> tuple[int, int]:
|
|
561
|
+
return raw_identity[0], (raw_identity[1] << 32) | raw_identity[2]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _same_windows_path(left: Path, right: Path) -> bool:
|
|
565
|
+
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _open_root_scan_directory(root: Path) -> _ScanDirectory | None:
|
|
569
|
+
handle = -1
|
|
570
|
+
try:
|
|
571
|
+
if os.name == "nt":
|
|
572
|
+
handle, raw_identity = _open_directory_handle(root)
|
|
573
|
+
final_path = _windows_handle_final_path(handle)
|
|
574
|
+
if not _same_windows_path(final_path, root):
|
|
575
|
+
raise OSError("directory_binding_changed")
|
|
576
|
+
return _ScanDirectory(root, handle, _windows_identity(raw_identity))
|
|
577
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
578
|
+
handle = os.open(root, flags)
|
|
579
|
+
os.set_inheritable(handle, False)
|
|
580
|
+
details = os.fstat(handle)
|
|
581
|
+
if not stat.S_ISDIR(details.st_mode):
|
|
582
|
+
raise OSError("directory_invalid")
|
|
583
|
+
return _ScanDirectory(root, handle, (details.st_dev, details.st_ino))
|
|
584
|
+
except (OSError, RuntimeError, ValueError):
|
|
585
|
+
if handle >= 0:
|
|
586
|
+
_close_raw_handle(handle)
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _open_child_scan_directory(
|
|
591
|
+
parent: _ScanDirectory,
|
|
592
|
+
name: str,
|
|
593
|
+
expected_identity: tuple[int, int],
|
|
594
|
+
canonical_root: Path,
|
|
595
|
+
) -> _ScanDirectory | None:
|
|
596
|
+
handle = -1
|
|
597
|
+
child_path = parent.path / name
|
|
598
|
+
try:
|
|
599
|
+
if os.name == "nt":
|
|
600
|
+
handle, raw_identity = _open_directory_handle(child_path)
|
|
601
|
+
identity = _windows_identity(raw_identity)
|
|
602
|
+
final_path = _windows_handle_final_path(handle)
|
|
603
|
+
if (
|
|
604
|
+
identity[1] != expected_identity[1]
|
|
605
|
+
or not _is_contained(final_path, canonical_root)
|
|
606
|
+
or not _same_windows_path(final_path, child_path)
|
|
607
|
+
):
|
|
608
|
+
raise OSError("directory_binding_changed")
|
|
609
|
+
return _ScanDirectory(child_path, handle, identity)
|
|
610
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
611
|
+
handle = os.open(name, flags, dir_fd=parent.handle)
|
|
612
|
+
os.set_inheritable(handle, False)
|
|
613
|
+
details = os.fstat(handle)
|
|
614
|
+
identity = (details.st_dev, details.st_ino)
|
|
615
|
+
if not stat.S_ISDIR(details.st_mode) or identity != expected_identity:
|
|
616
|
+
raise OSError("directory_binding_changed")
|
|
617
|
+
return _ScanDirectory(child_path, handle, identity)
|
|
618
|
+
except (OSError, RuntimeError, ValueError):
|
|
619
|
+
if handle >= 0:
|
|
620
|
+
_close_raw_handle(handle)
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _scan_open_directory(
|
|
625
|
+
directory: _ScanDirectory,
|
|
626
|
+
root: Path,
|
|
627
|
+
cfg: Config,
|
|
628
|
+
exclusions: tuple[tuple[str, ...], ...],
|
|
629
|
+
state: _EvidenceScanState,
|
|
630
|
+
*,
|
|
631
|
+
depth: int,
|
|
632
|
+
) -> _EvidenceScanOutcome:
|
|
633
|
+
if time.monotonic() >= state.deadline:
|
|
634
|
+
return _EvidenceScanOutcome.LIMITED
|
|
635
|
+
scan_target: int | Path = directory.path if os.name == "nt" else directory.handle
|
|
636
|
+
try:
|
|
637
|
+
with os.scandir(scan_target) as entries:
|
|
638
|
+
for entry in entries:
|
|
639
|
+
state.visited += 1
|
|
640
|
+
if (
|
|
641
|
+
state.visited > state.entry_limit
|
|
642
|
+
or time.monotonic() >= state.deadline
|
|
643
|
+
):
|
|
644
|
+
return _EvidenceScanOutcome.LIMITED
|
|
645
|
+
details = (
|
|
646
|
+
os.lstat(directory.path / entry.name)
|
|
647
|
+
if os.name == "nt"
|
|
648
|
+
else entry.stat(follow_symlinks=False)
|
|
649
|
+
)
|
|
650
|
+
if stat.S_ISLNK(details.st_mode) or _is_reparse(details):
|
|
651
|
+
continue
|
|
652
|
+
parts = (*directory.path.relative_to(root).parts, entry.name)
|
|
653
|
+
if stat.S_ISDIR(details.st_mode):
|
|
654
|
+
if (
|
|
655
|
+
entry.name in SKIP_DIRS
|
|
656
|
+
or (not cfg.include_dotfiles and entry.name.startswith("."))
|
|
657
|
+
or _is_excluded_path(parts, exclusions)
|
|
658
|
+
):
|
|
659
|
+
continue
|
|
660
|
+
if depth >= _EVIDENCE_MAX_DEPTH:
|
|
661
|
+
return _EvidenceScanOutcome.LIMITED
|
|
662
|
+
child = _open_child_scan_directory(
|
|
663
|
+
directory,
|
|
664
|
+
entry.name,
|
|
665
|
+
(details.st_dev, details.st_ino),
|
|
666
|
+
root,
|
|
667
|
+
)
|
|
668
|
+
if child is None:
|
|
669
|
+
return _EvidenceScanOutcome.UNSAFE
|
|
670
|
+
try:
|
|
671
|
+
child_outcome = _scan_open_directory(
|
|
672
|
+
child,
|
|
673
|
+
root,
|
|
674
|
+
cfg,
|
|
675
|
+
exclusions,
|
|
676
|
+
state,
|
|
677
|
+
depth=depth + 1,
|
|
678
|
+
)
|
|
679
|
+
finally:
|
|
680
|
+
_close_raw_handle(child.handle)
|
|
681
|
+
if child_outcome is not _EvidenceScanOutcome.ABSENT:
|
|
682
|
+
return child_outcome
|
|
683
|
+
continue
|
|
684
|
+
if not stat.S_ISREG(details.st_mode):
|
|
685
|
+
continue
|
|
686
|
+
if not cfg.include_dotfiles and entry.name.startswith("."):
|
|
687
|
+
continue
|
|
688
|
+
if Path(entry.name).suffix.lower() in {".ts", ".tsx"}:
|
|
689
|
+
return _EvidenceScanOutcome.FOUND
|
|
690
|
+
except (OSError, RuntimeError, ValueError):
|
|
691
|
+
return _EvidenceScanOutcome.UNSAFE
|
|
692
|
+
return _EvidenceScanOutcome.ABSENT
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _scan_typescript_evidence(
|
|
696
|
+
root: Path,
|
|
697
|
+
cfg: Config,
|
|
698
|
+
entry_limit: int,
|
|
699
|
+
) -> _EvidenceScanOutcome:
|
|
700
|
+
state = _EvidenceScanState(
|
|
701
|
+
entry_limit=entry_limit,
|
|
702
|
+
deadline=time.monotonic() + _EVIDENCE_SCAN_SECONDS,
|
|
703
|
+
)
|
|
704
|
+
exclusions = _configured_exclusions(root, cfg)
|
|
705
|
+
root_directory = _open_root_scan_directory(root)
|
|
706
|
+
if root_directory is None:
|
|
707
|
+
return _EvidenceScanOutcome.UNSAFE
|
|
708
|
+
try:
|
|
709
|
+
return _scan_open_directory(
|
|
710
|
+
root_directory,
|
|
711
|
+
root,
|
|
712
|
+
cfg,
|
|
713
|
+
exclusions,
|
|
714
|
+
state,
|
|
715
|
+
depth=0,
|
|
716
|
+
)
|
|
717
|
+
finally:
|
|
718
|
+
_close_raw_handle(root_directory.handle)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _read_stable_control_file(
|
|
722
|
+
root: Path,
|
|
723
|
+
relative_path: str,
|
|
724
|
+
) -> tuple[bytes, FileSnapshot] | None:
|
|
725
|
+
path = root / relative_path
|
|
726
|
+
descriptor = -1
|
|
727
|
+
close_failed = False
|
|
728
|
+
try:
|
|
729
|
+
before_snapshot = snapshot_control_file(root, relative_path)
|
|
730
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
731
|
+
descriptor = os.open(path, flags)
|
|
732
|
+
before = os.fstat(descriptor)
|
|
733
|
+
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_CONTROL_FILE_BYTES:
|
|
734
|
+
return None
|
|
735
|
+
chunks: list[bytes] = []
|
|
736
|
+
total = 0
|
|
737
|
+
while total <= MAX_CONTROL_FILE_BYTES:
|
|
738
|
+
chunk = os.read(descriptor, min(64 * 1024, MAX_CONTROL_FILE_BYTES + 1 - total))
|
|
739
|
+
if not chunk:
|
|
740
|
+
break
|
|
741
|
+
chunks.append(chunk)
|
|
742
|
+
total += len(chunk)
|
|
743
|
+
after = os.fstat(descriptor)
|
|
744
|
+
content = b"".join(chunks)
|
|
745
|
+
except (OSError, RuntimeError, ValueError):
|
|
746
|
+
return None
|
|
747
|
+
finally:
|
|
748
|
+
if descriptor >= 0:
|
|
749
|
+
try:
|
|
750
|
+
os.close(descriptor)
|
|
751
|
+
except (OSError, ValueError):
|
|
752
|
+
close_failed = True
|
|
753
|
+
if close_failed:
|
|
754
|
+
return None
|
|
755
|
+
try:
|
|
756
|
+
after_snapshot = snapshot_control_file(root, relative_path)
|
|
757
|
+
except ValueError:
|
|
758
|
+
return None
|
|
759
|
+
if (
|
|
760
|
+
len(content) > MAX_CONTROL_FILE_BYTES
|
|
761
|
+
or len(content) != before.st_size
|
|
762
|
+
or any(getattr(before, field) != getattr(after, field) for field in _STABLE_STAT_FIELDS)
|
|
763
|
+
or before_snapshot != after_snapshot
|
|
764
|
+
or hashlib.sha256(content).hexdigest() != before_snapshot.sha256
|
|
765
|
+
):
|
|
766
|
+
return None
|
|
767
|
+
return content, before_snapshot
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _reject_json_constant(_value: str) -> None:
|
|
771
|
+
raise ValueError("json_constant_invalid")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
775
|
+
result: dict[str, Any] = {}
|
|
776
|
+
for key, value in pairs:
|
|
777
|
+
if key in result:
|
|
778
|
+
raise ValueError("json_duplicate_key")
|
|
779
|
+
result[key] = value
|
|
780
|
+
return result
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _parse_bounded_json_int(value: str) -> int:
|
|
784
|
+
if len(value.lstrip("-")) > 1024:
|
|
785
|
+
raise ValueError("json_integer_too_long")
|
|
786
|
+
return int(value)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _parse_finite_json_float(value: str) -> float:
|
|
790
|
+
parsed = float(value)
|
|
791
|
+
if not math.isfinite(parsed):
|
|
792
|
+
raise ValueError("json_float_not_finite")
|
|
793
|
+
return parsed
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _parse_manifest(content: bytes) -> dict[str, Any] | None:
|
|
797
|
+
try:
|
|
798
|
+
parsed = json.loads(
|
|
799
|
+
content,
|
|
800
|
+
parse_constant=_reject_json_constant,
|
|
801
|
+
object_pairs_hook=_reject_duplicate_json_keys,
|
|
802
|
+
parse_int=_parse_bounded_json_int,
|
|
803
|
+
parse_float=_parse_finite_json_float,
|
|
804
|
+
)
|
|
805
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
806
|
+
return None
|
|
807
|
+
return parsed if isinstance(parsed, dict) else None
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _terminal(
|
|
811
|
+
outcome: ActivationOutcome,
|
|
812
|
+
reason: str,
|
|
813
|
+
*,
|
|
814
|
+
manager: Manager | None = None,
|
|
815
|
+
manifest: str | None = None,
|
|
816
|
+
lockfile: str | None = None,
|
|
817
|
+
) -> ActivationDetection:
|
|
818
|
+
return ActivationDetection(
|
|
819
|
+
ActivationResult(outcome, manager, reason, manifest, lockfile),
|
|
820
|
+
manager,
|
|
821
|
+
manifest,
|
|
822
|
+
lockfile,
|
|
823
|
+
None,
|
|
824
|
+
None,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def detect_activation(
|
|
829
|
+
root: Path,
|
|
830
|
+
cfg: Config,
|
|
831
|
+
*,
|
|
832
|
+
local_typescript_available: bool,
|
|
833
|
+
) -> ActivationDetection:
|
|
834
|
+
"""Return ephemeral eligibility; revalidate it immediately before mutation."""
|
|
835
|
+
try:
|
|
836
|
+
canonical_root = root.resolve(strict=True)
|
|
837
|
+
if not canonical_root.is_dir():
|
|
838
|
+
raise OSError("repository root is not a directory")
|
|
839
|
+
except (OSError, RuntimeError):
|
|
840
|
+
return _terminal(ActivationOutcome.GUIDANCE_ONLY, "repository_unsafe")
|
|
841
|
+
|
|
842
|
+
tsconfig_state = _root_file_state(canonical_root, "tsconfig.json")
|
|
843
|
+
if tsconfig_state not in {"missing", "safe"}:
|
|
844
|
+
return _terminal(
|
|
845
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
846
|
+
"typescript_configuration_unsafe",
|
|
847
|
+
)
|
|
848
|
+
entry_limit = _evidence_entry_limit(cfg.max_files)
|
|
849
|
+
if entry_limit is None:
|
|
850
|
+
return _terminal(ActivationOutcome.GUIDANCE_ONLY, "invalid_file_limit")
|
|
851
|
+
evidence = (
|
|
852
|
+
_EvidenceScanOutcome.FOUND
|
|
853
|
+
if tsconfig_state == "safe"
|
|
854
|
+
else _scan_typescript_evidence(canonical_root, cfg, entry_limit)
|
|
855
|
+
)
|
|
856
|
+
if evidence is _EvidenceScanOutcome.LIMITED:
|
|
857
|
+
return _terminal(ActivationOutcome.GUIDANCE_ONLY, "evidence_scan_limited")
|
|
858
|
+
if evidence is _EvidenceScanOutcome.UNSAFE:
|
|
859
|
+
return _terminal(ActivationOutcome.GUIDANCE_ONLY, "evidence_collection_failed")
|
|
860
|
+
if evidence is _EvidenceScanOutcome.ABSENT:
|
|
861
|
+
return _terminal(ActivationOutcome.NOT_APPLICABLE, "no_typescript_evidence")
|
|
862
|
+
|
|
863
|
+
if local_typescript_available:
|
|
864
|
+
return _terminal(
|
|
865
|
+
ActivationOutcome.ALREADY_AVAILABLE,
|
|
866
|
+
"local_typescript_available",
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
manifest_name = "package.json"
|
|
870
|
+
manifest_state = _root_file_state(
|
|
871
|
+
canonical_root,
|
|
872
|
+
manifest_name,
|
|
873
|
+
maximum_size=MAX_CONTROL_FILE_BYTES,
|
|
874
|
+
)
|
|
875
|
+
if manifest_state == "missing":
|
|
876
|
+
return _terminal(ActivationOutcome.GUIDANCE_ONLY, "manifest_missing")
|
|
877
|
+
if manifest_state == "oversized":
|
|
878
|
+
return _terminal(
|
|
879
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
880
|
+
"manifest_invalid",
|
|
881
|
+
manifest=manifest_name,
|
|
882
|
+
)
|
|
883
|
+
if manifest_state != "safe":
|
|
884
|
+
return _terminal(ActivationOutcome.GUIDANCE_ONLY, "manifest_unsafe")
|
|
885
|
+
|
|
886
|
+
stable_manifest = _read_stable_control_file(canonical_root, manifest_name)
|
|
887
|
+
if stable_manifest is None:
|
|
888
|
+
return _terminal(ActivationOutcome.GUIDANCE_ONLY, "manifest_unsafe")
|
|
889
|
+
manifest_bytes, manifest_snapshot = stable_manifest
|
|
890
|
+
manifest_data = _parse_manifest(manifest_bytes)
|
|
891
|
+
if manifest_data is None:
|
|
892
|
+
return _terminal(
|
|
893
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
894
|
+
"manifest_invalid",
|
|
895
|
+
manifest=manifest_name,
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
present_lockfiles: list[tuple[str, Manager]] = []
|
|
899
|
+
for lockfile_name, lockfile_manager in _SUPPORTED_LOCKFILES:
|
|
900
|
+
state = _root_file_state(
|
|
901
|
+
canonical_root,
|
|
902
|
+
lockfile_name,
|
|
903
|
+
maximum_size=MAX_CONTROL_FILE_BYTES,
|
|
904
|
+
)
|
|
905
|
+
if state == "missing":
|
|
906
|
+
continue
|
|
907
|
+
if state != "safe":
|
|
908
|
+
return _terminal(
|
|
909
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
910
|
+
"lockfile_unsafe",
|
|
911
|
+
manifest=manifest_name,
|
|
912
|
+
)
|
|
913
|
+
present_lockfiles.append((lockfile_name, lockfile_manager))
|
|
914
|
+
|
|
915
|
+
if not present_lockfiles:
|
|
916
|
+
return _terminal(
|
|
917
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
918
|
+
"lockfile_missing",
|
|
919
|
+
manifest=manifest_name,
|
|
920
|
+
)
|
|
921
|
+
if len(present_lockfiles) != 1:
|
|
922
|
+
return _terminal(
|
|
923
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
924
|
+
"lockfile_ambiguous",
|
|
925
|
+
manifest=manifest_name,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
lockfile_name, manager = present_lockfiles[0]
|
|
929
|
+
stable_lockfile = _read_stable_control_file(canonical_root, lockfile_name)
|
|
930
|
+
if stable_lockfile is None:
|
|
931
|
+
return _terminal(
|
|
932
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
933
|
+
"lockfile_unsafe",
|
|
934
|
+
manager=manager,
|
|
935
|
+
manifest=manifest_name,
|
|
936
|
+
lockfile=lockfile_name,
|
|
937
|
+
)
|
|
938
|
+
lockfile_bytes, lockfile_snapshot = stable_lockfile
|
|
939
|
+
|
|
940
|
+
if "packageManager" in manifest_data:
|
|
941
|
+
package_manager = manifest_data["packageManager"]
|
|
942
|
+
if not isinstance(package_manager, str):
|
|
943
|
+
return _terminal(
|
|
944
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
945
|
+
"package_manager_invalid",
|
|
946
|
+
manager=manager,
|
|
947
|
+
manifest=manifest_name,
|
|
948
|
+
lockfile=lockfile_name,
|
|
949
|
+
)
|
|
950
|
+
match = _PACKAGE_MANAGER_RE.fullmatch(package_manager)
|
|
951
|
+
if match is None:
|
|
952
|
+
return _terminal(
|
|
953
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
954
|
+
"package_manager_invalid",
|
|
955
|
+
manager=manager,
|
|
956
|
+
manifest=manifest_name,
|
|
957
|
+
lockfile=lockfile_name,
|
|
958
|
+
)
|
|
959
|
+
if Manager(match.group(1)) is not manager:
|
|
960
|
+
return _terminal(
|
|
961
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
962
|
+
"package_manager_conflict",
|
|
963
|
+
manager=manager,
|
|
964
|
+
manifest=manifest_name,
|
|
965
|
+
lockfile=lockfile_name,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
adapter = adapter_for(manager)
|
|
969
|
+
if any(
|
|
970
|
+
_path_is_lexically_present(canonical_root / path)
|
|
971
|
+
for path in adapter.unsafe_root_files
|
|
972
|
+
):
|
|
973
|
+
return _terminal(
|
|
974
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
975
|
+
"manager_configuration_unsafe",
|
|
976
|
+
manager=manager,
|
|
977
|
+
manifest=manifest_name,
|
|
978
|
+
lockfile=lockfile_name,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
if not control_files_use_trusted_sources(manifest_bytes, lockfile_bytes):
|
|
982
|
+
return _terminal(
|
|
983
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
984
|
+
"dependency_source_unsafe",
|
|
985
|
+
manager=manager,
|
|
986
|
+
manifest=manifest_name,
|
|
987
|
+
lockfile=lockfile_name,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
if not adapter.automatic:
|
|
991
|
+
return _terminal(
|
|
992
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
993
|
+
"manager_guidance_only",
|
|
994
|
+
manager=manager,
|
|
995
|
+
manifest=manifest_name,
|
|
996
|
+
lockfile=lockfile_name,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
return ActivationDetection(
|
|
1000
|
+
None,
|
|
1001
|
+
manager,
|
|
1002
|
+
manifest_name,
|
|
1003
|
+
lockfile_name,
|
|
1004
|
+
manifest_snapshot,
|
|
1005
|
+
lockfile_snapshot,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def revalidate_activation_detection(
|
|
1010
|
+
root: Path,
|
|
1011
|
+
cfg: Config,
|
|
1012
|
+
expected: ActivationDetection,
|
|
1013
|
+
) -> bool:
|
|
1014
|
+
"""Recheck ephemeral detection immediately before any repository mutation."""
|
|
1015
|
+
try:
|
|
1016
|
+
current = detect_activation(
|
|
1017
|
+
root,
|
|
1018
|
+
cfg,
|
|
1019
|
+
local_typescript_available=False,
|
|
1020
|
+
)
|
|
1021
|
+
return expected.result is None and current.result is None and current == expected
|
|
1022
|
+
except Exception:
|
|
1023
|
+
return False
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _result_for_detection(
|
|
1027
|
+
detection: ActivationDetection,
|
|
1028
|
+
outcome: ActivationOutcome,
|
|
1029
|
+
reason: str,
|
|
1030
|
+
*,
|
|
1031
|
+
attempted: bool = False,
|
|
1032
|
+
changed_files: tuple[str, ...] = (),
|
|
1033
|
+
) -> ActivationResult:
|
|
1034
|
+
return ActivationResult(
|
|
1035
|
+
outcome,
|
|
1036
|
+
detection.manager,
|
|
1037
|
+
reason,
|
|
1038
|
+
detection.manifest,
|
|
1039
|
+
detection.lockfile,
|
|
1040
|
+
changed_files,
|
|
1041
|
+
attempted,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _resolve_manager_command(
|
|
1046
|
+
root: Path,
|
|
1047
|
+
manager: Manager,
|
|
1048
|
+
path_source: str,
|
|
1049
|
+
node: Any,
|
|
1050
|
+
):
|
|
1051
|
+
if os.name == "nt" and manager is Manager.NPM:
|
|
1052
|
+
return resolve_windows_npm_prefix(root, path_source)
|
|
1053
|
+
executable = resolve_trusted_executable(manager.value, root, path_source)
|
|
1054
|
+
return command_for(executable, node) if executable is not None else None
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _validated_isolated_lease(
|
|
1058
|
+
temporary: Any,
|
|
1059
|
+
root: Path,
|
|
1060
|
+
) -> _TemporaryDirectoryLease | None:
|
|
1061
|
+
try:
|
|
1062
|
+
candidate = Path(temporary.name)
|
|
1063
|
+
if not candidate.is_absolute():
|
|
1064
|
+
return None
|
|
1065
|
+
lexical = candidate.absolute()
|
|
1066
|
+
details = lexical.lstat()
|
|
1067
|
+
canonical = lexical.resolve(strict=True)
|
|
1068
|
+
canonical_root = root.resolve(strict=True)
|
|
1069
|
+
identity = _temporary_directory_identity(canonical, details)
|
|
1070
|
+
supplied_identity = getattr(temporary, "identity", None)
|
|
1071
|
+
return (
|
|
1072
|
+
_TemporaryDirectoryLease(str(canonical), identity)
|
|
1073
|
+
if (supplied_identity is None or supplied_identity == identity)
|
|
1074
|
+
and canonical == lexical
|
|
1075
|
+
and canonical != canonical_root
|
|
1076
|
+
and not _is_contained(canonical, canonical_root)
|
|
1077
|
+
and not _is_contained(canonical_root, canonical)
|
|
1078
|
+
and stat.S_ISDIR(details.st_mode)
|
|
1079
|
+
and not stat.S_ISLNK(details.st_mode)
|
|
1080
|
+
and not _is_reparse(details)
|
|
1081
|
+
else None
|
|
1082
|
+
)
|
|
1083
|
+
except (AttributeError, OSError, RuntimeError, TypeError, ValueError):
|
|
1084
|
+
return None
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _inspect_post_attempt(
|
|
1088
|
+
root: Path,
|
|
1089
|
+
cfg: Config,
|
|
1090
|
+
detection: ActivationDetection,
|
|
1091
|
+
deadline: _Deadline,
|
|
1092
|
+
) -> tuple[ActivationResult | None, tuple[str, ...]]:
|
|
1093
|
+
changed_names: set[str] = set()
|
|
1094
|
+
control_unsafe = False
|
|
1095
|
+
for before, relative_path in (
|
|
1096
|
+
(detection.manifest_snapshot, detection.manifest),
|
|
1097
|
+
(detection.lockfile_snapshot, detection.lockfile),
|
|
1098
|
+
):
|
|
1099
|
+
if relative_path is None:
|
|
1100
|
+
continue
|
|
1101
|
+
try:
|
|
1102
|
+
after = snapshot_control_file(root, relative_path)
|
|
1103
|
+
except ValueError:
|
|
1104
|
+
control_unsafe = True
|
|
1105
|
+
changed_names.add(relative_path)
|
|
1106
|
+
else:
|
|
1107
|
+
if before != after:
|
|
1108
|
+
changed_names.add(relative_path)
|
|
1109
|
+
changed = tuple(sorted(changed_names))
|
|
1110
|
+
try:
|
|
1111
|
+
deadline.remaining()
|
|
1112
|
+
except _DeadlineExpired:
|
|
1113
|
+
return (
|
|
1114
|
+
_result_for_detection(
|
|
1115
|
+
detection,
|
|
1116
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1117
|
+
"operation_timeout",
|
|
1118
|
+
attempted=True,
|
|
1119
|
+
changed_files=changed,
|
|
1120
|
+
),
|
|
1121
|
+
changed,
|
|
1122
|
+
)
|
|
1123
|
+
except _DependencyFailure:
|
|
1124
|
+
return (
|
|
1125
|
+
_result_for_detection(
|
|
1126
|
+
detection,
|
|
1127
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1128
|
+
"dependency_failed",
|
|
1129
|
+
attempted=True,
|
|
1130
|
+
changed_files=changed,
|
|
1131
|
+
),
|
|
1132
|
+
changed,
|
|
1133
|
+
)
|
|
1134
|
+
try:
|
|
1135
|
+
current = detect_activation(root, cfg, local_typescript_available=False)
|
|
1136
|
+
except Exception:
|
|
1137
|
+
current = None
|
|
1138
|
+
try:
|
|
1139
|
+
deadline.remaining()
|
|
1140
|
+
except _DeadlineExpired:
|
|
1141
|
+
return (
|
|
1142
|
+
_result_for_detection(
|
|
1143
|
+
detection,
|
|
1144
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1145
|
+
"operation_timeout",
|
|
1146
|
+
attempted=True,
|
|
1147
|
+
changed_files=changed,
|
|
1148
|
+
),
|
|
1149
|
+
changed,
|
|
1150
|
+
)
|
|
1151
|
+
except _DependencyFailure:
|
|
1152
|
+
return (
|
|
1153
|
+
_result_for_detection(
|
|
1154
|
+
detection,
|
|
1155
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1156
|
+
"dependency_failed",
|
|
1157
|
+
attempted=True,
|
|
1158
|
+
changed_files=changed,
|
|
1159
|
+
),
|
|
1160
|
+
changed,
|
|
1161
|
+
)
|
|
1162
|
+
if control_unsafe:
|
|
1163
|
+
return (
|
|
1164
|
+
_result_for_detection(
|
|
1165
|
+
detection,
|
|
1166
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1167
|
+
"control_file_unsafe",
|
|
1168
|
+
attempted=True,
|
|
1169
|
+
changed_files=changed,
|
|
1170
|
+
),
|
|
1171
|
+
changed,
|
|
1172
|
+
)
|
|
1173
|
+
if (
|
|
1174
|
+
current is None
|
|
1175
|
+
or current.result is not None
|
|
1176
|
+
or current.manager is not detection.manager
|
|
1177
|
+
or current.manifest != detection.manifest
|
|
1178
|
+
or current.lockfile != detection.lockfile
|
|
1179
|
+
):
|
|
1180
|
+
return (
|
|
1181
|
+
_result_for_detection(
|
|
1182
|
+
detection,
|
|
1183
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1184
|
+
"project_state_changed",
|
|
1185
|
+
attempted=True,
|
|
1186
|
+
changed_files=changed,
|
|
1187
|
+
),
|
|
1188
|
+
changed,
|
|
1189
|
+
)
|
|
1190
|
+
return None, changed
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _verification_result(
|
|
1194
|
+
root: Path,
|
|
1195
|
+
detection: ActivationDetection,
|
|
1196
|
+
node: Any,
|
|
1197
|
+
deadline: _Deadline,
|
|
1198
|
+
runner: Runner,
|
|
1199
|
+
changed: tuple[str, ...],
|
|
1200
|
+
) -> ActivationResult:
|
|
1201
|
+
verification_timed_out = False
|
|
1202
|
+
try:
|
|
1203
|
+
verified = probe_local_typescript(root, node, deadline.remaining(), runner)
|
|
1204
|
+
deadline.remaining()
|
|
1205
|
+
except _DeadlineExpired:
|
|
1206
|
+
verified = False
|
|
1207
|
+
verification_timed_out = True
|
|
1208
|
+
except _DependencyFailure:
|
|
1209
|
+
return _result_for_detection(
|
|
1210
|
+
detection,
|
|
1211
|
+
ActivationOutcome.VERIFICATION_FAILED,
|
|
1212
|
+
"dependency_failed",
|
|
1213
|
+
attempted=True,
|
|
1214
|
+
changed_files=changed,
|
|
1215
|
+
)
|
|
1216
|
+
if not verified:
|
|
1217
|
+
return _result_for_detection(
|
|
1218
|
+
detection,
|
|
1219
|
+
ActivationOutcome.VERIFICATION_FAILED,
|
|
1220
|
+
"operation_timeout" if verification_timed_out else "typescript_not_available",
|
|
1221
|
+
attempted=True,
|
|
1222
|
+
changed_files=changed,
|
|
1223
|
+
)
|
|
1224
|
+
return _result_for_detection(
|
|
1225
|
+
detection,
|
|
1226
|
+
ActivationOutcome.INSTALLED,
|
|
1227
|
+
"installed",
|
|
1228
|
+
attempted=True,
|
|
1229
|
+
changed_files=changed,
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _install_with_isolation(
|
|
1234
|
+
root: Path,
|
|
1235
|
+
cfg: Config,
|
|
1236
|
+
detection: ActivationDetection,
|
|
1237
|
+
node: Any,
|
|
1238
|
+
command: Any,
|
|
1239
|
+
deadline: _Deadline,
|
|
1240
|
+
dependencies: ActivationDependencies,
|
|
1241
|
+
) -> ActivationResult:
|
|
1242
|
+
temporary = None
|
|
1243
|
+
isolated_lease: _TemporaryDirectoryLease | None = None
|
|
1244
|
+
isolated_home: Path | None = None
|
|
1245
|
+
result = _result_for_detection(
|
|
1246
|
+
detection,
|
|
1247
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1248
|
+
"isolation_unavailable",
|
|
1249
|
+
attempted=True,
|
|
1250
|
+
)
|
|
1251
|
+
try:
|
|
1252
|
+
temporary = dependencies.temporary_directory()
|
|
1253
|
+
isolated_lease = _validated_isolated_lease(temporary, root)
|
|
1254
|
+
isolated_home = Path(isolated_lease.name) if isolated_lease is not None else None
|
|
1255
|
+
if isolated_lease is None:
|
|
1256
|
+
result = _result_for_detection(
|
|
1257
|
+
detection,
|
|
1258
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1259
|
+
"isolation_unavailable",
|
|
1260
|
+
attempted=True,
|
|
1261
|
+
)
|
|
1262
|
+
elif not revalidate_activation_detection(root, cfg, detection):
|
|
1263
|
+
result = _result_for_detection(
|
|
1264
|
+
detection,
|
|
1265
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1266
|
+
"project_state_changed",
|
|
1267
|
+
attempted=True,
|
|
1268
|
+
)
|
|
1269
|
+
else:
|
|
1270
|
+
install = run_install(
|
|
1271
|
+
root,
|
|
1272
|
+
command,
|
|
1273
|
+
adapter_for(detection.manager),
|
|
1274
|
+
TRUSTED_REGISTRY,
|
|
1275
|
+
isolated_home,
|
|
1276
|
+
deadline.remaining(),
|
|
1277
|
+
dependencies.runner,
|
|
1278
|
+
)
|
|
1279
|
+
inspection, changed = _inspect_post_attempt(
|
|
1280
|
+
root,
|
|
1281
|
+
cfg,
|
|
1282
|
+
detection,
|
|
1283
|
+
deadline,
|
|
1284
|
+
)
|
|
1285
|
+
if inspection is not None:
|
|
1286
|
+
result = inspection
|
|
1287
|
+
elif not install.ok:
|
|
1288
|
+
result = _result_for_detection(
|
|
1289
|
+
detection,
|
|
1290
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1291
|
+
install.reason,
|
|
1292
|
+
attempted=True,
|
|
1293
|
+
changed_files=changed,
|
|
1294
|
+
)
|
|
1295
|
+
else:
|
|
1296
|
+
result = _verification_result(
|
|
1297
|
+
root,
|
|
1298
|
+
detection,
|
|
1299
|
+
node,
|
|
1300
|
+
deadline,
|
|
1301
|
+
dependencies.runner,
|
|
1302
|
+
changed,
|
|
1303
|
+
)
|
|
1304
|
+
except _DeadlineExpired:
|
|
1305
|
+
result = _result_for_detection(
|
|
1306
|
+
detection,
|
|
1307
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1308
|
+
"operation_timeout",
|
|
1309
|
+
attempted=True,
|
|
1310
|
+
)
|
|
1311
|
+
except _DependencyFailure:
|
|
1312
|
+
result = _result_for_detection(
|
|
1313
|
+
detection,
|
|
1314
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1315
|
+
"dependency_failed",
|
|
1316
|
+
attempted=True,
|
|
1317
|
+
)
|
|
1318
|
+
except Exception:
|
|
1319
|
+
result = _result_for_detection(
|
|
1320
|
+
detection,
|
|
1321
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1322
|
+
"isolation_unavailable",
|
|
1323
|
+
attempted=True,
|
|
1324
|
+
)
|
|
1325
|
+
finally:
|
|
1326
|
+
if isolated_lease is not None:
|
|
1327
|
+
cleanup_status, expired_before_cleanup = _bounded_temporary_cleanup(
|
|
1328
|
+
isolated_lease,
|
|
1329
|
+
deadline,
|
|
1330
|
+
root,
|
|
1331
|
+
dependencies.cleanup,
|
|
1332
|
+
)
|
|
1333
|
+
if cleanup_status == "cleanup_failed":
|
|
1334
|
+
result = _result_for_detection(
|
|
1335
|
+
detection,
|
|
1336
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1337
|
+
"isolation_cleanup_failed",
|
|
1338
|
+
attempted=True,
|
|
1339
|
+
changed_files=result.changed_files,
|
|
1340
|
+
)
|
|
1341
|
+
elif (
|
|
1342
|
+
cleanup_status == "dependency_failed"
|
|
1343
|
+
and result.reason != "dependency_failed"
|
|
1344
|
+
):
|
|
1345
|
+
result = _result_for_detection(
|
|
1346
|
+
detection,
|
|
1347
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1348
|
+
"dependency_failed",
|
|
1349
|
+
attempted=True,
|
|
1350
|
+
changed_files=result.changed_files,
|
|
1351
|
+
)
|
|
1352
|
+
elif cleanup_status == "cleanup_timeout" and (
|
|
1353
|
+
not expired_before_cleanup
|
|
1354
|
+
or result.outcome is not ActivationOutcome.VERIFICATION_FAILED
|
|
1355
|
+
or result.reason not in {"operation_timeout", "dependency_failed"}
|
|
1356
|
+
):
|
|
1357
|
+
result = _result_for_detection(
|
|
1358
|
+
detection,
|
|
1359
|
+
ActivationOutcome.INSTALLATION_FAILED,
|
|
1360
|
+
"operation_timeout",
|
|
1361
|
+
attempted=True,
|
|
1362
|
+
changed_files=result.changed_files,
|
|
1363
|
+
)
|
|
1364
|
+
return result
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def activate_typescript(
|
|
1368
|
+
request: ActivationRequest,
|
|
1369
|
+
dependencies: ActivationDependencies | None = None,
|
|
1370
|
+
) -> ActivationResult:
|
|
1371
|
+
"""Apply consent and execution policy to optional TypeScript activation."""
|
|
1372
|
+
deps = dependencies or ActivationDependencies()
|
|
1373
|
+
try:
|
|
1374
|
+
canonical_root = request.root.resolve(strict=True)
|
|
1375
|
+
except (OSError, RuntimeError):
|
|
1376
|
+
return ActivationResult(
|
|
1377
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1378
|
+
None,
|
|
1379
|
+
"repository_unsafe",
|
|
1380
|
+
)
|
|
1381
|
+
acquired = _acquire_activation_lock(canonical_root)
|
|
1382
|
+
if acquired is None:
|
|
1383
|
+
return ActivationResult(
|
|
1384
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1385
|
+
None,
|
|
1386
|
+
"activation_in_progress",
|
|
1387
|
+
)
|
|
1388
|
+
key, lock = acquired
|
|
1389
|
+
try:
|
|
1390
|
+
timeout = request.timeout_seconds
|
|
1391
|
+
if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
|
|
1392
|
+
return ActivationResult(
|
|
1393
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1394
|
+
None,
|
|
1395
|
+
"invalid_timeout",
|
|
1396
|
+
)
|
|
1397
|
+
evidence = detect_activation(
|
|
1398
|
+
canonical_root,
|
|
1399
|
+
request.cfg,
|
|
1400
|
+
local_typescript_available=True,
|
|
1401
|
+
)
|
|
1402
|
+
if (
|
|
1403
|
+
evidence.result is not None
|
|
1404
|
+
and evidence.result.outcome is not ActivationOutcome.ALREADY_AVAILABLE
|
|
1405
|
+
):
|
|
1406
|
+
return evidence.result
|
|
1407
|
+
try:
|
|
1408
|
+
path_source = deps.environ.get("PATH", "")
|
|
1409
|
+
except Exception:
|
|
1410
|
+
return ActivationResult(
|
|
1411
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1412
|
+
None,
|
|
1413
|
+
"dependency_failed",
|
|
1414
|
+
)
|
|
1415
|
+
if not isinstance(path_source, str):
|
|
1416
|
+
path_source = ""
|
|
1417
|
+
node = resolve_trusted_executable("node", canonical_root, path_source)
|
|
1418
|
+
local_available = bool(
|
|
1419
|
+
node is not None
|
|
1420
|
+
and probe_local_typescript(
|
|
1421
|
+
canonical_root,
|
|
1422
|
+
node,
|
|
1423
|
+
min(float(timeout), 10.0),
|
|
1424
|
+
deps.runner,
|
|
1425
|
+
)
|
|
1426
|
+
)
|
|
1427
|
+
if local_available:
|
|
1428
|
+
return evidence.result
|
|
1429
|
+
detection = detect_activation(
|
|
1430
|
+
canonical_root,
|
|
1431
|
+
request.cfg,
|
|
1432
|
+
local_typescript_available=False,
|
|
1433
|
+
)
|
|
1434
|
+
if detection.result is not None:
|
|
1435
|
+
return detection.result
|
|
1436
|
+
if (
|
|
1437
|
+
not request.stdin_is_tty
|
|
1438
|
+
or not request.stdout_is_tty
|
|
1439
|
+
or request.json_mode
|
|
1440
|
+
or request.assume_yes
|
|
1441
|
+
):
|
|
1442
|
+
return _result_for_detection(
|
|
1443
|
+
detection,
|
|
1444
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1445
|
+
"non_interactive",
|
|
1446
|
+
)
|
|
1447
|
+
if node is None:
|
|
1448
|
+
return _result_for_detection(
|
|
1449
|
+
detection,
|
|
1450
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1451
|
+
"node_unavailable",
|
|
1452
|
+
)
|
|
1453
|
+
command = _resolve_manager_command(
|
|
1454
|
+
canonical_root,
|
|
1455
|
+
detection.manager,
|
|
1456
|
+
path_source,
|
|
1457
|
+
node,
|
|
1458
|
+
)
|
|
1459
|
+
if command is None:
|
|
1460
|
+
return _result_for_detection(
|
|
1461
|
+
detection,
|
|
1462
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1463
|
+
"manager_unavailable",
|
|
1464
|
+
)
|
|
1465
|
+
if os.name == "nt" and detection.manager is Manager.NPM:
|
|
1466
|
+
node = command.references[0]
|
|
1467
|
+
version = run_manager_version(
|
|
1468
|
+
command,
|
|
1469
|
+
canonical_root,
|
|
1470
|
+
min(float(timeout), 10.0),
|
|
1471
|
+
deps.runner,
|
|
1472
|
+
)
|
|
1473
|
+
if not version.ok:
|
|
1474
|
+
reason = {
|
|
1475
|
+
"manager_timeout": "manager_version_timeout",
|
|
1476
|
+
"manager_version_invalid": "manager_version_invalid",
|
|
1477
|
+
}.get(version.reason, "manager_version_unavailable")
|
|
1478
|
+
return _result_for_detection(
|
|
1479
|
+
detection,
|
|
1480
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1481
|
+
reason,
|
|
1482
|
+
)
|
|
1483
|
+
if not adapter_for(detection.manager).supports(version.version):
|
|
1484
|
+
return _result_for_detection(
|
|
1485
|
+
detection,
|
|
1486
|
+
ActivationOutcome.GUIDANCE_ONLY,
|
|
1487
|
+
"manager_version_unsupported",
|
|
1488
|
+
)
|
|
1489
|
+
question = (
|
|
1490
|
+
"Project-local TypeScript is missing. Install it with "
|
|
1491
|
+
f"{detection.manager.value} as a development dependency? [y/N]"
|
|
1492
|
+
)
|
|
1493
|
+
try:
|
|
1494
|
+
answer = deps.prompt(question)
|
|
1495
|
+
accepted = (
|
|
1496
|
+
isinstance(answer, str)
|
|
1497
|
+
and answer.strip().lower() in {"y", "yes"}
|
|
1498
|
+
)
|
|
1499
|
+
except EOFError:
|
|
1500
|
+
accepted = False
|
|
1501
|
+
except Exception:
|
|
1502
|
+
return _result_for_detection(
|
|
1503
|
+
detection,
|
|
1504
|
+
ActivationOutcome.DECLINED,
|
|
1505
|
+
"prompt_failed",
|
|
1506
|
+
)
|
|
1507
|
+
if not accepted:
|
|
1508
|
+
return _result_for_detection(
|
|
1509
|
+
detection,
|
|
1510
|
+
ActivationOutcome.DECLINED,
|
|
1511
|
+
"user_declined",
|
|
1512
|
+
)
|
|
1513
|
+
try:
|
|
1514
|
+
started = deps.monotonic()
|
|
1515
|
+
if not isinstance(started, (int, float)) or not math.isfinite(started):
|
|
1516
|
+
raise ValueError("clock_invalid")
|
|
1517
|
+
deadline = _Deadline(started + float(timeout), deps.monotonic)
|
|
1518
|
+
except Exception:
|
|
1519
|
+
return _result_for_detection(
|
|
1520
|
+
detection,
|
|
1521
|
+
ActivationOutcome.VALIDATION_FAILED,
|
|
1522
|
+
"dependency_failed",
|
|
1523
|
+
attempted=True,
|
|
1524
|
+
)
|
|
1525
|
+
try:
|
|
1526
|
+
validator_value = deps.environ.get("GRAPHITE_PACKAGE_VALIDATOR")
|
|
1527
|
+
except Exception:
|
|
1528
|
+
return _result_for_detection(
|
|
1529
|
+
detection,
|
|
1530
|
+
ActivationOutcome.VALIDATION_FAILED,
|
|
1531
|
+
"dependency_failed",
|
|
1532
|
+
attempted=True,
|
|
1533
|
+
)
|
|
1534
|
+
validator = (
|
|
1535
|
+
resolve_trusted_file(Path(validator_value), canonical_root, executable=False)
|
|
1536
|
+
if isinstance(validator_value, str) and validator_value
|
|
1537
|
+
else None
|
|
1538
|
+
)
|
|
1539
|
+
if validator is None:
|
|
1540
|
+
return _result_for_detection(
|
|
1541
|
+
detection,
|
|
1542
|
+
ActivationOutcome.VALIDATION_FAILED,
|
|
1543
|
+
"validator_invalid",
|
|
1544
|
+
attempted=True,
|
|
1545
|
+
)
|
|
1546
|
+
try:
|
|
1547
|
+
validation = run_validator(
|
|
1548
|
+
canonical_root,
|
|
1549
|
+
node,
|
|
1550
|
+
validator,
|
|
1551
|
+
deadline.remaining(),
|
|
1552
|
+
deps.runner,
|
|
1553
|
+
)
|
|
1554
|
+
deadline.remaining()
|
|
1555
|
+
except _DeadlineExpired:
|
|
1556
|
+
return _result_for_detection(
|
|
1557
|
+
detection,
|
|
1558
|
+
ActivationOutcome.VALIDATION_FAILED,
|
|
1559
|
+
"operation_timeout",
|
|
1560
|
+
attempted=True,
|
|
1561
|
+
)
|
|
1562
|
+
except _DependencyFailure:
|
|
1563
|
+
return _result_for_detection(
|
|
1564
|
+
detection,
|
|
1565
|
+
ActivationOutcome.VALIDATION_FAILED,
|
|
1566
|
+
"dependency_failed",
|
|
1567
|
+
attempted=True,
|
|
1568
|
+
)
|
|
1569
|
+
if not validation.ok:
|
|
1570
|
+
return _result_for_detection(
|
|
1571
|
+
detection,
|
|
1572
|
+
ActivationOutcome.VALIDATION_FAILED,
|
|
1573
|
+
validation.reason,
|
|
1574
|
+
attempted=True,
|
|
1575
|
+
)
|
|
1576
|
+
return _install_with_isolation(
|
|
1577
|
+
canonical_root,
|
|
1578
|
+
request.cfg,
|
|
1579
|
+
detection,
|
|
1580
|
+
node,
|
|
1581
|
+
command,
|
|
1582
|
+
deadline,
|
|
1583
|
+
deps,
|
|
1584
|
+
)
|
|
1585
|
+
finally:
|
|
1586
|
+
_release_activation_lock(key, lock)
|