getcodexy 1.2.2__tar.gz → 1.3.0__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 → getcodexy-1.3.0}/PKG-INFO +1 -1
- {getcodexy-1.2.2 → getcodexy-1.3.0}/pyproject.toml +1 -1
- getcodexy-1.3.0/src/codexy_runtime_tools/contract.py +149 -0
- getcodexy-1.3.0/src/codexy_runtime_tools/identity.py +89 -0
- getcodexy-1.3.0/src/codexy_runtime_tools/installer.py +140 -0
- {getcodexy-1.2.2 → getcodexy-1.3.0}/src/codexy_runtime_tools/package.py +8 -6
- getcodexy-1.3.0/src/codexy_runtime_tools/plugin_resolution.py +131 -0
- getcodexy-1.3.0/src/codexy_runtime_tools/pre_session.py +156 -0
- getcodexy-1.3.0/src/codexy_runtime_tools/runtime.py +245 -0
- getcodexy-1.3.0/src/codexy_runtime_tools/source.py +117 -0
- getcodexy-1.3.0/src/codexy_runtime_tools/updater.py +177 -0
- getcodexy-1.2.2/src/codexy_runtime_tools/installer.py +0 -107
- getcodexy-1.2.2/src/codexy_runtime_tools/runtime.py +0 -169
- {getcodexy-1.2.2 → getcodexy-1.3.0}/src/codexy_runtime_tools/__init__.py +0 -0
- {getcodexy-1.2.2 → getcodexy-1.3.0}/src/codexy_runtime_tools/cache.py +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Immutable, standalone runtime-release contract validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import tarfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .identity import Compatibility, compatibility, digest, document, object, platforms, string
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REPOSITORY = "https://github.com/eunsoogi/codexy"
|
|
17
|
+
RELEASE_SCHEMA = "codexy-runtime-release/v1"
|
|
18
|
+
CANDIDATE_SCHEMA = "codexy-runtime-candidate/v1"
|
|
19
|
+
_COMMIT = re.compile(r"[0-9a-f]{40}\Z")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Source:
|
|
24
|
+
repository: str
|
|
25
|
+
commit: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Artifact:
|
|
30
|
+
tag: str
|
|
31
|
+
url: str
|
|
32
|
+
sha256: str
|
|
33
|
+
payload_manifest_sha256: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RuntimeRelease:
|
|
38
|
+
state: str
|
|
39
|
+
source: Source
|
|
40
|
+
artifact: Artifact
|
|
41
|
+
compatibility: Compatibility
|
|
42
|
+
platforms: dict[str, dict[str, dict[str, str]]]
|
|
43
|
+
|
|
44
|
+
def advertises(self, *, platform: str) -> bool:
|
|
45
|
+
return platform in self.platforms
|
|
46
|
+
|
|
47
|
+
def supports(self, *, server: str, platform: str, bootstrap_api: int,
|
|
48
|
+
plugin_runtime_api: int, transport: str, mcp_protocol: str) -> bool:
|
|
49
|
+
return (
|
|
50
|
+
server in self.platforms.get(platform, {})
|
|
51
|
+
and self.compatibility == Compatibility(
|
|
52
|
+
bootstrap_api, plugin_runtime_api, transport, mcp_protocol
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def cache_key(self, *, platform: str, server: str) -> str:
|
|
57
|
+
return "v3-" + hashlib.sha256(_encoded(self.identity(platform=platform, server=server))).hexdigest()
|
|
58
|
+
|
|
59
|
+
def identity(self, *, platform: str, server: str) -> dict[str, Any]:
|
|
60
|
+
binary = self.platforms.get(platform, {}).get(server)
|
|
61
|
+
if binary is None:
|
|
62
|
+
raise ValueError("runtime release does not advertise the selected binary")
|
|
63
|
+
return {"schema": RELEASE_SCHEMA, "state": self.state, "source": self.source.__dict__,
|
|
64
|
+
"artifact": self.artifact.__dict__, "compatibility": self.compatibility.__dict__,
|
|
65
|
+
"platform": platform, "server": server, "binarySha256": binary["sha256"]}
|
|
66
|
+
|
|
67
|
+
def marker(self, *, platform: str, server: str, binary_sha256: str) -> dict[str, Any]:
|
|
68
|
+
return {"schema": "codexy-runtime-marker/v1", "identity": self.identity(platform=platform, server=server),
|
|
69
|
+
"installedBinarySha256": binary_sha256}
|
|
70
|
+
|
|
71
|
+
def valid_marker(self, marker: Any, *, platform: str, server: str, binary: bytes) -> bool:
|
|
72
|
+
return marker == self.marker(platform=platform, server=server,
|
|
73
|
+
binary_sha256=hashlib.sha256(binary).hexdigest())
|
|
74
|
+
|
|
75
|
+
def verify_archive(self, archive: Path, *, platform: str) -> bool:
|
|
76
|
+
if self.state == "legacy-public":
|
|
77
|
+
return True
|
|
78
|
+
try:
|
|
79
|
+
with tarfile.open(archive, "r:gz") as package:
|
|
80
|
+
names = [member.name for member in package.getmembers()]
|
|
81
|
+
if len({name.casefold() for name in names}) != len(names):
|
|
82
|
+
raise ValueError("runtime archive has duplicate or casefold paths")
|
|
83
|
+
package.getmember("plugins/codexy/.codex-plugin/plugin.json")
|
|
84
|
+
candidate = document(package.extractfile("plugins/codexy/runtime-candidate.json").read())
|
|
85
|
+
if _canonical(candidate) != self.artifact.payload_manifest_sha256:
|
|
86
|
+
raise ValueError("runtime candidate digest does not match release")
|
|
87
|
+
_validate_candidate(candidate, self, package, platform)
|
|
88
|
+
except (AttributeError, KeyError, OSError, tarfile.TarError, TypeError, json.JSONDecodeError) as error:
|
|
89
|
+
raise ValueError(f"invalid runtime candidate: {error}") from error
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _canonical(value: Any) -> str:
|
|
94
|
+
return hashlib.sha256(_encoded(value)).hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _encoded(value: Any) -> bytes:
|
|
98
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load(plugin_root: Path) -> RuntimeRelease:
|
|
102
|
+
path = plugin_root / "runtime-release.json"
|
|
103
|
+
try:
|
|
104
|
+
value = document(path.read_text(encoding="utf-8"))
|
|
105
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
106
|
+
raise ValueError(f"runtime release is missing or invalid: {error}") from error
|
|
107
|
+
value = object(value, "document")
|
|
108
|
+
if set(value) != {"schema", "state", "source", "artifact", "compatibility", "platforms"}:
|
|
109
|
+
raise ValueError("runtime release has unknown or missing fields")
|
|
110
|
+
if value.get("schema") != RELEASE_SCHEMA:
|
|
111
|
+
raise ValueError("runtime release schema must be codexy-runtime-release/v1")
|
|
112
|
+
state = value.get("state")
|
|
113
|
+
if state not in {"legacy-public", "candidate-proven"}:
|
|
114
|
+
raise ValueError("runtime release state must be legacy-public or candidate-proven")
|
|
115
|
+
source = object(value.get("source"), "source")
|
|
116
|
+
if set(source) != {"repository", "commit"}:
|
|
117
|
+
raise ValueError("runtime release source has unknown or missing fields")
|
|
118
|
+
commit = string(source.get("commit"), "source.commit")
|
|
119
|
+
if source.get("repository") != REPOSITORY or not _COMMIT.fullmatch(commit):
|
|
120
|
+
raise ValueError("runtime release source must use the canonical repository and lowercase commit")
|
|
121
|
+
artifact = object(value.get("artifact"), "artifact")
|
|
122
|
+
if set(artifact) != {"tag", "url", "sha256", "payloadManifestSha256"}:
|
|
123
|
+
raise ValueError("runtime release artifact has unknown or missing fields")
|
|
124
|
+
tag = string(artifact.get("tag"), "artifact.tag")
|
|
125
|
+
url = string(artifact.get("url"), "artifact.url")
|
|
126
|
+
if url != f"{REPOSITORY}/releases/download/{tag}/codexy-marketplace-plugin.tar.gz":
|
|
127
|
+
raise ValueError("runtime release artifact URL is not canonical")
|
|
128
|
+
return RuntimeRelease(state, Source(REPOSITORY, commit), Artifact(tag, url,
|
|
129
|
+
digest(artifact.get("sha256"), "artifact.sha256"),
|
|
130
|
+
digest(artifact.get("payloadManifestSha256"), "artifact.payloadManifestSha256")),
|
|
131
|
+
compatibility(value.get("compatibility")),
|
|
132
|
+
platforms(value.get("platforms"), require_path=state == "candidate-proven"))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_candidate(candidate: Any, release: RuntimeRelease, package: tarfile.TarFile, platform: str) -> None:
|
|
136
|
+
candidate = object(candidate, "candidate")
|
|
137
|
+
if release.state != "candidate-proven":
|
|
138
|
+
raise ValueError("legacy runtime release has no candidate payload")
|
|
139
|
+
if set(candidate) != {"schema", "source", "artifact", "compatibility", "platforms"} or candidate.get("schema") != CANDIDATE_SCHEMA or candidate.get("source") != {"repository": release.source.repository, "commit": release.source.commit}:
|
|
140
|
+
raise ValueError("runtime candidate identity does not match release")
|
|
141
|
+
if candidate.get("artifact") != {"tag": release.artifact.tag} or compatibility(candidate.get("compatibility")) != release.compatibility:
|
|
142
|
+
raise ValueError("runtime candidate metadata does not match release")
|
|
143
|
+
inventory = platforms(candidate.get("platforms"), require_path=True)
|
|
144
|
+
if inventory != release.platforms or platform not in inventory:
|
|
145
|
+
raise ValueError("runtime candidate inventory does not match release")
|
|
146
|
+
for binary in inventory[platform].values():
|
|
147
|
+
member = package.extractfile(f"plugins/codexy/{binary['path']}")
|
|
148
|
+
if member is None or hashlib.sha256(member.read()).hexdigest() != binary["sha256"]:
|
|
149
|
+
raise ValueError("runtime candidate binary digest does not match")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Strict JSON and normalized runtime inventory primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
|
|
12
|
+
PLATFORMS = {"darwin-arm64", "linux-x86_64"}
|
|
13
|
+
SERVERS = {"lsp", "codegraph"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Compatibility:
|
|
18
|
+
bootstrap_api: int
|
|
19
|
+
plugin_runtime_api: int
|
|
20
|
+
transport: str
|
|
21
|
+
mcp_protocol: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def document(text: str) -> Any:
|
|
25
|
+
def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
26
|
+
value: dict[str, Any] = {}
|
|
27
|
+
for key, item in items:
|
|
28
|
+
if key in value:
|
|
29
|
+
raise ValueError(f"runtime release has duplicate JSON key: {key}")
|
|
30
|
+
value[key] = item
|
|
31
|
+
return value
|
|
32
|
+
return json.loads(text, object_pairs_hook=pairs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def object(value: Any, name: str) -> dict[str, Any]:
|
|
36
|
+
if not isinstance(value, dict):
|
|
37
|
+
raise ValueError(f"runtime release {name} must be an object")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def string(value: Any, name: str) -> str:
|
|
42
|
+
if not isinstance(value, str) or not value:
|
|
43
|
+
raise ValueError(f"runtime release {name} must be a non-empty string")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def digest(value: Any, name: str) -> str:
|
|
48
|
+
value = string(value, name)
|
|
49
|
+
if not _DIGEST.fullmatch(value):
|
|
50
|
+
raise ValueError(f"runtime release {name} must be a lowercase SHA-256")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compatibility(value: Any) -> Compatibility:
|
|
55
|
+
value = object(value, "compatibility")
|
|
56
|
+
if set(value) != {"bootstrapApi", "pluginRuntimeApi", "transport", "mcpProtocol"}:
|
|
57
|
+
raise ValueError("runtime release compatibility has unknown or missing fields")
|
|
58
|
+
if value.get("bootstrapApi") != 1 or value.get("pluginRuntimeApi") != 1:
|
|
59
|
+
raise ValueError("runtime release compatibility APIs must be 1")
|
|
60
|
+
if value.get("transport") != "stdio-newline-v1" or value.get("mcpProtocol") != "2024-11-05":
|
|
61
|
+
raise ValueError("runtime release compatibility transport or MCP protocol is unsupported")
|
|
62
|
+
return Compatibility(1, 1, "stdio-newline-v1", "2024-11-05")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def platforms(value: Any, *, require_path: bool) -> dict[str, dict[str, dict[str, str]]]:
|
|
66
|
+
value = object(value, "platforms")
|
|
67
|
+
if set(value) != PLATFORMS:
|
|
68
|
+
raise ValueError("runtime release has unknown or missing platform")
|
|
69
|
+
result: dict[str, dict[str, dict[str, str]]] = {}
|
|
70
|
+
for platform, inventory in value.items():
|
|
71
|
+
inventory = object(inventory, f"platforms.{platform}")
|
|
72
|
+
if set(inventory) != SERVERS:
|
|
73
|
+
raise ValueError("runtime release has unknown or missing server")
|
|
74
|
+
binaries: dict[str, dict[str, str]] = {}
|
|
75
|
+
for server, item in inventory.items():
|
|
76
|
+
item = object(item, "binary")
|
|
77
|
+
fields = {"path", "sha256"} if require_path else {"sha256"}
|
|
78
|
+
if set(item) != fields:
|
|
79
|
+
raise ValueError("runtime release binary has unknown or missing fields")
|
|
80
|
+
binary = {"sha256": digest(item.get("sha256"), "binary.sha256")}
|
|
81
|
+
if require_path:
|
|
82
|
+
path = string(item.get("path"), "binary.path")
|
|
83
|
+
expected = f"runtime/codexy-mcp-{server}-{platform}.bin"
|
|
84
|
+
if path != expected or path.casefold() != path:
|
|
85
|
+
raise ValueError("runtime release binary path is not canonical")
|
|
86
|
+
binary["path"] = path
|
|
87
|
+
binaries[server] = binary
|
|
88
|
+
result[platform] = binaries
|
|
89
|
+
return result
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NoReturn, Protocol
|
|
11
|
+
|
|
12
|
+
from .cache import releases_match
|
|
13
|
+
from .package import acquire_package, unpack_runtime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InstallConfig(Protocol):
|
|
17
|
+
server: str
|
|
18
|
+
manifest: Path
|
|
19
|
+
runtime_name: str
|
|
20
|
+
package_path: str
|
|
21
|
+
package_url: str
|
|
22
|
+
artifacts_api: str
|
|
23
|
+
package_override: bool
|
|
24
|
+
package_sha256: str
|
|
25
|
+
git_repository: str
|
|
26
|
+
git_ref: str
|
|
27
|
+
source_identity: object
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def executable(path: Path) -> bool:
|
|
31
|
+
try:
|
|
32
|
+
metadata = os.lstat(path)
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
return False
|
|
35
|
+
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
36
|
+
return (
|
|
37
|
+
stat.S_ISREG(metadata.st_mode)
|
|
38
|
+
and not stat.S_ISLNK(metadata.st_mode)
|
|
39
|
+
and not bool(getattr(metadata, "st_file_attributes", 0) & reparse)
|
|
40
|
+
and os.access(path, os.X_OK)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _publish_executable(
|
|
45
|
+
source: Path, destination: Path, *, require_staged_executable: bool = True
|
|
46
|
+
) -> None:
|
|
47
|
+
if require_staged_executable and not executable(source):
|
|
48
|
+
raise RuntimeError(f"staged runtime is not executable: {source}")
|
|
49
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
|
51
|
+
try:
|
|
52
|
+
shutil.copyfile(source, temporary)
|
|
53
|
+
mode = (
|
|
54
|
+
stat.S_IMODE(source.stat().st_mode) & 0o777
|
|
55
|
+
if require_staged_executable
|
|
56
|
+
else 0o755
|
|
57
|
+
)
|
|
58
|
+
temporary.chmod(mode)
|
|
59
|
+
if not executable(temporary):
|
|
60
|
+
raise RuntimeError(f"copied runtime is not executable: {temporary}")
|
|
61
|
+
os.replace(temporary, destination)
|
|
62
|
+
finally:
|
|
63
|
+
temporary.unlink(missing_ok=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def execute(
|
|
67
|
+
path: Path | str, arguments: list[str], environment: dict[str, str] | None = None
|
|
68
|
+
) -> NoReturn:
|
|
69
|
+
command = str(path)
|
|
70
|
+
runtime_environment = os.environ.copy()
|
|
71
|
+
runtime_environment.update(environment or {})
|
|
72
|
+
os.execvpe(command, [command, *arguments], runtime_environment)
|
|
73
|
+
raise AssertionError("exec returned unexpectedly")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def install_package(config: InstallConfig, install_root: Path, installed: Path) -> None:
|
|
77
|
+
install_root.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
with tempfile.TemporaryDirectory(prefix="package-", dir=install_root) as temporary:
|
|
79
|
+
work = Path(temporary)
|
|
80
|
+
archive = acquire_package(
|
|
81
|
+
path=config.package_path,
|
|
82
|
+
url=config.package_url,
|
|
83
|
+
artifacts_api=config.artifacts_api,
|
|
84
|
+
expected_sha256=config.package_sha256,
|
|
85
|
+
work=work,
|
|
86
|
+
)
|
|
87
|
+
source_identity = getattr(config, "source_identity", None)
|
|
88
|
+
release_contract = getattr(config, "release_contract", None)
|
|
89
|
+
if source_identity is not None:
|
|
90
|
+
source_identity.verify_archive(archive, platform=config.platform)
|
|
91
|
+
elif release_contract is not None:
|
|
92
|
+
release_contract.verify_archive(archive, platform=config.platform)
|
|
93
|
+
packaged_runtime, package_manifest = unpack_runtime(
|
|
94
|
+
archive=archive, work=work, runtime_name=config.runtime_name
|
|
95
|
+
)
|
|
96
|
+
if not config.package_override and release_contract is None:
|
|
97
|
+
matches, message = releases_match(config.manifest, package_manifest)
|
|
98
|
+
if not matches:
|
|
99
|
+
raise RuntimeError(message)
|
|
100
|
+
_publish_executable(
|
|
101
|
+
packaged_runtime, installed, require_staged_executable=False
|
|
102
|
+
)
|
|
103
|
+
if not config.package_override:
|
|
104
|
+
shutil.copyfile(package_manifest, install_root / "plugin.json")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def install_git(config: InstallConfig, install_root: Path, installed: Path) -> None:
|
|
108
|
+
cargo = shutil.which("cargo")
|
|
109
|
+
if not cargo:
|
|
110
|
+
raise RuntimeError("cargo is unavailable for the configured Git runtime source")
|
|
111
|
+
if config.git_repository != "https://github.com/eunsoogi/codexy" or not re.fullmatch(r"[0-9a-f]{40}", config.git_ref):
|
|
112
|
+
raise RuntimeError("Git fallback requires the canonical repository and lowercase 40-hex commit")
|
|
113
|
+
install_root.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with tempfile.TemporaryDirectory(prefix="git-", dir=install_root) as temporary:
|
|
115
|
+
staged_root = Path(temporary) / "root"
|
|
116
|
+
staged_runtime = staged_root / "bin" / f"codexy-mcp-{config.server}"
|
|
117
|
+
command = [
|
|
118
|
+
cargo,
|
|
119
|
+
"install",
|
|
120
|
+
"--force",
|
|
121
|
+
"--locked",
|
|
122
|
+
"--git",
|
|
123
|
+
config.git_repository,
|
|
124
|
+
"--rev",
|
|
125
|
+
config.git_ref,
|
|
126
|
+
"--root",
|
|
127
|
+
str(staged_root),
|
|
128
|
+
"--bin",
|
|
129
|
+
f"codexy-mcp-{config.server}",
|
|
130
|
+
]
|
|
131
|
+
environment = {key: value for key, value in os.environ.items() if key not in {"GH_TOKEN", "GITHUB_TOKEN"}}
|
|
132
|
+
completed = subprocess.run(command, check=False, env=environment)
|
|
133
|
+
if completed.returncode:
|
|
134
|
+
raise RuntimeError(f"cargo install exited with status {completed.returncode}")
|
|
135
|
+
try:
|
|
136
|
+
_publish_executable(staged_runtime, installed)
|
|
137
|
+
except RuntimeError as error:
|
|
138
|
+
raise RuntimeError(
|
|
139
|
+
f"cargo install exited with status {completed.returncode}: {error}"
|
|
140
|
+
) from error
|
|
@@ -68,16 +68,17 @@ def _extract_tar(archive: Path, destination: Path) -> None:
|
|
|
68
68
|
raise ValueError("runtime package contains too many members")
|
|
69
69
|
if sum(member.size for member in members) > MAX_UNPACKED_BYTES:
|
|
70
70
|
raise ValueError("runtime package exceeds the unpacked size limit")
|
|
71
|
-
destinations: set[
|
|
71
|
+
destinations: set[str] = set()
|
|
72
72
|
for member in members:
|
|
73
73
|
if not (member.isdir() or member.isfile()):
|
|
74
74
|
raise ValueError(f"runtime package contains unsafe link or device: {member.name}")
|
|
75
75
|
member_path = (destination / member.name).resolve()
|
|
76
76
|
if destination_resolved not in member_path.parents and member_path != destination_resolved:
|
|
77
77
|
raise ValueError(f"runtime package contains unsafe path: {member.name}")
|
|
78
|
-
|
|
78
|
+
identity = str(member_path).casefold()
|
|
79
|
+
if identity in destinations:
|
|
79
80
|
raise ValueError(f"runtime package contains duplicate path: {member.name}")
|
|
80
|
-
destinations.add(
|
|
81
|
+
destinations.add(identity)
|
|
81
82
|
package.extractall(destination)
|
|
82
83
|
|
|
83
84
|
|
|
@@ -96,7 +97,7 @@ def _extract_zip(archive: Path, destination: Path) -> None:
|
|
|
96
97
|
raise ValueError("artifact archive contains too many members")
|
|
97
98
|
if sum(member.file_size for member in members) > MAX_UNPACKED_BYTES:
|
|
98
99
|
raise ValueError("artifact archive exceeds the unpacked size limit")
|
|
99
|
-
destinations: set[
|
|
100
|
+
destinations: set[str] = set()
|
|
100
101
|
for member in members:
|
|
101
102
|
member_path = (destination / member.filename).resolve()
|
|
102
103
|
unix_mode = member.external_attr >> 16
|
|
@@ -104,9 +105,10 @@ def _extract_zip(archive: Path, destination: Path) -> None:
|
|
|
104
105
|
raise ValueError(f"artifact archive contains unsafe link: {member.filename}")
|
|
105
106
|
if destination_resolved not in member_path.parents and member_path != destination_resolved:
|
|
106
107
|
raise ValueError(f"artifact archive contains unsafe path: {member.filename}")
|
|
107
|
-
|
|
108
|
+
identity = str(member_path).casefold()
|
|
109
|
+
if identity in destinations:
|
|
108
110
|
raise ValueError(f"artifact archive contains duplicate path: {member.filename}")
|
|
109
|
-
destinations.add(
|
|
111
|
+
destinations.add(identity)
|
|
110
112
|
zipped.extractall(destination)
|
|
111
113
|
|
|
112
114
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .updater import _absolute, _validate_real_path
|
|
7
|
+
|
|
8
|
+
OFFICIAL = "https://github.com/eunsoogi/codexy.git"
|
|
9
|
+
PLUGIN_REPOSITORY = "https://github.com/eunsoogi/codexy"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def named_marketplace(payload: object) -> bool:
|
|
13
|
+
return any(
|
|
14
|
+
isinstance(item, dict) and item.get("name") == "codexy"
|
|
15
|
+
for item in _items(payload, "marketplaces")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def official_marketplace(payload: object) -> Path:
|
|
20
|
+
named = [
|
|
21
|
+
item
|
|
22
|
+
for item in _items(payload, "marketplaces")
|
|
23
|
+
if isinstance(item, dict) and item.get("name") == "codexy"
|
|
24
|
+
]
|
|
25
|
+
if len(named) != 1 or named[0].get("marketplaceSource") != {
|
|
26
|
+
"sourceType": "git",
|
|
27
|
+
"source": OFFICIAL,
|
|
28
|
+
}:
|
|
29
|
+
raise ValueError("expected exactly one official Codexy marketplace")
|
|
30
|
+
root_value = named[0].get("root")
|
|
31
|
+
if not isinstance(root_value, str):
|
|
32
|
+
raise ValueError("official Codexy marketplace root is missing")
|
|
33
|
+
if not Path(root_value).is_absolute():
|
|
34
|
+
raise ValueError("official Codexy marketplace root must be absolute")
|
|
35
|
+
root = _absolute(root_value)
|
|
36
|
+
_validate_real_path(root, require_exists=True)
|
|
37
|
+
return root
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def preflight_install(payload: object, marketplace_root: Path) -> None:
|
|
41
|
+
marketplace_root = _absolute(marketplace_root)
|
|
42
|
+
_validate_real_path(marketplace_root, require_exists=True)
|
|
43
|
+
entries = _codexy_enabled(payload)
|
|
44
|
+
if len(entries) > 1:
|
|
45
|
+
raise ValueError("expected zero or one enabled official Codexy install")
|
|
46
|
+
if entries:
|
|
47
|
+
_require_official(entries[0])
|
|
48
|
+
_source_root(entries[0], marketplace_root)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def official_install(
|
|
52
|
+
payload: object,
|
|
53
|
+
marketplace_root: Path,
|
|
54
|
+
distribution_version: str,
|
|
55
|
+
) -> tuple[Path, str]:
|
|
56
|
+
marketplace_root = _absolute(marketplace_root)
|
|
57
|
+
_validate_real_path(marketplace_root, require_exists=True)
|
|
58
|
+
entries = _codexy_enabled(payload)
|
|
59
|
+
if len(entries) != 1:
|
|
60
|
+
raise ValueError("expected exactly one enabled official Codexy install")
|
|
61
|
+
item = entries[0]
|
|
62
|
+
_require_official(item)
|
|
63
|
+
root = _source_root(item, marketplace_root)
|
|
64
|
+
version = item.get("version")
|
|
65
|
+
if not isinstance(version, str):
|
|
66
|
+
raise ValueError("official Codexy install has invalid metadata")
|
|
67
|
+
if version != distribution_version:
|
|
68
|
+
raise ValueError("Codexy plugin version must match the getcodexy distribution")
|
|
69
|
+
|
|
70
|
+
manifest = root / ".codex-plugin" / "plugin.json"
|
|
71
|
+
_validate_real_path(manifest, require_exists=True)
|
|
72
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
73
|
+
if not isinstance(data, dict) or (
|
|
74
|
+
data.get("name"),
|
|
75
|
+
data.get("repository"),
|
|
76
|
+
data.get("version"),
|
|
77
|
+
) != ("codexy", PLUGIN_REPOSITORY, version):
|
|
78
|
+
raise ValueError("official Codexy install identity does not match its manifest")
|
|
79
|
+
return root, version
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _items(payload: object, key: str) -> list[object]:
|
|
83
|
+
if not isinstance(payload, dict):
|
|
84
|
+
return []
|
|
85
|
+
value = payload.get(key)
|
|
86
|
+
return value if isinstance(value, list) else []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _codexy_enabled(payload: object) -> list[dict[str, object]]:
|
|
90
|
+
return [
|
|
91
|
+
item
|
|
92
|
+
for item in _items(payload, "installed")
|
|
93
|
+
if isinstance(item, dict)
|
|
94
|
+
and item.get("enabled") is True
|
|
95
|
+
and (
|
|
96
|
+
item.get("pluginId") == "codexy@codexy"
|
|
97
|
+
or item.get("name") == "codexy"
|
|
98
|
+
or item.get("marketplaceName") == "codexy"
|
|
99
|
+
)
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _require_official(item: dict[str, object]) -> None:
|
|
104
|
+
source = item.get("source")
|
|
105
|
+
if not (
|
|
106
|
+
item.get("pluginId") == "codexy@codexy"
|
|
107
|
+
and item.get("name") == "codexy"
|
|
108
|
+
and item.get("marketplaceName") == "codexy"
|
|
109
|
+
and item.get("installed") is True
|
|
110
|
+
and item.get("enabled") is True
|
|
111
|
+
and isinstance(source, dict)
|
|
112
|
+
and source.get("source") == "local"
|
|
113
|
+
and item.get("marketplaceSource")
|
|
114
|
+
== {"sourceType": "git", "source": OFFICIAL}
|
|
115
|
+
):
|
|
116
|
+
raise ValueError("expected zero or one enabled official Codexy install")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _source_root(item: dict[str, object], marketplace_root: Path) -> Path:
|
|
120
|
+
source = item.get("source")
|
|
121
|
+
path_value = source.get("path") if isinstance(source, dict) else None
|
|
122
|
+
if not isinstance(path_value, str):
|
|
123
|
+
raise ValueError("official Codexy install has invalid metadata")
|
|
124
|
+
if not Path(path_value).is_absolute():
|
|
125
|
+
raise ValueError("official Codexy install path must be absolute")
|
|
126
|
+
root = _absolute(path_value)
|
|
127
|
+
expected = marketplace_root / "plugins" / "codexy"
|
|
128
|
+
if root != expected:
|
|
129
|
+
raise ValueError("official Codexy install must be inside its marketplace root")
|
|
130
|
+
_validate_real_path(root, require_exists=True)
|
|
131
|
+
return root
|