getcodexy 1.2.2__tar.gz
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.
- getcodexy-1.2.2/PKG-INFO +8 -0
- getcodexy-1.2.2/pyproject.toml +21 -0
- getcodexy-1.2.2/src/codexy_runtime_tools/__init__.py +1 -0
- getcodexy-1.2.2/src/codexy_runtime_tools/cache.py +46 -0
- getcodexy-1.2.2/src/codexy_runtime_tools/installer.py +107 -0
- getcodexy-1.2.2/src/codexy_runtime_tools/package.py +200 -0
- getcodexy-1.2.2/src/codexy_runtime_tools/runtime.py +169 -0
getcodexy-1.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build==0.9.26"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "getcodexy"
|
|
7
|
+
version = "1.2.2"
|
|
8
|
+
description = "Version-pinned MCP runtime bootstrap tools for Codexy"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Eunsoo Lee" }]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Repository = "https://github.com/eunsoogi/codexy"
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
codexy-mcp-runtime = "codexy_runtime_tools.runtime:main"
|
|
18
|
+
|
|
19
|
+
[tool.uv.build-backend]
|
|
20
|
+
module-name = "codexy_runtime_tools"
|
|
21
|
+
module-root = "src"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Codexy runtime bootstrap package."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SEMVER = re.compile(
|
|
10
|
+
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
|
|
11
|
+
r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
|
|
12
|
+
r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?"
|
|
13
|
+
r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def plugin_release(manifest_path: Path, package_override: bool = False) -> str:
|
|
18
|
+
if not manifest_path.is_file():
|
|
19
|
+
if package_override:
|
|
20
|
+
return "package-override"
|
|
21
|
+
raise ValueError("plugin manifest is missing")
|
|
22
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
23
|
+
release = manifest.get("version") if isinstance(manifest, dict) else None
|
|
24
|
+
if not isinstance(release, str) or not SEMVER.fullmatch(release):
|
|
25
|
+
raise ValueError("plugin manifest version is invalid")
|
|
26
|
+
return release
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def releases_match(expected_manifest: Path, observed_manifest: Path) -> tuple[bool, str]:
|
|
30
|
+
try:
|
|
31
|
+
expected = plugin_release(expected_manifest)
|
|
32
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
33
|
+
return False, "runtime package release mismatch: expected valid plugin release, observed missing or invalid"
|
|
34
|
+
try:
|
|
35
|
+
observed = plugin_release(observed_manifest)
|
|
36
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
37
|
+
return False, f"runtime package release mismatch: expected {expected}, observed missing or invalid"
|
|
38
|
+
if expected != observed:
|
|
39
|
+
return False, f"runtime package release mismatch: expected {expected}, observed {observed}"
|
|
40
|
+
return True, ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def runtime_cache_key(*, manifest: Path, package_override: bool, identity: list[str]) -> str:
|
|
44
|
+
release = plugin_release(manifest, package_override)
|
|
45
|
+
digest_input = "\0".join(("codexy.runtime-cache/v2", *identity[:-1], release, identity[-1]))
|
|
46
|
+
return f"v2-{hashlib.sha256(digest_input.encode()).hexdigest()}"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NoReturn, Protocol
|
|
10
|
+
|
|
11
|
+
from .cache import releases_match
|
|
12
|
+
from .package import acquire_package, unpack_runtime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InstallConfig(Protocol):
|
|
16
|
+
server: str
|
|
17
|
+
manifest: Path
|
|
18
|
+
runtime_name: str
|
|
19
|
+
package_path: str
|
|
20
|
+
package_url: str
|
|
21
|
+
artifacts_api: str
|
|
22
|
+
package_override: bool
|
|
23
|
+
package_sha256: str
|
|
24
|
+
git_repository: str
|
|
25
|
+
git_ref: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def executable(path: Path) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
metadata = os.lstat(path)
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
return False
|
|
33
|
+
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
34
|
+
return (
|
|
35
|
+
stat.S_ISREG(metadata.st_mode)
|
|
36
|
+
and not stat.S_ISLNK(metadata.st_mode)
|
|
37
|
+
and not bool(getattr(metadata, "st_file_attributes", 0) & reparse)
|
|
38
|
+
and os.access(path, os.X_OK)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def execute(
|
|
43
|
+
path: Path | str, arguments: list[str], environment: dict[str, str] | None = None
|
|
44
|
+
) -> NoReturn:
|
|
45
|
+
command = str(path)
|
|
46
|
+
runtime_environment = os.environ.copy()
|
|
47
|
+
runtime_environment.update(environment or {})
|
|
48
|
+
os.execvpe(command, [command, *arguments], runtime_environment)
|
|
49
|
+
raise AssertionError("exec returned unexpectedly")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def install_package(config: InstallConfig, install_root: Path, installed: Path) -> None:
|
|
53
|
+
install_root.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with tempfile.TemporaryDirectory(prefix="package-", dir=install_root) as temporary:
|
|
55
|
+
work = Path(temporary)
|
|
56
|
+
archive = acquire_package(
|
|
57
|
+
path=config.package_path,
|
|
58
|
+
url=config.package_url,
|
|
59
|
+
artifacts_api=config.artifacts_api,
|
|
60
|
+
expected_sha256=config.package_sha256,
|
|
61
|
+
work=work,
|
|
62
|
+
)
|
|
63
|
+
packaged_runtime, package_manifest = unpack_runtime(
|
|
64
|
+
archive=archive, work=work, runtime_name=config.runtime_name
|
|
65
|
+
)
|
|
66
|
+
if not config.package_override:
|
|
67
|
+
matches, message = releases_match(config.manifest, package_manifest)
|
|
68
|
+
if not matches:
|
|
69
|
+
raise RuntimeError(message)
|
|
70
|
+
installed.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
temporary_runtime = installed.with_name(f".{installed.name}.{os.getpid()}.tmp")
|
|
72
|
+
shutil.copyfile(packaged_runtime, temporary_runtime)
|
|
73
|
+
temporary_runtime.chmod(
|
|
74
|
+
temporary_runtime.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
75
|
+
)
|
|
76
|
+
os.replace(temporary_runtime, installed)
|
|
77
|
+
if not config.package_override:
|
|
78
|
+
shutil.copyfile(package_manifest, install_root / "plugin.json")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def install_git(config: InstallConfig, install_root: Path, installed: Path) -> None:
|
|
82
|
+
cargo = shutil.which("cargo")
|
|
83
|
+
if not cargo:
|
|
84
|
+
raise RuntimeError("cargo is unavailable for the configured Git runtime source")
|
|
85
|
+
revision = len(config.git_ref) == 40 and all(
|
|
86
|
+
character in "0123456789abcdefABCDEF" for character in config.git_ref
|
|
87
|
+
)
|
|
88
|
+
if not revision:
|
|
89
|
+
raise RuntimeError("CODEXY_RUNTIME_GIT_REF must be an exact 40-hex commit")
|
|
90
|
+
command = [
|
|
91
|
+
cargo,
|
|
92
|
+
"install",
|
|
93
|
+
"--force",
|
|
94
|
+
"--locked",
|
|
95
|
+
"--git",
|
|
96
|
+
config.git_repository,
|
|
97
|
+
"--rev",
|
|
98
|
+
config.git_ref,
|
|
99
|
+
"--root",
|
|
100
|
+
str(install_root),
|
|
101
|
+
"--bin",
|
|
102
|
+
f"codexy-mcp-{config.server}",
|
|
103
|
+
]
|
|
104
|
+
completed = subprocess.run(command, check=False)
|
|
105
|
+
if completed.returncode or not executable(installed):
|
|
106
|
+
raise RuntimeError(f"cargo install exited with status {completed.returncode}")
|
|
107
|
+
shutil.copyfile(config.manifest, install_root / "plugin.json")
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import tarfile
|
|
10
|
+
import urllib.request
|
|
11
|
+
import zipfile
|
|
12
|
+
import zlib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_ARCHIVE_FILES = 2_048
|
|
18
|
+
MAX_UNPACKED_BYTES = 512 * 1024 * 1024
|
|
19
|
+
CANONICAL_REPOSITORY_ID = 1_269_350_143
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _GithubRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
23
|
+
def redirect_request(self, request, file_pointer, status, message, headers, new_url):
|
|
24
|
+
redirected = super().redirect_request(
|
|
25
|
+
request, file_pointer, status, message, headers, new_url
|
|
26
|
+
)
|
|
27
|
+
if redirected and _origin(request.full_url) != _origin(new_url):
|
|
28
|
+
for redirect_headers in (redirected.headers, redirected.unredirected_hdrs):
|
|
29
|
+
for name in list(redirect_headers):
|
|
30
|
+
if name.lower() == "authorization":
|
|
31
|
+
del redirect_headers[name]
|
|
32
|
+
return redirected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _download(url: str, destination: Path, token: str = "") -> None:
|
|
36
|
+
headers = {"Accept": "application/vnd.github+json"}
|
|
37
|
+
if token:
|
|
38
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
39
|
+
request = urllib.request.Request(url, headers=headers)
|
|
40
|
+
open_request = (
|
|
41
|
+
urllib.request.build_opener(_GithubRedirectHandler()).open if token else urllib.request.urlopen
|
|
42
|
+
)
|
|
43
|
+
with open_request(request, timeout=30) as response, destination.open("wb") as output:
|
|
44
|
+
shutil.copyfileobj(response, output)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
48
|
+
parsed = urlparse(url)
|
|
49
|
+
return parsed.scheme, parsed.hostname or "", parsed.port
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _trusted_github_api(url: str) -> bool:
|
|
53
|
+
return _origin(url) == ("https", "api.github.com", None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _safe_extract_tar(archive: Path, destination: Path) -> None:
|
|
57
|
+
try:
|
|
58
|
+
_extract_tar(archive, destination)
|
|
59
|
+
except (tarfile.TarError, EOFError) as error:
|
|
60
|
+
raise ValueError(f"invalid runtime package archive: {error}") from error
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extract_tar(archive: Path, destination: Path) -> None:
|
|
64
|
+
destination_resolved = destination.resolve()
|
|
65
|
+
with tarfile.open(archive, "r:gz") as package:
|
|
66
|
+
members = package.getmembers()
|
|
67
|
+
if len(members) > MAX_ARCHIVE_FILES:
|
|
68
|
+
raise ValueError("runtime package contains too many members")
|
|
69
|
+
if sum(member.size for member in members) > MAX_UNPACKED_BYTES:
|
|
70
|
+
raise ValueError("runtime package exceeds the unpacked size limit")
|
|
71
|
+
destinations: set[Path] = set()
|
|
72
|
+
for member in members:
|
|
73
|
+
if not (member.isdir() or member.isfile()):
|
|
74
|
+
raise ValueError(f"runtime package contains unsafe link or device: {member.name}")
|
|
75
|
+
member_path = (destination / member.name).resolve()
|
|
76
|
+
if destination_resolved not in member_path.parents and member_path != destination_resolved:
|
|
77
|
+
raise ValueError(f"runtime package contains unsafe path: {member.name}")
|
|
78
|
+
if member_path in destinations:
|
|
79
|
+
raise ValueError(f"runtime package contains duplicate path: {member.name}")
|
|
80
|
+
destinations.add(member_path)
|
|
81
|
+
package.extractall(destination)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _safe_extract_zip(archive: Path, destination: Path) -> None:
|
|
85
|
+
try:
|
|
86
|
+
_extract_zip(archive, destination)
|
|
87
|
+
except (zipfile.BadZipFile, zlib.error) as error:
|
|
88
|
+
raise ValueError(f"invalid artifact archive: {error}") from error
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_zip(archive: Path, destination: Path) -> None:
|
|
92
|
+
destination_resolved = destination.resolve()
|
|
93
|
+
with zipfile.ZipFile(archive) as zipped:
|
|
94
|
+
members = zipped.infolist()
|
|
95
|
+
if len(members) > MAX_ARCHIVE_FILES:
|
|
96
|
+
raise ValueError("artifact archive contains too many members")
|
|
97
|
+
if sum(member.file_size for member in members) > MAX_UNPACKED_BYTES:
|
|
98
|
+
raise ValueError("artifact archive exceeds the unpacked size limit")
|
|
99
|
+
destinations: set[Path] = set()
|
|
100
|
+
for member in members:
|
|
101
|
+
member_path = (destination / member.filename).resolve()
|
|
102
|
+
unix_mode = member.external_attr >> 16
|
|
103
|
+
if stat.S_ISLNK(unix_mode):
|
|
104
|
+
raise ValueError(f"artifact archive contains unsafe link: {member.filename}")
|
|
105
|
+
if destination_resolved not in member_path.parents and member_path != destination_resolved:
|
|
106
|
+
raise ValueError(f"artifact archive contains unsafe path: {member.filename}")
|
|
107
|
+
if member_path in destinations:
|
|
108
|
+
raise ValueError(f"artifact archive contains duplicate path: {member.filename}")
|
|
109
|
+
destinations.add(member_path)
|
|
110
|
+
zipped.extractall(destination)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _github_token_for(url: str) -> str:
|
|
114
|
+
if not _trusted_github_api(url):
|
|
115
|
+
return ""
|
|
116
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
117
|
+
if token:
|
|
118
|
+
return token
|
|
119
|
+
try:
|
|
120
|
+
return subprocess.run(
|
|
121
|
+
["gh", "auth", "token"],
|
|
122
|
+
check=True,
|
|
123
|
+
stdout=subprocess.PIPE,
|
|
124
|
+
stderr=subprocess.DEVNULL,
|
|
125
|
+
text=True,
|
|
126
|
+
).stdout.strip()
|
|
127
|
+
except (OSError, subprocess.CalledProcessError):
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _artifact_package(api_url: str, work: Path) -> Path:
|
|
132
|
+
token = _github_token_for(api_url)
|
|
133
|
+
metadata = work / "artifacts.json"
|
|
134
|
+
_download(api_url, metadata, token)
|
|
135
|
+
payload = json.loads(metadata.read_text(encoding="utf-8"))
|
|
136
|
+
artifacts = payload.get("artifacts") if isinstance(payload, dict) else None
|
|
137
|
+
if not isinstance(artifacts, list):
|
|
138
|
+
raise RuntimeError("artifact source has invalid artifacts listing")
|
|
139
|
+
selected = next(
|
|
140
|
+
(
|
|
141
|
+
item
|
|
142
|
+
for item in artifacts
|
|
143
|
+
if isinstance(item, dict)
|
|
144
|
+
and not item.get("expired", True)
|
|
145
|
+
and isinstance(item.get("workflow_run"), dict)
|
|
146
|
+
and item["workflow_run"].get("head_branch") == "main"
|
|
147
|
+
and item["workflow_run"].get("head_repository_id") == CANONICAL_REPOSITORY_ID
|
|
148
|
+
and isinstance(item.get("archive_download_url"), str)
|
|
149
|
+
),
|
|
150
|
+
None,
|
|
151
|
+
)
|
|
152
|
+
if selected is None:
|
|
153
|
+
raise RuntimeError("artifact source has no unexpired main-branch package")
|
|
154
|
+
archive = work / "artifact.zip"
|
|
155
|
+
download_url = selected["archive_download_url"]
|
|
156
|
+
if token and not (_trusted_github_api(download_url) or _origin(download_url) == ("https", "github.com", None)):
|
|
157
|
+
raise RuntimeError("artifact download URL is not a trusted GitHub host")
|
|
158
|
+
_download(download_url, archive, token)
|
|
159
|
+
artifact_root = work / "artifact"
|
|
160
|
+
_safe_extract_zip(archive, artifact_root)
|
|
161
|
+
matches = list(artifact_root.rglob("codexy-marketplace-plugin.tar.gz"))
|
|
162
|
+
if len(matches) != 1:
|
|
163
|
+
raise RuntimeError("artifact must contain exactly one marketplace package")
|
|
164
|
+
return matches[0]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def acquire_package(
|
|
168
|
+
*, path: str, url: str, artifacts_api: str, expected_sha256: str, work: Path
|
|
169
|
+
) -> Path:
|
|
170
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
archive = work / "codexy-marketplace-plugin.tar.gz"
|
|
172
|
+
if path:
|
|
173
|
+
source = Path(path)
|
|
174
|
+
if not source.is_absolute():
|
|
175
|
+
raise ValueError(f"runtime package path must be absolute: {source}")
|
|
176
|
+
shutil.copyfile(source, archive)
|
|
177
|
+
elif url:
|
|
178
|
+
_download(url, archive)
|
|
179
|
+
elif artifacts_api:
|
|
180
|
+
shutil.copyfile(_artifact_package(artifacts_api, work), archive)
|
|
181
|
+
else:
|
|
182
|
+
raise RuntimeError("no runtime package source was configured")
|
|
183
|
+
if expected_sha256:
|
|
184
|
+
observed = hashlib.sha256(archive.read_bytes()).hexdigest()
|
|
185
|
+
if observed != expected_sha256.lower():
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"runtime package SHA-256 mismatch: expected {expected_sha256.lower()}, observed {observed}"
|
|
188
|
+
)
|
|
189
|
+
return archive
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def unpack_runtime(*, archive: Path, work: Path, runtime_name: str) -> tuple[Path, Path]:
|
|
193
|
+
extracted = work / "package"
|
|
194
|
+
extracted.mkdir()
|
|
195
|
+
_safe_extract_tar(archive, extracted)
|
|
196
|
+
runtime = extracted / "plugins" / "codexy" / "runtime" / runtime_name
|
|
197
|
+
manifest = extracted / "plugins" / "codexy" / ".codex-plugin" / "plugin.json"
|
|
198
|
+
if not runtime.is_file() or runtime.is_symlink() or not manifest.is_file() or manifest.is_symlink():
|
|
199
|
+
raise RuntimeError("runtime package is missing its exact runtime binary or plugin manifest")
|
|
200
|
+
return runtime, manifest
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import platform as host_platform
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from .cache import plugin_release, releases_match, runtime_cache_key
|
|
12
|
+
from .installer import executable, execute, install_git, install_package
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SUPPORTED_PLATFORMS = ("darwin-arm64", "linux-x86_64")
|
|
16
|
+
PROTOCOL = "stdio-newline-v1"
|
|
17
|
+
REPOSITORY = "https://github.com/eunsoogi/codexy"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fail(message: str) -> NoReturn:
|
|
21
|
+
print(message, file=sys.stderr)
|
|
22
|
+
raise SystemExit(127)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _notice(message: str) -> None:
|
|
26
|
+
print(f"codexy runtime: {message}", file=sys.stderr)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _host_platform() -> str:
|
|
30
|
+
override = os.environ.get("CODEXY_RUNTIME_PLATFORM")
|
|
31
|
+
if override:
|
|
32
|
+
return override
|
|
33
|
+
os_name = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get(
|
|
34
|
+
host_platform.system(), "unknown"
|
|
35
|
+
)
|
|
36
|
+
architecture = {
|
|
37
|
+
"arm64": "arm64", "aarch64": "arm64", "x86_64": "x86_64",
|
|
38
|
+
"amd64": "x86_64", "AMD64": "x86_64",
|
|
39
|
+
}.get(host_platform.machine(), "unknown")
|
|
40
|
+
return f"{os_name}-{architecture}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _absolute_env_path(name: str) -> Path | None:
|
|
44
|
+
value = os.environ.get(name)
|
|
45
|
+
if not value:
|
|
46
|
+
return None
|
|
47
|
+
path = Path(value)
|
|
48
|
+
if not path.is_absolute():
|
|
49
|
+
_fail(f"{name} must be absolute: {path}")
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Configuration:
|
|
55
|
+
server: str
|
|
56
|
+
plugin_root: Path
|
|
57
|
+
arguments: list[str]
|
|
58
|
+
platform: str
|
|
59
|
+
manifest: Path
|
|
60
|
+
release: str
|
|
61
|
+
runtime_name: str
|
|
62
|
+
package_path: str
|
|
63
|
+
package_url: str
|
|
64
|
+
artifacts_api: str
|
|
65
|
+
package_override: bool
|
|
66
|
+
package_sha256: str
|
|
67
|
+
git_repository: str
|
|
68
|
+
git_ref: str
|
|
69
|
+
offline: bool
|
|
70
|
+
git_fallback: bool
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def load(cls, server: str, plugin_root: Path, arguments: list[str]) -> "Configuration":
|
|
74
|
+
manifest = plugin_root / ".codex-plugin/plugin.json"
|
|
75
|
+
try:
|
|
76
|
+
release = plugin_release(manifest)
|
|
77
|
+
except (OSError, ValueError) as error:
|
|
78
|
+
_fail(f"codexy-mcp-{server} cannot read plugin release: {error}")
|
|
79
|
+
package_path_was_set = "CODEXY_RUNTIME_PACKAGE_PATH" in os.environ
|
|
80
|
+
package_path = os.environ.get("CODEXY_RUNTIME_PACKAGE_PATH", "")
|
|
81
|
+
package_url_was_set = "CODEXY_RUNTIME_PACKAGE_URL" in os.environ
|
|
82
|
+
artifacts_was_set = "CODEXY_RUNTIME_ARTIFACTS_API_URL" in os.environ
|
|
83
|
+
package_url = os.environ.get("CODEXY_RUNTIME_PACKAGE_URL", "")
|
|
84
|
+
artifacts_api = os.environ.get("CODEXY_RUNTIME_ARTIFACTS_API_URL", "")
|
|
85
|
+
package_override = bool(package_path_was_set or package_url_was_set or artifacts_was_set)
|
|
86
|
+
package_sha256 = os.environ.get("CODEXY_RUNTIME_PACKAGE_SHA256", "").lower()
|
|
87
|
+
if package_override and (
|
|
88
|
+
len(package_sha256) != 64
|
|
89
|
+
or any(character not in "0123456789abcdefABCDEF" for character in package_sha256)
|
|
90
|
+
):
|
|
91
|
+
_fail("explicit runtime package source requires CODEXY_RUNTIME_PACKAGE_SHA256")
|
|
92
|
+
if not package_override:
|
|
93
|
+
package_url = f"{REPOSITORY}/releases/download/v{release}/codexy-marketplace-plugin.tar.gz"
|
|
94
|
+
return cls(
|
|
95
|
+
server=server, plugin_root=plugin_root, arguments=arguments,
|
|
96
|
+
platform=_host_platform(), manifest=manifest, release=release,
|
|
97
|
+
runtime_name=f"codexy-mcp-{server}-{_host_platform()}.bin",
|
|
98
|
+
package_path=package_path, package_url=package_url, artifacts_api=artifacts_api,
|
|
99
|
+
package_override=package_override, package_sha256=package_sha256,
|
|
100
|
+
git_repository=os.environ.get("CODEXY_RUNTIME_GIT_REPOSITORY", REPOSITORY),
|
|
101
|
+
git_ref=os.environ.get("CODEXY_RUNTIME_GIT_REF", ""),
|
|
102
|
+
offline=os.environ.get("UV_OFFLINE", "").lower() in {"1", "true", "yes"},
|
|
103
|
+
git_fallback=os.environ.get("CODEXY_RUNTIME_GIT_FALLBACK") == "1",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cache_root(server: str) -> Path:
|
|
108
|
+
explicit = _absolute_env_path("CODEXY_RUNTIME_CACHE_DIR")
|
|
109
|
+
if explicit:
|
|
110
|
+
return explicit
|
|
111
|
+
xdg, home = os.environ.get("XDG_CACHE_HOME"), os.environ.get("HOME")
|
|
112
|
+
if not xdg and not home:
|
|
113
|
+
_fail(f"codexy-mcp-{server} cannot bootstrap runtime without HOME, XDG_CACHE_HOME, or CODEXY_RUNTIME_CACHE_DIR")
|
|
114
|
+
root = Path(xdg) if xdg else Path(home or "") / ".cache"
|
|
115
|
+
if not root.is_absolute():
|
|
116
|
+
_fail(f"codexy-mcp-{server} runtime cache dir must be absolute: {root}")
|
|
117
|
+
return root / "codexy" / "runtime"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _execute(config: Configuration, path: Path) -> NoReturn:
|
|
121
|
+
execute(path, config.arguments, {"CODEXY_PLUGIN_ROOT": str(config.plugin_root)})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run(config: Configuration) -> NoReturn:
|
|
125
|
+
runtime_dir = _absolute_env_path("CODEXY_RUNTIME_DIR")
|
|
126
|
+
if runtime_dir:
|
|
127
|
+
runtime = runtime_dir / config.runtime_name
|
|
128
|
+
if not executable(runtime):
|
|
129
|
+
_fail(f"codexy-mcp-{config.server} runtime not found in CODEXY_RUNTIME_DIR: {runtime}")
|
|
130
|
+
_execute(config, runtime)
|
|
131
|
+
if config.platform not in SUPPORTED_PLATFORMS:
|
|
132
|
+
_fail(f"codexy-mcp-{config.server} bundled runtime supports: {' '.join(SUPPORTED_PLATFORMS)}; set CODEXY_RUNTIME_DIR for {config.platform}")
|
|
133
|
+
bundled = config.plugin_root / "runtime" / config.runtime_name
|
|
134
|
+
if executable(bundled):
|
|
135
|
+
_execute(config, bundled)
|
|
136
|
+
source = "\n".join(("package-override", config.package_path, config.package_url, config.artifacts_api, config.package_sha256)) if config.package_override else "\n".join(("package-default", config.package_sha256))
|
|
137
|
+
key = runtime_cache_key(manifest=config.manifest, package_override=config.package_override, identity=[config.git_repository, config.git_ref, config.platform, PROTOCOL, source, f"codexy-mcp-{config.server}"])
|
|
138
|
+
install_root = _cache_root(config.server) / key
|
|
139
|
+
installed, marker = install_root / "bin" / f"codexy-mcp-{config.server}", install_root / "plugin.json"
|
|
140
|
+
if executable(installed) and config.package_override:
|
|
141
|
+
_execute(config, installed)
|
|
142
|
+
if executable(installed) and marker.is_file() and releases_match(config.manifest, marker)[0]:
|
|
143
|
+
_execute(config, installed)
|
|
144
|
+
if config.offline:
|
|
145
|
+
_fail(f"codexy-mcp-{config.server} offline mode has no cached or bundled runtime for {config.platform}")
|
|
146
|
+
try:
|
|
147
|
+
_notice(f"acquiring exact release package v{config.release} for {config.server}")
|
|
148
|
+
install_package(config, install_root, installed)
|
|
149
|
+
_execute(config, installed)
|
|
150
|
+
except (OSError, RuntimeError, ValueError) as package_error:
|
|
151
|
+
if config.package_override:
|
|
152
|
+
_fail(f"codexy-mcp-{config.server} explicit package source failed: {package_error}")
|
|
153
|
+
if not config.git_fallback:
|
|
154
|
+
_fail(f"codexy-mcp-{config.server} exact release package failed: {package_error}")
|
|
155
|
+
_notice(f"release package failed ({package_error}); explicit Git fallback uses {config.git_ref}")
|
|
156
|
+
try:
|
|
157
|
+
install_git(config, install_root, installed)
|
|
158
|
+
_execute(config, installed)
|
|
159
|
+
except (OSError, RuntimeError) as git_error:
|
|
160
|
+
_fail(f"codexy-mcp-{config.server} pinned Git runtime failed: {git_error}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main() -> None:
|
|
164
|
+
parser = argparse.ArgumentParser(prog="codexy-mcp-runtime")
|
|
165
|
+
parser.add_argument("server", choices=("lsp", "codegraph"))
|
|
166
|
+
parser.add_argument("--plugin-root", type=Path, required=True)
|
|
167
|
+
parsed, arguments = parser.parse_known_args()
|
|
168
|
+
arguments = arguments[1:] if arguments[:1] == ["--"] else arguments
|
|
169
|
+
run(Configuration.load(parsed.server, parsed.plugin_root.resolve(), arguments))
|