chore(oss): audit the server package and resync the SDK mirrors
Split the 657-line operation service into eor, submit, and verify modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already emits, and condense every multi-line comment. Mirror Node, Python, and Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation grace, and path-encoding fixes. Document which routes and response fields the self-hosted server does not provide. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
1 parent
2010175a82
commit
bc6983a62e
364 files changed
+10180
-23349
No files matched your search
@@ -6,6 +6,7 @@ import os
|
||||
import stat
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from ._managed_files import same_file_metadata
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
@@ -27,16 +28,6 @@ def _regular_identity(
|
||||
raise OSError(f"{label} changed while copying: {path}")
|
||||
|
||||
|
||||
def _stable_metadata(metadata: os.stat_result) -> tuple[int, int, int]:
|
||||
# Timestamps are excluded: Windows exposes file times with delayed
|
||||
# cross-handle visibility, so they are unsound identity evidence.
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
metadata.st_size,
|
||||
)
|
||||
|
||||
|
||||
def copy_into_reserved(
|
||||
directory: PinnedDirectory,
|
||||
source_path: str,
|
||||
@@ -88,7 +79,7 @@ def copy_into_reserved(
|
||||
directory.chmod_descriptor(destination_descriptor, mode)
|
||||
os.fsync(destination_descriptor)
|
||||
after = os.fstat(source_descriptor)
|
||||
if _stable_metadata(after) != _stable_metadata(before):
|
||||
if not same_file_metadata(after, before):
|
||||
raise OSError(f"{label} changed while copying: {source_path}")
|
||||
finally:
|
||||
for descriptor in (destination_descriptor, source_descriptor):
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, Optional
|
||||
JsonObject = Dict[str, Any]
|
||||
|
||||
|
||||
def _cleanup_failed_write(path: str, label: str, cause: Exception) -> None:
|
||||
def _cleanup_failed_write(path: str, label: str, cause: BaseException) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
@@ -48,7 +48,7 @@ def write_text_atomic(path: str, content: str, mode: int, label: str) -> None:
|
||||
os.fsync(file.fileno())
|
||||
os.chmod(temporary_path, mode)
|
||||
os.replace(temporary_path, path)
|
||||
except Exception as error:
|
||||
except BaseException as error:
|
||||
if descriptor >= 0:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
@@ -59,13 +59,32 @@ def write_text_atomic(path: str, content: str, mode: int, label: str) -> None:
|
||||
f"close failed: {close_error}"
|
||||
) from error
|
||||
_cleanup_failed_write(temporary_path, label, error)
|
||||
raise OSError(f"Write {label} at {path}: {error}") from error
|
||||
if isinstance(error, Exception):
|
||||
raise OSError(f"Write {label} at {path}: {error}") from error
|
||||
raise
|
||||
|
||||
|
||||
def write_json_atomic(path: str, value: JsonObject, mode: int, label: str) -> None:
|
||||
write_text_atomic(path, json.dumps(value, indent=2) + "\n", mode, label)
|
||||
|
||||
|
||||
def read_json(path: str, label: str) -> Optional[JsonObject]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
raw = file.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"Failed to parse {label} at {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} at {path} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def remove_file(path: str, label: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -83,27 +102,3 @@ def regular_file_exists(path: str, label: str) -> bool:
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
return stat.S_ISREG(metadata.st_mode)
|
||||
|
||||
|
||||
def require_runtime(path: str, label: str) -> None:
|
||||
if not path:
|
||||
raise ValueError(f"{label} path is required")
|
||||
if not regular_file_exists(path, label):
|
||||
raise FileNotFoundError(f"{label} is missing: {path}")
|
||||
|
||||
|
||||
def read_json(path: str, label: str) -> Optional[JsonObject]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
raw = file.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"Failed to parse {label} at {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} at {path} must contain a JSON object")
|
||||
return value
|
||||
@@ -28,14 +28,7 @@ class DirectorySnapshot:
|
||||
|
||||
|
||||
def identity_mode_bits(mode: int) -> int:
|
||||
"""Collapse permission bits that are not identity evidence on Windows.
|
||||
|
||||
Windows synthesizes POSIX modes and derives execute bits from the file
|
||||
extension for path stats but not for handle stats, so the synthetic value
|
||||
cannot serve as identity there. Windows permissions are enforced through
|
||||
DACLs in _windows_security instead. Captured snapshots still store the
|
||||
raw bits because restoration re-applies them.
|
||||
"""
|
||||
"""Windows synthesizes mode bits, so they are not identity there."""
|
||||
return mode if os.name != "nt" else 0
|
||||
|
||||
|
||||
@@ -44,12 +37,8 @@ def portable_file_mode(metadata: os.stat_result) -> int:
|
||||
return identity_mode_bits(stat.S_IMODE(metadata.st_mode))
|
||||
|
||||
|
||||
def _stable_file_metadata(
|
||||
metadata: os.stat_result,
|
||||
) -> tuple[int, int, int, int]:
|
||||
# Timestamps are excluded deliberately: Windows exposes file times with
|
||||
# delayed cross-handle visibility, so they are unsound identity evidence.
|
||||
# Content changes are caught by the verification re-read instead.
|
||||
def stable_file_metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
||||
"""Identity without timestamps; Windows file times lag across handles."""
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
@@ -58,11 +47,8 @@ def _stable_file_metadata(
|
||||
)
|
||||
|
||||
|
||||
def _same_file_metadata(
|
||||
first: os.stat_result,
|
||||
second: os.stat_result,
|
||||
) -> bool:
|
||||
return _stable_file_metadata(first) == _stable_file_metadata(second)
|
||||
def same_file_metadata(first: os.stat_result, second: os.stat_result) -> bool:
|
||||
return stable_file_metadata(first) == stable_file_metadata(second)
|
||||
|
||||
|
||||
def assert_content_stable(
|
||||
@@ -73,12 +59,7 @@ def assert_content_stable(
|
||||
raw: bytes,
|
||||
maximum_bytes: int,
|
||||
) -> None:
|
||||
"""Re-read a just-read file and require identical identity and bytes.
|
||||
|
||||
This is the deterministic replacement for timestamp comparisons: a
|
||||
concurrent in-place rewrite is proven by content divergence between the
|
||||
two reads, independent of filesystem timer visibility.
|
||||
"""
|
||||
"""Re-read the file and require identical identity and bytes."""
|
||||
descriptor = opener()
|
||||
try:
|
||||
current = os.fstat(descriptor)
|
||||
@@ -110,26 +91,25 @@ def _inspect_regular_file(file_path: str, label: str) -> Optional[os.stat_result
|
||||
return metadata
|
||||
|
||||
|
||||
def read_physical_file(
|
||||
def read_file_snapshot(
|
||||
before: os.stat_result,
|
||||
opener: Callable[[int], int],
|
||||
file_path: str,
|
||||
label: str,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileSnapshot]:
|
||||
before = _inspect_regular_file(file_path, label)
|
||||
if before is None:
|
||||
return None
|
||||
maximum_bytes: int,
|
||||
) -> FileSnapshot:
|
||||
"""Read one regular file through opener and prove it stayed stable."""
|
||||
if before.st_size > maximum_bytes:
|
||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = os.open(file_path, flags)
|
||||
descriptor = opener(flags)
|
||||
after = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(after.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
if not _same_file_metadata(before, after):
|
||||
if not same_file_metadata(before, after):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
if after.st_size > maximum_bytes:
|
||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||
@@ -137,15 +117,10 @@ def read_physical_file(
|
||||
descriptor = -1
|
||||
raw = file.read(maximum_bytes + 1)
|
||||
finished = os.fstat(file.fileno())
|
||||
if not _same_file_metadata(finished, after):
|
||||
if not same_file_metadata(finished, after):
|
||||
raise OSError(f"{label} changed while reading: {file_path}")
|
||||
assert_content_stable(
|
||||
lambda: os.open(file_path, flags),
|
||||
file_path,
|
||||
label,
|
||||
after,
|
||||
raw,
|
||||
maximum_bytes,
|
||||
lambda: opener(flags), file_path, label, after, raw, maximum_bytes
|
||||
)
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
@@ -159,11 +134,19 @@ def read_physical_file(
|
||||
contents = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error
|
||||
return FileSnapshot(
|
||||
contents=contents,
|
||||
device=after.st_dev,
|
||||
inode=after.st_ino,
|
||||
mode=stat.S_IMODE(after.st_mode),
|
||||
return FileSnapshot(contents, after.st_dev, after.st_ino, stat.S_IMODE(after.st_mode))
|
||||
|
||||
|
||||
def read_physical_file(
|
||||
file_path: str,
|
||||
label: str,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileSnapshot]:
|
||||
before = _inspect_regular_file(file_path, label)
|
||||
if before is None:
|
||||
return None
|
||||
return read_file_snapshot(
|
||||
before, lambda flags: os.open(file_path, flags), file_path, label, maximum_bytes
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import stat
|
||||
from typing import Optional
|
||||
|
||||
from ._managed_files import portable_file_mode
|
||||
from ._managed_files import same_file_metadata
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
@@ -17,12 +17,7 @@ _HASH_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpaqueFileSnapshot:
|
||||
"""Content and physical identity without retaining file bytes in memory.
|
||||
|
||||
Identity is content-based (device, inode, mode, size, SHA-256). File
|
||||
timestamps are excluded because Windows exposes them with delayed
|
||||
visibility across handles, which makes them unsound identity evidence.
|
||||
"""
|
||||
"""Device, inode, mode, size, and SHA-256 without retaining the bytes."""
|
||||
|
||||
device: int
|
||||
inode: int
|
||||
@@ -31,19 +26,6 @@ class OpaqueFileSnapshot:
|
||||
sha256: str
|
||||
|
||||
|
||||
def _metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
portable_file_mode(metadata),
|
||||
metadata.st_size,
|
||||
)
|
||||
|
||||
|
||||
def _same_metadata(first: os.stat_result, second: os.stat_result) -> bool:
|
||||
return _metadata(first) == _metadata(second)
|
||||
|
||||
|
||||
def _require_regular(
|
||||
metadata: os.stat_result,
|
||||
file_path: str,
|
||||
@@ -82,7 +64,7 @@ def stream_opaque_snapshot(
|
||||
descriptor = directory.open_file(name, flags)
|
||||
opened = os.fstat(descriptor)
|
||||
_require_regular(opened, file_path, label, require_owner_only)
|
||||
if not _same_metadata(opened, before):
|
||||
if not same_file_metadata(opened, before):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
digest = hashlib.sha256()
|
||||
while True:
|
||||
@@ -92,7 +74,7 @@ def stream_opaque_snapshot(
|
||||
digest.update(chunk)
|
||||
finished = os.fstat(descriptor)
|
||||
_require_regular(finished, file_path, label, require_owner_only)
|
||||
if not _same_metadata(finished, opened):
|
||||
if not same_file_metadata(finished, opened):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
@@ -105,7 +87,7 @@ def stream_opaque_snapshot(
|
||||
except FileNotFoundError as error:
|
||||
raise OSError(f"{label} changed while hashing: {file_path}") from error
|
||||
_require_regular(visible, file_path, label, require_owner_only)
|
||||
if not _same_metadata(visible, finished):
|
||||
if not same_file_metadata(visible, finished):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
return OpaqueFileSnapshot(
|
||||
finished.st_dev,
|
||||
|
||||
@@ -10,8 +10,7 @@ from typing import Any, Optional, Tuple
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
_same_file_metadata,
|
||||
assert_content_stable,
|
||||
read_file_snapshot,
|
||||
read_physical_directory,
|
||||
)
|
||||
|
||||
@@ -381,8 +380,7 @@ class PinnedDirectory:
|
||||
"""Persist directory entries where the host exposes directory fsync."""
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
# NTFS journals atomic rename metadata. Windows directory handles do
|
||||
# not support FlushFileBuffers, while every staged file is flushed.
|
||||
# Windows directory handles do not support FlushFileBuffers.
|
||||
return
|
||||
if os.name != "posix":
|
||||
raise OSError(f"Unsupported transaction platform: {os.name}")
|
||||
@@ -410,71 +408,21 @@ class PinnedDirectory:
|
||||
label: str,
|
||||
maximum_bytes: int,
|
||||
) -> Optional[FileSnapshot]:
|
||||
file_path = self.path_for(name)
|
||||
try:
|
||||
before = self.stat_file(name)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {self.path_for(name)}: {error}") from error
|
||||
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||
if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {self.path_for(name)}")
|
||||
if before.st_size > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = self.open_file(name, flags)
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(
|
||||
before,
|
||||
opened,
|
||||
):
|
||||
raise OSError(f"{label} changed while opening: {self.path_for(name)}")
|
||||
if opened.st_size > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
with os.fdopen(descriptor, "rb") as file:
|
||||
descriptor = -1
|
||||
raw = file.read(maximum_bytes + 1)
|
||||
finished = os.fstat(file.fileno())
|
||||
if not _same_file_metadata(finished, opened):
|
||||
raise OSError(
|
||||
f"{label} changed while reading: {self.path_for(name)}"
|
||||
)
|
||||
assert_content_stable(
|
||||
lambda: self.open_file(name, flags),
|
||||
self.path_for(name),
|
||||
label,
|
||||
opened,
|
||||
raw,
|
||||
maximum_bytes,
|
||||
)
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {self.path_for(name)}: {error}") from error
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
if len(raw) > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
try:
|
||||
contents = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError(
|
||||
f"{label} at {self.path_for(name)} must contain UTF-8 text"
|
||||
) from error
|
||||
return FileSnapshot(
|
||||
contents,
|
||||
opened.st_dev,
|
||||
opened.st_ino,
|
||||
stat.S_IMODE(opened.st_mode),
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
return read_file_snapshot(
|
||||
before,
|
||||
lambda flags: self.open_file(name, flags),
|
||||
file_path,
|
||||
label,
|
||||
maximum_bytes,
|
||||
)
|
||||
|
||||
def chmod_file(
|
||||
|
||||
@@ -12,9 +12,12 @@ from ._managed_files import DirectorySnapshot, FileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
_RMDIR_SUPPORTS_DIR_FD = os.rmdir in os.supports_dir_fd
|
||||
|
||||
|
||||
def _remove_posix_private_directory(parent_descriptor: int, name: str) -> None:
|
||||
"""Remove one empty child through the platform's native *at semantics."""
|
||||
if os.rmdir not in os.supports_dir_fd:
|
||||
if not _RMDIR_SUPPORTS_DIR_FD:
|
||||
raise OSError("Host does not support identity-bound directory removal")
|
||||
os.rmdir(name, dir_fd=parent_descriptor)
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Runtime directory identity, config validation, and status checks shared by providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
|
||||
import urllib.parse
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, FilePrecondition, file_change, source_change
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
GUARD_SCRIPT = "guard.py"
|
||||
AUDIT_SCRIPT = "hook.py"
|
||||
DEFAULT_BASE_URL = "https://api.elydora.com"
|
||||
RUNTIME_CONFIG_FIELDS = frozenset(
|
||||
{"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
)
|
||||
RUNTIME_ARTIFACTS = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
class RuntimeContractLike(Protocol):
|
||||
@property
|
||||
def agent_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def guard_path(self) -> str: ...
|
||||
|
||||
@property
|
||||
def audit_path(self) -> str: ...
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_agent_id(value: Any, expected: str) -> bool:
|
||||
return isinstance(value, str) and os.path.normcase(value) == os.path.normcase(
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def managed_script_reference(
|
||||
script_path: str, script_name: str
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Return (agent_id, script_path) for an absolute ~/.elydora/<id>/<script_name>."""
|
||||
if not os.path.isabs(script_path) or os.path.basename(script_path) != script_name:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in ("", ".", ".."):
|
||||
return None
|
||||
return agent_id, script_path
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(file_path, "Elydora runtime config", MAX_CONFIG_BYTES)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(snapshot.contents, f"Elydora runtime config at {file_path}")
|
||||
|
||||
|
||||
def validate_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
agent_key: str,
|
||||
product_label: str,
|
||||
) -> None:
|
||||
extra = next((key for key in config if key not in RUNTIME_CONFIG_FIELDS), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = require_non_empty_string(config.get("agent_id"), "agent_id", config_path)
|
||||
if not same_agent_id(agent_id, expected_agent_id) or config.get("agent_name") != agent_key:
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match {product_label} hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = require_non_empty_string(config.get("base_url"), "base_url", config_path)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def runtime_config_source(
|
||||
config: InstallConfig, agent_id: str, agent_key: str, **extra: str
|
||||
) -> str:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", DEFAULT_BASE_URL),
|
||||
"agent_name": agent_key,
|
||||
**extra,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def resolve_runtime_paths(config: InstallConfig) -> RuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
if not same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
return RuntimePaths(
|
||||
agent_id, agent_directory, guard_path, os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
)
|
||||
|
||||
|
||||
def validate_install_config(
|
||||
config: InstallConfig, agent_key: str, product_label: str
|
||||
) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != agent_key:
|
||||
raise ValueError(f"{product_label} installation requires agent_name {agent_key}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
resolve_runtime_paths(config)
|
||||
|
||||
|
||||
def validate_runtime_tree(
|
||||
agent_directory: str,
|
||||
agent_id: str,
|
||||
agent_key: str,
|
||||
product_label: str,
|
||||
extra_artifacts: Tuple[Tuple[str, str], ...] = (),
|
||||
) -> None:
|
||||
"""Reject an existing agent directory that belongs to another identity."""
|
||||
if not physical_directory_exists(runtime_root(), "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(agent_directory, "Elydora agent runtime directory"):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = read_runtime_config(config_path)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in (*RUNTIME_ARTIFACTS, *extra_artifacts)
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
if (
|
||||
not same_agent_id(config.get("agent_id"), agent_id)
|
||||
or config.get("agent_name") != agent_key
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match {product_label} agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
ExpectedScripts = Callable[[str, JsonObject], Tuple[str, str]]
|
||||
|
||||
|
||||
def expected_runtime_scripts(
|
||||
agent_key: str,
|
||||
agent_id: str,
|
||||
config: Optional[JsonObject] = None,
|
||||
**hook_options: Any,
|
||||
) -> Tuple[str, str]:
|
||||
"""Guard and audit sources; identity fields come from config when given."""
|
||||
org_id, kid, base_url = (
|
||||
("", "", "")
|
||||
if config is None
|
||||
else (str(config["org_id"]), str(config["kid"]), str(config["base_url"]))
|
||||
)
|
||||
return (
|
||||
generate_guard_script(agent_key, agent_id),
|
||||
generate_hook_script(
|
||||
org_id=org_id,
|
||||
agent_id=agent_id,
|
||||
kid=kid,
|
||||
base_url=base_url,
|
||||
native_payload=True,
|
||||
agent_name=agent_key,
|
||||
**hook_options,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def runtime_contract_exists(
|
||||
contract: RuntimeContractLike,
|
||||
agent_key: str,
|
||||
product_label: str,
|
||||
expected_scripts: Optional[ExpectedScripts] = None,
|
||||
) -> bool:
|
||||
"""Strict status check: valid config and key plus runtime scripts."""
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not same_path(os.path.dirname(agent_directory), root) or not same_path(
|
||||
contract.audit_path, os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(agent_directory, "Elydora agent runtime directory"):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = read_runtime_config(config_path)
|
||||
key = read_physical_file(
|
||||
os.path.join(agent_directory, "private.key"), "Elydora private key", MAX_SECRET_BYTES
|
||||
)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
validate_runtime_config(config, contract.agent_id, config_path, agent_key, product_label)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
if expected_scripts is None:
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
expected_guard, expected_audit = expected_scripts(contract.agent_id, config)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def runtime_present(contract: RuntimeContractLike, agent_key: str) -> bool:
|
||||
"""Presence-only status check; identity mismatch reports False."""
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(agent_directory, "Elydora agent runtime directory"):
|
||||
return False
|
||||
config = read_runtime_config(os.path.join(agent_directory, "config.json"))
|
||||
if (
|
||||
config is None
|
||||
or config.get("agent_name") != agent_key
|
||||
or not same_agent_id(config.get("agent_id"), contract.agent_id)
|
||||
):
|
||||
return False
|
||||
return all(
|
||||
(
|
||||
physical_file_exists(contract.guard_path, "Elydora guard runtime"),
|
||||
physical_file_exists(contract.audit_path, "Elydora audit runtime"),
|
||||
physical_file_exists(
|
||||
os.path.join(agent_directory, "private.key"), "Elydora private key"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def runtime_file_changes(
|
||||
config: InstallConfig,
|
||||
paths: RuntimePaths,
|
||||
agent_key: str,
|
||||
guard_script: str,
|
||||
audit_script: str,
|
||||
) -> List[FileChange]:
|
||||
"""Changes for the guard, config, key, and audit runtime files."""
|
||||
return present([
|
||||
file_change(paths.guard_path, "Elydora guard runtime", guard_script, 0o700),
|
||||
file_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
runtime_config_source(config, paths.agent_id, agent_key),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
file_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
file_change(paths.audit_path, "Elydora audit runtime", audit_script, 0o700),
|
||||
])
|
||||
|
||||
|
||||
def rendered_source_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
original: Optional[str],
|
||||
next_source: Optional[str],
|
||||
) -> Optional[FileChange]:
|
||||
return source_change(file_path, label, original, next_source, 0o600, MAX_SOURCE_BYTES)
|
||||
|
||||
|
||||
def unique_preconditions(
|
||||
values: Sequence[FilePrecondition],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
result: Dict[str, FilePrecondition] = {}
|
||||
for value in values:
|
||||
result.setdefault(os.path.normcase(os.path.abspath(value.file_path)), value)
|
||||
return tuple(result.values())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Shell quoting and parsing for generated hook commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
CommandParts = Tuple[str, str]
|
||||
POWERSHELL_EXIT_SUFFIX = "; exit $LASTEXITCODE"
|
||||
_POSIX_APOSTROPHE = "'\"'\"'"
|
||||
_ENCODED_COMMAND = re.compile(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})"
|
||||
)
|
||||
_PYTHON_EXECUTABLE = re.compile(
|
||||
r"(?:python(?:[0-9]+(?:\.[0-9]+)*)?[td]?|pypy(?:[0-9]+(?:\.[0-9]+)*)?)(?:\.exe)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", _POSIX_APOSTROPHE) + "'"
|
||||
|
||||
|
||||
def quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def posix_source(script_path: str) -> str:
|
||||
return f"{quote_posix(sys.executable)} {quote_posix(script_path)}"
|
||||
|
||||
|
||||
def powershell_source(script_path: str) -> str:
|
||||
return (
|
||||
f"& {quote_powershell(sys.executable)} {quote_powershell(script_path)}"
|
||||
f"{POWERSHELL_EXIT_SUFFIX}"
|
||||
)
|
||||
|
||||
|
||||
def windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(system_root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
|
||||
|
||||
def encode_powershell_source(source: str) -> str:
|
||||
return base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
|
||||
|
||||
def encoded_windows_command(script_path: str, preamble: str = "") -> str:
|
||||
encoded = encode_powershell_source(preamble + powershell_source(script_path))
|
||||
return (
|
||||
f'"{windows_powershell_path()}" -NoLogo -NoProfile -NonInteractive '
|
||||
f"-EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def is_python_executable(file_path: str) -> bool:
|
||||
return _PYTHON_EXECUTABLE.fullmatch(os.path.basename(file_path)) is not None
|
||||
|
||||
|
||||
def is_powershell_executable(file_path: str) -> bool:
|
||||
return ntpath.isabs(file_path) and ntpath.basename(file_path).lower() == "powershell.exe"
|
||||
|
||||
|
||||
def read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(_POSIX_APOSTROPHE, index):
|
||||
value += "'"
|
||||
index += len(_POSIX_APOSTROPHE)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def parse_posix_command(command: str) -> Optional[CommandParts]:
|
||||
executable = read_posix_argument(command, 0)
|
||||
if executable is None or command[executable[1] : executable[1] + 1] != " ":
|
||||
return None
|
||||
script = read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def read_powershell_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def parse_powershell_source(source: str, prefix: str = "& ") -> Optional[CommandParts]:
|
||||
if not source.startswith(prefix):
|
||||
return None
|
||||
executable = read_powershell_argument(source, len(prefix))
|
||||
if executable is None or source[executable[1] : executable[1] + 1] != " ":
|
||||
return None
|
||||
script = read_powershell_argument(source, executable[1] + 1)
|
||||
if script is None or source[script[1] :] != POWERSHELL_EXIT_SUFFIX:
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def decode_powershell_source(encoded: str) -> Optional[str]:
|
||||
if re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", encoded) is None:
|
||||
return None
|
||||
try:
|
||||
payload = base64.b64decode(encoded, validate=True)
|
||||
if base64.b64encode(payload).decode("ascii") != encoded or len(payload) % 2:
|
||||
return None
|
||||
return payload.decode("utf-16le")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def same_windows_path(left: str, right: str) -> bool:
|
||||
return ntpath.normcase(ntpath.abspath(left)) == ntpath.normcase(ntpath.abspath(right))
|
||||
|
||||
|
||||
def parse_encoded_windows_command(
|
||||
command: str,
|
||||
prefix: str = "& ",
|
||||
powershell_path: Optional[str] = None,
|
||||
) -> Optional[CommandParts]:
|
||||
match = _ENCODED_COMMAND.fullmatch(command)
|
||||
if match is None or not is_powershell_executable(match.group(1)):
|
||||
return None
|
||||
if powershell_path is not None and not same_windows_path(match.group(1), powershell_path):
|
||||
return None
|
||||
source = decode_powershell_source(match.group(2))
|
||||
return None if source is None else parse_powershell_source(source, prefix)
|
||||
@@ -6,12 +6,7 @@ import os
|
||||
from typing import List, Optional, Sequence, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._managed_files import FileSnapshot, MAX_SOURCE_BYTES, read_physical_file
|
||||
from ._opaque_cleanup import cleanup_opaque_removal
|
||||
from ._opaque_removal import (
|
||||
OpaqueRemovalChange,
|
||||
@@ -65,15 +60,6 @@ def recover_pending_transactions() -> None:
|
||||
_recover_pending_transactions()
|
||||
|
||||
|
||||
def read_optional(
|
||||
file_path: str,
|
||||
label: str,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[str]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
return None if snapshot is None else snapshot.contents
|
||||
|
||||
|
||||
def source_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
@@ -479,14 +465,3 @@ def write_changes(
|
||||
directory_preconditions,
|
||||
opaque_removals,
|
||||
)
|
||||
|
||||
|
||||
def regular_file_exists(file_path: str, label: str) -> bool:
|
||||
return physical_file_exists(file_path, label)
|
||||
|
||||
|
||||
def require_runtime(file_path: str, label: str) -> None:
|
||||
if not file_path:
|
||||
raise ValueError(f"{label} path is required")
|
||||
if not regular_file_exists(file_path, label):
|
||||
raise FileNotFoundError(f"{label} is missing: {file_path}")
|
||||
@@ -13,6 +13,8 @@ from ._transaction_recovery import remove_committed_creation
|
||||
from ._transaction_staging import (
|
||||
assert_transaction_preconditions,
|
||||
assert_unchanged,
|
||||
read_target,
|
||||
same_identity_and_contents,
|
||||
same_snapshot,
|
||||
)
|
||||
from ._transaction_types import FileChange, StagedChange
|
||||
@@ -38,14 +40,6 @@ def _original_snapshot(change: FileChange) -> Optional[FileSnapshot]:
|
||||
)
|
||||
|
||||
|
||||
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_temporary(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
if staged.temporary_path is None:
|
||||
return None
|
||||
@@ -66,18 +60,6 @@ def _read_rollback(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
)
|
||||
|
||||
|
||||
def _same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
def _preserve_rollback(staged: StagedChange, error: Exception) -> OSError:
|
||||
if staged.rollback_path is None:
|
||||
return error if isinstance(error, OSError) else OSError(str(error))
|
||||
@@ -97,7 +79,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None:
|
||||
raise OSError(f"Missing rollback reservation for {staged.change.label}")
|
||||
capture_file(staged.change.file_path, rollback_path, staged.directory)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, original):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at commit boundary: "
|
||||
@@ -119,7 +101,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None:
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, original):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed while preparing rollback: "
|
||||
@@ -151,7 +133,7 @@ def _replace_next(
|
||||
raise OSError(f"Missing staged file for {staged.change.label}")
|
||||
replace_file(temporary_path, staged.change.file_path, staged.directory)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
expected = _original_snapshot(staged.change)
|
||||
if not same_snapshot(current, expected):
|
||||
error = OSError(
|
||||
@@ -227,7 +209,7 @@ def commit_change(
|
||||
else:
|
||||
_replace_next(staged, replace_file, after_replace)
|
||||
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, staged.committed_snapshot):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed immediately after commit: "
|
||||
@@ -251,7 +233,7 @@ def _restore_rollback_file(
|
||||
rollback = staged.rollback_snapshot
|
||||
if rollback_path is None or rollback is None:
|
||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
||||
if not same_snapshot(_read_target(staged), expected_current):
|
||||
if not same_snapshot(read_target(staged), expected_current):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
@@ -263,7 +245,7 @@ def _restore_rollback_file(
|
||||
restore_file(rollback_path, staged.change.file_path, staged.directory)
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
if not same_snapshot(_read_target(staged), expected_current):
|
||||
if not same_snapshot(read_target(staged), expected_current):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
@@ -272,7 +254,7 @@ def _restore_rollback_file(
|
||||
),
|
||||
)
|
||||
current_rollback = _read_rollback(staged)
|
||||
if not _same_identity_and_contents(current_rollback, rollback):
|
||||
if not same_identity_and_contents(current_rollback, rollback):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
@@ -288,7 +270,7 @@ def _restore_rollback_file(
|
||||
)
|
||||
atomic_replace(staged.directory, rollback_path, staged.change.file_path)
|
||||
staged.rollback_path = None
|
||||
restored = _read_target(staged)
|
||||
restored = read_target(staged)
|
||||
if (
|
||||
restored is None
|
||||
or restored.contents != expected.contents
|
||||
@@ -307,7 +289,7 @@ def rollback_change(
|
||||
) -> None:
|
||||
if not staged.committed:
|
||||
return
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, staged.committed_snapshot):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
|
||||
@@ -13,7 +13,7 @@ from uuid import uuid4
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
_same_file_metadata,
|
||||
same_file_metadata,
|
||||
identity_mode_bits,
|
||||
)
|
||||
from ._opaque_snapshot import OpaqueFileSnapshot
|
||||
@@ -300,7 +300,7 @@ def inspect_fingerprint(
|
||||
descriptor = directory.open_file(name, flags)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(before, opened):
|
||||
if not stat.S_ISREG(opened.st_mode) or not same_file_metadata(before, opened):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
@@ -311,12 +311,12 @@ def inspect_fingerprint(
|
||||
digest.update(chunk)
|
||||
size += len(chunk)
|
||||
finished = os.fstat(descriptor)
|
||||
if not _same_file_metadata(finished, opened):
|
||||
if not same_file_metadata(finished, opened):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
visible = directory.stat_file(name)
|
||||
if not _same_file_metadata(visible, finished):
|
||||
if not same_file_metadata(visible, finished):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
return FileFingerprint(
|
||||
digest.hexdigest(),
|
||||
@@ -419,7 +419,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not _same_file_metadata(opened, metadata):
|
||||
if not same_file_metadata(opened, metadata):
|
||||
raise OSError(f"Transaction journal changed while opening: {path}")
|
||||
chunks = []
|
||||
remaining = MAX_JOURNAL_BYTES + 1
|
||||
@@ -431,7 +431,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
finished = os.fstat(descriptor)
|
||||
if not _same_file_metadata(finished, opened):
|
||||
if not same_file_metadata(finished, opened):
|
||||
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -439,7 +439,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
||||
visible = os.lstat(path)
|
||||
except FileNotFoundError as error:
|
||||
raise OSError(f"Transaction journal changed while reading: {path}") from error
|
||||
if not _same_file_metadata(visible, finished):
|
||||
if not same_file_metadata(visible, finished):
|
||||
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||
if len(raw) > MAX_JOURNAL_BYTES:
|
||||
raise ValueError("Transaction journal exceeds its size limit")
|
||||
|
||||
@@ -9,21 +9,13 @@ from uuid import uuid4
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._private_artifact import QuarantinedFile, reserve_private_path
|
||||
from ._transaction_staging import same_snapshot
|
||||
from ._transaction_staging import read_target, same_snapshot
|
||||
from ._transaction_types import StagedChange
|
||||
|
||||
|
||||
ReplaceFile = Callable[[str, str, PinnedDirectory], None]
|
||||
|
||||
|
||||
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_private_path(
|
||||
staged: StagedChange,
|
||||
file_path: str,
|
||||
@@ -36,18 +28,6 @@ def _read_private_path(
|
||||
)
|
||||
|
||||
|
||||
def _same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
def _recovery_path(staged: StagedChange) -> tuple[str, FileSnapshot]:
|
||||
basename = os.path.basename(staged.change.file_path)
|
||||
path = staged.directory.path_for(f".{basename}.{uuid4().hex}.recovery")
|
||||
@@ -63,76 +43,6 @@ def _preserve_private_path(error: Exception, file_path: str) -> OSError:
|
||||
return OSError(f"{error}; transaction data preserved at {file_path}")
|
||||
|
||||
|
||||
def _release_rollback_source(staged: StagedChange, source_path: str) -> bool:
|
||||
if staged.rollback_path is not None and os.path.normcase(
|
||||
os.path.abspath(staged.rollback_path)
|
||||
) == os.path.normcase(os.path.abspath(source_path)):
|
||||
staged.rollback_path = None
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _preserve_source_path(
|
||||
error: Exception,
|
||||
file_path: str,
|
||||
original_source: bool,
|
||||
) -> OSError:
|
||||
kind = "original content" if original_source else "transaction data"
|
||||
return OSError(f"{error}; {kind} preserved at {file_path}")
|
||||
|
||||
|
||||
def install_private_file(
|
||||
staged: StagedChange,
|
||||
source_path: str,
|
||||
expected: FileSnapshot,
|
||||
replace_file: ReplaceFile,
|
||||
) -> None:
|
||||
try:
|
||||
replace_file(source_path, staged.change.file_path, staged.directory)
|
||||
except Exception:
|
||||
raise
|
||||
captured = QuarantinedFile.capture(
|
||||
staged.directory,
|
||||
source_path,
|
||||
f"{staged.change.label} recovery source",
|
||||
)
|
||||
original_source = _release_rollback_source(staged, source_path)
|
||||
captured_snapshot = captured.text_snapshot(staged.change.maximum_bytes)
|
||||
if not same_snapshot(captured_snapshot, expected):
|
||||
try:
|
||||
captured.restore(source_path)
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else source_path
|
||||
raise OSError(
|
||||
f"{error}; concurrent data preserved at {preserved}"
|
||||
) from error
|
||||
raise OSError(
|
||||
f"{staged.change.label} recovery source changed: {source_path}; "
|
||||
f"concurrent data preserved at {source_path}"
|
||||
)
|
||||
try:
|
||||
captured.link(staged.change.file_path)
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else source_path
|
||||
raise _preserve_source_path(
|
||||
error,
|
||||
preserved,
|
||||
original_source,
|
||||
) from error
|
||||
restored = _read_target(staged)
|
||||
if not same_snapshot(restored, expected):
|
||||
preserved = captured.preserve()
|
||||
raise _preserve_source_path(
|
||||
OSError(
|
||||
f"{staged.change.label} changed while installing recovery data: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
preserved,
|
||||
original_source,
|
||||
)
|
||||
captured.discard()
|
||||
|
||||
|
||||
def capture_recovery_target(
|
||||
staged: StagedChange,
|
||||
expected: FileSnapshot,
|
||||
@@ -263,7 +173,7 @@ def remove_committed_creation(
|
||||
replace_file,
|
||||
capture_file,
|
||||
)
|
||||
if _read_target(staged) is not None:
|
||||
if read_target(staged) is not None:
|
||||
discard_private_file(staged, recovery_path, recovery_snapshot)
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed after recovery capture: "
|
||||
@@ -275,6 +185,5 @@ def remove_committed_creation(
|
||||
__all__ = [
|
||||
"capture_recovery_target",
|
||||
"discard_private_file",
|
||||
"install_private_file",
|
||||
"remove_committed_creation",
|
||||
]
|
||||
@@ -46,6 +46,26 @@ def same_snapshot(
|
||||
return current == expected
|
||||
|
||||
|
||||
def read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
def remove_optional(directory: PinnedDirectory, file_path: Optional[str]) -> None:
|
||||
if not file_path:
|
||||
return
|
||||
@@ -338,8 +358,7 @@ def _remove_exact_text(
|
||||
!= (expected.device, expected.inode)
|
||||
)
|
||||
if not changed:
|
||||
# An in-place rewrite keeps the inode, so the removal is only exact
|
||||
# once the bytes also match the owned snapshot.
|
||||
# An in-place rewrite keeps the inode; bytes must match too.
|
||||
try:
|
||||
snapshot = captured.text_snapshot(
|
||||
len(expected.contents.encode("utf-8"))
|
||||
|
||||
@@ -210,12 +210,6 @@ def _current_user_sid() -> Tuple[Any, str]:
|
||||
close_handle(int(token_value))
|
||||
|
||||
|
||||
def current_user_sid_string() -> str:
|
||||
"""Return the stable current-user SID used by the state namespace."""
|
||||
_sid, sid_text = _current_user_sid()
|
||||
return sid_text
|
||||
|
||||
|
||||
def _security_descriptor(directory: bool) -> Tuple[int, int]:
|
||||
_sid, sid_text = _current_user_sid()
|
||||
inheritance = "OICI" if directory else ""
|
||||
@@ -491,7 +485,6 @@ def verify_private_path(path: str, *, directory: bool) -> None:
|
||||
|
||||
__all__ = [
|
||||
"close_handle",
|
||||
"current_user_sid_string",
|
||||
"ensure_private_directory",
|
||||
"open_private_file",
|
||||
"verify_private_path",
|
||||
|
||||
@@ -9,6 +9,9 @@ import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._strict_json import parse_json_object
|
||||
|
||||
|
||||
@@ -69,10 +72,6 @@ def home_dir() -> str:
|
||||
return os.path.expanduser("~")
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(home_dir(), ".elydora")
|
||||
|
||||
|
||||
def resolve_config_path() -> str:
|
||||
return os.path.join(home_dir(), ".augment", "settings.json")
|
||||
|
||||
@@ -162,16 +161,6 @@ def _parse_wrapper_command(command: str) -> Optional[str]:
|
||||
return parser(command)
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _managed_agent_id(handler: JsonObject, wrapper_name: str) -> Optional[str]:
|
||||
if (
|
||||
handler.get("type") != "command"
|
||||
@@ -188,7 +177,7 @@ def _managed_agent_id(handler: JsonObject, wrapper_name: str) -> Optional[str]:
|
||||
):
|
||||
return None
|
||||
agent_directory = os.path.dirname(wrapper_path)
|
||||
if not _same_path(os.path.dirname(agent_directory), elydora_dir()):
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
@@ -324,7 +313,7 @@ def _remove_managed(
|
||||
for handler in group["hooks"]:
|
||||
managed_id = _managed_agent_id(handler, wrapper_name)
|
||||
remove = managed_id is not None and (
|
||||
not agent_id or _same_agent_id(managed_id, agent_id)
|
||||
not agent_id or same_agent_id(managed_id, agent_id)
|
||||
)
|
||||
if remove:
|
||||
changed = True
|
||||
@@ -376,12 +365,12 @@ def runtime_contracts(hooks: AugmentHooks) -> List[RuntimeContract]:
|
||||
contracts: List[RuntimeContract] = []
|
||||
for guard_id in sorted(guards, key=os.path.normcase):
|
||||
audit_id = next(
|
||||
(candidate for candidate in audits if _same_agent_id(candidate, guard_id)),
|
||||
(candidate for candidate in audits if same_agent_id(candidate, guard_id)),
|
||||
None,
|
||||
)
|
||||
if audit_id is None:
|
||||
continue
|
||||
agent_directory = os.path.join(elydora_dir(), guard_id)
|
||||
agent_directory = os.path.join(runtime_root(), guard_id)
|
||||
contracts.append(
|
||||
RuntimeContract(
|
||||
agent_id=guard_id,
|
||||
|
||||
@@ -3,107 +3,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, file_change, write_changes
|
||||
from .augment_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
AUDIT_WRAPPER,
|
||||
GUARD_WRAPPER,
|
||||
AugmentDocument,
|
||||
RenderedAugmentDocument,
|
||||
build_wrapper,
|
||||
elydora_dir,
|
||||
wrapper_paths,
|
||||
)
|
||||
from .augment_io import (
|
||||
rendered_change,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .augment_io import rendered_change
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
WRAPPER_ARTIFACTS = (
|
||||
(GUARD_WRAPPER, "Auggie guard wrapper"),
|
||||
(AUDIT_WRAPPER, "Auggie audit wrapper"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AugmentRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
class AugmentRuntimePaths(RuntimePaths):
|
||||
guard_wrapper_path: str
|
||||
audit_wrapper_path: str
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> AugmentRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not _same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
wrappers = wrapper_paths(agent_directory)
|
||||
paths = resolve_runtime_paths(config)
|
||||
wrappers = wrapper_paths(paths.agent_directory)
|
||||
return AugmentRuntimePaths(
|
||||
agent_id,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
paths.agent_id,
|
||||
paths.agent_directory,
|
||||
paths.guard_path,
|
||||
paths.audit_path,
|
||||
wrappers.guard_path,
|
||||
wrappers.audit_path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Augment Code CLI installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
def _validate_runtime_tree(paths: AugmentRuntimePaths) -> None:
|
||||
validate_runtime_tree(
|
||||
paths.agent_directory, paths.agent_id, AGENT_KEY, "Auggie", WRAPPER_ARTIFACTS
|
||||
)
|
||||
|
||||
|
||||
def preflight_augment_installation(
|
||||
@@ -111,26 +65,12 @@ def preflight_augment_installation(
|
||||
) -> AugmentRuntimePaths:
|
||||
if not document.config_path:
|
||||
raise ValueError("Augment Code CLI installation requires a settings path")
|
||||
_validate_install_config(config)
|
||||
validate_install_config(config, AGENT_KEY, "Augment Code CLI")
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
_validate_runtime_tree(paths)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_augment_installation(
|
||||
config: InstallConfig,
|
||||
paths: AugmentRuntimePaths,
|
||||
@@ -138,54 +78,35 @@ def prepare_augment_installation(
|
||||
) -> List[FileChange]:
|
||||
if not rendered.changed and rendered.document.raw is None:
|
||||
raise ValueError("Auggie hook installation did not produce a settings document")
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
_validate_runtime_tree(paths)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present(
|
||||
[
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([
|
||||
file_change(
|
||||
paths.guard_wrapper_path,
|
||||
"Auggie guard wrapper",
|
||||
build_wrapper(paths.guard_path),
|
||||
0o700,
|
||||
),
|
||||
_runtime_change(
|
||||
file_change(
|
||||
paths.audit_wrapper_path,
|
||||
"Auggie audit wrapper",
|
||||
build_wrapper(paths.audit_path),
|
||||
0o700,
|
||||
),
|
||||
rendered_change(rendered),
|
||||
]
|
||||
)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
]),
|
||||
]
|
||||
_validate_runtime_tree(paths)
|
||||
return changes
|
||||
|
||||
|
||||
@@ -193,10 +114,8 @@ def commit_augment_installation(changes: List[FileChange]) -> None:
|
||||
write_changes(changes, "Install Augment Code CLI hooks")
|
||||
|
||||
|
||||
def prepare_augment_uninstall(
|
||||
rendered: RenderedAugmentDocument,
|
||||
) -> List[FileChange]:
|
||||
return _present([rendered_change(rendered)])
|
||||
def prepare_augment_uninstall(rendered: RenderedAugmentDocument) -> List[FileChange]:
|
||||
return present([rendered_change(rendered)])
|
||||
|
||||
|
||||
def commit_augment_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,61 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._managed_files import MAX_CONFIG_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import runtime_contract_exists, same_path
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .augment_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_WRAPPER,
|
||||
GUARD_SCRIPT,
|
||||
GUARD_WRAPPER,
|
||||
AugmentDocument,
|
||||
RenderedAugmentDocument,
|
||||
RuntimeContract,
|
||||
build_wrapper,
|
||||
create_augment_document,
|
||||
elydora_dir,
|
||||
parse_augment_document,
|
||||
resolve_config_path,
|
||||
)
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def read_augment_document() -> AugmentDocument:
|
||||
config_path = resolve_config_path()
|
||||
physical_directory_exists(
|
||||
os.path.dirname(config_path), "Auggie configuration directory"
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(config_path), "Auggie configuration directory")
|
||||
snapshot = read_physical_file(config_path, "Auggie user settings", MAX_CONFIG_BYTES)
|
||||
if snapshot is None:
|
||||
return create_augment_document(config_path)
|
||||
return parse_augment_document(config_path, snapshot.contents)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedAugmentDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedAugmentDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
@@ -75,192 +50,26 @@ def write_augment_document(rendered: RenderedAugmentDocument) -> None:
|
||||
write_changes([change], "Uninstall Augment Code CLI hooks")
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not _same_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Auggie hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(file_path, "Elydora runtime config", MAX_CONFIG_BYTES)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
(GUARD_WRAPPER, "Auggie guard wrapper"),
|
||||
(AUDIT_WRAPPER, "Auggie audit wrapper"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not _same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Auggie agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: RuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
def _wrappers_exist(contract: RuntimeContract) -> bool:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not _same_path(os.path.dirname(agent_directory), root)
|
||||
or not _same_path(
|
||||
contract.audit_path, os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
)
|
||||
or not _same_path(
|
||||
contract.guard_wrapper_path,
|
||||
os.path.join(agent_directory, GUARD_WRAPPER),
|
||||
)
|
||||
or not _same_path(
|
||||
contract.audit_wrapper_path,
|
||||
os.path.join(agent_directory, AUDIT_WRAPPER),
|
||||
)
|
||||
if not same_path(
|
||||
contract.guard_wrapper_path, os.path.join(agent_directory, GUARD_WRAPPER)
|
||||
) or not same_path(
|
||||
contract.audit_wrapper_path, os.path.join(agent_directory, AUDIT_WRAPPER)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
guard_wrapper = read_physical_file(
|
||||
contract.guard_wrapper_path, "Auggie guard wrapper"
|
||||
)
|
||||
audit_wrapper = read_physical_file(
|
||||
contract.audit_wrapper_path, "Auggie audit wrapper"
|
||||
)
|
||||
if any(
|
||||
item is None
|
||||
for item in (config, key, guard, audit, guard_wrapper, audit_wrapper)
|
||||
):
|
||||
return False
|
||||
assert config is not None
|
||||
assert key is not None
|
||||
assert guard is not None
|
||||
assert audit is not None
|
||||
assert guard_wrapper is not None
|
||||
assert audit_wrapper is not None
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
guard_wrapper = read_physical_file(contract.guard_wrapper_path, "Auggie guard wrapper")
|
||||
audit_wrapper = read_physical_file(contract.audit_wrapper_path, "Auggie audit wrapper")
|
||||
return (
|
||||
bool(guard.contents)
|
||||
and bool(audit.contents)
|
||||
guard_wrapper is not None
|
||||
and audit_wrapper is not None
|
||||
and guard_wrapper.contents == build_wrapper(contract.guard_path)
|
||||
and audit_wrapper.contents == build_wrapper(contract.audit_path)
|
||||
)
|
||||
|
||||
|
||||
def augment_runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Auggie") and _wrappers_exist(contract)
|
||||
for contract in contracts
|
||||
)
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import urllib.parse
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
|
||||
|
||||
@@ -82,20 +83,6 @@ class _RuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_claude_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_claude_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _require_known_keys(
|
||||
value: JsonObject, allowed: frozenset[str], label: str
|
||||
) -> None:
|
||||
@@ -292,15 +279,8 @@ def _exact_managed_group(group: JsonObject) -> bool:
|
||||
def _runtime_reference(
|
||||
script_path: str, script_name: str
|
||||
) -> Optional[_RuntimeReference]:
|
||||
if not os.path.isabs(script_path) or os.path.basename(script_path) != script_name:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_claude_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in ("", ".", ".."):
|
||||
return None
|
||||
return _RuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else _RuntimeReference(*reference)
|
||||
|
||||
|
||||
def _legacy_reference(
|
||||
@@ -314,7 +294,7 @@ def _legacy_reference(
|
||||
if (
|
||||
match is None
|
||||
or not os.path.isabs(match.group(1))
|
||||
or not same_claude_path(match.group(1), sys.executable)
|
||||
or not same_path(match.group(1), sys.executable)
|
||||
):
|
||||
return None
|
||||
return _runtime_reference(match.group(2), script_name)
|
||||
@@ -334,7 +314,7 @@ def _managed_reference(
|
||||
and handler.get("statusMessage") == status_message
|
||||
and isinstance(handler.get("command"), str)
|
||||
and os.path.isabs(str(handler["command"]))
|
||||
and same_claude_path(str(handler["command"]), sys.executable)
|
||||
and same_path(str(handler["command"]), sys.executable)
|
||||
and isinstance(handler.get("args"), list)
|
||||
and len(handler["args"]) == 1
|
||||
and isinstance(handler["args"][0], str)
|
||||
@@ -365,7 +345,7 @@ def _remove_from_groups(
|
||||
)
|
||||
owned = reference is not None and (
|
||||
not agent_id
|
||||
or same_claude_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if owned:
|
||||
removed = True
|
||||
@@ -485,7 +465,7 @@ def claude_runtime_contracts(
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_claude_path(success[0].script_path, failure[0].script_path):
|
||||
if not same_path(success[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(ClaudeRuntimeContract(
|
||||
guard[0].agent_id,
|
||||
|
||||
@@ -2,121 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .claudecode_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
ClaudeDocument,
|
||||
RenderedClaudeDocument,
|
||||
runtime_root,
|
||||
same_claude_path,
|
||||
)
|
||||
from .claudecode_io import (
|
||||
rendered_change,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .base import InstallConfig
|
||||
from .claudecode_contract import AGENT_KEY, ClaudeDocument, RenderedClaudeDocument
|
||||
from .claudecode_io import rendered_change
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> ClaudeRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_claude_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return ClaudeRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Claude Code installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Claude Code"
|
||||
|
||||
|
||||
def preflight_claude_installation(
|
||||
config: InstallConfig, document: ClaudeDocument
|
||||
) -> ClaudeRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not document.file_path:
|
||||
raise ValueError("Claude Code installation requires a settings path")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_claude_installation(
|
||||
config: InstallConfig,
|
||||
paths: ClaudeRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedClaudeDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
@@ -124,43 +44,22 @@ def prepare_claude_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def commit_claude_installation(changes: List[FileChange]) -> None:
|
||||
write_changes(changes, "Install Claude Code hooks")
|
||||
|
||||
|
||||
def prepare_claude_uninstall(
|
||||
rendered: RenderedClaudeDocument,
|
||||
) -> List[FileChange]:
|
||||
return _present([rendered_change(rendered)])
|
||||
def prepare_claude_uninstall(rendered: RenderedClaudeDocument) -> List[FileChange]:
|
||||
return present([rendered_change(rendered)])
|
||||
|
||||
|
||||
def commit_claude_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,21 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_contract_exists
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .claudecode_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
@@ -25,9 +16,6 @@ from .claudecode_contract import (
|
||||
RenderedClaudeDocument,
|
||||
create_claude_document,
|
||||
parse_claude_document,
|
||||
runtime_root,
|
||||
same_claude_agent_id,
|
||||
same_claude_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,29 +31,21 @@ def claude_settings_path() -> str:
|
||||
|
||||
def read_claude_document() -> ClaudeDocument:
|
||||
file_path = claude_settings_path()
|
||||
physical_directory_exists(
|
||||
os.path.dirname(file_path), "Claude Code configuration directory"
|
||||
)
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Claude Code user settings", MAX_SOURCE_BYTES
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(file_path), "Claude Code configuration directory")
|
||||
snapshot = read_physical_file(file_path, "Claude Code user settings", MAX_SOURCE_BYTES)
|
||||
if snapshot is None:
|
||||
return create_claude_document(file_path)
|
||||
return parse_claude_document(file_path, snapshot.contents)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedClaudeDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedClaudeDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Claude Code user settings",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,172 +55,7 @@ def write_claude_document(rendered: RenderedClaudeDocument) -> None:
|
||||
write_changes([change], "Uninstall Claude Code hooks")
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def claude_runtime_files_exist(contracts: List[ClaudeRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Claude Code") for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_claude_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Claude Code hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_claude_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Claude Code agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: ClaudeRuntimeContract) -> bool:
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_claude_path(os.path.dirname(agent_directory), root)
|
||||
or not same_claude_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def claude_runtime_files_exist(
|
||||
contracts: List[ClaudeRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -8,7 +8,9 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
|
||||
|
||||
AGENT_KEY = "cline"
|
||||
@@ -58,20 +60,6 @@ def home_dir() -> str:
|
||||
return os.path.expanduser("~")
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(home_dir(), ".elydora")
|
||||
|
||||
|
||||
def same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def resolve_hooks_directory() -> str:
|
||||
configured = os.environ.get("CLINE_DIR", "").strip()
|
||||
cline_directory = (
|
||||
@@ -267,10 +255,10 @@ def runtime_contract(
|
||||
raise ValueError("Elydora Cline hook files contain mismatched event metadata")
|
||||
if not same_agent_id(guard.agent_id, audit.agent_id):
|
||||
raise ValueError("Elydora Cline hook files reference different agents")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), guard.agent_id)
|
||||
agent_directory = resolve_agent_directory(runtime_root(), guard.agent_id)
|
||||
expected_guard = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
expected_audit = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not _same_path(guard.runtime_path, expected_guard) or not _same_path(
|
||||
if not same_path(guard.runtime_path, expected_guard) or not same_path(
|
||||
audit.runtime_path, expected_audit
|
||||
):
|
||||
raise ValueError(
|
||||
|
||||
@@ -2,107 +2,42 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, source_change, write_changes
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .cline_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
HookFile,
|
||||
assert_wrapper_integrity,
|
||||
build_metadata,
|
||||
build_wrapper,
|
||||
elydora_dir,
|
||||
resolve_hook_files,
|
||||
runtime_contract,
|
||||
same_agent_id,
|
||||
)
|
||||
from .cline_io import (
|
||||
validate_api_origin,
|
||||
validate_hook_tree,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .cline_io import validate_hook_tree
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClineRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> ClineRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not _same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
return ClineRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Cline installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("Cline requires an absolute Python executable path")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Cline"
|
||||
|
||||
|
||||
def _validate_hook_files(guard_file: HookFile, audit_file: HookFile) -> None:
|
||||
expected = resolve_hook_files()
|
||||
if not _same_path(guard_file.file_path, expected.guard_path) or not _same_path(
|
||||
if not same_path(guard_file.file_path, expected.guard_path) or not same_path(
|
||||
audit_file.file_path, expected.audit_path
|
||||
):
|
||||
raise ValueError("Cline installation received unexpected hook paths")
|
||||
@@ -113,41 +48,23 @@ def preflight_cline_installation(
|
||||
config: InstallConfig,
|
||||
guard_file: HookFile,
|
||||
audit_file: HookFile,
|
||||
) -> ClineRuntimePaths:
|
||||
_validate_install_config(config)
|
||||
) -> RuntimePaths:
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("Cline requires an absolute Python executable path")
|
||||
_validate_hook_files(guard_file, audit_file)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _hook_change(
|
||||
file: HookFile, label: str, next_source: Optional[str]
|
||||
) -> Optional[FileChange]:
|
||||
original = file.source if file.exists else None
|
||||
if file.exists and original is None:
|
||||
raise RuntimeError(f"{label} snapshot is missing source")
|
||||
return source_change(
|
||||
file.file_path,
|
||||
label,
|
||||
original,
|
||||
next_source,
|
||||
0o700,
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
return source_change(file.file_path, label, original, next_source, 0o700)
|
||||
|
||||
|
||||
def prepare_cline_installation(
|
||||
@@ -169,37 +86,18 @@ def prepare_cline_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present(
|
||||
(
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([
|
||||
_hook_change(guard_file, "Cline PreToolUse hook", guard_wrapper),
|
||||
_hook_change(audit_file, "Cline PostToolUse hook", audit_wrapper),
|
||||
)
|
||||
)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
]),
|
||||
]
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
validate_hook_tree(os.path.dirname(guard_file.file_path))
|
||||
return changes
|
||||
|
||||
@@ -211,21 +109,14 @@ def commit_cline_installation(changes: Sequence[FileChange]) -> None:
|
||||
def prepare_cline_uninstall(
|
||||
files: Sequence[HookFile], agent_id: str = ""
|
||||
) -> List[FileChange]:
|
||||
owned = [
|
||||
file
|
||||
for file in files
|
||||
if file.metadata is not None
|
||||
and (not agent_id or same_agent_id(file.metadata.agent_id, agent_id))
|
||||
]
|
||||
changes: List[Optional[FileChange]] = []
|
||||
for file in owned:
|
||||
for file in files:
|
||||
metadata = file.metadata
|
||||
assert metadata is not None
|
||||
if metadata is None or (agent_id and not same_agent_id(metadata.agent_id, agent_id)):
|
||||
continue
|
||||
assert_wrapper_integrity(file)
|
||||
changes.append(
|
||||
_hook_change(file, f"Cline {metadata.kind} hook", None)
|
||||
)
|
||||
return _present(changes)
|
||||
changes.append(_hook_change(file, f"Cline {metadata.kind} hook", None))
|
||||
return present(changes)
|
||||
|
||||
|
||||
def commit_cline_uninstall(changes: Sequence[FileChange]) -> None:
|
||||
|
||||
@@ -2,43 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
import urllib.parse
|
||||
from typing import Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .cline_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
HookFile,
|
||||
RuntimeContract,
|
||||
elydora_dir,
|
||||
parse_metadata,
|
||||
same_agent_id,
|
||||
)
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
from ._managed_files import physical_directory_exists, read_physical_file
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .cline_contract import AGENT_KEY, HookFile, RuntimeContract, parse_metadata
|
||||
|
||||
|
||||
def validate_hook_tree(hooks_directory: str) -> None:
|
||||
physical_directory_exists(
|
||||
os.path.dirname(hooks_directory), "Cline configuration directory"
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(hooks_directory), "Cline configuration directory")
|
||||
physical_directory_exists(hooks_directory, "Cline hooks directory")
|
||||
|
||||
|
||||
@@ -63,194 +37,9 @@ def require_available_hook_file(file: HookFile) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Cline hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Cline agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _valid_contract_paths(contract: RuntimeContract) -> bool:
|
||||
return (
|
||||
_same_path(os.path.dirname(contract.agent_directory), elydora_dir())
|
||||
and _same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(contract.agent_directory, GUARD_SCRIPT),
|
||||
)
|
||||
and _same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(contract.agent_directory, AUDIT_SCRIPT),
|
||||
)
|
||||
)
|
||||
def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contract: RuntimeContract) -> bool:
|
||||
if not _valid_contract_paths(contract):
|
||||
return False
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
contract.agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(contract.agent_directory, "config.json")
|
||||
key_path = os.path.join(contract.agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if any(item is None for item in (config, key, guard, audit)):
|
||||
return False
|
||||
assert config is not None
|
||||
assert key is not None
|
||||
assert guard is not None
|
||||
assert audit is not None
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_audit = generate_hook_script(
|
||||
org_id="",
|
||||
agent_id=contract.agent_id,
|
||||
kid="",
|
||||
base_url="",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return (
|
||||
guard.contents == generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
and audit.contents == expected_audit
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"read_hook_file",
|
||||
"require_available_hook_file",
|
||||
"runtime_files_exist",
|
||||
"validate_api_origin",
|
||||
"validate_hook_tree",
|
||||
"validate_private_key",
|
||||
"validate_runtime_tree",
|
||||
]
|
||||
return runtime_contract_exists(contract, AGENT_KEY, "Cline", _expected_scripts)
|
||||
@@ -2,133 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
from elydora.utils import base64url_encode
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES
|
||||
from ._transaction import FileChange
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import write_changes
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .codex_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_STATUS,
|
||||
GUARD_SCRIPT,
|
||||
GUARD_STATUS,
|
||||
build_handler,
|
||||
remove_managed_hooks,
|
||||
render_document,
|
||||
runtime_contracts,
|
||||
runtime_root,
|
||||
same_path,
|
||||
)
|
||||
from .codex_io import (
|
||||
read_document,
|
||||
rendered_change,
|
||||
runtime_change,
|
||||
runtime_files_exist,
|
||||
validate_hooks_directory,
|
||||
validate_runtime_tree,
|
||||
write_codex_changes,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> tuple[str, str, str, str]:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
configured_guard = config.get("guard_script_path", "")
|
||||
if not same_path(configured_guard, guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
return agent_id, agent_directory, guard_path, audit_path
|
||||
|
||||
|
||||
def _validate_private_key(value: str) -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
"private_key must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or base64url_encode(seed) != value:
|
||||
raise ValueError("private_key must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Codex installation requires agent_name {AGENT_KEY}")
|
||||
_validate_private_key(config["private_key"])
|
||||
|
||||
base_url = config["base_url"]
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
has_credentials = parsed.username is not None or parsed.password is not None
|
||||
except ValueError as error:
|
||||
raise ValueError("base_url must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in base_url or any(
|
||||
character.isspace() or ord(character) < 32 for character in base_url
|
||||
)
|
||||
valid_origin = (
|
||||
parsed.scheme in ("http", "https")
|
||||
and bool(parsed.netloc)
|
||||
and hostname is not None
|
||||
and not invalid_character
|
||||
)
|
||||
if not valid_origin:
|
||||
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
|
||||
if has_credentials or parsed.query or parsed.fragment:
|
||||
raise ValueError(
|
||||
"base_url must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Codex"
|
||||
|
||||
|
||||
class CodexPlugin(AgentPlugin):
|
||||
@@ -137,74 +39,39 @@ class CodexPlugin(AgentPlugin):
|
||||
manages_guard_runtime = True
|
||||
|
||||
def preflight_install(self, config: InstallConfig) -> None:
|
||||
_validate_install_config(config)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
document = read_document()
|
||||
validate_hooks_directory(document.file_path)
|
||||
agent_id, agent_directory, _, _ = _agent_paths(config)
|
||||
validate_runtime_tree(runtime_root(), agent_directory, agent_id)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
self.preflight_install(config)
|
||||
document = read_document()
|
||||
agent_id, agent_directory, guard_path, audit_path = _agent_paths(config)
|
||||
|
||||
paths = resolve_runtime_paths(config)
|
||||
hooks = remove_managed_hooks(document.hooks)
|
||||
hooks["PreToolUse"] = [
|
||||
*hooks.get("PreToolUse", []),
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [build_handler(guard_path, GUARD_STATUS)],
|
||||
},
|
||||
{"matcher": "*", "hooks": [build_handler(paths.guard_path, GUARD_STATUS)]},
|
||||
]
|
||||
hooks["PostToolUse"] = [
|
||||
*hooks.get("PostToolUse", []),
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [build_handler(audit_path, AUDIT_STATUS)],
|
||||
},
|
||||
{"matcher": "*", "hooks": [build_handler(paths.audit_path, AUDIT_STATUS)]},
|
||||
]
|
||||
|
||||
guard_script = generate_guard_script(AGENT_KEY, agent_id)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present(
|
||||
[
|
||||
runtime_change(
|
||||
guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
runtime_change(
|
||||
os.path.join(agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
runtime_change(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
runtime_change(
|
||||
audit_path,
|
||||
"Elydora audit runtime",
|
||||
audit_script,
|
||||
0o700,
|
||||
),
|
||||
rendered_change(render_document(document, hooks)),
|
||||
]
|
||||
)
|
||||
write_codex_changes(changes, "Install Codex hooks")
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(render_document(document, hooks))]),
|
||||
]
|
||||
write_changes(changes, "Install Codex hooks")
|
||||
print(f" Codex hooks: {document.file_path}")
|
||||
print(" Codex trust: run /hooks and approve both Elydora command hooks.")
|
||||
|
||||
@@ -215,25 +82,17 @@ class CodexPlugin(AgentPlugin):
|
||||
if change is None:
|
||||
return
|
||||
validate_hooks_directory(document.file_path)
|
||||
write_codex_changes([change], "Uninstall Codex hooks")
|
||||
write_changes([change], "Uninstall Codex hooks")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
document = read_document()
|
||||
contracts = runtime_contracts(document.hooks)
|
||||
if not contracts:
|
||||
return PluginStatus(
|
||||
installed=False,
|
||||
agent=AGENT_KEY,
|
||||
details="Not installed",
|
||||
)
|
||||
return PluginStatus(installed=False, agent=AGENT_KEY, details="Not installed")
|
||||
installed = runtime_files_exist(contracts)
|
||||
details = (
|
||||
f"Config: {document.file_path}"
|
||||
if installed
|
||||
else f"Configured at {document.file_path}; runtime files missing"
|
||||
)
|
||||
return PluginStatus(
|
||||
installed=installed,
|
||||
agent=AGENT_KEY,
|
||||
details=details,
|
||||
)
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
@@ -2,17 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess # nosec B404 - used only for legacy argument rendering
|
||||
import sys
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
|
||||
|
||||
@@ -51,164 +57,16 @@ class RuntimeContract:
|
||||
audit_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_handler(script_path: str, status_message: str) -> JsonObject:
|
||||
return {
|
||||
"type": "command",
|
||||
"command": f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}",
|
||||
"commandWindows": _windows_command(script_path),
|
||||
"command": posix_source(script_path),
|
||||
"commandWindows": encoded_windows_command(script_path),
|
||||
"timeout": HOOK_TIMEOUT_SECONDS,
|
||||
"statusMessage": status_message,
|
||||
}
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if (
|
||||
executable is None
|
||||
or source[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or ntpath.basename(match.group(1)).lower() != "powershell.exe"
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
|
||||
|
||||
def _parse_legacy_commands(handler: JsonObject) -> Optional[Tuple[str, str]]:
|
||||
command = handler.get("command")
|
||||
command_windows = handler.get("commandWindows")
|
||||
@@ -228,17 +86,6 @@ def _parse_legacy_commands(handler: JsonObject) -> Optional[Tuple[str, str]]:
|
||||
return arguments[0], arguments[1]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"python(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.I,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _managed_script_path(
|
||||
handler: JsonObject,
|
||||
status_message: str,
|
||||
@@ -252,8 +99,14 @@ def _managed_script_path(
|
||||
or handler.get("statusMessage") != status_message
|
||||
):
|
||||
return None
|
||||
posix = _parse_posix_command(handler.get("command"))
|
||||
windows = _parse_windows_command(handler.get("commandWindows"))
|
||||
command = handler.get("command")
|
||||
command_windows = handler.get("commandWindows")
|
||||
posix = parse_posix_command(command) if isinstance(command, str) else None
|
||||
windows = (
|
||||
parse_encoded_windows_command(command_windows)
|
||||
if isinstance(command_windows, str)
|
||||
else None
|
||||
)
|
||||
if posix is None or windows is None:
|
||||
legacy = _parse_legacy_commands(handler)
|
||||
posix = legacy
|
||||
@@ -263,8 +116,8 @@ def _managed_script_path(
|
||||
or windows is None
|
||||
or not os.path.isabs(posix[0])
|
||||
or not os.path.isabs(posix[1])
|
||||
or not _is_python_executable(posix[0])
|
||||
or not _is_python_executable(windows[0])
|
||||
or not is_python_executable(posix[0])
|
||||
or not is_python_executable(windows[0])
|
||||
or not same_path(posix[0], windows[0])
|
||||
or not same_path(posix[1], windows[1])
|
||||
):
|
||||
@@ -278,13 +131,10 @@ def _managed_agent_id(
|
||||
status_message: str,
|
||||
) -> Optional[str]:
|
||||
script_path = _managed_script_path(handler, status_message)
|
||||
if script_path is None or os.path.basename(script_path) != script_name:
|
||||
if script_path is None:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else reference[0]
|
||||
|
||||
|
||||
def _read_hooks(value: Any, label: str) -> CodexHooks:
|
||||
@@ -339,7 +189,7 @@ def _remove_from_groups(
|
||||
for handler in group["hooks"]:
|
||||
managed_id = _managed_agent_id(handler, script_name, status_message)
|
||||
remove = managed_id is not None and (
|
||||
not agent_id or _same_agent_id(managed_id, agent_id)
|
||||
not agent_id or same_agent_id(managed_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
handlers.append(handler)
|
||||
|
||||
@@ -4,17 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_present
|
||||
from ._transaction import FileChange
|
||||
from .codex_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
@@ -26,12 +20,6 @@ from .codex_contract import (
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(value: Any, expected: str) -> bool:
|
||||
return isinstance(value, str) and os.path.normcase(value) == os.path.normcase(
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def codex_home_path() -> str:
|
||||
configured = os.environ.get("CODEX_HOME")
|
||||
if configured is None or configured == "":
|
||||
@@ -64,130 +52,19 @@ def read_document() -> CodexDocument:
|
||||
|
||||
|
||||
def validate_hooks_directory(file_path: str) -> None:
|
||||
directory = os.path.dirname(file_path)
|
||||
physical_directory_exists(directory, "Codex hooks directory")
|
||||
physical_directory_exists(os.path.dirname(file_path), "Codex hooks directory")
|
||||
|
||||
|
||||
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Codex user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
def runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
return source_change(
|
||||
file_path,
|
||||
label,
|
||||
None if snapshot is None else snapshot.contents,
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path,
|
||||
"Elydora runtime config",
|
||||
MAX_CONFIG_BYTES,
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents,
|
||||
f"Elydora runtime config at {file_path}",
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_identity(agent_directory: str, agent_id: str) -> bool:
|
||||
config_path_value = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path_value)
|
||||
artifacts = [
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
]
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return False
|
||||
if config.get("agent_name") != AGENT_KEY or not _same_agent_id(
|
||||
config.get("agent_id"),
|
||||
agent_id,
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Codex agent {agent_id}: "
|
||||
f"{config_path_value}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def validate_runtime_tree(
|
||||
runtime_root: str, agent_directory: str, agent_id: str
|
||||
) -> None:
|
||||
if not physical_directory_exists(runtime_root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
return
|
||||
validate_runtime_identity(agent_directory, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
for contract in contracts:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
continue
|
||||
config = _read_runtime_config(os.path.join(agent_directory, "config.json"))
|
||||
if (
|
||||
config is None
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
or not _same_agent_id(config.get("agent_id"), contract.agent_id)
|
||||
):
|
||||
continue
|
||||
if all(
|
||||
(
|
||||
physical_file_exists(contract.guard_path, "Elydora guard runtime"),
|
||||
physical_file_exists(contract.audit_path, "Elydora audit runtime"),
|
||||
physical_file_exists(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
),
|
||||
)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def write_codex_changes(changes: List[FileChange], label: str) -> None:
|
||||
write_changes(changes, label)
|
||||
return any(runtime_present(contract, AGENT_KEY) for contract in contracts)
|
||||
@@ -7,10 +7,19 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
is_python_executable,
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
from ._strict_json import parse_json_object
|
||||
from .copilot_schema import CopilotHooks, validate_hooks
|
||||
|
||||
@@ -69,124 +78,35 @@ class RuntimeContract:
|
||||
audit_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ManagedEntry:
|
||||
agent_id: str
|
||||
script_path: str
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_handler(script_path: str) -> JsonObject:
|
||||
return {
|
||||
"type": "command",
|
||||
"bash": f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}",
|
||||
"powershell": (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
),
|
||||
"bash": posix_source(script_path),
|
||||
"powershell": powershell_source(script_path),
|
||||
"timeoutSec": HOOK_TIMEOUT_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_generated_bash(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or command[executable.next_index:executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_generated_powershell(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str) or not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if executable is None or command[executable.next_index:executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable.next_index + 1)
|
||||
if script is None or command[script.next_index:] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _exact_handler_keys(handler: JsonObject) -> bool:
|
||||
return set(handler) == {"type", "bash", "powershell", "timeoutSec"}
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return os.path.isabs(file_path) and re.fullmatch(
|
||||
r"(?:python|pypy)(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
) is not None
|
||||
def _parse_generated_bash(command: Any) -> Optional[Tuple[str, str]]:
|
||||
return parse_posix_command(command) if isinstance(command, str) else None
|
||||
|
||||
|
||||
def _parse_generated_powershell(command: Any) -> Optional[Tuple[str, str]]:
|
||||
return parse_powershell_source(command) if isinstance(command, str) else None
|
||||
|
||||
|
||||
def _absolute_python(file_path: str) -> bool:
|
||||
return os.path.isabs(file_path) and is_python_executable(file_path)
|
||||
|
||||
|
||||
def _current_script_path(handler: JsonObject) -> Optional[str]:
|
||||
@@ -201,7 +121,7 @@ def _current_script_path(handler: JsonObject) -> Optional[str]:
|
||||
if (
|
||||
bash is None
|
||||
or powershell is None
|
||||
or not _is_python_executable(bash[0])
|
||||
or not _absolute_python(bash[0])
|
||||
or not same_path(bash[0], powershell[0])
|
||||
or not same_path(bash[1], powershell[1])
|
||||
):
|
||||
@@ -227,7 +147,7 @@ def _prior_script_path(handler: JsonObject) -> Optional[str]:
|
||||
len(arguments) != 2
|
||||
or shlex.join(arguments) != bash_command
|
||||
or powershell is None
|
||||
or not _is_python_executable(arguments[0])
|
||||
or not _absolute_python(arguments[0])
|
||||
or not same_path(arguments[0], powershell[0])
|
||||
or not same_path(arguments[1], powershell[1])
|
||||
):
|
||||
|
||||
@@ -3,48 +3,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import (
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
file_change,
|
||||
source_change,
|
||||
write_changes,
|
||||
)
|
||||
from .base import InstallConfig
|
||||
from .copilot_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
CopilotSources,
|
||||
RenderedDocument,
|
||||
runtime_root,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
)
|
||||
from .copilot_io import (
|
||||
require_hooks_enabled,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .copilot_contract import AGENT_KEY, CopilotSources, RenderedDocument
|
||||
from .copilot_io import require_hooks_enabled
|
||||
from .copilot_schema import validate_javascript_regexes
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CopilotRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
PRODUCT = "GitHub Copilot CLI"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,95 +34,25 @@ class PreparedCopilotInstallation:
|
||||
preconditions: Tuple[FilePrecondition, ...]
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> CopilotRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return CopilotRuntimePaths(
|
||||
agent_id,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"GitHub Copilot CLI installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError(
|
||||
"GitHub Copilot CLI requires an absolute Python executable path"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
|
||||
|
||||
def preflight_copilot_installation(
|
||||
config: InstallConfig,
|
||||
sources: CopilotSources,
|
||||
) -> CopilotRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not sources.user.file_path:
|
||||
raise ValueError(
|
||||
"GitHub Copilot CLI installation requires a user hook path"
|
||||
)
|
||||
raise ValueError("GitHub Copilot CLI installation requires a user hook path")
|
||||
require_hooks_enabled(sources)
|
||||
hook_sources = [sources.user.hooks]
|
||||
if sources.legacy is not None:
|
||||
hook_sources.append(sources.legacy.hooks)
|
||||
validate_javascript_regexes(hook_sources)
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("GitHub Copilot CLI requires an absolute Python executable path")
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, "Copilot")
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
@@ -156,12 +67,6 @@ def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
)
|
||||
|
||||
|
||||
def _present(
|
||||
changes: Sequence[Optional[FileChange]],
|
||||
) -> Tuple[FileChange, ...]:
|
||||
return tuple(change for change in changes if change is not None)
|
||||
|
||||
|
||||
def _document_preconditions(
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
@@ -177,16 +82,9 @@ def _document_preconditions(
|
||||
)
|
||||
|
||||
|
||||
def _settings_preconditions(
|
||||
sources: CopilotSources,
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
def _settings_preconditions(sources: CopilotSources) -> Tuple[FilePrecondition, ...]:
|
||||
return tuple(
|
||||
FilePrecondition(
|
||||
item.file_path,
|
||||
item.label,
|
||||
item.snapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
FilePrecondition(item.file_path, item.label, item.snapshot, MAX_SOURCE_BYTES)
|
||||
for item in sources.settings_preconditions
|
||||
)
|
||||
|
||||
@@ -206,9 +104,7 @@ def _validate_rendered_documents(
|
||||
if same_path(item.document.file_path, document.file_path)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
"GitHub Copilot rendered source set contains unexpected paths"
|
||||
)
|
||||
raise ValueError("GitHub Copilot rendered source set contains unexpected paths")
|
||||
|
||||
|
||||
def prepare_copilot_installation(
|
||||
@@ -223,33 +119,18 @@ def prepare_copilot_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present((
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
*(_document_change(item) for item in rendered),
|
||||
))
|
||||
document_changes = [
|
||||
change for change in (_document_change(item) for item in rendered)
|
||||
if change is not None
|
||||
]
|
||||
changes = (
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*document_changes,
|
||||
)
|
||||
preconditions = (
|
||||
*_settings_preconditions(sources),
|
||||
*_document_preconditions(rendered),
|
||||
@@ -257,20 +138,17 @@ def prepare_copilot_installation(
|
||||
return PreparedCopilotInstallation(changes, preconditions)
|
||||
|
||||
|
||||
def commit_copilot_installation(
|
||||
prepared: PreparedCopilotInstallation,
|
||||
) -> None:
|
||||
write_changes(
|
||||
prepared.changes,
|
||||
"Install GitHub Copilot hooks",
|
||||
prepared.preconditions,
|
||||
)
|
||||
def commit_copilot_installation(prepared: PreparedCopilotInstallation) -> None:
|
||||
write_changes(prepared.changes, "Install GitHub Copilot hooks", prepared.preconditions)
|
||||
|
||||
|
||||
def prepare_copilot_uninstall(
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> Tuple[FileChange, ...]:
|
||||
return _present(tuple(_document_change(item) for item in rendered))
|
||||
return tuple(
|
||||
change for change in (_document_change(item) for item in rendered)
|
||||
if change is not None
|
||||
)
|
||||
|
||||
|
||||
def commit_copilot_uninstall(changes: Sequence[FileChange]) -> None:
|
||||
|
||||
@@ -2,40 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any, List, Optional, Tuple
|
||||
import urllib.parse
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ._jsonc import parse_jsonc
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .copilot_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
CONFIG_FILE,
|
||||
GUARD_SCRIPT,
|
||||
CopilotDocument,
|
||||
CopilotSources,
|
||||
RuntimeContract,
|
||||
SourcePrecondition,
|
||||
create_document,
|
||||
parse_document,
|
||||
runtime_root,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
)
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -216,186 +205,17 @@ def require_hooks_enabled(sources: CopilotSources) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"{label} must be an absolute HTTP or HTTPS URL"
|
||||
) from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject,
|
||||
contract: RuntimeContract,
|
||||
config_path: str,
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_string(config.get("agent_id"), "agent_id", config_path)
|
||||
if (
|
||||
not same_agent_id(agent_id, contract.agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Copilot hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_string(config.get("base_url"), "base_url", config_path)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Copilot agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _valid_contract_paths(contract: RuntimeContract) -> bool:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
return (
|
||||
same_path(os.path.dirname(agent_directory), runtime_root())
|
||||
and same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(agent_directory, GUARD_SCRIPT),
|
||||
)
|
||||
and same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(agent_directory, AUDIT_SCRIPT),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: RuntimeContract) -> bool:
|
||||
if not _valid_contract_paths(contract):
|
||||
return False
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id="",
|
||||
agent_id=contract.agent_id,
|
||||
kid="",
|
||||
base_url="",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(
|
||||
runtime_contract_exists(
|
||||
contract,
|
||||
AGENT_KEY,
|
||||
"Copilot",
|
||||
_expected_scripts,
|
||||
)
|
||||
for contract in contracts
|
||||
)
|
||||
@@ -2,78 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
from elydora.utils import base64url_encode
|
||||
|
||||
from ._transaction import FileChange
|
||||
from ._managed_files import physical_directory_exists
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import write_changes
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .cursor_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
build_handler,
|
||||
remove_managed_hooks,
|
||||
render_document,
|
||||
runtime_contracts,
|
||||
runtime_root,
|
||||
same_path,
|
||||
)
|
||||
from .cursor_io import (
|
||||
read_document,
|
||||
rendered_change,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
require_runtime_directory,
|
||||
runtime_change,
|
||||
runtime_files_exist,
|
||||
validate_config_directory,
|
||||
validate_runtime_identity,
|
||||
write_cursor_changes,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token", "")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> tuple[str, str, str]:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
configured_guard = config.get("guard_script_path", "")
|
||||
if not same_path(configured_guard, guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return agent_id, agent_directory, guard_path
|
||||
PRODUCT = "Cursor"
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
@@ -83,27 +41,14 @@ def _validate_install_config(config: InstallConfig) -> None:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Cursor installation requires agent_name {AGENT_KEY}")
|
||||
private_key = config["private_key"]
|
||||
try:
|
||||
padded = private_key + "=" * ((4 - len(private_key) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
"private_key must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or base64url_encode(seed) != private_key:
|
||||
raise ValueError("private_key must be a canonical 32-byte base64url value")
|
||||
base_url = config.get("base_url", "https://api.elydora.com")
|
||||
validate_private_key(config["private_key"])
|
||||
base_url = config.get("base_url", DEFAULT_BASE_URL)
|
||||
if not isinstance(base_url, str):
|
||||
raise ValueError("base_url must be a string")
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
|
||||
token = config.get("token", "")
|
||||
if not isinstance(token, str):
|
||||
if not isinstance(config.get("token", ""), str):
|
||||
raise ValueError("token must be a string")
|
||||
|
||||
|
||||
@@ -116,58 +61,32 @@ class CursorPlugin(AgentPlugin):
|
||||
_validate_install_config(config)
|
||||
read_document()
|
||||
validate_config_directory()
|
||||
agent_id, agent_directory, guard_path = _agent_paths(config)
|
||||
if not physical_directory_exists(runtime_root()):
|
||||
return
|
||||
if not physical_directory_exists(agent_directory):
|
||||
return
|
||||
identity_exists = validate_runtime_identity(
|
||||
os.path.join(agent_directory, "config.json"),
|
||||
agent_id,
|
||||
)
|
||||
runtime_exists = False
|
||||
for file_path, label in (
|
||||
(guard_path, "Elydora guard runtime"),
|
||||
(os.path.join(agent_directory, AUDIT_SCRIPT), "Elydora audit runtime"),
|
||||
(os.path.join(agent_directory, "private.key"), "Elydora private key"),
|
||||
(os.path.join(agent_directory, "chain-state.json"), "Elydora chain state"),
|
||||
(os.path.join(agent_directory, "status-cache.json"), "Elydora status cache"),
|
||||
(os.path.join(agent_directory, "error.log"), "Elydora error log"),
|
||||
):
|
||||
runtime_exists = physical_file_exists(file_path, label) or runtime_exists
|
||||
if runtime_exists and not identity_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
self.preflight_install(config)
|
||||
document = read_document()
|
||||
agent_id, agent_directory, guard_path = _agent_paths(config)
|
||||
require_runtime_directory(agent_directory)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
|
||||
runtime_config_path = os.path.join(agent_directory, "config.json")
|
||||
validate_runtime_identity(runtime_config_path, agent_id)
|
||||
paths = resolve_runtime_paths(config)
|
||||
if not physical_directory_exists(
|
||||
paths.agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
raise FileNotFoundError(
|
||||
f"Elydora agent runtime directory is missing: {paths.agent_directory}"
|
||||
)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
hooks = remove_managed_hooks(document.hooks)
|
||||
hooks["preToolUse"] = [
|
||||
*hooks.get("preToolUse", []),
|
||||
build_handler(guard_path),
|
||||
]
|
||||
hooks["postToolUse"] = [
|
||||
*hooks.get("postToolUse", []),
|
||||
build_handler(audit_path),
|
||||
]
|
||||
hooks["preToolUse"] = [*hooks.get("preToolUse", []), build_handler(paths.guard_path)]
|
||||
hooks["postToolUse"] = [*hooks.get("postToolUse", []), build_handler(paths.audit_path)]
|
||||
hooks["postToolUseFailure"] = [
|
||||
*hooks.get("postToolUseFailure", []),
|
||||
build_handler(audit_path),
|
||||
build_handler(paths.audit_path),
|
||||
]
|
||||
hook_script = generate_hook_script(
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
success_output="{}\n",
|
||||
fail_closed=True,
|
||||
native_payload=True,
|
||||
@@ -175,67 +94,34 @@ class CursorPlugin(AgentPlugin):
|
||||
)
|
||||
guard_script = generate_guard_script(
|
||||
AGENT_KEY,
|
||||
agent_id,
|
||||
paths.agent_id,
|
||||
success_output='{"permission":"allow"}\n',
|
||||
fail_closed=True,
|
||||
deny_protocol="cursor",
|
||||
)
|
||||
changes = _present([
|
||||
runtime_change(
|
||||
guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
runtime_change(
|
||||
runtime_config_path,
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, agent_id)),
|
||||
0o600,
|
||||
),
|
||||
runtime_change(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
),
|
||||
runtime_change(
|
||||
audit_path,
|
||||
"Elydora audit runtime",
|
||||
hook_script,
|
||||
0o700,
|
||||
),
|
||||
rendered_change(render_document(document, hooks)),
|
||||
])
|
||||
write_cursor_changes(changes, "Install Cursor hooks")
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(render_document(document, hooks))]),
|
||||
]
|
||||
write_changes(changes, "Install Cursor hooks")
|
||||
print(f" Cursor hooks: {document.file_path}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
document = read_document()
|
||||
hooks = remove_managed_hooks(document.hooks, agent_id)
|
||||
change = rendered_change(render_document(document, hooks))
|
||||
write_cursor_changes(
|
||||
[] if change is None else [change],
|
||||
"Uninstall Cursor hooks",
|
||||
)
|
||||
if change is not None:
|
||||
write_changes([change], "Uninstall Cursor hooks")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
document = read_document()
|
||||
contracts = runtime_contracts(document.hooks)
|
||||
if not contracts:
|
||||
return PluginStatus(
|
||||
installed=False,
|
||||
agent=AGENT_KEY,
|
||||
details="Not installed",
|
||||
)
|
||||
return PluginStatus(installed=False, agent=AGENT_KEY, details="Not installed")
|
||||
installed = runtime_files_exist(contracts)
|
||||
details = (
|
||||
f"Config: {document.file_path}"
|
||||
if installed
|
||||
else f"Configured at {document.file_path}; runtime files missing"
|
||||
)
|
||||
return PluginStatus(
|
||||
installed=installed,
|
||||
agent=AGENT_KEY,
|
||||
details=details,
|
||||
)
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
@@ -7,8 +7,17 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
|
||||
|
||||
@@ -43,42 +52,8 @@ class RuntimeContract:
|
||||
audit_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_handler(script_path: str) -> JsonObject:
|
||||
if os.name == "nt":
|
||||
command = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
else:
|
||||
command = f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
command = powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
|
||||
return {
|
||||
"command": command,
|
||||
"timeout": HOOK_TIMEOUT_SECONDS,
|
||||
@@ -86,73 +61,6 @@ def build_handler(script_path: str) -> JsonObject:
|
||||
}
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str,
|
||||
start: int,
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable.next_index + 1)
|
||||
if script is None or command[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _legacy_script_path(command: str) -> Optional[str]:
|
||||
quoted = re.fullmatch(r'"([^"\r\n]+)" ([^\r\n]+)', command)
|
||||
if quoted and same_path(quoted.group(1), sys.executable):
|
||||
@@ -169,9 +77,9 @@ def _managed_script_path(handler: JsonObject) -> Optional[str]:
|
||||
and handler.get("failClosed") is True
|
||||
):
|
||||
parsed = (
|
||||
_parse_powershell_command(handler["command"])
|
||||
parse_powershell_source(handler["command"])
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(handler["command"])
|
||||
else parse_posix_command(handler["command"])
|
||||
)
|
||||
if (
|
||||
parsed is not None
|
||||
@@ -185,18 +93,12 @@ def _managed_script_path(handler: JsonObject) -> Optional[str]:
|
||||
return _legacy_script_path(handler["command"])
|
||||
|
||||
|
||||
def _managed_agent_id(
|
||||
handler: JsonObject,
|
||||
script_name: str,
|
||||
) -> Optional[str]:
|
||||
def _managed_agent_id(handler: JsonObject, script_name: str) -> Optional[str]:
|
||||
script_path = _managed_script_path(handler)
|
||||
if script_path is None or os.path.basename(script_path) != script_name:
|
||||
if script_path is None:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else reference[0]
|
||||
|
||||
|
||||
def _read_hooks(value: Any, label: str) -> CursorHooks:
|
||||
@@ -256,7 +158,7 @@ def remove_managed_hooks(
|
||||
for handler in result.get(event, []):
|
||||
managed_id = _managed_agent_id(handler, script_name)
|
||||
remove = managed_id is not None and (
|
||||
not agent_id or _same_agent_id(managed_id, agent_id)
|
||||
not agent_id or same_agent_id(managed_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
handlers.append(handler)
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_present
|
||||
from ._transaction import FileChange
|
||||
from .cursor_contract import (
|
||||
AGENT_KEY,
|
||||
CursorDocument,
|
||||
JsonObject,
|
||||
RenderedDocument,
|
||||
RuntimeContract,
|
||||
create_document,
|
||||
parse_document,
|
||||
parse_json_object,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,180 +25,28 @@ def config_path() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".cursor", CONFIG_FILE)
|
||||
|
||||
|
||||
def _read_optional_physical(file_path: str, label: str) -> Optional[str]:
|
||||
try:
|
||||
metadata = os.lstat(file_path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", newline="") as file:
|
||||
return file.read()
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
|
||||
|
||||
def read_document() -> CursorDocument:
|
||||
file_path = config_path()
|
||||
raw = _read_optional_physical(file_path, "Cursor user hooks")
|
||||
return create_document(file_path) if raw is None else parse_document(file_path, raw)
|
||||
snapshot = read_physical_file(file_path, "Cursor user hooks", MAX_SOURCE_BYTES)
|
||||
if snapshot is None:
|
||||
return create_document(file_path)
|
||||
return parse_document(file_path, snapshot.contents)
|
||||
|
||||
|
||||
def validate_config_directory() -> None:
|
||||
directory = os.path.dirname(config_path())
|
||||
try:
|
||||
metadata = os.lstat(directory)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect Cursor hooks directory at {directory}: {error}") from error
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"Cursor hooks directory is not a physical directory: {directory}")
|
||||
physical_directory_exists(os.path.dirname(config_path()), "Cursor hooks directory")
|
||||
|
||||
|
||||
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Cursor user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
)
|
||||
|
||||
|
||||
def runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
) -> Optional[FileChange]:
|
||||
original = _read_optional_physical(file_path, label)
|
||||
return source_change(file_path, label, original, next_source, mode)
|
||||
|
||||
|
||||
def physical_file_exists(file_path: str, label: str) -> bool:
|
||||
try:
|
||||
metadata = os.lstat(file_path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
return True
|
||||
|
||||
|
||||
def require_runtime(file_path: str, label: str) -> None:
|
||||
if not file_path:
|
||||
raise ValueError(f"{label} path is required")
|
||||
if not physical_file_exists(file_path, label):
|
||||
raise FileNotFoundError(f"{label} is missing: {file_path}")
|
||||
|
||||
|
||||
def physical_directory_exists(directory: str) -> bool:
|
||||
try:
|
||||
metadata = os.lstat(directory)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(
|
||||
f"Inspect Elydora agent runtime directory at {directory}: {error}"
|
||||
) from error
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(
|
||||
f"Elydora agent runtime path is not a physical directory: {directory}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def require_runtime_directory(directory: str) -> None:
|
||||
if not physical_directory_exists(directory):
|
||||
raise FileNotFoundError(
|
||||
f"Elydora agent runtime directory is missing: {directory}"
|
||||
)
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
raw = _read_optional_physical(file_path, "Elydora runtime config")
|
||||
if raw is None:
|
||||
return None
|
||||
return parse_json_object(raw, f"Elydora runtime config at {file_path}")
|
||||
|
||||
|
||||
def validate_runtime_identity(
|
||||
file_path: str,
|
||||
agent_id: str,
|
||||
) -> bool:
|
||||
config = _read_runtime_config(file_path)
|
||||
if config is None:
|
||||
return False
|
||||
if config.get("agent_name") != AGENT_KEY or not _same_agent_id(
|
||||
config.get("agent_id"), agent_id
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Cursor agent {agent_id}: "
|
||||
f"{file_path}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _same_agent_id(value: Any, expected: str) -> bool:
|
||||
return isinstance(value, str) and os.path.normcase(value) == os.path.normcase(
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
for contract in contracts:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(agent_directory):
|
||||
continue
|
||||
config_path_value = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path_value)
|
||||
if (
|
||||
config is None
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
or not _same_agent_id(config.get("agent_id"), contract.agent_id)
|
||||
):
|
||||
continue
|
||||
if physical_file_exists(
|
||||
contract.guard_path,
|
||||
"Elydora guard runtime",
|
||||
) and physical_file_exists(
|
||||
contract.audit_path,
|
||||
"Elydora audit runtime",
|
||||
) and physical_file_exists(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_physical_directory(directory: str, label: str) -> None:
|
||||
try:
|
||||
os.makedirs(directory, mode=0o700, exist_ok=True)
|
||||
metadata = os.lstat(directory)
|
||||
except OSError as error:
|
||||
raise OSError(f"Prepare {label} directory at {directory}: {error}") from error
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} directory is not a physical directory: {directory}")
|
||||
|
||||
|
||||
def write_cursor_changes(changes: List[FileChange], label: str) -> None:
|
||||
effective = [change for change in changes if change.original != change.next_source]
|
||||
if not effective:
|
||||
return
|
||||
directories = {
|
||||
os.path.normcase(os.path.abspath(os.path.dirname(change.file_path))):
|
||||
os.path.dirname(change.file_path)
|
||||
for change in effective
|
||||
}
|
||||
for directory in directories.values():
|
||||
_ensure_physical_directory(directory, label)
|
||||
write_changes(effective, label)
|
||||
return any(runtime_present(contract, AGENT_KEY) for contract in contracts)
|
||||
@@ -8,13 +8,13 @@ from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from ._jsonc import JsonPathPart, JsoncEditor
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._runtime import same_path
|
||||
from .droid_contract import (
|
||||
TOOL_EVENTS,
|
||||
DroidHookMap,
|
||||
JsonObject,
|
||||
managed_removals,
|
||||
read_hook_map,
|
||||
same_path,
|
||||
)
|
||||
from .droid_policy import DroidPolicyState
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ import subprocess # nosec B404
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
|
||||
|
||||
AGENT_KEY = "droid"
|
||||
GUARD_SCRIPT = "guard.py"
|
||||
@@ -22,7 +32,6 @@ TOOL_EVENTS = ("PreToolUse", "PostToolUse")
|
||||
_HANDLER_KEYS = {"command", "timeout", "type"}
|
||||
_GROUP_KEYS = {"hooks", "matcher"}
|
||||
_REGEX_TIMEOUT_SECONDS = 10
|
||||
_WINDOWS_EXIT_SUFFIX = "; exit $LASTEXITCODE"
|
||||
_REGEX_VALIDATOR = """import fs from 'node:fs';
|
||||
const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
for (const entry of entries) {
|
||||
@@ -55,39 +64,14 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_power_shell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def build_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Factory Droid hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return (
|
||||
f"& {_quote_power_shell(sys.executable)} "
|
||||
f"{_quote_power_shell(script_path)}{_WINDOWS_EXIT_SUFFIX}"
|
||||
)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
return powershell_source(script_path)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def build_group(script_path: str) -> JsonObject:
|
||||
@@ -214,83 +198,6 @@ def validate_javascript_regexes(sources: Sequence[DroidHookMap]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _read_power_shell_argument(
|
||||
command: str,
|
||||
start: int,
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
_POSIX_APOSTROPHE = "'\"'\"'"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(_POSIX_APOSTROPHE, index):
|
||||
value += "'"
|
||||
index += len(_POSIX_APOSTROPHE)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_two_arguments(
|
||||
command: str,
|
||||
start: int,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, start)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
if not executable[0] or not script[0]:
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _parse_power_shell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_power_shell_argument(command, 2)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_power_shell_argument(command, executable[1] + 1)
|
||||
if (
|
||||
script is None
|
||||
or command[script[1]:] != _WINDOWS_EXIT_SUFFIX
|
||||
or not executable[0]
|
||||
or not script[0]
|
||||
):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(r'"([^"\r\n]+)" "([^"\r\n]+)"', command)
|
||||
return (match.group(1), match.group(2)) if match else None
|
||||
@@ -301,8 +208,8 @@ def _parse_generated_command(
|
||||
include_legacy: bool,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
if os.name != "nt":
|
||||
return _parse_two_arguments(command, 0)
|
||||
current = _parse_power_shell_command(command)
|
||||
return parse_posix_command(command)
|
||||
current = parse_powershell_source(command)
|
||||
return current or (
|
||||
_parse_legacy_windows_command(command) if include_legacy else None
|
||||
)
|
||||
@@ -326,14 +233,8 @@ def managed_agent_id(
|
||||
parsed = _parse_generated_command(command, include_legacy)
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
script_path = parsed[1]
|
||||
if os.path.basename(script_path) != script_name:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), elydora_dir()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
reference = managed_script_reference(parsed[1], script_name)
|
||||
return None if reference is None else reference[0]
|
||||
|
||||
|
||||
def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool:
|
||||
@@ -397,7 +298,7 @@ def runtime_contracts(hooks: DroidHookMap) -> List[RuntimeContract]:
|
||||
for agent_id in sorted(guards):
|
||||
if not any(same_agent_id(agent_id, audit_id) for audit_id in audits):
|
||||
continue
|
||||
root = os.path.join(elydora_dir(), agent_id)
|
||||
root = os.path.join(runtime_root(), agent_id)
|
||||
contracts.append(RuntimeContract(
|
||||
agent_id,
|
||||
os.path.join(root, GUARD_SCRIPT),
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import (
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
file_change,
|
||||
source_change,
|
||||
write_changes,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .droid_config import (
|
||||
DroidDocument,
|
||||
@@ -26,30 +26,12 @@ from .droid_config import (
|
||||
installation_documents,
|
||||
source_documents,
|
||||
)
|
||||
from .droid_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
elydora_dir,
|
||||
same_path,
|
||||
validate_javascript_regexes,
|
||||
)
|
||||
from .droid_io import (
|
||||
require_hooks_enabled,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .droid_contract import AGENT_KEY, validate_javascript_regexes
|
||||
from .droid_io import require_hooks_enabled
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DroidRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
PRODUCT = "Factory Droid"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -74,90 +56,20 @@ def _source_label(document: DroidDocument) -> str:
|
||||
return "Factory Droid user hooks"
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> DroidRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return DroidRuntimePaths(
|
||||
agent_id,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Factory Droid installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError(
|
||||
"Factory Droid requires an absolute Python executable path"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
|
||||
|
||||
def preflight_droid_installation(
|
||||
config: InstallConfig,
|
||||
sources: DroidSources,
|
||||
) -> DroidRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
require_hooks_enabled(sources)
|
||||
validate_javascript_regexes([
|
||||
document.hooks for document in source_documents(sources)
|
||||
])
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_javascript_regexes([document.hooks for document in source_documents(sources)])
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("Factory Droid requires an absolute Python executable path")
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
@@ -173,10 +85,11 @@ def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
)
|
||||
|
||||
|
||||
def _present(
|
||||
changes: Sequence[Optional[FileChange]],
|
||||
) -> Tuple[FileChange, ...]:
|
||||
return tuple(change for change in changes if change is not None)
|
||||
def _document_changes(rendered: Sequence[RenderedDocument]) -> Tuple[FileChange, ...]:
|
||||
return tuple(
|
||||
change for change in (_document_change(item) for item in rendered)
|
||||
if change is not None
|
||||
)
|
||||
|
||||
|
||||
def _validate_rendered_documents(
|
||||
@@ -188,18 +101,15 @@ def _validate_rendered_documents(
|
||||
raise ValueError("Factory Droid rendered source set is incomplete")
|
||||
for document in expected:
|
||||
matches = [
|
||||
item
|
||||
for item in rendered
|
||||
item for item in rendered
|
||||
if same_path(item.document.file_path, document.file_path)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
"Factory Droid rendered source set contains unexpected paths"
|
||||
)
|
||||
raise ValueError("Factory Droid rendered source set contains unexpected paths")
|
||||
|
||||
|
||||
def _source_preconditions(
|
||||
sources: DroidSources,
|
||||
def _unchanged_preconditions(
|
||||
documents: Sequence[DroidDocument],
|
||||
changed_paths: Sequence[str],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
return tuple(
|
||||
@@ -209,11 +119,8 @@ def _source_preconditions(
|
||||
document.snapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
for document in source_documents(sources)
|
||||
if not any(
|
||||
same_path(document.file_path, changed)
|
||||
for changed in changed_paths
|
||||
)
|
||||
for document in documents
|
||||
if not any(same_path(document.file_path, changed) for changed in changed_paths)
|
||||
)
|
||||
|
||||
|
||||
@@ -229,84 +136,35 @@ def prepare_droid_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
document_changes = _present(tuple(
|
||||
_document_change(item) for item in rendered
|
||||
))
|
||||
changes = _present((
|
||||
_runtime_change(
|
||||
paths.guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path,
|
||||
"Elydora audit runtime",
|
||||
audit_script,
|
||||
0o700,
|
||||
),
|
||||
document_changes = _document_changes(rendered)
|
||||
changes = (
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*document_changes,
|
||||
))
|
||||
)
|
||||
changed_paths = [change.file_path for change in document_changes]
|
||||
preconditions = (
|
||||
*_source_preconditions(sources, changed_paths),
|
||||
*_unchanged_preconditions(source_documents(sources), changed_paths),
|
||||
*sources.policy.preconditions,
|
||||
)
|
||||
return PreparedDroidInstallation(changes, tuple(preconditions))
|
||||
return PreparedDroidInstallation(changes, preconditions)
|
||||
|
||||
|
||||
def commit_droid_installation(
|
||||
prepared: PreparedDroidInstallation,
|
||||
) -> None:
|
||||
write_changes(
|
||||
prepared.changes,
|
||||
"Install Factory Droid hooks",
|
||||
prepared.preconditions,
|
||||
)
|
||||
def commit_droid_installation(prepared: PreparedDroidInstallation) -> None:
|
||||
write_changes(prepared.changes, "Install Factory Droid hooks", prepared.preconditions)
|
||||
|
||||
|
||||
def prepare_droid_uninstall(
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> PreparedDroidUninstall:
|
||||
changes = _present(tuple(_document_change(item) for item in rendered))
|
||||
changes = _document_changes(rendered)
|
||||
changed_paths = [change.file_path for change in changes]
|
||||
preconditions = tuple(
|
||||
FilePrecondition(
|
||||
item.document.file_path,
|
||||
_source_label(item.document),
|
||||
item.document.snapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
for item in rendered
|
||||
if not any(
|
||||
same_path(item.document.file_path, changed)
|
||||
for changed in changed_paths
|
||||
)
|
||||
)
|
||||
return PreparedDroidUninstall(changes, preconditions)
|
||||
documents = [item.document for item in rendered]
|
||||
return PreparedDroidUninstall(changes, _unchanged_preconditions(documents, changed_paths))
|
||||
|
||||
|
||||
def commit_droid_uninstall(prepared: PreparedDroidUninstall) -> None:
|
||||
write_changes(
|
||||
prepared.changes,
|
||||
"Uninstall Factory Droid hooks",
|
||||
prepared.preconditions,
|
||||
)
|
||||
write_changes(prepared.changes, "Uninstall Factory Droid hooks", prepared.preconditions)
|
||||
@@ -2,21 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .droid_config import (
|
||||
DroidDocument,
|
||||
DroidSources,
|
||||
@@ -27,18 +19,8 @@ from .droid_config import (
|
||||
hook_block,
|
||||
parse_document,
|
||||
)
|
||||
from .droid_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
RuntimeContract,
|
||||
elydora_dir,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
)
|
||||
from .droid_contract import AGENT_KEY, RuntimeContract
|
||||
from .droid_policy import read_droid_policy
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -128,196 +110,20 @@ def require_hooks_enabled(sources: DroidSources) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"{label} must be an absolute HTTP or HTTPS URL"
|
||||
) from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(
|
||||
contract,
|
||||
AGENT_KEY,
|
||||
"Factory Droid",
|
||||
_expected_scripts,
|
||||
)
|
||||
for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject,
|
||||
contract: RuntimeContract,
|
||||
config_path: str,
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": '
|
||||
f"{config_path}"
|
||||
)
|
||||
_require_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_string(config.get("agent_id"), "agent_id", config_path)
|
||||
if (
|
||||
not same_agent_id(agent_id, contract.agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Factory Droid hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_string(config.get("base_url"), "base_url", config_path)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path,
|
||||
"Elydora runtime config",
|
||||
MAX_CONFIG_BYTES,
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents,
|
||||
f"Elydora runtime config at {file_path}",
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Factory Droid agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _valid_contract_paths(contract: RuntimeContract) -> bool:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
return (
|
||||
same_path(os.path.dirname(agent_directory), elydora_dir())
|
||||
and same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(agent_directory, GUARD_SCRIPT),
|
||||
)
|
||||
and same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(agent_directory, AUDIT_SCRIPT),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: RuntimeContract) -> bool:
|
||||
if not _valid_contract_paths(contract):
|
||||
return False
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id="",
|
||||
agent_id=contract.agent_id,
|
||||
kid="",
|
||||
base_url="",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: list[RuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
|
||||
|
||||
def display_config_path(sources: DroidSources) -> str:
|
||||
|
||||
@@ -2,14 +2,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._runtime import managed_script_reference, same_path
|
||||
from ._shell_command import (
|
||||
decode_powershell_source,
|
||||
encode_powershell_source,
|
||||
is_powershell_executable,
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
quote_powershell,
|
||||
read_powershell_argument,
|
||||
windows_powershell_path,
|
||||
)
|
||||
|
||||
_ENCODED_FLAGS = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeminiRuntimeReference:
|
||||
@@ -17,163 +31,33 @@ class GeminiRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
def same_gemini_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_gemini_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot")
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f"& {_quote_powershell(_windows_powershell_path())} "
|
||||
"-NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
||||
f"{encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_gemini_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Gemini CLI hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if executable is None or executable[1] >= len(source):
|
||||
return None
|
||||
if source[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable[1] + 1)
|
||||
if script is None or source[script[1] :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
encoded = encode_powershell_source(powershell_source(script_path))
|
||||
return f"& {quote_powershell(windows_powershell_path())}{_ENCODED_FLAGS}{encoded}"
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
powershell = _read_powershell_argument(command, 2)
|
||||
if (
|
||||
powershell is None
|
||||
or not ntpath.isabs(powershell[0])
|
||||
or ntpath.basename(powershell[0]).lower() != "powershell.exe"
|
||||
):
|
||||
powershell = read_powershell_argument(command, 2)
|
||||
if powershell is None or not is_powershell_executable(powershell[0]):
|
||||
return None
|
||||
prefix = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
||||
if not command.startswith(prefix, powershell[1]):
|
||||
if not command.startswith(_ENCODED_FLAGS, powershell[1]):
|
||||
return None
|
||||
encoded = command[powershell[1] + len(prefix) :]
|
||||
if re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", encoded) is None:
|
||||
return None
|
||||
try:
|
||||
payload = base64.b64decode(encoded, validate=True)
|
||||
if base64.b64encode(payload).decode("ascii") != encoded:
|
||||
return None
|
||||
if len(payload) % 2:
|
||||
return None
|
||||
source = payload.decode("utf-16le")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
source = decode_powershell_source(command[powershell[1] + len(_ENCODED_FLAGS) :])
|
||||
return None if source is None else parse_powershell_source(source)
|
||||
|
||||
|
||||
def _parse_legacy_command(
|
||||
command: str, script_name: str
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
def _parse_legacy_command(command: str, script_name: str) -> Optional[Tuple[str, str]]:
|
||||
if script_name == "guard.py":
|
||||
match = re.fullmatch(r'"([^"\r\n]+)" ([^\r\n]+)', command)
|
||||
if match is None or not same_gemini_path(match.group(1), sys.executable):
|
||||
if match is None or not same_path(match.group(1), sys.executable):
|
||||
return None
|
||||
return match.group(1), match.group(2)
|
||||
if "\r" in command or "\n" in command:
|
||||
@@ -186,21 +70,10 @@ def gemini_runtime_reference(
|
||||
script_name: str,
|
||||
include_legacy: bool = False,
|
||||
) -> Optional[GeminiRuntimeReference]:
|
||||
parsed = _parse_posix_command(command) or _parse_windows_command(command)
|
||||
parsed = parse_posix_command(command) or _parse_windows_command(command)
|
||||
if parsed is None and include_legacy:
|
||||
parsed = _parse_legacy_command(command, script_name)
|
||||
if (
|
||||
parsed is None
|
||||
or not same_gemini_path(parsed[0], sys.executable)
|
||||
or not os.path.isabs(parsed[1])
|
||||
or os.path.basename(parsed[1]) != script_name
|
||||
):
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(parsed[1])
|
||||
runtime_root = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
if not same_gemini_path(os.path.dirname(agent_directory), runtime_root):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return GeminiRuntimeReference(agent_id, parsed[1])
|
||||
reference = managed_script_reference(parsed[1], script_name)
|
||||
return None if reference is None else GeminiRuntimeReference(*reference)
|
||||
@@ -7,11 +7,11 @@ import math
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ._runtime import same_agent_id
|
||||
from .gemini_command import (
|
||||
GeminiRuntimeReference,
|
||||
build_gemini_command,
|
||||
gemini_runtime_reference,
|
||||
same_gemini_agent_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,10 +63,6 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def _optional_string(value: JsonObject, field: str, label: str) -> None:
|
||||
if field in value and not isinstance(value[field], str):
|
||||
raise ValueError(f'{label} field "{field}" must be a string')
|
||||
@@ -286,7 +282,7 @@ def managed_gemini_removals(
|
||||
is not None
|
||||
and (
|
||||
agent_id is None
|
||||
or same_gemini_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
)
|
||||
if indexes:
|
||||
|
||||
@@ -2,159 +2,58 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .gemini_command import same_gemini_path
|
||||
from .gemini_config import GeminiDocument, RenderedGeminiDocument
|
||||
from .gemini_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
elydora_dir,
|
||||
)
|
||||
from .gemini_io import (
|
||||
rendered_change,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .base import InstallConfig
|
||||
from .gemini_config import GeminiDocument, RenderedGeminiDocument
|
||||
from .gemini_contract import AGENT_KEY
|
||||
from .gemini_io import rendered_change
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeminiRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> GeminiRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_gemini_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return GeminiRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Gemini CLI installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Gemini CLI"
|
||||
|
||||
|
||||
def preflight_gemini_installation(
|
||||
config: InstallConfig, document: GeminiDocument
|
||||
) -> GeminiRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not document.file_path:
|
||||
raise ValueError("Gemini CLI installation requires a settings path")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_gemini_installation(
|
||||
config: InstallConfig,
|
||||
paths: GeminiRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedGeminiDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(
|
||||
AGENT_KEY,
|
||||
paths.agent_id,
|
||||
success_output="{}\n",
|
||||
)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id, success_output="{}\n")
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
success_output="{}\n",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def commit_gemini_installation(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,34 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .gemini_command import same_gemini_agent_id, same_gemini_path
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_contract_exists
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .gemini_config import (
|
||||
GeminiDocument,
|
||||
RenderedGeminiDocument,
|
||||
create_gemini_document,
|
||||
parse_gemini_document,
|
||||
)
|
||||
from .gemini_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
GeminiRuntimeContract,
|
||||
elydora_dir,
|
||||
)
|
||||
from .gemini_contract import AGENT_KEY, CONFIG_FILE, GeminiRuntimeContract
|
||||
|
||||
|
||||
def gemini_configuration_directory() -> str:
|
||||
@@ -44,33 +29,21 @@ def gemini_settings_path() -> str:
|
||||
|
||||
def read_gemini_document() -> GeminiDocument:
|
||||
file_path = gemini_settings_path()
|
||||
physical_directory_exists(
|
||||
os.path.dirname(file_path), "Gemini CLI configuration directory"
|
||||
)
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Gemini CLI user settings", MAX_SOURCE_BYTES
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(file_path), "Gemini CLI configuration directory")
|
||||
snapshot = read_physical_file(file_path, "Gemini CLI user settings", MAX_SOURCE_BYTES)
|
||||
if snapshot is None:
|
||||
return create_gemini_document(file_path)
|
||||
return parse_gemini_document(
|
||||
exists=True,
|
||||
file_path=file_path,
|
||||
raw=snapshot.contents,
|
||||
)
|
||||
return parse_gemini_document(exists=True, file_path=file_path, raw=snapshot.contents)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedGeminiDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedGeminiDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Gemini CLI user settings",
|
||||
rendered.document.raw if rendered.document.exists else None,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,171 +53,7 @@ def write_gemini_document(rendered: RenderedGeminiDocument) -> None:
|
||||
write_changes([change], "Uninstall Gemini CLI hooks")
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def gemini_runtime_files_exist(contracts: List[GeminiRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Gemini CLI") for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_gemini_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Gemini CLI hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_gemini_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Gemini CLI agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: GeminiRuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_gemini_path(os.path.dirname(agent_directory), root)
|
||||
or not same_gemini_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def gemini_runtime_files_exist(
|
||||
contracts: List[GeminiRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess # nosec B404 - used only for legacy command rendering
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._runtime import managed_script_reference
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrokRuntimeReference:
|
||||
@@ -19,154 +26,14 @@ class GrokRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_grok_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_grok_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_grok_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Grok hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or command[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if executable is None or source[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or ntpath.basename(match.group(1)).lower() != "powershell.exe"
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
return encoded_windows_command(script_path)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _parse_legacy_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
@@ -193,40 +60,19 @@ def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return arguments[0], arguments[1]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"python(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def grok_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[GrokRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_posix_command(command)
|
||||
or _parse_windows_command(command)
|
||||
parse_posix_command(command)
|
||||
or parse_encoded_windows_command(command)
|
||||
or _parse_legacy_posix_command(command)
|
||||
or _parse_legacy_windows_command(command)
|
||||
)
|
||||
if parsed is None:
|
||||
return None
|
||||
executable, script_path = parsed
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not os.path.isabs(script_path)
|
||||
or not _is_python_executable(executable)
|
||||
or os.path.basename(script_path) != script_name
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_grok_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return GrokRuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else GrokRuntimeReference(*reference)
|
||||
@@ -7,13 +7,12 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .grok_command import (
|
||||
GrokRuntimeReference,
|
||||
build_grok_command,
|
||||
grok_runtime_reference,
|
||||
same_grok_agent_id,
|
||||
same_grok_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -183,7 +182,7 @@ def _remove_from_groups(
|
||||
for handler in group["hooks"]:
|
||||
reference = _managed_reference(handler, script_name)
|
||||
remove = reference is not None and (
|
||||
not agent_id or same_grok_agent_id(reference.agent_id, agent_id)
|
||||
not agent_id or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
kept.append(handler)
|
||||
@@ -286,7 +285,7 @@ def grok_runtime_contracts(hooks: GrokHooks) -> List[GrokRuntimeContract]:
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_grok_path(success[0].script_path, failure[0].script_path):
|
||||
if not same_path(success[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(GrokRuntimeContract(
|
||||
agent_id=guard[0].agent_id,
|
||||
|
||||
@@ -2,168 +2,56 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .grok_command import runtime_root, same_grok_path
|
||||
from .grok_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
GrokDocument,
|
||||
RenderedGrokDocument,
|
||||
)
|
||||
from .grok_io import (
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .base import InstallConfig
|
||||
from .grok_contract import AGENT_KEY, GrokDocument, RenderedGrokDocument
|
||||
from .grok_io import rendered_change
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrokRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> GrokRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_grok_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return GrokRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Grok installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Grok"
|
||||
|
||||
|
||||
def preflight_grok_installation(
|
||||
config: InstallConfig, document: GrokDocument
|
||||
) -> GrokRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not document.config_path:
|
||||
raise ValueError("Grok installation requires a hook configuration path")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedGrokDocument,
|
||||
) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
rendered.document.config_path,
|
||||
"Grok user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_grok_installation(
|
||||
config: InstallConfig,
|
||||
paths: GrokRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedGrokDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(
|
||||
AGENT_KEY, paths.agent_id, deny_protocol="grok"
|
||||
)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id, deny_protocol="grok")
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def commit_grok_installation(changes: List[FileChange]) -> None:
|
||||
@@ -171,7 +59,7 @@ def commit_grok_installation(changes: List[FileChange]) -> None:
|
||||
|
||||
|
||||
def prepare_grok_uninstall(rendered: RenderedGrokDocument) -> List[FileChange]:
|
||||
return _present([rendered_change(rendered)])
|
||||
return present([rendered_change(rendered)])
|
||||
|
||||
|
||||
def commit_grok_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,24 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .grok_command import runtime_root, same_grok_agent_id, same_grok_path
|
||||
from ._managed_files import physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_contract_exists
|
||||
from ._transaction import FileChange
|
||||
from .grok_contract import (
|
||||
AGENT_KEY,
|
||||
GrokDocument,
|
||||
GrokRuntimeContract,
|
||||
RenderedGrokDocument,
|
||||
create_grok_document,
|
||||
parse_grok_document,
|
||||
)
|
||||
@@ -41,9 +34,7 @@ def grok_config_path() -> str:
|
||||
def read_grok_document() -> GrokDocument:
|
||||
config_path = grok_config_path()
|
||||
hooks_directory = os.path.dirname(config_path)
|
||||
physical_directory_exists(
|
||||
os.path.dirname(hooks_directory), "Grok home directory"
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(hooks_directory), "Grok home directory")
|
||||
physical_directory_exists(hooks_directory, "Grok hooks directory")
|
||||
snapshot = read_physical_file(config_path, "Grok user hooks")
|
||||
if snapshot is None:
|
||||
@@ -51,168 +42,16 @@ def read_grok_document() -> GrokDocument:
|
||||
return parse_grok_document(config_path, snapshot.contents)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_grok_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Grok hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
def rendered_change(rendered: RenderedGrokDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
return rendered_source_change(
|
||||
rendered.document.config_path,
|
||||
"Grok user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_grok_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Grok agent {agent_id}: "
|
||||
f"{config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: GrokRuntimeContract) -> bool:
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_grok_path(os.path.dirname(agent_directory), root)
|
||||
or not same_grok_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def grok_runtime_files_exist(contracts: List[GrokRuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(runtime_contract_exists(contract, AGENT_KEY, "Grok") for contract in contracts)
|
||||
@@ -5,11 +5,8 @@ from __future__ import annotations
|
||||
import sys
|
||||
|
||||
from .error_log_template import ERROR_LOG_RUNTIME
|
||||
from .guard_template import generate_guard_script
|
||||
from .runtime_reader_template import PROTECTED_RUNTIME_READER
|
||||
|
||||
__all__ = ["generate_guard_script", "generate_hook_script"]
|
||||
|
||||
|
||||
def generate_hook_script(
|
||||
*,
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess # nosec B404 - used only for legacy command rendering
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._runtime import managed_script_reference
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiRuntimeReference:
|
||||
@@ -19,154 +26,14 @@ class KimiRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_kimi_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_kimi_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_kimi_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Kimi hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or command[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if executable is None or source[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or ntpath.basename(match.group(1)).lower() != "powershell.exe"
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
return encoded_windows_command(script_path)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _parse_legacy_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
@@ -193,40 +60,19 @@ def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return arguments[0], arguments[1]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"python(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def kimi_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[KimiRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_posix_command(command)
|
||||
or _parse_windows_command(command)
|
||||
parse_posix_command(command)
|
||||
or parse_encoded_windows_command(command)
|
||||
or _parse_legacy_posix_command(command)
|
||||
or _parse_legacy_windows_command(command)
|
||||
)
|
||||
if parsed is None:
|
||||
return None
|
||||
executable, script_path = parsed
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not os.path.isabs(script_path)
|
||||
or not _is_python_executable(executable)
|
||||
or os.path.basename(script_path) != script_name
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_kimi_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return KimiRuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else KimiRuntimeReference(*reference)
|
||||
@@ -10,12 +10,11 @@ from typing import Any, Dict, FrozenSet, List, Optional, Tuple
|
||||
from tomlkit import aot, document, dumps, inline_table, parse, table
|
||||
from tomlkit.items import AoT, Array
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from .kimi_command import (
|
||||
KimiRuntimeReference,
|
||||
build_kimi_command,
|
||||
kimi_runtime_reference,
|
||||
same_kimi_agent_id,
|
||||
same_kimi_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -216,7 +215,7 @@ def remove_managed_kimi_hooks(
|
||||
else None
|
||||
)
|
||||
remove = reference is not None and (
|
||||
not agent_id or same_kimi_agent_id(reference.agent_id, agent_id)
|
||||
not agent_id or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
result.append(hook)
|
||||
@@ -318,7 +317,7 @@ def kimi_runtime_contracts(
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_kimi_path(success[0].script_path, failure[0].script_path):
|
||||
if not same_path(success[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(
|
||||
KimiRuntimeContract(
|
||||
|
||||
@@ -2,120 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from .kimi_command import runtime_root, same_kimi_path
|
||||
from .kimi_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
KimiDocument,
|
||||
RenderedKimiDocument,
|
||||
)
|
||||
from .kimi_io import (
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kimi_contract import AGENT_KEY, KimiDocument, RenderedKimiDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> KimiRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
configured_guard = config.get("guard_script_path", "")
|
||||
if not same_kimi_path(configured_guard, guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return KimiRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Kimi installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Kimi"
|
||||
|
||||
|
||||
def preflight_kimi_installation(
|
||||
config: InstallConfig, documents: List[KimiDocument]
|
||||
) -> KimiRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not documents:
|
||||
raise ValueError("Kimi installation requires at least one hook contract")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(
|
||||
file_path,
|
||||
label,
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedKimiDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedKimiDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
source = rendered.document
|
||||
@@ -129,66 +48,32 @@ def rendered_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_kimi_installation(
|
||||
config: InstallConfig,
|
||||
paths: KimiRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: List[RenderedKimiDocument],
|
||||
) -> List[FileChange]:
|
||||
config_changes = _present([rendered_change(item) for item in rendered])
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
runtime_changes = _present(
|
||||
[
|
||||
_runtime_change(
|
||||
paths.guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path,
|
||||
"Elydora audit runtime",
|
||||
audit_script,
|
||||
0o700,
|
||||
),
|
||||
]
|
||||
)
|
||||
return [*runtime_changes, *config_changes]
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(item) for item in rendered]),
|
||||
]
|
||||
|
||||
|
||||
def commit_kimi_installation(changes: List[FileChange]) -> None:
|
||||
write_changes(changes, "Install Kimi hooks")
|
||||
|
||||
|
||||
def prepare_kimi_uninstall(
|
||||
rendered: List[RenderedKimiDocument],
|
||||
) -> List[FileChange]:
|
||||
return _present([rendered_change(item) for item in rendered])
|
||||
def prepare_kimi_uninstall(rendered: List[RenderedKimiDocument]) -> List[FileChange]:
|
||||
return present([rendered_change(item) for item in rendered])
|
||||
|
||||
|
||||
def commit_kimi_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,24 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .kimi_command import (
|
||||
runtime_root,
|
||||
same_kimi_agent_id,
|
||||
same_kimi_path,
|
||||
)
|
||||
from ._managed_files import physical_directory_exists, read_physical_file
|
||||
from ._runtime import runtime_contract_exists, same_path
|
||||
from .kimi_contract import (
|
||||
AGENT_KEY,
|
||||
LEGACY_EVENTS,
|
||||
@@ -76,7 +63,7 @@ def resolve_kimi_contracts() -> List[KimiContract]:
|
||||
legacy_home = os.path.join(home, ".kimi")
|
||||
stable = _stable_contract(os.path.join(stable_home, "config.toml"))
|
||||
legacy = _legacy_contract(os.path.join(legacy_home, "config.toml"))
|
||||
if same_kimi_path(stable.config_path, legacy.config_path):
|
||||
if same_path(stable.config_path, legacy.config_path):
|
||||
return [stable]
|
||||
|
||||
stable_detected = explicit_home is not None or _entry_exists(
|
||||
@@ -101,176 +88,5 @@ def read_kimi_documents() -> List[KimiDocument]:
|
||||
return [_read_kimi_document(contract) for contract in resolve_kimi_contracts()]
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_kimi_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Kimi hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path,
|
||||
"Elydora runtime config",
|
||||
MAX_CONFIG_BYTES,
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents,
|
||||
f"Elydora runtime config at {file_path}",
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = [
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
]
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_kimi_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Kimi agent {agent_id}: "
|
||||
f"{config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: KimiRuntimeContract) -> bool:
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_kimi_path(os.path.dirname(agent_directory), root)
|
||||
or not same_kimi_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def kimi_runtime_files_exist(contracts: List[KimiRuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(runtime_contract_exists(contract, AGENT_KEY, "Kimi") for contract in contracts)
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ._file_io import read_json, regular_file_exists, remove_file, write_json_atomic, write_text_atomic
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
@@ -42,11 +40,11 @@ class KiroCliPlugin(AgentPlugin):
|
||||
raise ValueError("agent_id is required")
|
||||
if not guard_path:
|
||||
raise ValueError("guard_script_path is required")
|
||||
if not _regular_file_exists(guard_path, "Elydora guard runtime"):
|
||||
if not regular_file_exists(guard_path, "Elydora guard runtime"):
|
||||
raise FileNotFoundError(f"Elydora guard runtime is missing: {guard_path}")
|
||||
|
||||
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config") or {}
|
||||
v3_settings = _read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") or {}
|
||||
v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config") or {}
|
||||
v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") or {}
|
||||
v2_hooks = _hooks_object(v2_settings, "Kiro CLI v2 agent config")
|
||||
current_v3_hooks = _v3_hooks(v3_settings)
|
||||
|
||||
@@ -108,22 +106,28 @@ class KiroCliPlugin(AgentPlugin):
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
_write_json_atomic(os.path.join(agent_dir, "config.json"), runtime_config, 0o600)
|
||||
_write_text_atomic(
|
||||
write_json_atomic(
|
||||
os.path.join(agent_dir, "config.json"),
|
||||
runtime_config,
|
||||
0o600,
|
||||
"Elydora runtime config",
|
||||
)
|
||||
write_text_atomic(
|
||||
os.path.join(agent_dir, "private.key"),
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
"Elydora private key",
|
||||
)
|
||||
_write_text_atomic(hook_path, hook_script, 0o700)
|
||||
_write_json_atomic(V2_AGENT_PATH, next_v2_settings, 0o600)
|
||||
_write_json_atomic(V3_HOOKS_PATH, next_v3_settings, 0o600)
|
||||
write_text_atomic(hook_path, hook_script, 0o700, "Elydora audit runtime")
|
||||
write_json_atomic(V2_AGENT_PATH, next_v2_settings, 0o600, "Kiro CLI v2 agent config")
|
||||
write_json_atomic(V3_HOOKS_PATH, next_v3_settings, 0o600, "Kiro CLI v3 hooks config")
|
||||
|
||||
print('Kiro CLI v2: start with "kiro-cli --agent elydora-audit".')
|
||||
print('Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.')
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = _read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
|
||||
next_v2: Optional[Tuple[JsonObject, bool]] = None
|
||||
if v2_settings is not None:
|
||||
@@ -153,22 +157,22 @@ class KiroCliPlugin(AgentPlugin):
|
||||
if next_v2 is not None:
|
||||
settings, remove = next_v2
|
||||
if remove:
|
||||
_remove_file(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
remove_file(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
else:
|
||||
_write_json_atomic(V2_AGENT_PATH, settings, 0o600)
|
||||
write_json_atomic(V2_AGENT_PATH, settings, 0o600, "Kiro CLI v2 agent config")
|
||||
|
||||
if next_v3 is not None:
|
||||
settings, remove = next_v3
|
||||
if remove:
|
||||
_remove_file(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
remove_file(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
else:
|
||||
_write_json_atomic(V3_HOOKS_PATH, settings, 0o600)
|
||||
write_json_atomic(V3_HOOKS_PATH, settings, 0o600, "Kiro CLI v3 hooks config")
|
||||
|
||||
print("Elydora hooks uninstalled from Kiro CLI.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = _read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
contracts: List[HookContract] = []
|
||||
|
||||
if v2_settings is not None:
|
||||
@@ -204,63 +208,6 @@ class KiroCliPlugin(AgentPlugin):
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
|
||||
|
||||
def _read_json(path: str, label: str) -> Optional[JsonObject]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
raw = file.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"Failed to parse {label} at {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} at {path} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _write_text_atomic(path: str, content: str, mode: int) -> None:
|
||||
directory = os.path.dirname(path) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
descriptor, temporary_path = tempfile.mkstemp(
|
||||
dir=directory,
|
||||
prefix=f".{os.path.basename(path)}.",
|
||||
suffix=".tmp",
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as file:
|
||||
file.write(content)
|
||||
os.chmod(temporary_path, mode)
|
||||
os.replace(temporary_path, path)
|
||||
except BaseException as write_error:
|
||||
try:
|
||||
os.remove(temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as cleanup_error:
|
||||
raise RuntimeError(
|
||||
f"Write {path} failed and temporary file cleanup also failed: {cleanup_error}"
|
||||
) from write_error
|
||||
raise
|
||||
|
||||
|
||||
def _write_json_atomic(path: str, value: JsonObject, mode: int) -> None:
|
||||
_write_text_atomic(path, json.dumps(value, indent=2) + "\n", mode)
|
||||
|
||||
|
||||
def _remove_file(path: str, label: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as error:
|
||||
raise OSError(f"Remove {label} at {path}: {error}") from error
|
||||
|
||||
|
||||
def _build_command(script_path: str) -> str:
|
||||
arguments = [sys.executable, script_path]
|
||||
return subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments)
|
||||
@@ -385,16 +332,6 @@ def _find_v3_command(
|
||||
return None
|
||||
|
||||
|
||||
def _regular_file_exists(path: str, label: str) -> bool:
|
||||
try:
|
||||
metadata = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
return stat.S_ISREG(metadata.st_mode)
|
||||
|
||||
|
||||
def _runtime_scripts_exist(contracts: List[HookContract]) -> bool:
|
||||
try:
|
||||
with os.scandir(ELYDORA_DIR) as iterator:
|
||||
@@ -421,10 +358,10 @@ def _runtime_scripts_exist(contracts: List[HookContract]) -> bool:
|
||||
continue
|
||||
|
||||
config_path = os.path.join(entry.path, "config.json")
|
||||
config = _read_json(config_path, "Elydora runtime config")
|
||||
config = read_json(config_path, "Elydora runtime config")
|
||||
if config is None or config.get("agent_name") != AGENT_KEY:
|
||||
continue
|
||||
return _regular_file_exists(
|
||||
return regular_file_exists(
|
||||
guard_path, "Elydora guard runtime"
|
||||
) and _regular_file_exists(hook_path, "Elydora audit runtime")
|
||||
) and regular_file_exists(hook_path, "Elydora audit runtime")
|
||||
return False
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._runtime import same_agent_id
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
@@ -21,7 +22,6 @@ from .kiroide_installation import (
|
||||
prepare_kiroide_installation,
|
||||
prepare_kiroide_uninstall,
|
||||
)
|
||||
from .kiroide_command import same_kiroide_agent_id
|
||||
from .kiroide_io import (
|
||||
kiroide_runtime_files_exist,
|
||||
legacy_kiroide_contract_matches_agent,
|
||||
@@ -78,13 +78,13 @@ class KiroIdePlugin(AgentPlugin):
|
||||
agent_ids: list[str] = []
|
||||
for discovered_agent_id in discovered_agent_ids:
|
||||
if not any(
|
||||
same_kiroide_agent_id(discovered_agent_id, existing_agent_id)
|
||||
same_agent_id(discovered_agent_id, existing_agent_id)
|
||||
for existing_agent_id in agent_ids
|
||||
):
|
||||
agent_ids.append(discovered_agent_id)
|
||||
for managed_agent_id in agent_ids:
|
||||
owns_workspace_runtime = any(
|
||||
same_kiroide_agent_id(contract.agent_id, managed_agent_id)
|
||||
same_agent_id(contract.agent_id, managed_agent_id)
|
||||
for contract in contracts
|
||||
)
|
||||
require_kiroide_workspace_owner(
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
from ._runtime import managed_script_reference, same_path
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
windows_powershell_path,
|
||||
)
|
||||
|
||||
_PREAMBLE = "$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; "
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,71 +27,10 @@ class KiroIdeRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def same_kiroide_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_kiroide_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _same_windows_path(left: str, right: str) -> bool:
|
||||
return ntpath.normcase(ntpath.abspath(left)) == ntpath.normcase(
|
||||
ntpath.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
"$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; "
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_kiroide_command(script_path: str) -> str:
|
||||
if (
|
||||
not os.path.isabs(sys.executable)
|
||||
or not _is_python_executable(sys.executable)
|
||||
or not is_python_executable(sys.executable)
|
||||
or not os.path.isabs(script_path)
|
||||
):
|
||||
raise ValueError(
|
||||
@@ -91,136 +38,25 @@ def build_kiroide_command(script_path: str) -> str:
|
||||
"and script path"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
prefix = "$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; & "
|
||||
if not source.startswith(prefix):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, len(prefix))
|
||||
if (
|
||||
executable is None
|
||||
or source[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or not _same_windows_path(match.group(1), _windows_powershell_path())
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"(?:python(?:[0-9]+(?:\.[0-9]+)*)?[td]?|"
|
||||
r"pypy(?:[0-9]+(?:\.[0-9]+)*)?)(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
return encoded_windows_command(script_path, _PREAMBLE)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _runtime_reference(
|
||||
executable: str, script_path: str, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not _is_python_executable(executable)
|
||||
or not os.path.isabs(script_path)
|
||||
or os.path.basename(script_path) != script_name
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_kiroide_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return KiroIdeRuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else KiroIdeRuntimeReference(*reference)
|
||||
|
||||
|
||||
def _parse_kiroide_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return (
|
||||
_parse_windows_command(command)
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(command)
|
||||
)
|
||||
if os.name == "nt":
|
||||
return parse_encoded_windows_command(
|
||||
command, _PREAMBLE + "& ", windows_powershell_path()
|
||||
)
|
||||
return parse_posix_command(command)
|
||||
|
||||
|
||||
def owned_kiroide_runtime_reference(
|
||||
@@ -236,9 +72,7 @@ def kiroide_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
parsed = _parse_kiroide_command(command)
|
||||
if parsed is None:
|
||||
return None
|
||||
if not same_kiroide_path(parsed[0], sys.executable):
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
return _runtime_reference(parsed[0], parsed[1], script_name)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .kiroide_command import (
|
||||
KiroIdeRuntimeReference,
|
||||
@@ -16,8 +17,6 @@ from .kiroide_command import (
|
||||
legacy_audit_reference,
|
||||
legacy_guard_reference,
|
||||
owned_kiroide_runtime_reference,
|
||||
same_kiroide_agent_id,
|
||||
same_kiroide_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -240,7 +239,7 @@ def _managed_pair(
|
||||
if (
|
||||
guard is None
|
||||
or audit is None
|
||||
or not same_kiroide_agent_id(guard.agent_id, audit.agent_id)
|
||||
or not same_agent_id(guard.agent_id, audit.agent_id)
|
||||
):
|
||||
return None
|
||||
return (guards[0][0], guard), (audits[0][0], audit)
|
||||
@@ -289,7 +288,7 @@ def without_managed_kiroide_hooks(
|
||||
for index, hook in enumerate(hooks):
|
||||
reference = owned.get(index)
|
||||
remove = reference is not None and (
|
||||
not agent_id or same_kiroide_agent_id(reference.agent_id, agent_id)
|
||||
not agent_id or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
result.append(hook)
|
||||
@@ -382,8 +381,8 @@ def legacy_kiroide_runtime_contract(
|
||||
if (
|
||||
guard is None
|
||||
or audit is None
|
||||
or not same_kiroide_agent_id(guard.agent_id, audit.agent_id)
|
||||
or not same_kiroide_path(
|
||||
or not same_agent_id(guard.agent_id, audit.agent_id)
|
||||
or not same_path(
|
||||
os.path.dirname(guard.script_path), os.path.dirname(audit.script_path)
|
||||
)
|
||||
):
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
@@ -16,6 +13,14 @@ from ._managed_files import (
|
||||
read_physical_file,
|
||||
)
|
||||
from ._opaque_removal import OpaqueRemovalChange
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_config_source,
|
||||
same_agent_id,
|
||||
validate_install_config,
|
||||
)
|
||||
from ._transaction import (
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
@@ -34,11 +39,8 @@ from ._runtime_removal import (
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kiroide_command import same_kiroide_agent_id, same_kiroide_path
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
RenderedKiroIdeDocument,
|
||||
kiroide_runtime_contracts,
|
||||
)
|
||||
@@ -54,11 +56,13 @@ from .kiroide_io import (
|
||||
LegacyKiroIdeDocument,
|
||||
legacy_kiroide_contract_matches_agent,
|
||||
require_physical_legacy_directory,
|
||||
validate_api_origin,
|
||||
validate_kiroide_runtime_config,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
|
||||
PRODUCT = "Kiro IDE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeRuntimePaths:
|
||||
agent_id: str
|
||||
@@ -74,6 +78,8 @@ class KiroIdeRuntimePaths:
|
||||
class PreparedKiroIdeInstallation:
|
||||
changes: List[FileChange]
|
||||
runtime_preconditions: List[FilePrecondition]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKiroIdeUninstall:
|
||||
changes: List[FileChange]
|
||||
@@ -92,79 +98,30 @@ def _require_runtime_state(
|
||||
)
|
||||
|
||||
|
||||
def _runtime_config(
|
||||
config: InstallConfig, agent_id: str, workspace_root: str
|
||||
) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
"workspace_root": workspace_root,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> KiroIdeRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
root = runtime_root()
|
||||
paths = resolve_runtime_paths(config)
|
||||
root = os.path.dirname(paths.agent_directory)
|
||||
runtime_parent = os.path.dirname(root)
|
||||
agent_directory = resolve_agent_directory(root, agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_kiroide_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
directories = inspect_runtime_directories(
|
||||
runtime_parent, root, agent_directory
|
||||
)
|
||||
return KiroIdeRuntimePaths(
|
||||
agent_id,
|
||||
paths.agent_id,
|
||||
runtime_parent,
|
||||
root,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
directories,
|
||||
paths.agent_directory,
|
||||
paths.guard_path,
|
||||
paths.audit_path,
|
||||
inspect_runtime_directories(runtime_parent, root, paths.agent_directory),
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> KiroIdeRuntimePaths:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Kiro IDE installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
return _agent_paths(config)
|
||||
|
||||
|
||||
def preflight_kiroide_installation(
|
||||
config: InstallConfig, sources: KiroIdeSources
|
||||
) -> KiroIdeRuntimePaths:
|
||||
if not sources.document.file_path:
|
||||
raise ValueError("Kiro IDE installation requires a workspace hook path")
|
||||
paths = _validate_install_config(config)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = _agent_paths(config)
|
||||
owns_workspace_runtime = any(
|
||||
same_kiroide_agent_id(contract.agent_id, paths.agent_id)
|
||||
same_agent_id(contract.agent_id, paths.agent_id)
|
||||
for contract in kiroide_runtime_contracts(sources.document.hooks)
|
||||
)
|
||||
owns_global_legacy_runtime = legacy_kiroide_contract_matches_agent(
|
||||
@@ -229,7 +186,7 @@ def _removes_legacy(
|
||||
) -> bool:
|
||||
return legacy.contract is not None and (
|
||||
not agent_id
|
||||
or same_kiroide_agent_id(legacy.contract.agent_id, agent_id)
|
||||
or same_agent_id(legacy.contract.agent_id, agent_id)
|
||||
)
|
||||
|
||||
|
||||
@@ -252,10 +209,6 @@ def _legacy_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_kiroide_installation(
|
||||
config: InstallConfig,
|
||||
paths: KiroIdeRuntimePaths,
|
||||
@@ -269,7 +222,7 @@ def prepare_kiroide_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
@@ -280,12 +233,11 @@ def prepare_kiroide_installation(
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(
|
||||
_runtime_config(
|
||||
config,
|
||||
paths.agent_id,
|
||||
sources.paths.workspace_root,
|
||||
)
|
||||
runtime_config_source(
|
||||
config,
|
||||
paths.agent_id,
|
||||
AGENT_KEY,
|
||||
workspace_root=sources.paths.workspace_root,
|
||||
),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
@@ -303,7 +255,7 @@ def prepare_kiroide_installation(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
]
|
||||
changes = _present(
|
||||
changes = present(
|
||||
[
|
||||
*(change for change, _condition in runtime),
|
||||
_rendered_change(rendered),
|
||||
@@ -420,7 +372,7 @@ def prepare_kiroide_uninstall(
|
||||
candidates = runtime_agent_ids or ((agent_id,) if agent_id else ())
|
||||
for runtime_agent_id in candidates:
|
||||
if any(
|
||||
same_kiroide_agent_id(runtime_agent_id, selected_agent_id)
|
||||
same_agent_id(runtime_agent_id, selected_agent_id)
|
||||
for selected_agent_id in selected_agent_ids
|
||||
):
|
||||
continue
|
||||
@@ -436,7 +388,7 @@ def prepare_kiroide_uninstall(
|
||||
config_path,
|
||||
sources.paths.workspace_root,
|
||||
allow_missing_workspace_root=any(
|
||||
same_kiroide_agent_id(
|
||||
same_agent_id(
|
||||
contract.agent_id, runtime_agent_id
|
||||
)
|
||||
for contract in kiroide_runtime_contracts(
|
||||
@@ -454,7 +406,7 @@ def prepare_kiroide_uninstall(
|
||||
)
|
||||
if runtime_removal is not None:
|
||||
runtime_removals.append(runtime_removal)
|
||||
provider_changes = _present(
|
||||
provider_changes = present(
|
||||
[_rendered_change(rendered), _legacy_change(sources.legacy, agent_id)]
|
||||
)
|
||||
return PreparedKiroIdeUninstall(
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
@@ -20,10 +18,17 @@ from ._managed_files import (
|
||||
read_physical_directory,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._runtime import (
|
||||
RUNTIME_ARTIFACTS,
|
||||
RUNTIME_CONFIG_FIELDS,
|
||||
expected_runtime_scripts,
|
||||
require_non_empty_string,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kiroide_command import same_kiroide_agent_id, same_kiroide_path
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
@@ -187,67 +192,7 @@ def require_physical_legacy_directory(legacy: LegacyKiroIdeDocument) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
def validate_kiroide_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
@@ -255,27 +200,19 @@ def _validate_runtime_config(
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
supported = {
|
||||
"org_id",
|
||||
"agent_id",
|
||||
"kid",
|
||||
"base_url",
|
||||
"token",
|
||||
"agent_name",
|
||||
"workspace_root",
|
||||
}
|
||||
supported = RUNTIME_CONFIG_FIELDS | {"workspace_root"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_kiroide_agent_id(agent_id, expected_agent_id)
|
||||
not same_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
@@ -289,8 +226,8 @@ def _validate_runtime_config(
|
||||
if "token" in config and not (
|
||||
legacy_ownerless_config and config.get("token") == ""
|
||||
):
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
@@ -298,39 +235,20 @@ def _validate_runtime_config(
|
||||
allow_missing_workspace_root or legacy_ownerless_config
|
||||
):
|
||||
return
|
||||
workspace_root = _require_non_empty_string(
|
||||
workspace_root = require_non_empty_string(
|
||||
configured_workspace, "workspace_root", config_path
|
||||
)
|
||||
if not os.path.isabs(workspace_root):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config workspace_root must be absolute: {config_path}"
|
||||
)
|
||||
if not same_kiroide_path(workspace_root, expected_workspace_root):
|
||||
if not same_path(workspace_root, expected_workspace_root):
|
||||
raise ValueError(
|
||||
"Elydora Kiro IDE agent is bound to another workspace: "
|
||||
f"{workspace_root}"
|
||||
)
|
||||
|
||||
|
||||
def validate_kiroide_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
expected_workspace_root: str,
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
"""Validate a runtime config already read through a pinned directory."""
|
||||
_validate_runtime_config(
|
||||
config,
|
||||
expected_agent_id,
|
||||
config_path,
|
||||
expected_workspace_root,
|
||||
allow_missing_workspace_root,
|
||||
allow_legacy_ownerless_config,
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(
|
||||
agent_directory: str,
|
||||
agent_id: str,
|
||||
@@ -346,27 +264,21 @@ def validate_runtime_tree(
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifact_exists = any(
|
||||
config_snapshot = read_physical_file(config_path, "Elydora runtime config", MAX_CONFIG_BYTES)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
)
|
||||
if config is None:
|
||||
for name, label in RUNTIME_ARTIFACTS
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config_snapshot is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
_validate_runtime_config(
|
||||
config,
|
||||
validate_kiroide_runtime_config(
|
||||
parse_json_object(config_snapshot.contents, f"Elydora runtime config at {config_path}"),
|
||||
agent_id,
|
||||
config_path,
|
||||
workspace_root,
|
||||
@@ -381,8 +293,8 @@ def _runtime_contract_exists(
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_kiroide_path(os.path.dirname(agent_directory), root)
|
||||
or not same_kiroide_path(
|
||||
not same_path(os.path.dirname(agent_directory), root)
|
||||
or not same_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
@@ -414,7 +326,7 @@ def _runtime_contract_exists(
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(
|
||||
validate_kiroide_runtime_config(
|
||||
config,
|
||||
contract.agent_id,
|
||||
config_path,
|
||||
@@ -436,15 +348,7 @@ def _runtime_contract_exists(
|
||||
f"its owner: {config_path}"
|
||||
)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id=str(config["org_id"]),
|
||||
agent_id=contract.agent_id,
|
||||
kid=str(config["kid"]),
|
||||
base_url=str(config["base_url"]),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
expected_guard, expected_audit = expected_runtime_scripts(AGENT_KEY, contract.agent_id, config)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
@@ -478,15 +382,15 @@ def legacy_kiroide_contract_matches_agent(
|
||||
legacy: LegacyKiroIdeDocument, agent_id: str
|
||||
) -> bool:
|
||||
contract = legacy.contract
|
||||
if contract is None or not same_kiroide_agent_id(
|
||||
if contract is None or not same_agent_id(
|
||||
contract.agent_id, agent_id
|
||||
):
|
||||
return False
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
return same_kiroide_path(
|
||||
return same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(agent_directory, "guard.py"),
|
||||
) and same_kiroide_path(
|
||||
) and same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(agent_directory, "hook.py"),
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from ._runtime import RuntimePaths
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .letta_config import render_letta_document
|
||||
from .letta_contract import (
|
||||
@@ -13,7 +14,6 @@ from .letta_contract import (
|
||||
letta_runtime_contracts,
|
||||
)
|
||||
from .letta_installation import (
|
||||
LettaRuntimePaths,
|
||||
commit_letta_installation,
|
||||
commit_letta_uninstall,
|
||||
preflight_letta_installation,
|
||||
@@ -24,7 +24,7 @@ from .letta_io import letta_runtime_files_exist
|
||||
from .letta_sources import read_letta_sources
|
||||
|
||||
|
||||
def _installed_groups(paths: LettaRuntimePaths) -> Dict[str, JsonObject]:
|
||||
def _installed_groups(paths: RuntimePaths) -> Dict[str, JsonObject]:
|
||||
return {
|
||||
"PreToolUse": build_letta_group(paths.guard_path),
|
||||
"PostToolUse": build_letta_group(paths.audit_path),
|
||||
|
||||
@@ -4,11 +4,17 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
from ._runtime import managed_script_reference
|
||||
from ._shell_command import (
|
||||
is_python_executable,
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -18,103 +24,12 @@ class LettaRuntimeReference:
|
||||
executable_path: Optional[str] = None
|
||||
|
||||
|
||||
def same_letta_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_letta_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_letta_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Letta Code hook commands require absolute executable and script paths"
|
||||
)
|
||||
invocation = (
|
||||
f"{_quote_powershell(sys.executable)} {_quote_powershell(script_path)}"
|
||||
if os.name == "nt"
|
||||
else f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
)
|
||||
return f"& {invocation}; exit $LASTEXITCODE" if os.name == "nt" else invocation
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable[1] + 1)
|
||||
if script is None or command[script[1] :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return bool(re.fullmatch(r"python(?:3(?:\.\d+)?)?(?:\.exe)?", os.path.basename(file_path), re.IGNORECASE))
|
||||
return powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
|
||||
|
||||
|
||||
def _runtime_reference(
|
||||
@@ -122,30 +37,21 @@ def _runtime_reference(
|
||||
script_name: str,
|
||||
executable_path: Optional[str] = None,
|
||||
) -> Optional[LettaRuntimeReference]:
|
||||
if not os.path.isabs(script_path) or os.path.basename(script_path) != script_name:
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
if reference is None:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_letta_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return LettaRuntimeReference(agent_id, script_path, executable_path)
|
||||
return LettaRuntimeReference(reference[0], reference[1], executable_path)
|
||||
|
||||
|
||||
def letta_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[LettaRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_powershell_command(command)
|
||||
parse_powershell_source(command)
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(command)
|
||||
else parse_posix_command(command)
|
||||
)
|
||||
if (
|
||||
parsed is None
|
||||
or not os.path.isabs(parsed[0])
|
||||
or not _is_python_executable(parsed[0])
|
||||
):
|
||||
if parsed is None or not os.path.isabs(parsed[0]) or not is_python_executable(parsed[0]):
|
||||
return None
|
||||
return _runtime_reference(parsed[1], script_name, parsed[0])
|
||||
|
||||
@@ -160,11 +66,7 @@ def letta_legacy_guard_reference(
|
||||
return None
|
||||
executable = command[1:closing]
|
||||
script_path = command[closing + 2 :]
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not _is_python_executable(executable)
|
||||
or not script_path
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable) or not script_path:
|
||||
return None
|
||||
return _runtime_reference(script_path, script_name, executable)
|
||||
|
||||
|
||||
@@ -8,16 +8,13 @@ import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from .letta_command import (
|
||||
LettaRuntimeReference,
|
||||
build_letta_command,
|
||||
letta_legacy_audit_reference,
|
||||
letta_legacy_guard_reference,
|
||||
letta_runtime_reference,
|
||||
same_letta_agent_id,
|
||||
same_letta_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,10 +60,6 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return runtime_root()
|
||||
|
||||
|
||||
def _validate_timeout(value: JsonObject, label: str) -> None:
|
||||
if "timeout" not in value:
|
||||
return
|
||||
@@ -222,7 +215,7 @@ def managed_letta_removals(
|
||||
is not None
|
||||
and (
|
||||
agent_id is None
|
||||
or same_letta_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
)
|
||||
if indexes:
|
||||
@@ -247,7 +240,7 @@ def _references_for_event(
|
||||
if (
|
||||
reference is None
|
||||
or reference.executable_path is None
|
||||
or not same_letta_path(reference.executable_path, sys.executable)
|
||||
or not same_path(reference.executable_path, sys.executable)
|
||||
):
|
||||
continue
|
||||
key = os.path.normcase(reference.agent_id)
|
||||
@@ -267,7 +260,7 @@ def letta_runtime_contracts(hooks: LettaHooks) -> List[LettaRuntimeContract]:
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(post) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_letta_path(post[0].script_path, failure[0].script_path):
|
||||
if not same_path(post[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(LettaRuntimeContract(
|
||||
guard[0].agent_id,
|
||||
|
||||
@@ -2,113 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Sequence
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import (
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
file_change,
|
||||
source_change,
|
||||
write_changes,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .letta_command import same_letta_path
|
||||
from .letta_config import RenderedLettaDocument, letta_document_label
|
||||
from .letta_contract import AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT, elydora_dir
|
||||
from .letta_io import validate_api_origin, validate_private_key, validate_runtime_tree
|
||||
from .letta_contract import AGENT_KEY
|
||||
from .letta_sources import LettaSources, require_letta_hooks_enabled
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LettaRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> LettaRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_letta_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return LettaRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Letta Code installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Letta Code"
|
||||
|
||||
|
||||
def preflight_letta_installation(
|
||||
config: InstallConfig, sources: LettaSources
|
||||
) -> LettaRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
require_letta_hooks_enabled(sources)
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _rendered_change(
|
||||
rendered: RenderedLettaDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def _rendered_change(rendered: RenderedLettaDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
@@ -122,13 +50,9 @@ def _rendered_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_letta_installation(
|
||||
config: InstallConfig,
|
||||
paths: LettaRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedLettaDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
@@ -136,50 +60,28 @@ def prepare_letta_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
fail_closed=True,
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
_rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([_rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def _read_only_preconditions(
|
||||
sources: LettaSources, changed_path: Optional[str]
|
||||
sources: LettaSources, changed_path: str
|
||||
) -> List[FilePrecondition]:
|
||||
return [
|
||||
condition
|
||||
for condition in sources.preconditions
|
||||
if changed_path is None
|
||||
or not same_letta_path(condition.file_path, changed_path)
|
||||
if not same_path(condition.file_path, changed_path)
|
||||
]
|
||||
|
||||
|
||||
def commit_letta_installation(
|
||||
changes: List[FileChange], sources: LettaSources
|
||||
) -> None:
|
||||
def commit_letta_installation(changes: List[FileChange], sources: LettaSources) -> None:
|
||||
write_changes(
|
||||
changes,
|
||||
"Install Letta Code hooks",
|
||||
@@ -187,15 +89,11 @@ def commit_letta_installation(
|
||||
)
|
||||
|
||||
|
||||
def prepare_letta_uninstall(
|
||||
rendered: RenderedLettaDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def prepare_letta_uninstall(rendered: RenderedLettaDocument) -> Optional[FileChange]:
|
||||
return _rendered_change(rendered)
|
||||
|
||||
|
||||
def commit_letta_uninstall(
|
||||
change: Optional[FileChange], sources: LettaSources
|
||||
) -> None:
|
||||
def commit_letta_uninstall(change: Optional[FileChange], sources: LettaSources) -> None:
|
||||
if change is None:
|
||||
return
|
||||
write_changes(
|
||||
|
||||
@@ -2,200 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .letta_command import same_letta_agent_id, same_letta_path
|
||||
from .letta_contract import AGENT_KEY, LettaRuntimeContract, elydora_dir
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .letta_contract import AGENT_KEY, LettaRuntimeContract
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def _expected_scripts(agent_id: str, config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id, config, fail_closed=True)
|
||||
|
||||
|
||||
def letta_runtime_files_exist(contracts: List[LettaRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Letta Code", _expected_scripts)
|
||||
for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_letta_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Letta Code hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_letta_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Letta Code agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: LettaRuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_letta_path(os.path.dirname(agent_directory), root)
|
||||
or not same_letta_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id=str(config["org_id"]),
|
||||
agent_id=contract.agent_id,
|
||||
kid=str(config["kid"]),
|
||||
base_url=str(config["base_url"]),
|
||||
fail_closed=True,
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def letta_runtime_files_exist(
|
||||
contracts: List[LettaRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -4,15 +4,15 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._runtime import same_path, unique_preconditions
|
||||
from ._transaction import FilePrecondition
|
||||
from .letta_command import same_letta_path
|
||||
from .letta_config import (
|
||||
LettaDocument,
|
||||
create_letta_document,
|
||||
@@ -76,15 +76,6 @@ def _source_precondition(document: LettaDocument) -> FilePrecondition:
|
||||
)
|
||||
|
||||
|
||||
def _deduplicate_preconditions(
|
||||
values: list[FilePrecondition],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
result: Dict[str, FilePrecondition] = {}
|
||||
for value in values:
|
||||
result.setdefault(_comparison_path(value.file_path), value)
|
||||
return tuple(result.values())
|
||||
|
||||
|
||||
def _effective_disable(
|
||||
global_settings: LettaDocument,
|
||||
project: LettaDocument,
|
||||
@@ -111,7 +102,7 @@ def read_letta_sources() -> LettaSources:
|
||||
physical_directory_exists(
|
||||
global_directory, "Letta Code global configuration directory"
|
||||
)
|
||||
if not same_letta_path(project_directory, global_directory):
|
||||
if not same_path(project_directory, global_directory):
|
||||
physical_directory_exists(
|
||||
project_directory, "Letta Code project configuration directory"
|
||||
)
|
||||
@@ -141,7 +132,7 @@ def read_letta_sources() -> LettaSources:
|
||||
_effective_disable(
|
||||
global_settings, project, project_local, project_active
|
||||
),
|
||||
_deduplicate_preconditions(preconditions),
|
||||
unique_preconditions(preconditions),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from ._runtime import RuntimePaths
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .qwen_config import render_qwen_document
|
||||
from .qwen_contract import (
|
||||
@@ -15,7 +16,6 @@ from .qwen_contract import (
|
||||
qwen_runtime_contracts,
|
||||
)
|
||||
from .qwen_installation import (
|
||||
QwenRuntimePaths,
|
||||
commit_qwen_installation,
|
||||
commit_qwen_uninstall,
|
||||
preflight_qwen_installation,
|
||||
@@ -26,7 +26,7 @@ from .qwen_io import qwen_runtime_files_exist
|
||||
from .qwen_sources import read_qwen_sources
|
||||
|
||||
|
||||
def _installed_groups(paths: QwenRuntimePaths) -> Dict[str, JsonObject]:
|
||||
def _installed_groups(paths: RuntimePaths) -> Dict[str, JsonObject]:
|
||||
return {
|
||||
"PreToolUse": build_qwen_group(paths.guard_path, GUARD_HOOK_NAME),
|
||||
"PostToolUse": build_qwen_group(paths.audit_path, AUDIT_HOOK_NAME),
|
||||
|
||||
@@ -5,7 +5,15 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
from ._runtime import managed_script_reference, same_path
|
||||
from ._shell_command import (
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -14,121 +22,23 @@ class QwenRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
def same_qwen_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_qwen_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_qwen_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Qwen Code hook commands require absolute executable and script paths"
|
||||
)
|
||||
invocation = (
|
||||
f"{_quote_powershell(sys.executable)} {_quote_powershell(script_path)}"
|
||||
if os.name == "nt"
|
||||
else f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
)
|
||||
return f"& {invocation}; exit $LASTEXITCODE" if os.name == "nt" else invocation
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable[1] + 1)
|
||||
if script is None or command[script[1] :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
return powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
|
||||
|
||||
|
||||
def qwen_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[QwenRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_powershell_command(command)
|
||||
parse_powershell_source(command)
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(command)
|
||||
else parse_posix_command(command)
|
||||
)
|
||||
if (
|
||||
parsed is None
|
||||
or not same_qwen_path(parsed[0], sys.executable)
|
||||
or not os.path.isabs(parsed[1])
|
||||
or os.path.basename(parsed[1]) != script_name
|
||||
):
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(parsed[1])
|
||||
runtime_root = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
if not same_qwen_path(os.path.dirname(agent_directory), runtime_root):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return QwenRuntimeReference(agent_id, parsed[1])
|
||||
reference = managed_script_reference(parsed[1], script_name)
|
||||
return None if reference is None else QwenRuntimeReference(*reference)
|
||||
@@ -10,12 +10,11 @@ import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from .qwen_command import (
|
||||
QwenRuntimeReference,
|
||||
build_qwen_command,
|
||||
qwen_runtime_reference,
|
||||
same_qwen_agent_id,
|
||||
same_qwen_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -102,10 +101,6 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def _optional_string(value: JsonObject, field: str, label: str) -> None:
|
||||
if field in value and not isinstance(value[field], str):
|
||||
raise ValueError(f'{label} field "{field}" must be a string')
|
||||
@@ -352,7 +347,7 @@ def managed_qwen_removals(
|
||||
is not None
|
||||
and (
|
||||
agent_id is None
|
||||
or same_qwen_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
)
|
||||
if indexes:
|
||||
@@ -398,7 +393,7 @@ def qwen_runtime_contracts(hooks: QwenHooks) -> List[QwenRuntimeContract]:
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(post) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_qwen_path(post[0].script_path, failure[0].script_path):
|
||||
if not same_path(post[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(QwenRuntimeContract(
|
||||
guard[0].agent_id,
|
||||
|
||||
@@ -2,107 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Sequence
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, FilePrecondition, file_change, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .qwen_command import same_qwen_path
|
||||
from .qwen_config import RenderedQwenDocument, qwen_document_label
|
||||
from .qwen_contract import AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT, elydora_dir
|
||||
from .qwen_io import validate_api_origin, validate_private_key, validate_runtime_tree
|
||||
from .qwen_contract import AGENT_KEY
|
||||
from .qwen_sources import QwenSources, require_qwen_hooks_enabled
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QwenRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> QwenRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_qwen_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return QwenRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Qwen Code installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Qwen Code"
|
||||
|
||||
|
||||
def preflight_qwen_installation(
|
||||
config: InstallConfig, sources: QwenSources
|
||||
) -> QwenRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
require_qwen_hooks_enabled(sources)
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _rendered_change(
|
||||
rendered: RenderedQwenDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def _rendered_change(rendered: RenderedQwenDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
@@ -116,13 +50,9 @@ def _rendered_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_qwen_installation(
|
||||
config: InstallConfig,
|
||||
paths: QwenRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedQwenDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
@@ -130,49 +60,27 @@ def prepare_qwen_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
_rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([_rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def _read_only_preconditions(
|
||||
sources: QwenSources, changed_path: Optional[str]
|
||||
sources: QwenSources, changed_path: str
|
||||
) -> List[FilePrecondition]:
|
||||
return [
|
||||
condition
|
||||
for condition in sources.preconditions
|
||||
if changed_path is None
|
||||
or not same_qwen_path(condition.file_path, changed_path)
|
||||
if not same_path(condition.file_path, changed_path)
|
||||
]
|
||||
|
||||
|
||||
def commit_qwen_installation(
|
||||
changes: List[FileChange], sources: QwenSources
|
||||
) -> None:
|
||||
def commit_qwen_installation(changes: List[FileChange], sources: QwenSources) -> None:
|
||||
write_changes(
|
||||
changes,
|
||||
"Install Qwen Code hooks",
|
||||
@@ -180,15 +88,11 @@ def commit_qwen_installation(
|
||||
)
|
||||
|
||||
|
||||
def prepare_qwen_uninstall(
|
||||
rendered: RenderedQwenDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def prepare_qwen_uninstall(rendered: RenderedQwenDocument) -> Optional[FileChange]:
|
||||
return _rendered_change(rendered)
|
||||
|
||||
|
||||
def commit_qwen_uninstall(
|
||||
change: Optional[FileChange], sources: QwenSources
|
||||
) -> None:
|
||||
def commit_qwen_uninstall(change: Optional[FileChange], sources: QwenSources) -> None:
|
||||
if change is None:
|
||||
return
|
||||
write_changes(
|
||||
|
||||
@@ -2,199 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .qwen_command import same_qwen_agent_id, same_qwen_path
|
||||
from .qwen_contract import AGENT_KEY, QwenRuntimeContract, elydora_dir
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .qwen_contract import AGENT_KEY, QwenRuntimeContract
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def _expected_scripts(agent_id: str, config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id, config)
|
||||
|
||||
|
||||
def qwen_runtime_files_exist(contracts: List[QwenRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Qwen Code", _expected_scripts)
|
||||
for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_qwen_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Qwen Code hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_qwen_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Qwen Code agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: QwenRuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_qwen_path(os.path.dirname(agent_directory), root)
|
||||
or not same_qwen_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id=str(config["org_id"]),
|
||||
agent_id=contract.agent_id,
|
||||
kid=str(config["kid"]),
|
||||
base_url=str(config["base_url"]),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def qwen_runtime_files_exist(
|
||||
contracts: List[QwenRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -10,9 +10,9 @@ import tempfile
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ._dotenv import parse_dotenv
|
||||
from ._managed_files import MAX_SOURCE_BYTES, FileSnapshot, read_physical_file
|
||||
from ._managed_files import MAX_SOURCE_BYTES, read_physical_file
|
||||
from ._runtime import same_path, unique_preconditions
|
||||
from ._transaction import FilePrecondition
|
||||
from .qwen_command import same_qwen_path
|
||||
from .qwen_config import (
|
||||
QwenDocument,
|
||||
create_qwen_document,
|
||||
@@ -127,7 +127,7 @@ def _resolve_qwen_routing() -> _RoutingResult:
|
||||
discovered_home = values.get("QWEN_HOME")
|
||||
if discovered_home and discovered_home != initial_qwen_home:
|
||||
discovered_directory = _resolve_config_path(discovered_home)
|
||||
if not same_qwen_path(discovered_directory, initial_directory):
|
||||
if not same_path(discovered_directory, initial_directory):
|
||||
read_candidate(os.path.join(discovered_directory, ".env"))
|
||||
resolved_home = values.get("QWEN_HOME")
|
||||
return _RoutingResult(
|
||||
@@ -288,15 +288,6 @@ def _source_precondition(document: QwenDocument) -> FilePrecondition:
|
||||
)
|
||||
|
||||
|
||||
def _deduplicate_preconditions(
|
||||
values: List[FilePrecondition],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
result: Dict[str, FilePrecondition] = {}
|
||||
for value in values:
|
||||
result.setdefault(_comparison_path(value.file_path), value)
|
||||
return tuple(result.values())
|
||||
|
||||
|
||||
def read_qwen_sources() -> QwenSources:
|
||||
routing = _resolve_qwen_routing()
|
||||
system_path = _system_settings_path()
|
||||
@@ -355,7 +346,7 @@ def read_qwen_sources() -> QwenSources:
|
||||
system,
|
||||
workspace_active and workspace_trusted,
|
||||
),
|
||||
preconditions=_deduplicate_preconditions(preconditions),
|
||||
preconditions=unique_preconditions(preconditions),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user