Compare commits

...
10 Commits
Author SHA1 Message Date
雷电芽衣 4f68bf0f9a chore(python): release 2.1.1
2.1.0 was tagged but never published. This release carries the
deep-health 503 handling, the restored issue_token aliases, the rotation
grace deadline, and agent-id path encoding.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 21:46:48 -04:00
雷电芽衣 11a152bab8 fix: expose the rotation grace deadline and encode agent ids in paths
Add previous_token_grace_until to RotateApiTokenResponse and
percent-encode the agent id path segment in both clients.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 20:12:50 -04:00
雷电芽衣 7b3ea2922c fix: restore the deprecated issue_token aliases
Published 2.0.2 exposed ElydoraClient.issue_token(),
AsyncElydoraClient.issue_token(), and the IssueTokenResponse type alias.
The audit removed them, which breaks 2.0.x callers on a minor upgrade.
Restore all three, now raising DeprecationWarning.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 19:55:45 -04:00
雷电芽衣 b438ead6ae fix: return the degraded deep-health report instead of raising
The API answers GET /v1/health/deep with 503 and a full report when a
dependency is down. _handle_response raised for every status at or above
400, so deep_health() raised exactly when callers need the dependency
details. Both the sync and async clients now accept 503 for this endpoint.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 19:36:25 -04:00
雷电芽衣 932a4d3022 chore(python): release 2.1.0
Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 00:46:49 -04:00
雷电芽衣 f7aa525bdb docs(python): audit packaging and docs
Document shared modules and the mypy platform in AGENTS.md, add the
missing client methods and types to README, and remove build residue.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 00:45:30 -04:00
雷电芽衣 aa4b8a8bdf chore(python): audit tests
Split the three oversized test files into support modules and topic
files, evaluate the rmdir dir_fd capability once at import, and drop
unused imports.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 00:30:17 -04:00
雷电芽衣 6ec3e902f5 chore(python): audit providers kirocli kiroide opencode
Move Kiro IDE onto _runtime and _shell_command, share kirocli file
helpers through _file_io, and clean temp files on any interruption.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 00:19:40 -04:00
雷电芽衣 407e4628a0 chore(python): audit providers kimi grok qwen letta
Move the four providers onto _runtime and _shell_command; expected
runtime scripts take the runtime config; add unique_preconditions.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-02 23:58:10 -04:00
雷电芽衣 37e8289510 chore(python): audit providers copilot droid augment cline
Move the four providers onto _runtime and _shell_command; add
expected_runtime_scripts and extra artifact checks to _runtime.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-02 23:45:01 -04:00
68 changed files with 3485 additions and 6572 deletions

No files matched your search

+12 -1
View File
@@ -4,6 +4,15 @@
This repository owns the published `elydora` package, its CLI, synchronous and asynchronous API clients, local signing behavior, generated hook runtimes, and Python-specific agent adapters.
## Shared Modules
- `elydora/_client_common.py` owns request bodies, headers, and error mapping for both clients.
- `elydora/plugins/_runtime.py` owns runtime paths, install config validation, runtime config validation, runtime file changes, and status checks for the transactional adapters. Kiro IDE keeps its workspace-bound variants in `kiroide_io.py`; Kiro CLI and OpenCode keep their own simple checks.
- `elydora/plugins/_shell_command.py` owns shell quoting, PowerShell encoding, and command parsing.
- `elydora/plugins/_file_io.py` owns atomic text and JSON writes plus plain JSON reads for the non-transactional adapters.
- `elydora/plugins/_managed_files.py` and `_transaction*.py` own physical-file reads and rollback-capable commits.
- Put new cross-provider logic in these modules; provider files keep only their schema, ownership, and rendering rules.
## Integration Sources
- Verify every agent hook contract against current official provider documentation before changing an adapter.
@@ -44,7 +53,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a
- Keep the package, distribution, and CLI version in `elydora/_version.py`; build metadata must read that literal through Setuptools dynamic metadata.
- Ship `elydora/py.typed` in every wheel and verify public annotations from an installed-wheel consumer.
- Support every Python version declared in `pyproject.toml`.
- Keep production source files at or below 500 lines.
- Keep every source and test file at or below 500 lines; share test fixtures through `tests/<provider>_support.py`.
- Keep functions focused on one ownership boundary.
- Propagate unexpected errors to the CLI boundary.
- Use documented defaults only for genuinely optional configuration.
@@ -74,6 +83,8 @@ py -3 -m pip wheel . --no-deps --wheel-dir <temporary-directory>
git diff --check
```
On Linux and macOS use `python -m` in place of `py -3 -m`, and point `HOME` and `XDG_CONFIG_HOME` at a scratch directory so the suite never touches real agent configuration. `[tool.mypy] platform = "win32"` type-checks the Windows branches on every host.
Provider adapter tests must cover installation, idempotency, official event forwarding, blocking behavior, status, missing runtime files, uninstall ownership, and malformed configuration preservation.
Commit and push one root issue before starting the next one.
+52 -11
View File
@@ -161,14 +161,20 @@ Retryable responses honor both `Retry-After` seconds and HTTP-date values.
# Register a new user and organization
reg = ElydoraClient.register(base_url, email, password, display_name=None, org_name=None)
# Login and receive a session token
# Login and use the session token
auth = ElydoraClient.login(base_url, email, password)
client.token = auth["token"]
# Get current authenticated user profile
me = client.get_me()
# Issue a new API token (with optional TTL in seconds)
# Issue an API token from the session token (optional TTL in seconds)
token_resp = client.issue_api_token(ttl_seconds=3600)
client.token = token_resp["token"]
# Rotate the API token the client now uses
rotated = client.rotate_api_token()
client.token = rotated["token"]
```
### Operations
@@ -219,6 +225,9 @@ agents_resp = client.list_agents()
# Unfreeze a previously frozen agent
client.unfreeze_agent(agent_id, reason="review complete")
# Change the integration type
client.update_agent(agent_id, integration_type="cursor")
# Delete an agent permanently
deleted_resp = client.delete_agent(agent_id)
```
@@ -260,6 +269,25 @@ detail = client.get_export(export_id)
data = client.download_export(export_id)
```
### Webhooks
```python
webhooks = client.list_webhooks()
created = client.register_webhook(
endpoint_url="https://example.com/elydora",
events=["operation.accepted", "agent.status_changed"],
secret="<signing-secret>",
)
client.delete_webhook(created["webhook"]["webhook_id"])
```
### Organization
```python
members = client.list_members()
events = client.list_admin_events(limit=50)
```
### JWKS
```python
@@ -269,9 +297,12 @@ jwks = client.get_jwks()
### Health
```python
# Check API health (no authentication required)
# No authentication required
health = client.health()
# health["status"], health["version"], health["protocol_version"], health["timestamp"]
# health["status"], health["version"], health["protocol_version"], health["capabilities"]
deep = client.deep_health()
# deep["dependencies"]["d1"]["status"], deep["dependencies"]["d1"]["latency_ms"]
```
### Crypto Functions
@@ -303,7 +334,7 @@ from elydora import (
### Type Definitions
All types are `TypedDict` classes for structural typing:
Records are `TypedDict` classes; enumerations are `Literal` aliases, and `INTEGRATION_TYPES` is a tuple:
```python
from elydora import (
@@ -312,18 +343,28 @@ from elydora import (
EAR, # Elydora Acknowledgment Receipt
# Entity types
Agent, AgentKey, Operation, Receipt, Epoch, Export, Organization, User,
Agent, AgentAssignment, AgentKey, AdminEvent, AuthMeUser,
CurrentOrganization, DependencyHealth, EpochAnchor, MemberEntry,
Operation, Receipt, Epoch, Export, Organization, User, WebhookEntry,
# Enumerations
AdminAction, AgentStatus, ErrorCode, ExportStatus, IntegrationType,
KeyStatus, RbacRole, INTEGRATION_TYPES,
# API response types
RegisterAgentResponse, GetAgentResponse, ListAgentsResponse,
DeleteAgentResponse, SubmitOperationResponse, GetOperationResponse,
RegisterAgentResponse, UpdateAgentResponse, GetAgentResponse,
ListAgentsResponse, DeleteAgentResponse, FreezeAgentResponse,
UnfreezeAgentResponse, SubmitOperationResponse, GetOperationResponse,
VerifyOperationResponse, AuditQueryResponse, GetEpochResponse,
ListEpochsResponse, CreateExportResponse, GetExportResponse,
ListExportsResponse, JWKSResponse, AuthRegisterResponse,
AuthLoginResponse, GetMeResponse, IssueApiTokenResponse, HealthResponse,
ListExportsResponse, ListWebhooksResponse, RegisterWebhookResponse,
ListMembersResponse, ListAdminEventsResponse, JWKSResponse,
AuthRegisterResponse, AuthLoginResponse, GetMeResponse,
IssueApiTokenResponse, RotateApiTokenResponse, HealthResponse,
DeepHealthResponse,
# Request types
RegisterAgentRequest,
RegisterAgentRequest, UpdateAgentRequest,
)
```
+2
View File
@@ -68,6 +68,7 @@ from .types import (
AuthLoginResponse,
GetMeResponse,
IssueApiTokenResponse,
IssueTokenResponse,
RotateApiTokenResponse,
HealthResponse,
DeepHealthResponse,
@@ -142,6 +143,7 @@ __all__ = [
"AuthLoginResponse",
"GetMeResponse",
"IssueApiTokenResponse",
"IssueTokenResponse",
"RotateApiTokenResponse",
"HealthResponse",
"DeepHealthResponse",
+1 -1
View File
@@ -1,3 +1,3 @@
"""Canonical Elydora package version."""
__version__ = "2.0.2"
__version__ = "2.1.1"
+21 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import warnings
from urllib.parse import quote
from typing import Any, Dict, List, Optional, Union
import aiohttp
@@ -130,9 +131,11 @@ class AsyncElydoraClient:
headers=build_headers(self.token),
)
async def _get_public(self, path: str) -> Any:
async def _get_public(self, path: str, *, accept_status: Optional[int] = None) -> Any:
session = await self._get_session()
async with session.get(f"{self.base_url}{path}", timeout=TIMEOUT) as response:
if accept_status is not None and response.status == accept_status:
return await response.json()
return await self._handle_response(response)
@staticmethod
@@ -193,6 +196,15 @@ class AsyncElydoraClient:
"POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds)
)
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
"""Deprecated alias for issue_api_token()."""
warnings.warn(
"AsyncElydoraClient.issue_token() is deprecated. Use issue_api_token().",
DeprecationWarning,
stacklevel=2,
)
return await self.issue_api_token(ttl_seconds=ttl_seconds)
async def rotate_api_token(self) -> RotateApiTokenResponse:
return await self._request("POST", "/v1/auth/rotate", json_body={})
@@ -201,19 +213,19 @@ class AsyncElydoraClient:
return await self._request("POST", "/v1/agents/register", json_body=request)
async def get_agent(self, agent_id: str) -> GetAgentResponse:
return await self._request("GET", f"/v1/agents/{agent_id}")
return await self._request("GET", f"/v1/agents/{quote(agent_id, safe='')}")
async def list_agents(self) -> ListAgentsResponse:
return await self._request("GET", "/v1/agents")
async def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
return await self._request(
"POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason}
"POST", f"/v1/agents/{quote(agent_id, safe='')}/freeze", json_body={"reason": reason}
)
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
return await self._request(
"POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}
"POST", f"/v1/agents/{quote(agent_id, safe='')}/unfreeze", json_body={"reason": reason}
)
async def update_agent(
@@ -221,15 +233,15 @@ class AsyncElydoraClient:
) -> UpdateAgentResponse:
require_integration_type(integration_type)
return await self._request(
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
"PATCH", f"/v1/agents/{quote(agent_id, safe='')}", json_body={"integration_type": integration_type}
)
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
return await self._request("DELETE", f"/v1/agents/{agent_id}")
return await self._request("DELETE", f"/v1/agents/{quote(agent_id, safe='')}")
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
await self._request(
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
"POST", f"/v1/agents/{quote(agent_id, safe='')}/revoke", json_body={"kid": kid, "reason": reason}
)
def create_operation(
@@ -349,4 +361,5 @@ class AsyncElydoraClient:
return await self._get_public("/v1/health")
async def deep_health(self) -> DeepHealthResponse:
return await self._get_public("/v1/health/deep")
"""503 carries the degraded report, not an error."""
return await self._get_public("/v1/health/deep", accept_status=503)
+21 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import warnings
from urllib.parse import quote
from typing import Any, Dict, List, Optional, Union
import requests
@@ -119,8 +120,10 @@ class ElydoraClient:
headers=build_headers(self.token),
)
def _get_public(self, path: str) -> Any:
def _get_public(self, path: str, *, accept_status: Optional[int] = None) -> Any:
response = self._session.get(f"{self.base_url}{path}", timeout=REQUEST_TIMEOUT_SECONDS)
if accept_status is not None and response.status_code == accept_status:
return response.json()
return self._handle_response(response)
@staticmethod
@@ -179,6 +182,15 @@ class ElydoraClient:
def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
return self._request("POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds))
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
"""Deprecated alias for issue_api_token()."""
warnings.warn(
"ElydoraClient.issue_token() is deprecated. Use issue_api_token().",
DeprecationWarning,
stacklevel=2,
)
return self.issue_api_token(ttl_seconds=ttl_seconds)
def rotate_api_token(self) -> RotateApiTokenResponse:
return self._request("POST", "/v1/auth/rotate", json_body={})
@@ -187,29 +199,29 @@ class ElydoraClient:
return self._request("POST", "/v1/agents/register", json_body=request)
def get_agent(self, agent_id: str) -> GetAgentResponse:
return self._request("GET", f"/v1/agents/{agent_id}")
return self._request("GET", f"/v1/agents/{quote(agent_id, safe='')}")
def list_agents(self) -> ListAgentsResponse:
return self._request("GET", "/v1/agents")
def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
return self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
return self._request("POST", f"/v1/agents/{quote(agent_id, safe='')}/freeze", json_body={"reason": reason})
def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
return self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
return self._request("POST", f"/v1/agents/{quote(agent_id, safe='')}/unfreeze", json_body={"reason": reason})
def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
require_integration_type(integration_type)
return self._request(
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
"PATCH", f"/v1/agents/{quote(agent_id, safe='')}", json_body={"integration_type": integration_type}
)
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
return self._request("DELETE", f"/v1/agents/{agent_id}")
return self._request("DELETE", f"/v1/agents/{quote(agent_id, safe='')}")
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
self._request(
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
"POST", f"/v1/agents/{quote(agent_id, safe='')}/revoke", json_body={"kid": kid, "reason": reason}
)
def create_operation(
@@ -324,4 +336,5 @@ class ElydoraClient:
return self._get_public("/v1/health")
def deep_health(self) -> DeepHealthResponse:
return self._get_public("/v1/health/deep")
"""503 carries the degraded report, not an error."""
return self._get_public("/v1/health/deep", accept_status=503)
+43 -4
View File
@@ -4,14 +4,15 @@ from __future__ import annotations
import json
import os
import stat
import tempfile
from typing import Any, Dict
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:
@@ -47,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)
@@ -58,8 +59,46 @@ 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)
except FileNotFoundError:
return
except OSError as error:
raise OSError(f"Remove {label} at {path}: {error}") from error
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)
+4 -1
View 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)
+61 -8
View File
@@ -6,7 +6,7 @@ import base64
from dataclasses import dataclass
import json
import os
from typing import Any, List, Optional, Protocol, Tuple
from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
import urllib.parse
from elydora._runtime_paths import resolve_agent_directory, runtime_root
@@ -20,8 +20,10 @@ from ._managed_files import (
read_physical_file,
)
from ._strict_json import JsonObject, parse_json_object
from ._transaction import FileChange, file_change, source_change
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"
@@ -167,13 +169,16 @@ def validate_runtime_config(
validate_api_origin(base_url, "Elydora runtime config base_url")
def runtime_config_source(config: InstallConfig, agent_id: str, agent_key: str) -> str:
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:
@@ -215,7 +220,11 @@ def validate_install_config(
def validate_runtime_tree(
agent_directory: str, agent_id: str, agent_key: str, product_label: str
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"):
@@ -226,7 +235,7 @@ def validate_runtime_tree(
config = read_runtime_config(config_path)
artifact_states = [
physical_file_exists(os.path.join(agent_directory, name), label)
for name, label in RUNTIME_ARTIFACTS
for name, label in (*RUNTIME_ARTIFACTS, *extra_artifacts)
]
artifact_exists = any(artifact_states)
if config is None:
@@ -246,10 +255,42 @@ def validate_runtime_tree(
)
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
contract: RuntimeContractLike,
agent_key: str,
product_label: str,
expected_scripts: Optional[ExpectedScripts] = None,
) -> bool:
"""Strict status check: valid config and key plus non-empty runtime scripts."""
"""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(
@@ -271,7 +312,10 @@ def runtime_contract_exists(
return False
validate_runtime_config(config, contract.agent_id, config_path, agent_key, product_label)
validate_private_key(key.contents, "Elydora private key")
return bool(guard.contents) and bool(audit.contents)
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:
@@ -336,3 +380,12 @@ def rendered_source_change(
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())
+12 -3
View File
@@ -57,10 +57,11 @@ def encode_powershell_source(source: str) -> str:
return base64.b64encode(source.encode("utf-16le")).decode("ascii")
def encoded_windows_command(script_path: str) -> str:
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 {encode_powershell_source(powershell_source(script_path))}"
f"-EncodedCommand {encoded}"
)
@@ -141,11 +142,19 @@ def decode_powershell_source(encoded: str) -> Optional[str]:
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 = "& "
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)
+7 -18
View File
@@ -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,
+43 -124
View File
@@ -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:
+18 -209
View File
@@ -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
)
+5 -17
View File
@@ -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(
+34 -143
View File
@@ -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:
+9 -220
View File
@@ -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)
+24 -104
View File
@@ -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])
):
+38 -160
View File
@@ -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:
+13 -193
View File
@@ -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
)
+1 -1
View File
@@ -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
+17 -116
View File
@@ -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),
+44 -186
View File
@@ -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)
+18 -212
View File
@@ -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:
+17 -171
View File
@@ -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)
+3 -4
View File
@@ -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,
+25 -138
View File
@@ -2,169 +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 ._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:
@@ -172,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:
+14 -175
View File
@@ -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)
+17 -171
View File
@@ -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)
+3 -4
View File
@@ -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(
+26 -142
View File
@@ -2,121 +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 ._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_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,
validate_runtime_tree,
)
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
@@ -130,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:
+5 -189
View File
@@ -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)
+26 -89
View File
@@ -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
+3 -3
View File
@@ -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(
+23 -189
View File
@@ -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)
+5 -6
View File
@@ -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)
)
):
+35 -83
View File
@@ -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(
+36 -132
View File
@@ -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"),
)
+2 -2
View File
@@ -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),
+17 -115
View File
@@ -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)
+4 -11
View File
@@ -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,
+30 -132
View File
@@ -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(
+12 -193
View File
@@ -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 -13
View File
@@ -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),
)
+2 -2
View File
@@ -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),
+15 -105
View File
@@ -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)
+3 -8
View File
@@ -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,
+31 -127
View File
@@ -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(
+12 -192
View File
@@ -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)
+4 -13
View File
@@ -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),
)
+5
View File
@@ -351,10 +351,15 @@ class IssueApiTokenResponse(TypedDict):
token_id: str
# Deprecated alias for IssueApiTokenResponse.
IssueTokenResponse = IssueApiTokenResponse
class RotateApiTokenResponse(TypedDict):
token: str
expires_at: Optional[int]
token_id: str
previous_token_grace_until: int
class HealthResponse(TypedDict):
+286
View File
@@ -0,0 +1,286 @@
from __future__ import annotations
import base64
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
from typing import Any
import pytest
from elydora.plugins import kiroide
from elydora.plugins import (
kiroide_contract,
kiroide_installation,
kiroide_io,
)
from elydora.plugins.base import InstallConfig
AGENT_ID = "agent-1"
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([11]) * 32).rstrip(b"=").decode()
ROTATED_PRIVATE_KEY = (
base64.urlsafe_b64encode(bytes([12]) * 32).rstrip(b"=").decode()
)
@dataclass(frozen=True)
class KiroIdeFixture:
home: Path
workspace: Path
config_path: Path
legacy_path: Path
agent_directory: Path
guard_path: Path
audit_path: Path
plugin: kiroide.KiroIdePlugin
config: InstallConfig
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
def install_config(home: Path) -> InstallConfig:
return {
"org_id": "org-1",
"agent_id": AGENT_ID,
"agent_name": "kiroide",
"private_key": PRIVATE_KEY,
"kid": "kid-1",
"token": "token-1",
"base_url": "https://api.elydora.test",
"guard_script_path": str(home / ".elydora" / AGENT_ID / "guard.py"),
}
def prepare_fixture(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
current: Any = None,
legacy: Any = None,
base_url: str = "https://api.elydora.test",
) -> KiroIdeFixture:
home = tmp_path / "home with spaces and 'quote %KIRO%"
workspace = tmp_path / "workspace with spaces"
home.mkdir(parents=True)
workspace.mkdir(parents=True)
config_path = workspace / ".kiro" / "hooks" / "elydora-audit.json"
legacy_path = home / ".kiro" / "hooks" / "elydora-audit.kiro.hook"
if current is not None:
if isinstance(current, str):
config_path.parent.mkdir(parents=True)
config_path.write_text(current, encoding="utf-8")
else:
write_json(config_path, current)
if legacy is not None:
if isinstance(legacy, str):
legacy_path.parent.mkdir(parents=True)
legacy_path.write_text(legacy, encoding="utf-8")
else:
write_json(legacy_path, legacy)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.chdir(workspace)
agent_directory = home / ".elydora" / AGENT_ID
config = install_config(home)
config["base_url"] = base_url
return KiroIdeFixture(
home,
workspace,
config_path,
legacy_path,
agent_directory,
agent_directory / "guard.py",
agent_directory / "hook.py",
kiroide.KiroIdePlugin(),
config,
)
def find_hook(document: dict[str, Any], name: str) -> dict[str, Any]:
return next(hook for hook in document["hooks"] if hook.get("name") == name)
def managed_document(guard_command: str, audit_command: str) -> dict[str, Any]:
return {
"version": "v1",
"hooks": [
{
"name": "elydora-guard",
"description": "Block tool use when the Elydora agent is frozen",
"trigger": "PreToolUse",
"matcher": ".*",
"action": {"type": "command", "command": guard_command},
"timeout": 10,
"enabled": True,
},
{
"name": "elydora-audit",
"description": "Record tool use in the Elydora audit trail",
"trigger": "PostToolUse",
"matcher": ".*",
"action": {"type": "command", "command": audit_command},
"timeout": 10,
"enabled": True,
},
],
}
def legacy_document(fixture: KiroIdeFixture, agent_id: str = AGENT_ID) -> dict:
agent_directory = fixture.home / ".elydora" / agent_id
return {
"name": "Elydora Audit",
"description": (
"Sends tool-use events to the Elydora tamper-evident audit platform"
),
"version": "1.0.0",
"hooks": {
"pre_tool_use": {
"command": f'"{sys.executable}" {agent_directory / "guard.py"}',
"timeout_ms": 5000,
},
"post_tool_use": {
"command": str(agent_directory / "hook.py"),
"timeout_ms": 5000,
},
},
}
def legacy_runtime_config(
fixture: KiroIdeFixture, agent_id: str = AGENT_ID
) -> dict[str, str]:
return {
"org_id": "org-1",
"agent_id": agent_id,
"kid": "kid-1",
"base_url": "https://api.elydora.test",
"token": "",
"agent_name": "kiroide",
}
def write_legacy_runtime(fixture: KiroIdeFixture, agent_id: str) -> Path:
agent_directory = fixture.home / ".elydora" / agent_id
write_json(
agent_directory / "config.json",
legacy_runtime_config(fixture, agent_id),
)
(agent_directory / "private.key").write_text(PRIVATE_KEY, encoding="utf-8")
(agent_directory / "guard.py").write_text("guard\n", encoding="utf-8")
(agent_directory / "hook.py").write_text("audit\n", encoding="utf-8")
return agent_directory
def write_large_error_log(agent_directory: Path, marker: bytes) -> Path:
error_log = agent_directory / "error.log"
with error_log.open("wb") as file:
for _index in range(48):
file.write(marker * 4096)
if os.name != "nt":
error_log.chmod(0o600)
assert error_log.stat().st_size > 2 * 1024 * 1024
return error_log
def run_command(
command: str, fixture: KiroIdeFixture, payload: dict[str, Any]
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
shell=True,
cwd=fixture.workspace,
env={
**os.environ,
"HOME": str(fixture.home),
"USERPROFILE": str(fixture.home),
},
input=json.dumps(payload),
text=True,
capture_output=True,
check=False,
)
class AuditHandler(BaseHTTPRequestHandler):
operations: list[dict[str, Any]] = []
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8")
self.operations.append(json.loads(raw))
self.send_response(201)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
@dataclass(frozen=True)
class AuditServer:
server: ThreadingHTTPServer
thread: threading.Thread
@property
def base_url(self) -> str:
host, port = self.server.server_address
return f"http://{host}:{port}"
def close(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join()
def start_audit_server() -> AuditServer:
AuditHandler.operations = []
server = ThreadingHTTPServer(("127.0.0.1", 0), AuditHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return AuditServer(server, thread)
def prepare_installation(
fixture: KiroIdeFixture,
config: InstallConfig | None = None,
) -> tuple[
kiroide_io.KiroIdeSources,
kiroide_installation.KiroIdeRuntimePaths,
kiroide_installation.PreparedKiroIdeInstallation,
kiroide_contract.RenderedKiroIdeDocument,
]:
selected = fixture.config if config is None else config
sources = kiroide_io.read_kiroide_sources()
kiroide_contract.require_available_kiroide_hooks(sources.document.hooks)
paths = kiroide_installation.preflight_kiroide_installation(
selected, sources
)
hooks = kiroide_contract.without_managed_kiroide_hooks(
sources.document.hooks
)
rendered = kiroide_contract.render_kiroide_document(
sources.document,
[
*hooks,
kiroide_contract.build_kiroide_hook(
kiroide_contract.GUARD_NAME, paths.guard_path
),
kiroide_contract.build_kiroide_hook(
kiroide_contract.AUDIT_NAME, paths.audit_path
),
],
)
prepared = kiroide_installation.prepare_kiroide_installation(
selected, paths, sources, rendered
)
return sources, paths, prepared, rendered
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
from typing import Any
import pytest
from elydora import cli
AGENT_ID = "agent-1"
AGENT_NAME = "opencode"
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([19]) * 32).rstrip(b"=").decode()
def set_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> None:
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
def write_secret(path: Path, value: str) -> None:
path.write_text(value, encoding="utf-8")
if os.name != "nt":
path.chmod(0o600)
def write_error_log(path: Path, chunks: int = 1) -> str:
digest = hashlib.sha256()
block = b"opaque historical error\n" * 4096
with path.open("wb") as file:
for _index in range(chunks):
file.write(block)
digest.update(block)
if os.name != "nt":
path.chmod(0o600)
return digest.hexdigest()
def stream_digest(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
while chunk := file.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def prepare_runtime(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
agent_name: str = AGENT_NAME,
) -> tuple[Path, Path]:
home = tmp_path / "home"
root = home / ".elydora"
agent = root / AGENT_ID
set_home(monkeypatch, home)
write_json(
agent / "config.json",
{"agent_id": AGENT_ID, "agent_name": agent_name},
)
return root, agent
def uninstall_args(agent_name: str = AGENT_NAME) -> argparse.Namespace:
return cli.build_parser().parse_args(
[
"uninstall",
"--agent",
agent_name,
"--agent_id",
AGENT_ID,
]
)
-1
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from copy import deepcopy
import json
import os
from pathlib import Path
import sys
-1
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess # nosec B404
-1
View File
@@ -5,7 +5,6 @@ import json
import os
from pathlib import Path
import subprocess
import sys
from typing import Any
import pytest
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import copy
import json
import os
from pathlib import Path
import subprocess
import stat
import pytest
from elydora.plugins._shell_command import is_python_executable, windows_powershell_path
from elydora.plugins import (
kiroide_command,
)
from kiroide_support import (
AGENT_ID,
find_hook,
managed_document,
prepare_fixture,
write_json,
)
def test_kiroide_exact_managed_commands_survive_python_relocation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
executable_name = "python.exe" if os.name == "nt" else "python3"
fake_executable = tmp_path / "other-runtime" / executable_name
with monkeypatch.context() as command_context:
command_context.setattr(
kiroide_command.sys, "executable", str(fake_executable)
)
guard_command = kiroide_command.build_kiroide_command(
str(fixture.guard_path)
)
audit_command = kiroide_command.build_kiroide_command(
str(fixture.audit_path)
)
stale_document = managed_document(guard_command, audit_command)
write_json(fixture.config_path, stale_document)
assert fixture.plugin.status() == {
"installed": False,
"agent": "kiroide",
"details": (
"Managed hooks are incomplete or invalid: "
f"{fixture.config_path}"
),
}
fixture.plugin.install(fixture.config)
repaired = json.loads(fixture.config_path.read_text(encoding="utf-8"))
assert kiroide_command.kiroide_runtime_reference(
find_hook(repaired, "elydora-guard")["action"]["command"],
"guard.py",
) is not None
assert kiroide_command.kiroide_runtime_reference(
find_hook(repaired, "elydora-audit")["action"]["command"],
"hook.py",
) is not None
write_json(fixture.config_path, stale_document)
fixture.plugin.uninstall(AGENT_ID)
assert not fixture.config_path.exists()
@pytest.mark.parametrize("damage", ["field", "orphan"])
def test_kiroide_stale_command_ownership_survives_contract_drift(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
damage: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
executable_name = "python.exe" if os.name == "nt" else "python3"
fake_executable = tmp_path / "other-runtime" / executable_name
with monkeypatch.context() as command_context:
command_context.setattr(
kiroide_command.sys, "executable", str(fake_executable)
)
document = managed_document(
kiroide_command.build_kiroide_command(str(fixture.guard_path)),
kiroide_command.build_kiroide_command(str(fixture.audit_path)),
)
if damage == "field":
find_hook(document, "elydora-guard")["timeout"] = 9
else:
document["hooks"].remove(find_hook(document, "elydora-audit"))
write_json(fixture.config_path, document)
fixture.plugin.install(fixture.config)
repaired = json.loads(fixture.config_path.read_text(encoding="utf-8"))
assert len(repaired["hooks"]) == 2
assert all(hook["timeout"] == 10 for hook in repaired["hooks"])
write_json(fixture.config_path, document)
fixture.plugin.uninstall(AGENT_ID)
assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[
"hooks"
] == []
@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract")
def test_kiroide_current_commands_require_the_system_powershell_launcher(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
command = kiroide_command.build_kiroide_command(str(fixture.guard_path))
expected = f'"{windows_powershell_path()}"'
altered = command.replace(expected, r'"C:\Other\powershell.exe"', 1)
assert kiroide_command.kiroide_runtime_reference(altered, "guard.py") is None
@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract")
def test_kiroide_windows_launcher_fails_when_python_is_missing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
missing_python = tmp_path / "missing-runtime" / "python.exe"
script_path = tmp_path / "guard.py"
with monkeypatch.context() as command_context:
command_context.setattr(
kiroide_command.sys, "executable", str(missing_python)
)
command = kiroide_command.build_kiroide_command(str(script_path))
result = subprocess.run(
command,
shell=True,
capture_output=True,
check=False,
text=True,
)
assert result.returncode == 1
assert result.stderr
@pytest.mark.parametrize(
"executable_name",
["python3.13t", "python3.14t", "python3.13d.exe", "pypy3", "pypy3.10.exe"],
)
def test_kiroide_ownership_accepts_supported_python_runtime_names(
executable_name: str,
) -> None:
assert is_python_executable(executable_name)
def test_kiroide_builder_rejects_unrecoverable_executable_names(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
with monkeypatch.context() as command_context:
command_context.setattr(
kiroide_command.sys,
"executable",
str(tmp_path / "embedded-runtime"),
)
with pytest.raises(ValueError, match="absolute Python executable"):
kiroide_command.build_kiroide_command(str(tmp_path / "guard.py"))
@pytest.mark.parametrize("damage", ["orphan", "duplicate", "altered"])
def test_kiroide_status_rejects_every_incomplete_managed_hook_set(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
damage: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
audit = find_hook(document, "elydora-audit")
if damage == "orphan":
document["hooks"].remove(audit)
elif damage == "duplicate":
document["hooks"].append(copy.deepcopy(audit))
else:
find_hook(document, "elydora-guard")["timeout"] = 9
write_json(fixture.config_path, document)
status = fixture.plugin.status()
assert status["installed"] is False
assert status["details"] == (
f"Managed hooks are incomplete or invalid: {fixture.config_path}"
)
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
@pytest.mark.parametrize(
("target", "message"),
[
("key", "private key must be accessible only by its owner"),
("config", "Token-bearing Elydora runtime config must be accessible"),
],
)
def test_kiroide_repairs_and_enforces_owner_only_runtime_secrets(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
target: str,
message: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
paths = {
"key": fixture.agent_directory / "private.key",
"config": fixture.agent_directory / "config.json",
}
paths[target].chmod(0o644)
with pytest.raises(ValueError, match=message):
fixture.plugin.status()
fixture.plugin.install(fixture.config)
assert stat.S_IMODE(paths[target].stat().st_mode) == 0o600
assert fixture.plugin.status()["installed"] is True
File diff suppressed because it is too large. Load diff
+359
View File
@@ -0,0 +1,359 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from elydora.plugins import (
_transaction,
kiroide_installation,
)
from elydora.plugins.base import InstallConfig
from kiroide_support import (
AGENT_ID,
PRIVATE_KEY,
ROTATED_PRIVATE_KEY,
find_hook,
legacy_document,
prepare_fixture,
prepare_installation,
write_json,
write_large_error_log,
write_legacy_runtime,
)
def test_kiroide_uninstall_removes_exact_ownership_and_preserves_user_hooks(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
user_hook = {
"name": "workspace-context",
"trigger": "SessionStart",
"action": {"type": "agent", "prompt": "Read AGENTS.md"},
}
fixture = prepare_fixture(
monkeypatch,
tmp_path,
current={"version": "v1", "owner": "workspace", "hooks": [user_hook]},
)
fixture.plugin.install(fixture.config)
fixture.plugin.uninstall("agent-2")
assert len(json.loads(fixture.config_path.read_text())["hooks"]) == 3
assert fixture.agent_directory.exists()
assert (fixture.agent_directory / "private.key").read_text(
encoding="utf-8"
) == PRIVATE_KEY
fixture.plugin.uninstall(AGENT_ID)
assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == {
"version": "v1",
"owner": "workspace",
"hooks": [user_hook],
}
owned = prepare_fixture(monkeypatch, tmp_path / "owned")
owned.plugin.install(owned.config)
owned.plugin.uninstall(AGENT_ID)
assert not owned.config_path.exists()
def test_kiroide_uninstall_without_agent_id_removes_owned_runtime(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
fixture.plugin.uninstall()
assert not fixture.config_path.exists()
assert not fixture.agent_directory.exists()
assert not (fixture.agent_directory / "private.key").exists()
def test_kiroide_uninstall_without_agent_id_removes_current_and_legacy_runtimes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
legacy_agent_id = "agent-2"
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
fixture.plugin.uninstall()
assert not fixture.config_path.exists()
assert not fixture.legacy_path.exists()
assert not fixture.agent_directory.exists()
assert not legacy_directory.exists()
assert not (legacy_directory / "private.key").exists()
def test_kiroide_no_id_uninstall_streams_all_oversized_error_logs(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
current_error = write_large_error_log(
fixture.agent_directory,
b"current runtime error\n",
)
legacy_agent_id = "agent-2"
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
legacy_error = write_large_error_log(
legacy_directory,
b"legacy runtime error\n",
)
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
fixture.plugin.uninstall()
assert not current_error.exists()
assert not legacy_error.exists()
assert not fixture.agent_directory.exists()
assert not legacy_directory.exists()
def test_kiroide_uninstall_without_agent_id_deduplicates_current_and_legacy_owner(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
write_json(fixture.legacy_path, legacy_document(fixture))
fixture.plugin.uninstall()
assert not fixture.config_path.exists()
assert not fixture.legacy_path.exists()
assert not fixture.agent_directory.exists()
def test_kiroide_uninstall_without_agent_id_rejects_unknown_runtime_before_writes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
legacy_agent_id = "agent-2"
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
unknown = legacy_directory / "user-data.txt"
unknown.write_text("preserve\n", encoding="utf-8")
original_provider = fixture.config_path.read_bytes()
original_legacy = fixture.legacy_path.read_bytes()
original_key = (fixture.agent_directory / "private.key").read_bytes()
original_legacy_key = (legacy_directory / "private.key").read_bytes()
with pytest.raises(OSError, match="contains unmanaged entries"):
fixture.plugin.uninstall()
assert fixture.config_path.read_bytes() == original_provider
assert fixture.legacy_path.read_bytes() == original_legacy
assert (fixture.agent_directory / "private.key").read_bytes() == original_key
assert (legacy_directory / "private.key").read_bytes() == original_legacy_key
assert unknown.read_text(encoding="utf-8") == "preserve\n"
def test_kiroide_uninstall_without_agent_id_rolls_back_provider_and_runtime(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
runtime_paths = [
fixture.guard_path,
fixture.audit_path,
fixture.agent_directory / "private.key",
fixture.agent_directory / "config.json",
]
original_provider = fixture.config_path.read_bytes()
original_runtime = {path: path.read_bytes() for path in runtime_paths}
original_capture = _transaction._capture_physical
def fail_private_key_removal(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if Path(source) == fixture.agent_directory / "private.key" and (
destination.endswith(".rollback")
):
raise OSError("injected private key removal failure")
original_capture(source, destination, directory)
monkeypatch.setattr(_transaction, "_capture_physical", fail_private_key_removal)
with pytest.raises(OSError, match="injected private key removal failure"):
fixture.plugin.uninstall()
assert fixture.config_path.read_bytes() == original_provider
assert {path: path.read_bytes() for path in runtime_paths} == original_runtime
def test_kiroide_transaction_rolls_back_when_legacy_cleanup_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
original = {"version": "v1", "owner": "workspace", "hooks": []}
fixture = prepare_fixture(monkeypatch, tmp_path, current=original)
write_json(fixture.legacy_path, legacy_document(fixture))
original_capture = _transaction._capture_physical
def fail_legacy_removal(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if Path(source) == fixture.legacy_path and destination.endswith(".rollback"):
raise OSError("injected legacy cleanup failure")
original_capture(source, destination, directory)
monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal)
with pytest.raises(OSError, match="injected legacy cleanup failure"):
fixture.plugin.install(fixture.config)
assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == original
assert fixture.legacy_path.exists()
for path in (
fixture.guard_path,
fixture.audit_path,
fixture.agent_directory / "config.json",
fixture.agent_directory / "private.key",
):
assert not path.exists()
leftovers = list(fixture.config_path.parent.glob(".*.tmp"))
leftovers += list(fixture.config_path.parent.glob(".*.rollback"))
assert leftovers == []
@pytest.mark.parametrize("swapped", ["workspace", "kiro", "hooks"])
def test_kiroide_transaction_rejects_provider_directory_identity_swaps(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
swapped: str,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
current={"version": "v1", "hooks": []},
)
sources, paths, prepared, _rendered = prepare_installation(fixture)
monkeypatch.chdir(tmp_path)
original_paths = {
"workspace": fixture.workspace,
"kiro": fixture.workspace / ".kiro",
"hooks": fixture.workspace / ".kiro" / "hooks",
}
original = original_paths[swapped]
backup = original.with_name(original.name + "-original")
replacement = tmp_path / f"redirected-{swapped}"
if swapped == "workspace":
(replacement / ".kiro" / "hooks").mkdir(parents=True)
redirected_config = replacement / ".kiro" / "hooks" / "elydora-audit.json"
elif swapped == "kiro":
(replacement / "hooks").mkdir(parents=True)
redirected_config = replacement / "hooks" / "elydora-audit.json"
else:
replacement.mkdir()
redirected_config = replacement / "elydora-audit.json"
original.rename(backup)
try:
original.symlink_to(replacement, target_is_directory=True)
except OSError as error:
backup.rename(original)
pytest.skip(f"Directory symbolic links are unavailable: {error}")
with pytest.raises(OSError, match="not a physical directory|changed during"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert not redirected_config.exists()
assert not fixture.agent_directory.exists()
def test_kiroide_key_rotation_pins_unchanged_provider_sources(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
original_key = (fixture.agent_directory / "private.key").read_text(
encoding="utf-8"
)
rotated = InstallConfig(**fixture.config)
rotated["private_key"] = ROTATED_PRIVATE_KEY
sources, paths, prepared, rendered = prepare_installation(fixture, rotated)
assert rendered.changed is False
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
document["concurrent_change"] = True
write_json(fixture.config_path, document)
with pytest.raises(OSError, match="Kiro IDE hooks changed during"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert (fixture.agent_directory / "private.key").read_text(
encoding="utf-8"
) == original_key
assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[
"concurrent_change"
] is True
def test_kiroide_noop_install_rejects_concurrent_provider_changes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
sources, paths, prepared, rendered = prepare_installation(fixture)
assert prepared.changes == []
assert rendered.changed is False
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
find_hook(document, "elydora-guard")["enabled"] = False
write_json(fixture.config_path, document)
with pytest.raises(OSError, match="Kiro IDE hooks changed during"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert find_hook(
json.loads(fixture.config_path.read_text(encoding="utf-8")),
"elydora-guard",
)["enabled"] is False
@pytest.mark.parametrize(
"relative_path",
["private.key", "config.json", "guard.py", "hook.py"],
)
def test_kiroide_noop_install_pins_every_runtime_source(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
relative_path: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
sources, paths, prepared, _rendered = prepare_installation(fixture)
assert prepared.changes == []
assert len(prepared.runtime_preconditions) == 4
target = fixture.agent_directory / relative_path
concurrent = target.read_text(encoding="utf-8") + "\nconcurrent-change"
target.write_text(concurrent, encoding="utf-8")
with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert target.read_text(encoding="utf-8") == concurrent
+367
View File
@@ -0,0 +1,367 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import stat
import pytest
from elydora import cli
from elydora.plugins import (
_transaction,
kiroide_installation,
)
from elydora.plugins.base import InstallConfig
from kiroide_support import (
AGENT_ID,
PRIVATE_KEY,
ROTATED_PRIVATE_KEY,
legacy_document,
legacy_runtime_config,
prepare_fixture,
prepare_installation,
write_json,
)
def test_kiroide_runtime_config_binds_agent_to_absolute_workspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
runtime_config = json.loads(
(fixture.agent_directory / "config.json").read_text(encoding="utf-8")
)
assert runtime_config["workspace_root"] == str(fixture.workspace.resolve())
assert Path(runtime_config["workspace_root"]).is_absolute()
def test_kiroide_rejects_same_agent_install_from_another_workspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
original_hook = fixture.config_path.read_bytes()
runtime_config_path = fixture.agent_directory / "config.json"
original_runtime = runtime_config_path.read_bytes()
second_workspace = tmp_path / "second workspace"
second_workspace.mkdir()
monkeypatch.chdir(second_workspace)
with pytest.raises(ValueError, match="bound to another workspace"):
fixture.plugin.install(fixture.config)
assert fixture.config_path.read_bytes() == original_hook
assert runtime_config_path.read_bytes() == original_runtime
assert not (second_workspace / ".kiro").exists()
def test_kiroide_cli_uninstall_requires_bound_workspace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
original_hook = fixture.config_path.read_bytes()
second_workspace = tmp_path / "second workspace"
second_workspace.mkdir()
args = cli.build_parser().parse_args(
[
"uninstall",
"--agent",
"kiroide",
"--agent_id",
AGENT_ID,
]
)
monkeypatch.chdir(second_workspace)
with pytest.raises(ValueError, match="bound to another workspace"):
cli.cmd_uninstall(args)
assert fixture.config_path.read_bytes() == original_hook
assert fixture.agent_directory.exists()
assert not (second_workspace / ".kiro").exists()
monkeypatch.chdir(fixture.workspace)
cli.cmd_uninstall(args)
assert not fixture.config_path.exists()
assert not fixture.agent_directory.exists()
def test_kiroide_install_claims_legacy_runtime_owner_from_matching_hooks(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
runtime_config_path = fixture.agent_directory / "config.json"
runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8"))
runtime_config.pop("workspace_root")
write_json(runtime_config_path, runtime_config)
fixture.plugin.install(fixture.config)
claimed = json.loads(runtime_config_path.read_text(encoding="utf-8"))
assert claimed["workspace_root"] == str(fixture.workspace.resolve())
def test_kiroide_install_migrates_exact_global_legacy_runtime_owner(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
write_json(fixture.legacy_path, legacy_document(fixture))
runtime_config_path = fixture.agent_directory / "config.json"
write_json(runtime_config_path, legacy_runtime_config(fixture))
config = dict(fixture.config)
config.pop("token")
fixture.plugin.install(config)
migrated = json.loads(runtime_config_path.read_text(encoding="utf-8"))
assert migrated["workspace_root"] == str(fixture.workspace.resolve())
assert "token" not in migrated
assert fixture.config_path.exists()
assert not fixture.legacy_path.exists()
def test_kiroide_legacy_owner_migration_rolls_back_runtime_claim(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
write_json(fixture.legacy_path, legacy_document(fixture))
runtime_config_path = fixture.agent_directory / "config.json"
original_runtime = legacy_runtime_config(fixture)
write_json(runtime_config_path, original_runtime)
original_capture = _transaction._capture_physical
def fail_legacy_removal(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if Path(source) == fixture.legacy_path and destination.endswith(
".rollback"
):
raise OSError("injected legacy migration failure")
original_capture(source, destination, directory)
monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal)
with pytest.raises(OSError, match="injected legacy migration failure"):
fixture.plugin.install(fixture.config)
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == (
original_runtime
)
assert fixture.legacy_path.exists()
assert not fixture.config_path.exists()
def test_kiroide_cli_uninstall_accepts_exact_global_legacy_ownership(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
write_json(fixture.legacy_path, legacy_document(fixture))
write_json(
fixture.agent_directory / "config.json",
legacy_runtime_config(fixture),
)
args = cli.build_parser().parse_args(
[
"uninstall",
"--agent",
"kiroide",
"--agent_id",
AGENT_ID,
]
)
cli.cmd_uninstall(args)
assert not fixture.legacy_path.exists()
assert not fixture.agent_directory.exists()
assert not fixture.config_path.exists()
def test_kiroide_rejects_unproved_legacy_runtime_owner(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
write_json(fixture.legacy_path, legacy_document(fixture, "agent-2"))
runtime_config_path = fixture.agent_directory / "config.json"
original = legacy_runtime_config(fixture)
write_json(runtime_config_path, original)
with pytest.raises(ValueError, match="token is invalid"):
fixture.plugin.install(fixture.config)
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == original
assert fixture.legacy_path.exists()
assert not fixture.config_path.exists()
def test_kiroide_rejects_invalid_ownerless_legacy_runtime_config(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
write_json(fixture.legacy_path, legacy_document(fixture))
runtime_config_path = fixture.agent_directory / "config.json"
invalid = legacy_runtime_config(fixture)
invalid["token"] = " "
write_json(runtime_config_path, invalid)
with pytest.raises(ValueError, match="token is invalid"):
fixture.plugin.install(fixture.config)
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == invalid
assert fixture.legacy_path.exists()
assert not fixture.config_path.exists()
@pytest.mark.parametrize(
("owner", "message"),
[("relative", "must be absolute"), ("other", "bound to another workspace")],
)
def test_kiroide_status_rejects_invalid_runtime_workspace_owner(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
owner: str,
message: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
runtime_config_path = fixture.agent_directory / "config.json"
runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8"))
runtime_config["workspace_root"] = (
"relative" if owner == "relative" else str((tmp_path / "other").resolve())
)
write_json(runtime_config_path, runtime_config)
with pytest.raises(ValueError, match=message):
fixture.plugin.status()
def test_kiroide_failed_install_removes_created_workspace_and_runtime_directories(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
def fail_first_commit(
_source: str,
_destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
raise OSError("injected Kiro IDE commit failure")
monkeypatch.setattr(_transaction, "_replace_physical", fail_first_commit)
with pytest.raises(OSError, match="injected Kiro IDE commit failure"):
fixture.plugin.install(fixture.config)
assert not (fixture.workspace / ".kiro").exists()
# The durable transaction state directory survives failures by design;
# everything else under the runtime root must be rolled back.
elydora_root = fixture.home / ".elydora"
if elydora_root.exists():
assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"]
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
@pytest.mark.parametrize("relative_path", ["private.key", "config.json"])
def test_kiroide_noop_install_pins_runtime_secret_modes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
relative_path: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
sources, paths, prepared, _rendered = prepare_installation(fixture)
target = fixture.agent_directory / relative_path
target.chmod(0o640)
with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert stat.S_IMODE(target.stat().st_mode) == 0o640
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
def test_kiroide_key_rotation_rejects_concurrent_mode_changes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
rotated = InstallConfig(**fixture.config)
rotated["private_key"] = ROTATED_PRIVATE_KEY
sources, paths, prepared, _rendered = prepare_installation(
fixture, rotated
)
key_path = fixture.agent_directory / "private.key"
assert any(change.file_path == str(key_path) for change in prepared.changes)
key_path.chmod(0o640)
with pytest.raises(OSError, match="changed during installation"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert key_path.read_text(encoding="utf-8") == PRIVATE_KEY
assert stat.S_IMODE(key_path.stat().st_mode) == 0o640
def test_kiroide_install_rejects_runtime_root_link_created_after_prepare(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
sources, paths, prepared, _rendered = prepare_installation(fixture)
runtime_root = fixture.home / ".elydora"
redirected_root = tmp_path / "redirected-runtime-root"
redirected_root.mkdir()
try:
runtime_root.symlink_to(redirected_root, target_is_directory=True)
except OSError as error:
pytest.skip(f"Directory symbolic links are unavailable: {error}")
with pytest.raises(OSError, match="not a physical directory|changed during"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert not (redirected_root / AGENT_ID / "private.key").exists()
assert not fixture.config_path.exists()
def test_kiroide_install_rejects_agent_directory_identity_swap(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.agent_directory.mkdir(parents=True)
sources, paths, prepared, _rendered = prepare_installation(fixture)
original_directory = fixture.agent_directory.with_name("agent-original")
fixture.agent_directory.rename(original_directory)
fixture.agent_directory.mkdir()
with pytest.raises(OSError, match="changed during"):
kiroide_installation.commit_kiroide_installation(
prepared, sources, paths
)
assert not (fixture.agent_directory / "private.key").exists()
assert not fixture.config_path.exists()
-1
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import pytest
+1 -1
View File
@@ -21,7 +21,7 @@ def test_darwin_private_directory_removal_uses_stdlib_dir_fd(
calls.append((path, dir_fd))
monkeypatch.setattr(_private_artifact.os, "rmdir", record_rmdir)
monkeypatch.setattr(_private_artifact.os, "supports_dir_fd", {record_rmdir})
monkeypatch.setattr(_private_artifact, "_RMDIR_SUPPORTS_DIR_FD", True)
_private_artifact._remove_posix_private_directory(41, "private-container")
+25 -552
View File
@@ -1,23 +1,14 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
from typing import Any
import pytest
from elydora import cli
from elydora.plugins import (
_directory_removal,
_opaque_cleanup,
_opaque_removal,
_transaction,
)
from elydora.plugins._runtime_removal import (
@@ -25,76 +16,15 @@ from elydora.plugins._runtime_removal import (
finalize_runtime_removal,
prepare_runtime_removal,
)
AGENT_ID = "agent-1"
AGENT_NAME = "opencode"
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([19]) * 32).rstrip(b"=").decode()
def _set_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> None:
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
def _write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
def _write_secret(path: Path, value: str) -> None:
path.write_text(value, encoding="utf-8")
if os.name != "nt":
path.chmod(0o600)
def _write_error_log(path: Path, chunks: int = 1) -> str:
digest = hashlib.sha256()
block = b"opaque historical error\n" * 4096
with path.open("wb") as file:
for _index in range(chunks):
file.write(block)
digest.update(block)
if os.name != "nt":
path.chmod(0o600)
return digest.hexdigest()
def _stream_digest(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
while chunk := file.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def _runtime(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
agent_name: str = AGENT_NAME,
) -> tuple[Path, Path]:
home = tmp_path / "home"
root = home / ".elydora"
agent = root / AGENT_ID
_set_home(monkeypatch, home)
_write_json(
agent / "config.json",
{"agent_id": AGENT_ID, "agent_name": agent_name},
)
return root, agent
def _uninstall_args(agent_name: str = AGENT_NAME) -> argparse.Namespace:
return cli.build_parser().parse_args(
[
"uninstall",
"--agent",
agent_name,
"--agent_id",
AGENT_ID,
]
)
from runtime_removal_support import (
AGENT_ID,
AGENT_NAME,
prepare_runtime,
stream_digest,
uninstall_args,
write_error_log,
write_json,
)
@pytest.mark.parametrize("entry_kind", ["file", "directory", "symlink"])
@@ -103,7 +33,7 @@ def test_cli_runtime_removal_rejects_unknown_entries_before_provider_mutation(
tmp_path: Path,
entry_kind: str,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
_root, agent = prepare_runtime(monkeypatch, tmp_path)
unknown = agent / "user-owned"
if entry_kind == "file":
unknown.write_text("preserve", encoding="utf-8")
@@ -127,7 +57,7 @@ def test_cli_runtime_removal_rejects_unknown_entries_before_provider_mutation(
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
with pytest.raises(OSError, match="unmanaged entries"):
cli.cmd_uninstall(_uninstall_args())
cli.cmd_uninstall(uninstall_args())
assert provider_calls == []
assert unknown.exists() or unknown.is_symlink()
@@ -138,7 +68,7 @@ def test_cli_skips_generic_cleanup_for_plugin_managed_runtime_removal(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path, agent_name="kiroide")
_root, agent = prepare_runtime(monkeypatch, tmp_path, agent_name="kiroide")
provider_calls: list[str] = []
class Plugin:
@@ -153,7 +83,7 @@ def test_cli_skips_generic_cleanup_for_plugin_managed_runtime_removal(
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
monkeypatch.setattr(cli, "prepare_runtime_removal", reject_generic_prepare)
cli.cmd_uninstall(_uninstall_args("kiroide"))
cli.cmd_uninstall(uninstall_args("kiroide"))
assert provider_calls == [AGENT_ID]
assert (agent / "config.json").is_file()
@@ -163,11 +93,11 @@ def test_runtime_removal_rejects_config_identity_drift(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
_root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
concurrent = {"agent_id": AGENT_ID, "agent_name": "cursor"}
_write_json(agent / "config.json", concurrent)
write_json(agent / "config.json", concurrent)
with pytest.raises(OSError, match="changed"):
commit_runtime_removal(plan)
@@ -181,7 +111,7 @@ def test_runtime_removal_treats_other_plugin_wrappers_as_unmanaged(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
_root, agent = prepare_runtime(monkeypatch, tmp_path)
wrapper_name = "augment-guard.cmd" if os.name == "nt" else "augment-guard.sh"
user_file = agent / wrapper_name
user_file.write_text("user-owned payload", encoding="utf-8")
@@ -198,7 +128,7 @@ def test_runtime_removal_rejects_directory_swaps_without_deleting_replacements(
tmp_path: Path,
swap: str,
) -> None:
root, agent = _runtime(monkeypatch, tmp_path)
root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
@@ -228,7 +158,7 @@ def test_runtime_removal_rejects_concurrent_creation_of_missing_managed_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
_root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
concurrent = agent / "status-cache.json"
@@ -247,7 +177,7 @@ def test_runtime_removal_preserves_unknown_entry_created_before_finalize(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
_root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
_transaction.write_changes(
@@ -270,7 +200,7 @@ def test_runtime_removal_deletes_all_known_dynamic_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root, agent = _runtime(monkeypatch, tmp_path)
root, agent = prepare_runtime(monkeypatch, tmp_path)
dynamic = {
"chain-state.json": '{"sequence":1}\n',
"status-cache.json": '{"status":"active"}\n',
@@ -293,8 +223,8 @@ def test_runtime_removal_streams_oversized_error_log(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root, agent = _runtime(monkeypatch, tmp_path)
expected_digest = _write_error_log(agent / "error.log", chunks=32)
root, agent = prepare_runtime(monkeypatch, tmp_path)
expected_digest = write_error_log(agent / "error.log", chunks=32)
assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
@@ -312,9 +242,9 @@ def test_oversized_error_log_rolls_back_after_later_text_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
expected_digest = _write_error_log(error_log, chunks=32)
expected_digest = write_error_log(error_log, chunks=32)
expected_mode = stat.S_IMODE(error_log.stat().st_mode)
original_commit = _transaction._commit
@@ -329,464 +259,7 @@ def test_oversized_error_log_rolls_back_after_later_text_failure(
commit_runtime_removal(plan)
monkeypatch.setattr(_transaction, "_commit", original_commit)
assert _stream_digest(error_log) == expected_digest
assert stream_digest(error_log) == expected_digest
assert stat.S_IMODE(error_log.stat().st_mode) == expected_mode
assert (agent / "config.json").is_file()
assert list(agent.glob("*.opaque-rollback")) == []
def test_process_exit_after_opaque_removal_restores_error_log_on_restart(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
expected_digest = _write_error_log(error_log, chunks=4)
script = """
import os
import sys
from elydora.plugins import _transaction
from elydora.plugins._runtime_removal import prepare_runtime_removal, commit_runtime_removal
agent_id, agent_name = sys.argv[1:]
plan = prepare_runtime_removal(agent_id, agent_name)
assert plan is not None
_transaction._commit = lambda staged: os._exit(78)
commit_runtime_removal(plan)
"""
environment = os.environ.copy()
environment["HOME"] = os.environ["HOME"]
environment["USERPROFILE"] = os.environ["USERPROFILE"]
completed = subprocess.run(
[sys.executable, "-c", script, AGENT_ID, AGENT_NAME],
cwd=Path(__file__).resolve().parents[1],
env=environment,
check=False,
)
assert completed.returncode == 78
assert not error_log.exists()
_transaction.recover_pending_transactions()
assert _stream_digest(error_log) == expected_digest
assert (agent / "config.json").is_file()
assert list(agent.glob(".*.opaque-rollback")) == []
def test_error_log_append_at_capture_boundary_fails_and_preserves_append(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
_write_error_log(error_log)
appended = b"concurrent append\n"
original_capture = _opaque_removal._capture_physical
def append_then_capture(staged: Any) -> None:
with error_log.open("ab") as file:
file.write(appended)
file.flush()
os.fsync(file.fileno())
original_capture(staged)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_removal,
"_capture_physical",
append_then_capture,
)
with pytest.raises(OSError, match="changed at commit boundary"):
commit_runtime_removal(plan)
assert error_log.read_bytes().endswith(appended)
assert (agent / "config.json").is_file()
def test_error_log_replace_at_capture_boundary_preserves_both_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_digest = _write_error_log(error_log)
displaced = agent / "displaced-error.log"
replacement = b"concurrent replacement\n"
original_capture = _opaque_removal._capture_physical
def replace_then_capture(staged: Any) -> None:
error_log.rename(displaced)
error_log.write_bytes(replacement)
if os.name != "nt":
error_log.chmod(0o600)
original_capture(staged)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_removal,
"_capture_physical",
replace_then_capture,
)
with pytest.raises(OSError, match="changed at commit boundary"):
commit_runtime_removal(plan)
assert error_log.read_bytes() == replacement
assert _stream_digest(displaced) == original_digest
assert (agent / "config.json").is_file()
def test_error_log_rollback_destination_collision_preserves_victim(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_digest = _write_error_log(error_log)
victim = b"concurrent opaque rollback destination\n"
rollback_path: Path | None = None
original_capture = _opaque_removal._capture_physical
def collide_with_rollback_destination(staged: Any) -> None:
nonlocal rollback_path
assert staged.rollback_path is not None
rollback_path = Path(staged.rollback_path)
rollback_path.write_bytes(victim)
if os.name != "nt":
rollback_path.chmod(0o600)
original_capture(staged)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_removal,
"_capture_physical",
collide_with_rollback_destination,
)
with pytest.raises(OSError, match="exist"):
commit_runtime_removal(plan)
assert rollback_path is not None
assert rollback_path.read_bytes() == victim
assert _stream_digest(error_log) == original_digest
assert (agent / "config.json").is_file()
def test_error_log_cleanup_boundary_append_is_preserved_and_reported(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
_write_error_log(error_log)
appended = b"cleanup boundary append\n"
original_capture = _opaque_cleanup._capture_cleanup_physical
def append_then_capture(
staged: Any,
source_path: str,
) -> None:
with Path(source_path).open("ab") as file:
file.write(appended)
file.flush()
os.fsync(file.fileno())
original_capture(staged, source_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_cleanup,
"_capture_cleanup_physical",
append_then_capture,
)
with pytest.raises(OSError, match="changed at cleanup boundary"):
commit_runtime_removal(plan)
preserved = list(agent.glob(".error.log.*.opaque-rollback"))
assert len(preserved) == 1
assert preserved[0].read_bytes().endswith(appended)
assert list(agent.glob(".*.elydora-quarantine")) == []
def test_error_log_cleanup_replacement_preserves_both_identities(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_digest = _write_error_log(error_log)
replacement = b"concurrent cleanup replacement\n"
displaced = agent / "displaced-opaque-rollback"
rollback_path: Path | None = None
original_capture = _opaque_cleanup._capture_cleanup_physical
def replace_before_cleanup_capture(staged: Any, source_path: str) -> None:
nonlocal rollback_path
rollback_path = Path(source_path)
rollback_path.rename(displaced)
rollback_path.write_bytes(replacement)
if os.name != "nt":
rollback_path.chmod(0o600)
original_capture(staged, source_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_cleanup,
"_capture_cleanup_physical",
replace_before_cleanup_capture,
)
with pytest.raises(OSError, match="changed at cleanup boundary") as raised:
commit_runtime_removal(plan)
assert rollback_path is not None
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
assert rollback_path.read_bytes() == replacement
assert _stream_digest(displaced) == original_digest
assert list(agent.glob(".*.elydora-quarantine")) == []
@pytest.mark.parametrize("entry_kind", ["directory", "symlink"])
def test_error_log_rejects_nonphysical_entries_before_runtime_mutation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
entry_kind: str,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_config = (agent / "config.json").read_bytes()
if entry_kind == "directory":
error_log.mkdir()
(error_log / "marker").write_text("preserve", encoding="utf-8")
else:
target = tmp_path / "external-error.log"
target.write_text("preserve", encoding="utf-8")
try:
error_log.symlink_to(target)
except OSError as error:
pytest.skip(f"File symbolic links are unavailable: {error}")
with pytest.raises(OSError, match="not a physical file"):
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert (agent / "config.json").read_bytes() == original_config
assert error_log.exists() or error_log.is_symlink()
@pytest.mark.skipif(os.name == "nt", reason="POSIX owner-only contract")
def test_error_log_requires_owner_only_permissions_during_prepare(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
error_log.write_text("historical error\n", encoding="utf-8")
error_log.chmod(0o640)
with pytest.raises(PermissionError, match="accessible only by its owner"):
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert error_log.read_text(encoding="utf-8") == "historical error\n"
@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-dirfd contract")
def test_posix_runtime_directory_removal_cleans_exact_quarantine(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
original_rename = _directory_removal._rename_posix_into_quarantine
calls: list[tuple[int, str, int, str, str]] = []
def record_rename(
parent_descriptor: int,
source_name: str,
quarantine_descriptor: int,
source_path: str,
destination_path: str,
) -> None:
calls.append(
(
parent_descriptor,
source_name,
quarantine_descriptor,
source_path,
destination_path,
)
)
original_rename(
parent_descriptor,
source_name,
quarantine_descriptor,
source_path,
destination_path,
)
monkeypatch.setattr(
_directory_removal,
"_rename_posix_into_quarantine",
record_rename,
)
commit_runtime_removal(plan)
assert len(calls) == 1
assert isinstance(calls[0][0], int)
assert calls[0][1] == AGENT_ID
assert isinstance(calls[0][2], int)
assert Path(calls[0][3]) == agent
assert Path(calls[0][4]).name == "entry"
quarantined = list(
Path(plan.runtime_root).glob(
f".{AGENT_ID}.*.elydora-removed/entry"
)
)
assert quarantined == []
assert list(Path(plan.runtime_root).glob(f".{AGENT_ID}.*.elydora-removed")) == []
assert not agent.exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX quarantine contract")
def test_posix_runtime_quarantine_preserves_atomic_boundary_swap(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root, agent = _runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
replacement = root / "replacement"
replacement.mkdir()
marker = replacement / "user-owned"
marker.write_text("preserve", encoding="utf-8")
original = root / "agent-original"
real_rename = _directory_removal._rename_posix_into_quarantine
def swap_then_quarantine(
parent_descriptor: int,
source_name: str,
quarantine_descriptor: int,
source_path: str,
destination_path: str,
) -> None:
agent.rename(original)
replacement.rename(agent)
real_rename(
parent_descriptor,
source_name,
quarantine_descriptor,
source_path,
destination_path,
)
real_rmdir = _directory_removal.os.rmdir
def reject_path_rmdir(
path: str,
*,
dir_fd: int | None = None,
) -> None:
if dir_fd is None:
raise AssertionError("POSIX runtime cleanup must use a parent dirfd")
real_rmdir(path, dir_fd=dir_fd)
monkeypatch.setattr(
_directory_removal,
"_rename_posix_into_quarantine",
swap_then_quarantine,
)
monkeypatch.setattr(_directory_removal.os, "rmdir", reject_path_rmdir)
with pytest.raises(OSError, match="changed while quarantining"):
commit_runtime_removal(plan)
quarantined_markers = list(
root.glob(f".{AGENT_ID}.*.elydora-removed/entry/user-owned")
)
assert len(quarantined_markers) == 1
assert quarantined_markers[0].read_text(encoding="utf-8") == "preserve"
assert original.is_dir()
@pytest.mark.skipif(os.name != "nt", reason="Windows exact-handle contract")
def test_windows_runtime_directory_removal_marks_exact_pinned_handle(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = _runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
original_mark = _directory_removal._mark_windows_directory_for_deletion
observed: list[tuple[int, int]] = []
def record_mark(directory: Any) -> None:
assert directory.descriptor is not None
opened = os.fstat(directory.descriptor)
observed.append((opened.st_dev, opened.st_ino))
assert observed[-1] == (
plan.agent_snapshot.device,
plan.agent_snapshot.inode,
)
original_mark(directory)
monkeypatch.setattr(
_directory_removal,
"_mark_windows_directory_for_deletion",
record_mark,
)
commit_runtime_removal(plan)
assert observed == [(plan.agent_snapshot.device, plan.agent_snapshot.inode)]
assert not agent.exists()
def test_kiroide_cli_install_failure_leaves_no_runtime_root(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
home = tmp_path / "home"
workspace = tmp_path / "workspace"
home.mkdir()
workspace.mkdir()
_set_home(monkeypatch, home)
monkeypatch.chdir(workspace)
key_path = tmp_path / "install-private.key"
_write_secret(key_path, PRIVATE_KEY)
args = cli.build_parser().parse_args(
[
"install",
"--agent",
"kiroide",
"--org_id",
"org-1",
"--agent_id",
AGENT_ID,
"--private_key_file",
str(key_path),
"--kid",
"kid-1",
]
)
def fail_commit(_staged: object) -> None:
raise OSError("injected Kiro IDE CLI commit failure")
monkeypatch.setattr(_transaction, "_commit", fail_commit)
with pytest.raises(OSError, match="injected Kiro IDE CLI commit failure"):
cli.cmd_install(args)
# The durable transaction state directory survives failures by design;
# everything else under the runtime root must be rolled back.
elydora_root = home / ".elydora"
if elydora_root.exists():
assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"]
assert not (workspace / ".kiro").exists()
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import pytest
from elydora import cli
from elydora.plugins import (
_directory_removal,
_transaction,
)
from elydora.plugins._runtime_removal import (
commit_runtime_removal,
prepare_runtime_removal,
)
from runtime_removal_support import (
AGENT_ID,
AGENT_NAME,
PRIVATE_KEY,
prepare_runtime,
set_home,
write_secret,
)
@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-dirfd contract")
def test_posix_runtime_directory_removal_cleans_exact_quarantine(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
original_rename = _directory_removal._rename_posix_into_quarantine
calls: list[tuple[int, str, int, str, str]] = []
def record_rename(
parent_descriptor: int,
source_name: str,
quarantine_descriptor: int,
source_path: str,
destination_path: str,
) -> None:
calls.append(
(
parent_descriptor,
source_name,
quarantine_descriptor,
source_path,
destination_path,
)
)
original_rename(
parent_descriptor,
source_name,
quarantine_descriptor,
source_path,
destination_path,
)
monkeypatch.setattr(
_directory_removal,
"_rename_posix_into_quarantine",
record_rename,
)
commit_runtime_removal(plan)
assert len(calls) == 1
assert isinstance(calls[0][0], int)
assert calls[0][1] == AGENT_ID
assert isinstance(calls[0][2], int)
assert Path(calls[0][3]) == agent
assert Path(calls[0][4]).name == "entry"
quarantined = list(
Path(plan.runtime_root).glob(
f".{AGENT_ID}.*.elydora-removed/entry"
)
)
assert quarantined == []
assert list(Path(plan.runtime_root).glob(f".{AGENT_ID}.*.elydora-removed")) == []
assert not agent.exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX quarantine contract")
def test_posix_runtime_quarantine_preserves_atomic_boundary_swap(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
replacement = root / "replacement"
replacement.mkdir()
marker = replacement / "user-owned"
marker.write_text("preserve", encoding="utf-8")
original = root / "agent-original"
real_rename = _directory_removal._rename_posix_into_quarantine
def swap_then_quarantine(
parent_descriptor: int,
source_name: str,
quarantine_descriptor: int,
source_path: str,
destination_path: str,
) -> None:
agent.rename(original)
replacement.rename(agent)
real_rename(
parent_descriptor,
source_name,
quarantine_descriptor,
source_path,
destination_path,
)
real_rmdir = _directory_removal.os.rmdir
def reject_path_rmdir(
path: str,
*,
dir_fd: int | None = None,
) -> None:
if dir_fd is None:
raise AssertionError("POSIX runtime cleanup must use a parent dirfd")
real_rmdir(path, dir_fd=dir_fd)
monkeypatch.setattr(
_directory_removal,
"_rename_posix_into_quarantine",
swap_then_quarantine,
)
monkeypatch.setattr(_directory_removal.os, "rmdir", reject_path_rmdir)
with pytest.raises(OSError, match="changed while quarantining"):
commit_runtime_removal(plan)
quarantined_markers = list(
root.glob(f".{AGENT_ID}.*.elydora-removed/entry/user-owned")
)
assert len(quarantined_markers) == 1
assert quarantined_markers[0].read_text(encoding="utf-8") == "preserve"
assert original.is_dir()
@pytest.mark.skipif(os.name != "nt", reason="Windows exact-handle contract")
def test_windows_runtime_directory_removal_marks_exact_pinned_handle(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
original_mark = _directory_removal._mark_windows_directory_for_deletion
observed: list[tuple[int, int]] = []
def record_mark(directory: Any) -> None:
assert directory.descriptor is not None
opened = os.fstat(directory.descriptor)
observed.append((opened.st_dev, opened.st_ino))
assert observed[-1] == (
plan.agent_snapshot.device,
plan.agent_snapshot.inode,
)
original_mark(directory)
monkeypatch.setattr(
_directory_removal,
"_mark_windows_directory_for_deletion",
record_mark,
)
commit_runtime_removal(plan)
assert observed == [(plan.agent_snapshot.device, plan.agent_snapshot.inode)]
assert not agent.exists()
def test_kiroide_cli_install_failure_leaves_no_runtime_root(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
home = tmp_path / "home"
workspace = tmp_path / "workspace"
home.mkdir()
workspace.mkdir()
set_home(monkeypatch, home)
monkeypatch.chdir(workspace)
key_path = tmp_path / "install-private.key"
write_secret(key_path, PRIVATE_KEY)
args = cli.build_parser().parse_args(
[
"install",
"--agent",
"kiroide",
"--org_id",
"org-1",
"--agent_id",
AGENT_ID,
"--private_key_file",
str(key_path),
"--kid",
"kid-1",
]
)
def fail_commit(_staged: object) -> None:
raise OSError("injected Kiro IDE CLI commit failure")
monkeypatch.setattr(_transaction, "_commit", fail_commit)
with pytest.raises(OSError, match="injected Kiro IDE CLI commit failure"):
cli.cmd_install(args)
# The durable transaction state directory survives failures by design;
# everything else under the runtime root must be rolled back.
elydora_root = home / ".elydora"
if elydora_root.exists():
assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"]
assert not (workspace / ".kiro").exists()
+286
View File
@@ -0,0 +1,286 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
from typing import Any
import pytest
from elydora.plugins import (
_opaque_cleanup,
_opaque_removal,
_transaction,
)
from elydora.plugins._runtime_removal import (
commit_runtime_removal,
prepare_runtime_removal,
)
from runtime_removal_support import (
AGENT_ID,
AGENT_NAME,
prepare_runtime,
stream_digest,
write_error_log,
)
def test_process_exit_after_opaque_removal_restores_error_log_on_restart(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
expected_digest = write_error_log(error_log, chunks=4)
script = """
import os
import sys
from elydora.plugins import _transaction
from elydora.plugins._runtime_removal import prepare_runtime_removal, commit_runtime_removal
agent_id, agent_name = sys.argv[1:]
plan = prepare_runtime_removal(agent_id, agent_name)
assert plan is not None
_transaction._commit = lambda staged: os._exit(78)
commit_runtime_removal(plan)
"""
environment = os.environ.copy()
environment["HOME"] = os.environ["HOME"]
environment["USERPROFILE"] = os.environ["USERPROFILE"]
completed = subprocess.run(
[sys.executable, "-c", script, AGENT_ID, AGENT_NAME],
cwd=Path(__file__).resolve().parents[1],
env=environment,
check=False,
)
assert completed.returncode == 78
assert not error_log.exists()
_transaction.recover_pending_transactions()
assert stream_digest(error_log) == expected_digest
assert (agent / "config.json").is_file()
assert list(agent.glob(".*.opaque-rollback")) == []
def test_error_log_append_at_capture_boundary_fails_and_preserves_append(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
write_error_log(error_log)
appended = b"concurrent append\n"
original_capture = _opaque_removal._capture_physical
def append_then_capture(staged: Any) -> None:
with error_log.open("ab") as file:
file.write(appended)
file.flush()
os.fsync(file.fileno())
original_capture(staged)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_removal,
"_capture_physical",
append_then_capture,
)
with pytest.raises(OSError, match="changed at commit boundary"):
commit_runtime_removal(plan)
assert error_log.read_bytes().endswith(appended)
assert (agent / "config.json").is_file()
def test_error_log_replace_at_capture_boundary_preserves_both_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_digest = write_error_log(error_log)
displaced = agent / "displaced-error.log"
replacement = b"concurrent replacement\n"
original_capture = _opaque_removal._capture_physical
def replace_then_capture(staged: Any) -> None:
error_log.rename(displaced)
error_log.write_bytes(replacement)
if os.name != "nt":
error_log.chmod(0o600)
original_capture(staged)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_removal,
"_capture_physical",
replace_then_capture,
)
with pytest.raises(OSError, match="changed at commit boundary"):
commit_runtime_removal(plan)
assert error_log.read_bytes() == replacement
assert stream_digest(displaced) == original_digest
assert (agent / "config.json").is_file()
def test_error_log_rollback_destination_collision_preserves_victim(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_digest = write_error_log(error_log)
victim = b"concurrent opaque rollback destination\n"
rollback_path: Path | None = None
original_capture = _opaque_removal._capture_physical
def collide_with_rollback_destination(staged: Any) -> None:
nonlocal rollback_path
assert staged.rollback_path is not None
rollback_path = Path(staged.rollback_path)
rollback_path.write_bytes(victim)
if os.name != "nt":
rollback_path.chmod(0o600)
original_capture(staged)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_removal,
"_capture_physical",
collide_with_rollback_destination,
)
with pytest.raises(OSError, match="exist"):
commit_runtime_removal(plan)
assert rollback_path is not None
assert rollback_path.read_bytes() == victim
assert stream_digest(error_log) == original_digest
assert (agent / "config.json").is_file()
def test_error_log_cleanup_boundary_append_is_preserved_and_reported(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
write_error_log(error_log)
appended = b"cleanup boundary append\n"
original_capture = _opaque_cleanup._capture_cleanup_physical
def append_then_capture(
staged: Any,
source_path: str,
) -> None:
with Path(source_path).open("ab") as file:
file.write(appended)
file.flush()
os.fsync(file.fileno())
original_capture(staged, source_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_cleanup,
"_capture_cleanup_physical",
append_then_capture,
)
with pytest.raises(OSError, match="changed at cleanup boundary"):
commit_runtime_removal(plan)
preserved = list(agent.glob(".error.log.*.opaque-rollback"))
assert len(preserved) == 1
assert preserved[0].read_bytes().endswith(appended)
assert list(agent.glob(".*.elydora-quarantine")) == []
def test_error_log_cleanup_replacement_preserves_both_identities(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_digest = write_error_log(error_log)
replacement = b"concurrent cleanup replacement\n"
displaced = agent / "displaced-opaque-rollback"
rollback_path: Path | None = None
original_capture = _opaque_cleanup._capture_cleanup_physical
def replace_before_cleanup_capture(staged: Any, source_path: str) -> None:
nonlocal rollback_path
rollback_path = Path(source_path)
rollback_path.rename(displaced)
rollback_path.write_bytes(replacement)
if os.name != "nt":
rollback_path.chmod(0o600)
original_capture(staged, source_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None
monkeypatch.setattr(
_opaque_cleanup,
"_capture_cleanup_physical",
replace_before_cleanup_capture,
)
with pytest.raises(OSError, match="changed at cleanup boundary") as raised:
commit_runtime_removal(plan)
assert rollback_path is not None
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
assert rollback_path.read_bytes() == replacement
assert stream_digest(displaced) == original_digest
assert list(agent.glob(".*.elydora-quarantine")) == []
@pytest.mark.parametrize("entry_kind", ["directory", "symlink"])
def test_error_log_rejects_nonphysical_entries_before_runtime_mutation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
entry_kind: str,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
original_config = (agent / "config.json").read_bytes()
if entry_kind == "directory":
error_log.mkdir()
(error_log / "marker").write_text("preserve", encoding="utf-8")
else:
target = tmp_path / "external-error.log"
target.write_text("preserve", encoding="utf-8")
try:
error_log.symlink_to(target)
except OSError as error:
pytest.skip(f"File symbolic links are unavailable: {error}")
with pytest.raises(OSError, match="not a physical file"):
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert (agent / "config.json").read_bytes() == original_config
assert error_log.exists() or error_log.is_symlink()
@pytest.mark.skipif(os.name == "nt", reason="POSIX owner-only contract")
def test_error_log_requires_owner_only_permissions_during_prepare(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log"
error_log.write_text("historical error\n", encoding="utf-8")
error_log.chmod(0o640)
with pytest.raises(PermissionError, match="accessible only by its owner"):
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert error_log.read_text(encoding="utf-8") == "historical error\n"
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from elydora.plugins import _transaction, _transaction_staging
from elydora.plugins._managed_files import read_physical_directory
from transaction_support import (
make_change,
swap_directory,
)
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
def test_staging_directory_swap_never_writes_secret_to_redirected_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
parent = tmp_path / "runtime"
backup = tmp_path / "runtime-original"
redirected = tmp_path / "runtime-redirected"
parent.mkdir()
redirected.mkdir()
target = parent / "private.key"
real_write = _transaction_staging.write_staged
swapped = False
def swap_then_stage(*args: object, **kwargs: object):
nonlocal swapped
if not swapped:
swapped = True
swap_directory(parent, backup, redirected)
return real_write(*args, **kwargs)
monkeypatch.setattr(_transaction_staging, "write_staged", swap_then_stage)
with pytest.raises(OSError, match="changed during transaction"):
_transaction.write_changes(
[make_change(target, None, "secret\n")],
"staging swap",
)
assert not (parent / "private.key").exists()
assert not (backup / "private.key").exists()
assert list(parent.iterdir()) == []
assert list(backup.iterdir()) == []
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
def test_commit_directory_swap_rolls_back_inside_pinned_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
parent = tmp_path / "runtime"
backup = tmp_path / "runtime-original"
redirected = tmp_path / "runtime-redirected"
parent.mkdir()
redirected.mkdir()
target = parent / "private.key"
real_replace = _transaction._replace_physical
swapped = False
def swap_then_replace(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal swapped
if not swapped:
swapped = True
swap_directory(parent, backup, redirected)
real_replace(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", swap_then_replace)
with pytest.raises(OSError, match="changed during transaction"):
_transaction.write_changes(
[make_change(target, None, "secret\n")],
"commit swap",
)
assert not (parent / "private.key").exists()
assert not (backup / "private.key").exists()
assert list(parent.iterdir()) == []
assert list(backup.iterdir()) == []
def test_failed_transaction_removes_only_created_empty_directories(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
kiro = tmp_path / ".kiro"
hooks = kiro / "hooks"
hooks.mkdir(parents=True)
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
target = hooks / "elydora-audit.json"
def fail_replace(
_source: str,
_destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
raise OSError("injected commit failure")
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
with pytest.raises(OSError, match="injected commit failure"):
_transaction.write_changes(
[make_change(target, None, "{}\n")],
"created directory recovery",
directory_preconditions=[
_transaction.DirectoryPrecondition(
str(kiro), "Kiro directory", kiro_snapshot, created=True
),
_transaction.DirectoryPrecondition(
str(hooks), "hooks directory", hooks_snapshot, created=True
),
],
)
assert not hooks.exists()
assert not kiro.exists()
def test_created_directory_recovery_keeps_parent_and_child_pinned(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
kiro = tmp_path / ".kiro"
hooks = kiro / "hooks"
hooks.mkdir(parents=True)
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
target = hooks / "elydora-audit.json"
observed: list[tuple[Path, Path]] = []
real_remove = _transaction_staging.remove_pinned_empty_directory
def observe_pins(
parent: object,
child: object,
*,
require_empty: bool = True,
) -> None:
assert isinstance(parent, _transaction.PinnedDirectory)
assert isinstance(child, _transaction.PinnedDirectory)
assert parent.descriptor is not None
assert child.descriptor is not None
assert os.fstat(child.descriptor).st_ino == child.snapshot.inode
observed.append((Path(parent.path), Path(child.path)))
real_remove(parent, child, require_empty=require_empty)
def fail_replace(
_source: str,
_destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
raise OSError("injected pinned cleanup failure")
monkeypatch.setattr(
_transaction_staging,
"remove_pinned_empty_directory",
observe_pins,
)
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
with pytest.raises(OSError, match="injected pinned cleanup failure"):
_transaction.write_changes(
[make_change(target, None, "{}\n")],
"pinned directory recovery",
directory_preconditions=[
_transaction.DirectoryPrecondition(
str(kiro), "Kiro directory", kiro_snapshot, created=True
),
_transaction.DirectoryPrecondition(
str(hooks), "hooks directory", hooks_snapshot, created=True
),
],
)
expected = [(kiro, hooks), (tmp_path, kiro)]
assert observed == expected
assert not kiro.exists()
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
def test_created_directory_recovery_preserves_swapped_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
created = tmp_path / "created"
created.mkdir()
created_snapshot = read_physical_directory(str(created), "created directory")
target = created / "managed.json"
replacement = tmp_path / "replacement"
replacement.mkdir()
marker = replacement / "user-owned"
marker.write_text("preserve", encoding="utf-8")
original = tmp_path / "created-original"
real_remove = _transaction_staging.remove_pinned_empty_directory
def swap_before_removal(
parent: _transaction.PinnedDirectory,
child: _transaction.PinnedDirectory,
*,
require_empty: bool = True,
) -> None:
created.rename(original)
replacement.rename(created)
real_remove(parent, child, require_empty=require_empty)
def fail_replace(
_source: str,
_destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
raise OSError("injected swap cleanup failure")
monkeypatch.setattr(
_transaction_staging,
"remove_pinned_empty_directory",
swap_before_removal,
)
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
with pytest.raises(OSError, match="recovery failed"):
_transaction.write_changes(
[make_change(target, None, "{}\n")],
"swapped directory recovery",
directory_preconditions=[
_transaction.DirectoryPrecondition(
str(created),
"created directory",
created_snapshot,
created=True,
)
],
)
assert (created / marker.name).read_text(encoding="utf-8") == "preserve"
assert original.is_dir()
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
+404
View File
@@ -0,0 +1,404 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from elydora.plugins import _transaction
from transaction_support import (
make_change,
write_source,
)
def test_final_replace_exception_after_success_restores_original(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
write_source(target, "old\n")
real_replace = _transaction._replace_physical
def replace_then_raise(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
real_replace(source, destination, directory)
if os.path.abspath(destination) == os.path.abspath(str(target)):
raise OSError("injected post-replace failure")
monkeypatch.setattr(_transaction, "_replace_physical", replace_then_raise)
with pytest.raises(OSError, match="injected post-replace failure"):
_transaction.write_changes(
[make_change(target, "old\n", "new\n")],
"post-replace failure",
)
assert target.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
def test_capture_exception_after_success_restores_original(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
write_source(target, "old\n")
real_capture = _transaction._capture_physical
def capture_then_raise(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
real_capture(source, destination, directory)
raise OSError("injected post-capture failure")
monkeypatch.setattr(_transaction, "_capture_physical", capture_then_raise)
with pytest.raises(OSError, match="injected post-capture failure"):
_transaction.write_changes(
[make_change(target, "old\n", "new\n")],
"post-capture failure",
)
assert target.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
def test_rollback_reservation_collision_preserves_victim_and_original(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
write_source(target, "old\n")
victim = "concurrent rollback destination\n"
collision_path: Path | None = None
def collide_with_rollback_destination(
_source: str,
destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
nonlocal collision_path
if destination.endswith(".rollback"):
collision_path = Path(destination)
write_source(collision_path, victim)
monkeypatch.setattr(
_transaction,
"_capture_physical",
collide_with_rollback_destination,
)
with pytest.raises(OSError, match="exist"):
_transaction.write_changes(
[make_change(target, "old\n", "new\n")],
"rollback destination collision",
)
assert collision_path is not None
assert target.read_text(encoding="utf-8") == "old\n"
assert collision_path.read_text(encoding="utf-8") == victim
def test_final_install_does_not_overwrite_concurrent_target(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
write_source(target, "old\n")
real_replace = _transaction._replace_physical
injected = False
def create_target_before_install(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal injected
if not injected and os.path.abspath(destination) == os.path.abspath(target):
injected = True
write_source(target, "concurrent\n")
real_replace(source, destination, directory)
monkeypatch.setattr(
_transaction,
"_replace_physical",
create_target_before_install,
)
with pytest.raises(OSError, match="original content preserved"):
_transaction.write_changes(
[make_change(target, "old\n", "new\n")],
"no-clobber install",
)
assert injected
assert target.read_text(encoding="utf-8") == "concurrent\n"
rollbacks = list(tmp_path.glob(".*.rollback"))
assert len(rollbacks) == 1
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
def test_restore_does_not_overwrite_concurrent_target(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
second = tmp_path / "second.txt"
write_source(target, "old\n")
write_source(second, "second-old\n")
real_replace = _transaction._replace_physical
real_restore = _transaction._restore_physical
injected = False
def fail_second_commit(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(second):
raise OSError("injected second failure")
real_replace(source, destination, directory)
def create_target_before_restore(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal injected
if not injected and os.path.abspath(destination) == os.path.abspath(target):
injected = True
write_source(target, "concurrent recovery\n")
real_restore(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
monkeypatch.setattr(
_transaction,
"_restore_physical",
create_target_before_restore,
)
with pytest.raises(OSError, match="original content preserved"):
_transaction.write_changes(
[
make_change(target, "old\n", "new\n"),
make_change(second, "second-old\n", "second-new\n"),
],
"no-clobber restoration",
)
assert injected
assert target.read_text(encoding="utf-8") == "concurrent recovery\n"
rollbacks = list(tmp_path.glob(".*.rollback"))
assert len(rollbacks) == 1
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
def test_restore_source_replacement_reports_existing_preserved_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
second = tmp_path / "second.txt"
displaced = tmp_path / "displaced-original.txt"
write_source(target, "old\n")
write_source(second, "second-old\n")
real_replace = _transaction._replace_physical
real_restore = _transaction._restore_physical
rollback_path: Path | None = None
def fail_second_commit(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(second):
raise OSError("injected second failure")
real_replace(source, destination, directory)
def replace_restore_source(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal rollback_path
if (
rollback_path is None
and source.endswith(".rollback")
and os.path.abspath(destination) == os.path.abspath(target)
):
rollback_path = Path(source)
rollback_path.rename(displaced)
write_source(rollback_path, "concurrent recovery source\n")
real_restore(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
monkeypatch.setattr(_transaction, "_restore_physical", replace_restore_source)
with pytest.raises(OSError, match="recovery source changed") as raised:
_transaction.write_changes(
[
make_change(target, "old\n", "new\n"),
make_change(second, "second-old\n", "second-new\n"),
],
"restore source replacement",
)
assert rollback_path is not None
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
assert rollback_path.read_text(encoding="utf-8") == "concurrent recovery source\n"
assert displaced.read_text(encoding="utf-8") == "old\n"
assert target.read_text(encoding="utf-8") == "new\n"
assert second.read_text(encoding="utf-8") == "second-old\n"
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
def test_creation_rollback_captures_and_preserves_concurrent_replacement(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
created = tmp_path / "created.txt"
second = tmp_path / "second.txt"
write_source(second, "second-old\n")
real_replace = _transaction._replace_physical
real_capture = _transaction._capture_physical
injected = False
def fail_second_commit(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(second):
raise OSError("injected second failure")
real_replace(source, destination, directory)
def replace_created_before_recovery_capture(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal injected
if (
not injected
and os.path.abspath(source) == os.path.abspath(created)
and destination.endswith(".recovery")
):
injected = True
write_source(created, "concurrent replacement\n")
real_capture(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
monkeypatch.setattr(
_transaction,
"_capture_physical",
replace_created_before_recovery_capture,
)
with pytest.raises(OSError, match="changed at recovery boundary"):
_transaction.write_changes(
[
make_change(created, None, "generated\n"),
make_change(second, "second-old\n", "second-new\n"),
],
"creation recovery capture",
)
assert injected
assert created.read_text(encoding="utf-8") == "concurrent replacement\n"
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
def test_mode_repair_uses_private_rollback_and_restores_original_mode(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
secret = tmp_path / "private.key"
second = tmp_path / "config.json"
write_source(secret, "secret\n")
write_source(second, "old\n")
secret.chmod(0o644)
real_replace = _transaction._replace_physical
rollback_modes: list[int] = []
def inspect_then_fail_second(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(str(secret)):
rollback = next(tmp_path.glob(".private.key.*.rollback"))
rollback_modes.append(rollback.stat().st_mode & 0o777)
if os.path.abspath(destination) == os.path.abspath(str(second)):
raise OSError("injected second commit failure")
real_replace(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
with pytest.raises(OSError, match="injected second commit failure"):
_transaction.write_changes(
[
make_change(secret, "secret\n", "secret\n", enforce_mode=True),
make_change(second, "old\n", "new\n"),
],
"mode repair",
)
assert rollback_modes
assert set(rollback_modes) == {0o600}
assert secret.read_text(encoding="utf-8") == "secret\n"
assert secret.stat().st_mode & 0o777 == 0o644
assert second.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
def test_deletion_uses_private_rollback_and_restores_original_mode(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
removed = tmp_path / "removed.txt"
second = tmp_path / "second.txt"
write_source(removed, "remove me\n")
write_source(second, "old\n")
removed.chmod(0o644)
real_replace = _transaction._replace_physical
rollback_modes: list[int] = []
def inspect_then_fail_second(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(str(second)):
rollback = next(tmp_path.glob(".removed.txt.*.rollback"))
rollback_modes.append(rollback.stat().st_mode & 0o777)
raise OSError("injected second commit failure")
real_replace(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
with pytest.raises(OSError, match="injected second commit failure"):
_transaction.write_changes(
[
make_change(removed, "remove me\n", None),
make_change(second, "old\n", "new\n"),
],
"deletion rollback mode",
)
assert rollback_modes == [0o600]
assert removed.read_text(encoding="utf-8") == "remove me\n"
assert removed.stat().st_mode & 0o777 == 0o644
assert second.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
+27 -681
View File
@@ -10,31 +10,13 @@ import time
import pytest
from elydora.plugins import _transaction, _transaction_staging
from elydora.plugins._managed_files import read_physical_directory, read_physical_file
def _write(path: Path, source: str) -> None:
path.write_bytes(source.encode("utf-8"))
def _change(
path: Path,
original: str | None,
next_source: str | None,
mode: int = 0o600,
enforce_mode: bool = False,
) -> _transaction.FileChange:
change = _transaction.source_change(
str(path),
path.name,
original,
next_source,
mode,
enforce_mode=enforce_mode,
)
assert change is not None
return change
from elydora.plugins import _transaction
from elydora.plugins._managed_files import read_physical_file
from transaction_support import (
make_change,
transaction_subprocess_environment,
write_source,
)
def test_post_replace_precondition_failure_restores_original_target(
@@ -43,8 +25,8 @@ def test_post_replace_precondition_failure_restores_original_target(
) -> None:
target = tmp_path / "target.txt"
provider = tmp_path / "provider.txt"
_write(target, "old\n")
_write(provider, "stable\n")
write_source(target, "old\n")
write_source(provider, "stable\n")
provider_snapshot = read_physical_file(str(provider), "provider")
real_replace = _transaction._replace_physical
@@ -55,7 +37,7 @@ def test_post_replace_precondition_failure_restores_original_target(
) -> None:
real_replace(source, destination, directory)
if os.path.abspath(destination) == os.path.abspath(str(target)):
_write(provider, "concurrent\n")
write_source(provider, "concurrent\n")
monkeypatch.setattr(
_transaction, "_replace_physical", replace_then_change_provider
@@ -63,7 +45,7 @@ def test_post_replace_precondition_failure_restores_original_target(
with pytest.raises(OSError, match="provider changed during"):
_transaction.write_changes(
[_change(target, "old\n", "new\n")],
[make_change(target, "old\n", "new\n")],
"provider transaction",
[_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)],
)
@@ -78,7 +60,7 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected(
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
_write(target, "old\n")
write_source(target, "old\n")
real_capture = _transaction._capture_physical
changed = False
@@ -94,14 +76,14 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected(
and destination.endswith(".rollback")
):
changed = True
_write(target, "concurrent\n")
write_source(target, "concurrent\n")
real_capture(source, destination, directory)
monkeypatch.setattr(_transaction, "_capture_physical", write_before_capture)
with pytest.raises(OSError, match="changed at commit boundary"):
_transaction.write_changes(
[_change(target, "old\n", "new\n")],
[make_change(target, "old\n", "new\n")],
"commit boundary",
)
@@ -116,7 +98,7 @@ def test_existing_target_remains_visible_during_atomic_update(
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
_write(target, "old\n")
write_source(target, "old\n")
replace_started = threading.Event()
release_replace = threading.Event()
failures: list[BaseException] = []
@@ -135,7 +117,7 @@ def test_existing_target_remains_visible_during_atomic_update(
def update() -> None:
try:
_transaction.write_changes(
[_change(target, "old\n", "new\n")],
[make_change(target, "old\n", "new\n")],
"visible atomic update",
)
except BaseException as error:
@@ -156,13 +138,6 @@ def test_existing_target_remains_visible_during_atomic_update(
assert target.read_text(encoding="utf-8") == "new\n"
def _transaction_subprocess_environment(home: Path) -> dict[str, str]:
environment = os.environ.copy()
environment["HOME"] = str(home)
environment["USERPROFILE"] = str(home)
return environment
def test_process_exit_after_atomic_replace_recovers_on_restart(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -170,7 +145,7 @@ def test_process_exit_after_atomic_replace_recovers_on_restart(
home = tmp_path / "home"
home.mkdir()
target = tmp_path / "target.txt"
_write(target, "old\n")
write_source(target, "old\n")
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
script = """
@@ -193,7 +168,7 @@ _transaction.write_changes([change], "crash recovery")
completed = subprocess.run(
[sys.executable, "-c", script, str(target)],
cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home),
env=transaction_subprocess_environment(home),
check=False,
)
@@ -212,7 +187,7 @@ def test_process_exit_after_committed_marker_keeps_atomic_update(
home = tmp_path / "home"
home.mkdir()
target = tmp_path / "target.txt"
_write(target, "old\n")
write_source(target, "old\n")
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
script = """
@@ -230,7 +205,7 @@ _transaction.write_changes([change], "committed crash recovery")
completed = subprocess.run(
[sys.executable, "-c", script, str(target)],
cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home),
env=transaction_subprocess_environment(home),
check=False,
)
@@ -250,8 +225,8 @@ def test_process_exit_mid_transaction_restores_all_targets(
home.mkdir()
first = tmp_path / "first.txt"
second = tmp_path / "second.txt"
_write(first, "first-old\n")
_write(second, "second-old\n")
write_source(first, "first-old\n")
write_source(second, "second-old\n")
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
script = """
@@ -281,7 +256,7 @@ _transaction.write_changes(changes, "multi-file crash recovery")
completed = subprocess.run(
[sys.executable, "-c", script, str(first), str(second)],
cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home),
env=transaction_subprocess_environment(home),
check=False,
)
@@ -313,7 +288,7 @@ def test_process_exit_recovers_atomic_create_and_remove(
home.mkdir()
target = tmp_path / "target.txt"
if original is not None:
_write(target, original)
write_source(target, original)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
script = """
@@ -343,7 +318,7 @@ _transaction.write_changes([change], "create-remove crash recovery")
str(exit_code),
],
cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home),
env=transaction_subprocess_environment(home),
check=False,
)
@@ -366,8 +341,8 @@ def test_cross_process_writers_are_serialized(
ready = tmp_path / "ready"
second_ready = tmp_path / "second-ready"
release = tmp_path / "release"
_write(target, "old\n")
environment = _transaction_subprocess_environment(home)
write_source(target, "old\n")
environment = transaction_subprocess_environment(home)
first_script = """
import pathlib
import sys
@@ -443,632 +418,3 @@ except OSError as error:
assert second.returncode == 19, output
assert "changed during" in output
assert target.read_text(encoding="utf-8") == "first\n"
def test_final_replace_exception_after_success_restores_original(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
_write(target, "old\n")
real_replace = _transaction._replace_physical
def replace_then_raise(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
real_replace(source, destination, directory)
if os.path.abspath(destination) == os.path.abspath(str(target)):
raise OSError("injected post-replace failure")
monkeypatch.setattr(_transaction, "_replace_physical", replace_then_raise)
with pytest.raises(OSError, match="injected post-replace failure"):
_transaction.write_changes(
[_change(target, "old\n", "new\n")],
"post-replace failure",
)
assert target.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
def test_capture_exception_after_success_restores_original(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
_write(target, "old\n")
real_capture = _transaction._capture_physical
def capture_then_raise(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
real_capture(source, destination, directory)
raise OSError("injected post-capture failure")
monkeypatch.setattr(_transaction, "_capture_physical", capture_then_raise)
with pytest.raises(OSError, match="injected post-capture failure"):
_transaction.write_changes(
[_change(target, "old\n", "new\n")],
"post-capture failure",
)
assert target.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
def test_rollback_reservation_collision_preserves_victim_and_original(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
_write(target, "old\n")
victim = "concurrent rollback destination\n"
collision_path: Path | None = None
def collide_with_rollback_destination(
_source: str,
destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
nonlocal collision_path
if destination.endswith(".rollback"):
collision_path = Path(destination)
_write(collision_path, victim)
monkeypatch.setattr(
_transaction,
"_capture_physical",
collide_with_rollback_destination,
)
with pytest.raises(OSError, match="exist"):
_transaction.write_changes(
[_change(target, "old\n", "new\n")],
"rollback destination collision",
)
assert collision_path is not None
assert target.read_text(encoding="utf-8") == "old\n"
assert collision_path.read_text(encoding="utf-8") == victim
def test_final_install_does_not_overwrite_concurrent_target(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
_write(target, "old\n")
real_replace = _transaction._replace_physical
injected = False
def create_target_before_install(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal injected
if not injected and os.path.abspath(destination) == os.path.abspath(target):
injected = True
_write(target, "concurrent\n")
real_replace(source, destination, directory)
monkeypatch.setattr(
_transaction,
"_replace_physical",
create_target_before_install,
)
with pytest.raises(OSError, match="original content preserved"):
_transaction.write_changes(
[_change(target, "old\n", "new\n")],
"no-clobber install",
)
assert injected
assert target.read_text(encoding="utf-8") == "concurrent\n"
rollbacks = list(tmp_path.glob(".*.rollback"))
assert len(rollbacks) == 1
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
def test_restore_does_not_overwrite_concurrent_target(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
second = tmp_path / "second.txt"
_write(target, "old\n")
_write(second, "second-old\n")
real_replace = _transaction._replace_physical
real_restore = _transaction._restore_physical
injected = False
def fail_second_commit(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(second):
raise OSError("injected second failure")
real_replace(source, destination, directory)
def create_target_before_restore(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal injected
if not injected and os.path.abspath(destination) == os.path.abspath(target):
injected = True
_write(target, "concurrent recovery\n")
real_restore(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
monkeypatch.setattr(
_transaction,
"_restore_physical",
create_target_before_restore,
)
with pytest.raises(OSError, match="original content preserved"):
_transaction.write_changes(
[
_change(target, "old\n", "new\n"),
_change(second, "second-old\n", "second-new\n"),
],
"no-clobber restoration",
)
assert injected
assert target.read_text(encoding="utf-8") == "concurrent recovery\n"
rollbacks = list(tmp_path.glob(".*.rollback"))
assert len(rollbacks) == 1
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
def test_restore_source_replacement_reports_existing_preserved_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target = tmp_path / "target.txt"
second = tmp_path / "second.txt"
displaced = tmp_path / "displaced-original.txt"
_write(target, "old\n")
_write(second, "second-old\n")
real_replace = _transaction._replace_physical
real_restore = _transaction._restore_physical
rollback_path: Path | None = None
def fail_second_commit(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(second):
raise OSError("injected second failure")
real_replace(source, destination, directory)
def replace_restore_source(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal rollback_path
if (
rollback_path is None
and source.endswith(".rollback")
and os.path.abspath(destination) == os.path.abspath(target)
):
rollback_path = Path(source)
rollback_path.rename(displaced)
_write(rollback_path, "concurrent recovery source\n")
real_restore(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
monkeypatch.setattr(_transaction, "_restore_physical", replace_restore_source)
with pytest.raises(OSError, match="recovery source changed") as raised:
_transaction.write_changes(
[
_change(target, "old\n", "new\n"),
_change(second, "second-old\n", "second-new\n"),
],
"restore source replacement",
)
assert rollback_path is not None
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
assert rollback_path.read_text(encoding="utf-8") == "concurrent recovery source\n"
assert displaced.read_text(encoding="utf-8") == "old\n"
assert target.read_text(encoding="utf-8") == "new\n"
assert second.read_text(encoding="utf-8") == "second-old\n"
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
def test_creation_rollback_captures_and_preserves_concurrent_replacement(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
created = tmp_path / "created.txt"
second = tmp_path / "second.txt"
_write(second, "second-old\n")
real_replace = _transaction._replace_physical
real_capture = _transaction._capture_physical
injected = False
def fail_second_commit(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(second):
raise OSError("injected second failure")
real_replace(source, destination, directory)
def replace_created_before_recovery_capture(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal injected
if (
not injected
and os.path.abspath(source) == os.path.abspath(created)
and destination.endswith(".recovery")
):
injected = True
_write(created, "concurrent replacement\n")
real_capture(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
monkeypatch.setattr(
_transaction,
"_capture_physical",
replace_created_before_recovery_capture,
)
with pytest.raises(OSError, match="changed at recovery boundary"):
_transaction.write_changes(
[
_change(created, None, "generated\n"),
_change(second, "second-old\n", "second-new\n"),
],
"creation recovery capture",
)
assert injected
assert created.read_text(encoding="utf-8") == "concurrent replacement\n"
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
def test_mode_repair_uses_private_rollback_and_restores_original_mode(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
secret = tmp_path / "private.key"
second = tmp_path / "config.json"
_write(secret, "secret\n")
_write(second, "old\n")
secret.chmod(0o644)
real_replace = _transaction._replace_physical
rollback_modes: list[int] = []
def inspect_then_fail_second(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(str(secret)):
rollback = next(tmp_path.glob(".private.key.*.rollback"))
rollback_modes.append(rollback.stat().st_mode & 0o777)
if os.path.abspath(destination) == os.path.abspath(str(second)):
raise OSError("injected second commit failure")
real_replace(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
with pytest.raises(OSError, match="injected second commit failure"):
_transaction.write_changes(
[
_change(secret, "secret\n", "secret\n", enforce_mode=True),
_change(second, "old\n", "new\n"),
],
"mode repair",
)
assert rollback_modes
assert set(rollback_modes) == {0o600}
assert secret.read_text(encoding="utf-8") == "secret\n"
assert secret.stat().st_mode & 0o777 == 0o644
assert second.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
def test_deletion_uses_private_rollback_and_restores_original_mode(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
removed = tmp_path / "removed.txt"
second = tmp_path / "second.txt"
_write(removed, "remove me\n")
_write(second, "old\n")
removed.chmod(0o644)
real_replace = _transaction._replace_physical
rollback_modes: list[int] = []
def inspect_then_fail_second(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
if os.path.abspath(destination) == os.path.abspath(str(second)):
rollback = next(tmp_path.glob(".removed.txt.*.rollback"))
rollback_modes.append(rollback.stat().st_mode & 0o777)
raise OSError("injected second commit failure")
real_replace(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
with pytest.raises(OSError, match="injected second commit failure"):
_transaction.write_changes(
[
_change(removed, "remove me\n", None),
_change(second, "old\n", "new\n"),
],
"deletion rollback mode",
)
assert rollback_modes == [0o600]
assert removed.read_text(encoding="utf-8") == "remove me\n"
assert removed.stat().st_mode & 0o777 == 0o644
assert second.read_text(encoding="utf-8") == "old\n"
assert list(tmp_path.glob(".*.rollback")) == []
def _swap_directory(parent: Path, backup: Path, redirected: Path) -> None:
parent.rename(backup)
redirected.rename(parent)
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
def test_staging_directory_swap_never_writes_secret_to_redirected_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
parent = tmp_path / "runtime"
backup = tmp_path / "runtime-original"
redirected = tmp_path / "runtime-redirected"
parent.mkdir()
redirected.mkdir()
target = parent / "private.key"
real_write = _transaction_staging.write_staged
swapped = False
def swap_then_stage(*args: object, **kwargs: object):
nonlocal swapped
if not swapped:
swapped = True
_swap_directory(parent, backup, redirected)
return real_write(*args, **kwargs)
monkeypatch.setattr(_transaction_staging, "write_staged", swap_then_stage)
with pytest.raises(OSError, match="changed during transaction"):
_transaction.write_changes(
[_change(target, None, "secret\n")],
"staging swap",
)
assert not (parent / "private.key").exists()
assert not (backup / "private.key").exists()
assert list(parent.iterdir()) == []
assert list(backup.iterdir()) == []
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
def test_commit_directory_swap_rolls_back_inside_pinned_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
parent = tmp_path / "runtime"
backup = tmp_path / "runtime-original"
redirected = tmp_path / "runtime-redirected"
parent.mkdir()
redirected.mkdir()
target = parent / "private.key"
real_replace = _transaction._replace_physical
swapped = False
def swap_then_replace(
source: str,
destination: str,
directory: _transaction.PinnedDirectory,
) -> None:
nonlocal swapped
if not swapped:
swapped = True
_swap_directory(parent, backup, redirected)
real_replace(source, destination, directory)
monkeypatch.setattr(_transaction, "_replace_physical", swap_then_replace)
with pytest.raises(OSError, match="changed during transaction"):
_transaction.write_changes(
[_change(target, None, "secret\n")],
"commit swap",
)
assert not (parent / "private.key").exists()
assert not (backup / "private.key").exists()
assert list(parent.iterdir()) == []
assert list(backup.iterdir()) == []
def test_failed_transaction_removes_only_created_empty_directories(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
kiro = tmp_path / ".kiro"
hooks = kiro / "hooks"
hooks.mkdir(parents=True)
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
target = hooks / "elydora-audit.json"
def fail_replace(
_source: str,
_destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
raise OSError("injected commit failure")
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
with pytest.raises(OSError, match="injected commit failure"):
_transaction.write_changes(
[_change(target, None, "{}\n")],
"created directory recovery",
directory_preconditions=[
_transaction.DirectoryPrecondition(
str(kiro), "Kiro directory", kiro_snapshot, created=True
),
_transaction.DirectoryPrecondition(
str(hooks), "hooks directory", hooks_snapshot, created=True
),
],
)
assert not hooks.exists()
assert not kiro.exists()
def test_created_directory_recovery_keeps_parent_and_child_pinned(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
kiro = tmp_path / ".kiro"
hooks = kiro / "hooks"
hooks.mkdir(parents=True)
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
target = hooks / "elydora-audit.json"
observed: list[tuple[Path, Path]] = []
real_remove = _transaction_staging.remove_pinned_empty_directory
def observe_pins(
parent: object,
child: object,
*,
require_empty: bool = True,
) -> None:
assert isinstance(parent, _transaction.PinnedDirectory)
assert isinstance(child, _transaction.PinnedDirectory)
assert parent.descriptor is not None
assert child.descriptor is not None
assert os.fstat(child.descriptor).st_ino == child.snapshot.inode
observed.append((Path(parent.path), Path(child.path)))
real_remove(parent, child, require_empty=require_empty)
def fail_replace(
_source: str,
_destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
raise OSError("injected pinned cleanup failure")
monkeypatch.setattr(
_transaction_staging,
"remove_pinned_empty_directory",
observe_pins,
)
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
with pytest.raises(OSError, match="injected pinned cleanup failure"):
_transaction.write_changes(
[_change(target, None, "{}\n")],
"pinned directory recovery",
directory_preconditions=[
_transaction.DirectoryPrecondition(
str(kiro), "Kiro directory", kiro_snapshot, created=True
),
_transaction.DirectoryPrecondition(
str(hooks), "hooks directory", hooks_snapshot, created=True
),
],
)
expected = [(kiro, hooks), (tmp_path, kiro)]
assert observed == expected
assert not kiro.exists()
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
def test_created_directory_recovery_preserves_swapped_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
created = tmp_path / "created"
created.mkdir()
created_snapshot = read_physical_directory(str(created), "created directory")
target = created / "managed.json"
replacement = tmp_path / "replacement"
replacement.mkdir()
marker = replacement / "user-owned"
marker.write_text("preserve", encoding="utf-8")
original = tmp_path / "created-original"
real_remove = _transaction_staging.remove_pinned_empty_directory
def swap_before_removal(
parent: _transaction.PinnedDirectory,
child: _transaction.PinnedDirectory,
*,
require_empty: bool = True,
) -> None:
created.rename(original)
replacement.rename(created)
real_remove(parent, child, require_empty=require_empty)
def fail_replace(
_source: str,
_destination: str,
_directory: _transaction.PinnedDirectory,
) -> None:
raise OSError("injected swap cleanup failure")
monkeypatch.setattr(
_transaction_staging,
"remove_pinned_empty_directory",
swap_before_removal,
)
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
with pytest.raises(OSError, match="recovery failed"):
_transaction.write_changes(
[_change(target, None, "{}\n")],
"swapped directory recovery",
directory_preconditions=[
_transaction.DirectoryPrecondition(
str(created),
"created directory",
created_snapshot,
created=True,
)
],
)
assert (created / marker.name).read_text(encoding="utf-8") == "preserve"
assert original.is_dir()
assert Path(_transaction.active_journal_path()).is_file()
_transaction.clear_journal()
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import os
from pathlib import Path
from elydora.plugins import _transaction
def write_source(path: Path, source: str) -> None:
path.write_bytes(source.encode("utf-8"))
def make_change(
path: Path,
original: str | None,
next_source: str | None,
mode: int = 0o600,
enforce_mode: bool = False,
) -> _transaction.FileChange:
change = _transaction.source_change(
str(path),
path.name,
original,
next_source,
mode,
enforce_mode=enforce_mode,
)
assert change is not None
return change
def transaction_subprocess_environment(home: Path) -> dict[str, str]:
environment = os.environ.copy()
environment["HOME"] = str(home)
environment["USERPROFILE"] = str(home)
return environment
def swap_directory(parent: Path, backup: Path, redirected: Path) -> None:
parent.rename(backup)
redirected.rename(parent)