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. 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 ## Integration Sources
- Verify every agent hook contract against current official provider documentation before changing an adapter. - 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. - 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. - Ship `elydora/py.typed` in every wheel and verify public annotations from an installed-wheel consumer.
- Support every Python version declared in `pyproject.toml`. - 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. - Keep functions focused on one ownership boundary.
- Propagate unexpected errors to the CLI boundary. - Propagate unexpected errors to the CLI boundary.
- Use documented defaults only for genuinely optional configuration. - 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 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. 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. 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 # Register a new user and organization
reg = ElydoraClient.register(base_url, email, password, display_name=None, org_name=None) 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) auth = ElydoraClient.login(base_url, email, password)
client.token = auth["token"]
# Get current authenticated user profile # Get current authenticated user profile
me = client.get_me() 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) 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 ### Operations
@@ -219,6 +225,9 @@ agents_resp = client.list_agents()
# Unfreeze a previously frozen agent # Unfreeze a previously frozen agent
client.unfreeze_agent(agent_id, reason="review complete") client.unfreeze_agent(agent_id, reason="review complete")
# Change the integration type
client.update_agent(agent_id, integration_type="cursor")
# Delete an agent permanently # Delete an agent permanently
deleted_resp = client.delete_agent(agent_id) deleted_resp = client.delete_agent(agent_id)
``` ```
@@ -260,6 +269,25 @@ detail = client.get_export(export_id)
data = client.download_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 ### JWKS
```python ```python
@@ -269,9 +297,12 @@ jwks = client.get_jwks()
### Health ### Health
```python ```python
# Check API health (no authentication required) # No authentication required
health = client.health() 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 ### Crypto Functions
@@ -303,7 +334,7 @@ from elydora import (
### Type Definitions ### 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 ```python
from elydora import ( from elydora import (
@@ -312,18 +343,28 @@ from elydora import (
EAR, # Elydora Acknowledgment Receipt EAR, # Elydora Acknowledgment Receipt
# Entity types # 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 # API response types
RegisterAgentResponse, GetAgentResponse, ListAgentsResponse, RegisterAgentResponse, UpdateAgentResponse, GetAgentResponse,
DeleteAgentResponse, SubmitOperationResponse, GetOperationResponse, ListAgentsResponse, DeleteAgentResponse, FreezeAgentResponse,
UnfreezeAgentResponse, SubmitOperationResponse, GetOperationResponse,
VerifyOperationResponse, AuditQueryResponse, GetEpochResponse, VerifyOperationResponse, AuditQueryResponse, GetEpochResponse,
ListEpochsResponse, CreateExportResponse, GetExportResponse, ListEpochsResponse, CreateExportResponse, GetExportResponse,
ListExportsResponse, JWKSResponse, AuthRegisterResponse, ListExportsResponse, ListWebhooksResponse, RegisterWebhookResponse,
AuthLoginResponse, GetMeResponse, IssueApiTokenResponse, HealthResponse, ListMembersResponse, ListAdminEventsResponse, JWKSResponse,
AuthRegisterResponse, AuthLoginResponse, GetMeResponse,
IssueApiTokenResponse, RotateApiTokenResponse, HealthResponse,
DeepHealthResponse,
# Request types # Request types
RegisterAgentRequest, RegisterAgentRequest, UpdateAgentRequest,
) )
``` ```
+2
View File
@@ -68,6 +68,7 @@ from .types import (
AuthLoginResponse, AuthLoginResponse,
GetMeResponse, GetMeResponse,
IssueApiTokenResponse, IssueApiTokenResponse,
IssueTokenResponse,
RotateApiTokenResponse, RotateApiTokenResponse,
HealthResponse, HealthResponse,
DeepHealthResponse, DeepHealthResponse,
@@ -142,6 +143,7 @@ __all__ = [
"AuthLoginResponse", "AuthLoginResponse",
"GetMeResponse", "GetMeResponse",
"IssueApiTokenResponse", "IssueApiTokenResponse",
"IssueTokenResponse",
"RotateApiTokenResponse", "RotateApiTokenResponse",
"HealthResponse", "HealthResponse",
"DeepHealthResponse", "DeepHealthResponse",
+1 -1
View File
@@ -1,3 +1,3 @@
"""Canonical Elydora package version.""" """Canonical Elydora package version."""
__version__ = "2.0.2" __version__ = "2.1.1"
+21 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import warnings import warnings
from urllib.parse import quote
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
import aiohttp import aiohttp
@@ -130,9 +131,11 @@ class AsyncElydoraClient:
headers=build_headers(self.token), 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() session = await self._get_session()
async with session.get(f"{self.base_url}{path}", timeout=TIMEOUT) as response: 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) return await self._handle_response(response)
@staticmethod @staticmethod
@@ -193,6 +196,15 @@ class AsyncElydoraClient:
"POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds) "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: async def rotate_api_token(self) -> RotateApiTokenResponse:
return await self._request("POST", "/v1/auth/rotate", json_body={}) 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) return await self._request("POST", "/v1/agents/register", json_body=request)
async def get_agent(self, agent_id: str) -> GetAgentResponse: 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: async def list_agents(self) -> ListAgentsResponse:
return await self._request("GET", "/v1/agents") return await self._request("GET", "/v1/agents")
async def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse: async def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
return await self._request( 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: async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
return await self._request( 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( async def update_agent(
@@ -221,15 +233,15 @@ class AsyncElydoraClient:
) -> UpdateAgentResponse: ) -> UpdateAgentResponse:
require_integration_type(integration_type) require_integration_type(integration_type)
return await self._request( 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: 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: async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
await self._request( 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( def create_operation(
@@ -349,4 +361,5 @@ class AsyncElydoraClient:
return await self._get_public("/v1/health") return await self._get_public("/v1/health")
async def deep_health(self) -> DeepHealthResponse: 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 from __future__ import annotations
import warnings import warnings
from urllib.parse import quote
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
import requests import requests
@@ -119,8 +120,10 @@ class ElydoraClient:
headers=build_headers(self.token), 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) 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) return self._handle_response(response)
@staticmethod @staticmethod
@@ -179,6 +182,15 @@ class ElydoraClient:
def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse: 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)) 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: def rotate_api_token(self) -> RotateApiTokenResponse:
return self._request("POST", "/v1/auth/rotate", json_body={}) 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) return self._request("POST", "/v1/agents/register", json_body=request)
def get_agent(self, agent_id: str) -> GetAgentResponse: 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: def list_agents(self) -> ListAgentsResponse:
return self._request("GET", "/v1/agents") return self._request("GET", "/v1/agents")
def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse: 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: 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: def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
require_integration_type(integration_type) require_integration_type(integration_type)
return self._request( 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: 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: def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
self._request( 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( def create_operation(
@@ -324,4 +336,5 @@ class ElydoraClient:
return self._get_public("/v1/health") return self._get_public("/v1/health")
def deep_health(self) -> DeepHealthResponse: 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 json
import os import os
import stat
import tempfile import tempfile
from typing import Any, Dict from typing import Any, Dict, Optional
JsonObject = Dict[str, Any] 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: if not path:
return return
try: try:
@@ -47,7 +48,7 @@ def write_text_atomic(path: str, content: str, mode: int, label: str) -> None:
os.fsync(file.fileno()) os.fsync(file.fileno())
os.chmod(temporary_path, mode) os.chmod(temporary_path, mode)
os.replace(temporary_path, path) os.replace(temporary_path, path)
except Exception as error: except BaseException as error:
if descriptor >= 0: if descriptor >= 0:
try: try:
os.close(descriptor) 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}" f"close failed: {close_error}"
) from error ) from error
_cleanup_failed_write(temporary_path, label, 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: 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) 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 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: def _remove_posix_private_directory(parent_descriptor: int, name: str) -> None:
"""Remove one empty child through the platform's native *at semantics.""" """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") raise OSError("Host does not support identity-bound directory removal")
os.rmdir(name, dir_fd=parent_descriptor) os.rmdir(name, dir_fd=parent_descriptor)
+61 -8
View File
@@ -6,7 +6,7 @@ import base64
from dataclasses import dataclass from dataclasses import dataclass
import json import json
import os import os
from typing import Any, List, Optional, Protocol, Tuple from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
import urllib.parse import urllib.parse
from elydora._runtime_paths import resolve_agent_directory, runtime_root from elydora._runtime_paths import resolve_agent_directory, runtime_root
@@ -20,8 +20,10 @@ from ._managed_files import (
read_physical_file, read_physical_file,
) )
from ._strict_json import JsonObject, parse_json_object 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 .base import InstallConfig
from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
GUARD_SCRIPT = "guard.py" GUARD_SCRIPT = "guard.py"
@@ -167,13 +169,16 @@ def validate_runtime_config(
validate_api_origin(base_url, "Elydora runtime config base_url") 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 = { value = {
"org_id": config.get("org_id", ""), "org_id": config.get("org_id", ""),
"agent_id": agent_id, "agent_id": agent_id,
"kid": config.get("kid", ""), "kid": config.get("kid", ""),
"base_url": config.get("base_url", DEFAULT_BASE_URL), "base_url": config.get("base_url", DEFAULT_BASE_URL),
"agent_name": agent_key, "agent_name": agent_key,
**extra,
} }
token = config.get("token") token = config.get("token")
if token: if token:
@@ -215,7 +220,11 @@ def validate_install_config(
def validate_runtime_tree( 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: ) -> None:
"""Reject an existing agent directory that belongs to another identity.""" """Reject an existing agent directory that belongs to another identity."""
if not physical_directory_exists(runtime_root(), "Elydora runtime directory"): if not physical_directory_exists(runtime_root(), "Elydora runtime directory"):
@@ -226,7 +235,7 @@ def validate_runtime_tree(
config = read_runtime_config(config_path) config = read_runtime_config(config_path)
artifact_states = [ artifact_states = [
physical_file_exists(os.path.join(agent_directory, name), label) 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) artifact_exists = any(artifact_states)
if config is None: 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( 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: ) -> 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() root = runtime_root()
agent_directory = os.path.dirname(contract.guard_path) agent_directory = os.path.dirname(contract.guard_path)
if not same_path(os.path.dirname(agent_directory), root) or not same_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 return False
validate_runtime_config(config, contract.agent_id, config_path, agent_key, product_label) validate_runtime_config(config, contract.agent_id, config_path, agent_key, product_label)
validate_private_key(key.contents, "Elydora private key") 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: def runtime_present(contract: RuntimeContractLike, agent_key: str) -> bool:
@@ -336,3 +380,12 @@ def rendered_source_change(
next_source: Optional[str], next_source: Optional[str],
) -> Optional[FileChange]: ) -> Optional[FileChange]:
return source_change(file_path, label, original, next_source, 0o600, MAX_SOURCE_BYTES) 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") 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 ( return (
f'"{windows_powershell_path()}" -NoLogo -NoProfile -NonInteractive ' 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 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( def parse_encoded_windows_command(
command: str, prefix: str = "& " command: str,
prefix: str = "& ",
powershell_path: Optional[str] = None,
) -> Optional[CommandParts]: ) -> Optional[CommandParts]:
match = _ENCODED_COMMAND.fullmatch(command) match = _ENCODED_COMMAND.fullmatch(command)
if match is None or not is_powershell_executable(match.group(1)): if match is None or not is_powershell_executable(match.group(1)):
return None 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)) source = decode_powershell_source(match.group(2))
return None if source is None else parse_powershell_source(source, prefix) return None if source is None else parse_powershell_source(source, prefix)
+7 -18
View File
@@ -9,6 +9,9 @@ import os
import sys import sys
from typing import Any, Dict, List, Optional, Set, Tuple 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 from ._strict_json import parse_json_object
@@ -69,10 +72,6 @@ def home_dir() -> str:
return os.path.expanduser("~") return os.path.expanduser("~")
def elydora_dir() -> str:
return os.path.join(home_dir(), ".elydora")
def resolve_config_path() -> str: def resolve_config_path() -> str:
return os.path.join(home_dir(), ".augment", "settings.json") return os.path.join(home_dir(), ".augment", "settings.json")
@@ -162,16 +161,6 @@ def _parse_wrapper_command(command: str) -> Optional[str]:
return parser(command) 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]: def _managed_agent_id(handler: JsonObject, wrapper_name: str) -> Optional[str]:
if ( if (
handler.get("type") != "command" handler.get("type") != "command"
@@ -188,7 +177,7 @@ def _managed_agent_id(handler: JsonObject, wrapper_name: str) -> Optional[str]:
): ):
return None return None
agent_directory = os.path.dirname(wrapper_path) 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 return None
agent_id = os.path.basename(agent_directory) agent_id = os.path.basename(agent_directory)
return agent_id if agent_id not in {"", ".", ".."} else None return agent_id if agent_id not in {"", ".", ".."} else None
@@ -324,7 +313,7 @@ def _remove_managed(
for handler in group["hooks"]: for handler in group["hooks"]:
managed_id = _managed_agent_id(handler, wrapper_name) managed_id = _managed_agent_id(handler, wrapper_name)
remove = managed_id is not None and ( 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: if remove:
changed = True changed = True
@@ -376,12 +365,12 @@ def runtime_contracts(hooks: AugmentHooks) -> List[RuntimeContract]:
contracts: List[RuntimeContract] = [] contracts: List[RuntimeContract] = []
for guard_id in sorted(guards, key=os.path.normcase): for guard_id in sorted(guards, key=os.path.normcase):
audit_id = next( 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, None,
) )
if audit_id is None: if audit_id is None:
continue continue
agent_directory = os.path.join(elydora_dir(), guard_id) agent_directory = os.path.join(runtime_root(), guard_id)
contracts.append( contracts.append(
RuntimeContract( RuntimeContract(
agent_id=guard_id, agent_id=guard_id,
+43 -124
View File
@@ -3,107 +3,61 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json from typing import List
import os
from typing import List, Optional
from elydora._runtime_paths import resolve_agent_directory from ._runtime import (
DEFAULT_BASE_URL,
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES RuntimePaths,
present,
resolve_runtime_paths,
runtime_file_changes,
validate_install_config,
validate_runtime_tree,
)
from ._transaction import FileChange, file_change, write_changes from ._transaction import FileChange, file_change, write_changes
from .augment_contract import ( from .augment_contract import (
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT, AUDIT_WRAPPER,
GUARD_SCRIPT, GUARD_WRAPPER,
AugmentDocument, AugmentDocument,
RenderedAugmentDocument, RenderedAugmentDocument,
build_wrapper, build_wrapper,
elydora_dir,
wrapper_paths, wrapper_paths,
) )
from .augment_io import ( from .augment_io import rendered_change
rendered_change,
validate_api_origin,
validate_private_key,
validate_runtime_tree,
)
from .base import InstallConfig from .base import InstallConfig
from .guard_template import generate_guard_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
WRAPPER_ARTIFACTS = (
(GUARD_WRAPPER, "Auggie guard wrapper"),
(AUDIT_WRAPPER, "Auggie audit wrapper"),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class AugmentRuntimePaths: class AugmentRuntimePaths(RuntimePaths):
agent_id: str
agent_directory: str
guard_path: str
audit_path: str
guard_wrapper_path: str guard_wrapper_path: str
audit_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: def _agent_paths(config: InstallConfig) -> AugmentRuntimePaths:
agent_id = config.get("agent_id", "") paths = resolve_runtime_paths(config)
if not agent_id: wrappers = wrapper_paths(paths.agent_directory)
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)
return AugmentRuntimePaths( return AugmentRuntimePaths(
agent_id, paths.agent_id,
agent_directory, paths.agent_directory,
guard_path, paths.guard_path,
audit_path, paths.audit_path,
wrappers.guard_path, wrappers.guard_path,
wrappers.audit_path, wrappers.audit_path,
) )
def _validate_install_config(config: InstallConfig) -> None: def _validate_runtime_tree(paths: AugmentRuntimePaths) -> None:
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"): validate_runtime_tree(
value = config.get(field) paths.agent_directory, paths.agent_id, AGENT_KEY, "Auggie", WRAPPER_ARTIFACTS
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 preflight_augment_installation( def preflight_augment_installation(
@@ -111,26 +65,12 @@ def preflight_augment_installation(
) -> AugmentRuntimePaths: ) -> AugmentRuntimePaths:
if not document.config_path: if not document.config_path:
raise ValueError("Augment Code CLI installation requires a settings 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) paths = _agent_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id) _validate_runtime_tree(paths)
return 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( def prepare_augment_installation(
config: InstallConfig, config: InstallConfig,
paths: AugmentRuntimePaths, paths: AugmentRuntimePaths,
@@ -138,54 +78,35 @@ def prepare_augment_installation(
) -> List[FileChange]: ) -> List[FileChange]:
if not rendered.changed and rendered.document.raw is None: if not rendered.changed and rendered.document.raw is None:
raise ValueError("Auggie hook installation did not produce a settings document") 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) guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
audit_script = generate_hook_script( audit_script = generate_hook_script(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
changes = _present( changes = [
[ *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
_runtime_change( *present([
paths.guard_path, "Elydora guard runtime", guard_script, 0o700 file_change(
),
_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(
paths.guard_wrapper_path, paths.guard_wrapper_path,
"Auggie guard wrapper", "Auggie guard wrapper",
build_wrapper(paths.guard_path), build_wrapper(paths.guard_path),
0o700, 0o700,
), ),
_runtime_change( file_change(
paths.audit_wrapper_path, paths.audit_wrapper_path,
"Auggie audit wrapper", "Auggie audit wrapper",
build_wrapper(paths.audit_path), build_wrapper(paths.audit_path),
0o700, 0o700,
), ),
rendered_change(rendered), rendered_change(rendered),
] ]),
) ]
validate_runtime_tree(paths.agent_directory, paths.agent_id) _validate_runtime_tree(paths)
return changes return changes
@@ -193,10 +114,8 @@ def commit_augment_installation(changes: List[FileChange]) -> None:
write_changes(changes, "Install Augment Code CLI hooks") write_changes(changes, "Install Augment Code CLI hooks")
def prepare_augment_uninstall( def prepare_augment_uninstall(rendered: RenderedAugmentDocument) -> List[FileChange]:
rendered: RenderedAugmentDocument, return present([rendered_change(rendered)])
) -> List[FileChange]:
return _present([rendered_change(rendered)])
def commit_augment_uninstall(changes: List[FileChange]) -> None: def commit_augment_uninstall(changes: List[FileChange]) -> None:
+18 -209
View File
@@ -2,61 +2,36 @@
from __future__ import annotations from __future__ import annotations
import base64
import os import os
from typing import Any, List, Optional from typing import List, Optional
import urllib.parse
from ._managed_files import ( from ._managed_files import MAX_CONFIG_BYTES, physical_directory_exists, read_physical_file
MAX_CONFIG_BYTES, from ._runtime import runtime_contract_exists, same_path
MAX_SECRET_BYTES,
physical_directory_exists,
physical_file_exists,
read_physical_file,
)
from ._strict_json import JsonObject, parse_json_object
from ._transaction import FileChange, source_change, write_changes from ._transaction import FileChange, source_change, write_changes
from .augment_contract import ( from .augment_contract import (
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT,
AUDIT_WRAPPER, AUDIT_WRAPPER,
GUARD_SCRIPT,
GUARD_WRAPPER, GUARD_WRAPPER,
AugmentDocument, AugmentDocument,
RenderedAugmentDocument, RenderedAugmentDocument,
RuntimeContract, RuntimeContract,
build_wrapper, build_wrapper,
create_augment_document, create_augment_document,
elydora_dir,
parse_augment_document, parse_augment_document,
resolve_config_path, 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: def read_augment_document() -> AugmentDocument:
config_path = resolve_config_path() config_path = resolve_config_path()
physical_directory_exists( physical_directory_exists(os.path.dirname(config_path), "Auggie configuration directory")
os.path.dirname(config_path), "Auggie configuration directory"
)
snapshot = read_physical_file(config_path, "Auggie user settings", MAX_CONFIG_BYTES) snapshot = read_physical_file(config_path, "Auggie user settings", MAX_CONFIG_BYTES)
if snapshot is None: if snapshot is None:
return create_augment_document(config_path) return create_augment_document(config_path)
return parse_augment_document(config_path, snapshot.contents) return parse_augment_document(config_path, snapshot.contents)
def rendered_change( def rendered_change(rendered: RenderedAugmentDocument) -> Optional[FileChange]:
rendered: RenderedAugmentDocument,
) -> Optional[FileChange]:
if not rendered.changed: if not rendered.changed:
return None return None
return source_change( return source_change(
@@ -75,192 +50,26 @@ def write_augment_document(rendered: RenderedAugmentDocument) -> None:
write_changes([change], "Uninstall Augment Code CLI hooks") write_changes([change], "Uninstall Augment Code CLI hooks")
def validate_api_origin(value: str, label: str = "base_url") -> None: def _wrappers_exist(contract: RuntimeContract) -> bool:
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()
agent_directory = os.path.dirname(contract.guard_path) agent_directory = os.path.dirname(contract.guard_path)
if ( if not same_path(
not _same_path(os.path.dirname(agent_directory), root) contract.guard_wrapper_path, os.path.join(agent_directory, GUARD_WRAPPER)
or not _same_path( ) or not same_path(
contract.audit_path, os.path.join(agent_directory, AUDIT_SCRIPT) contract.audit_wrapper_path, os.path.join(agent_directory, AUDIT_WRAPPER)
)
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),
)
): ):
return False return False
if not physical_directory_exists(root, "Elydora runtime directory"): guard_wrapper = read_physical_file(contract.guard_wrapper_path, "Auggie guard wrapper")
return False audit_wrapper = read_physical_file(contract.audit_wrapper_path, "Auggie audit wrapper")
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")
return ( return (
bool(guard.contents) guard_wrapper is not None
and bool(audit.contents) and audit_wrapper is not None
and guard_wrapper.contents == build_wrapper(contract.guard_path) and guard_wrapper.contents == build_wrapper(contract.guard_path)
and audit_wrapper.contents == build_wrapper(contract.audit_path) and audit_wrapper.contents == build_wrapper(contract.audit_path)
) )
def augment_runtime_files_exist(contracts: List[RuntimeContract]) -> bool: 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 import os
from typing import Any, Dict, Optional 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" AGENT_KEY = "cline"
@@ -58,20 +60,6 @@ def home_dir() -> str:
return os.path.expanduser("~") 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: def resolve_hooks_directory() -> str:
configured = os.environ.get("CLINE_DIR", "").strip() configured = os.environ.get("CLINE_DIR", "").strip()
cline_directory = ( cline_directory = (
@@ -267,10 +255,10 @@ def runtime_contract(
raise ValueError("Elydora Cline hook files contain mismatched event metadata") raise ValueError("Elydora Cline hook files contain mismatched event metadata")
if not same_agent_id(guard.agent_id, audit.agent_id): if not same_agent_id(guard.agent_id, audit.agent_id):
raise ValueError("Elydora Cline hook files reference different agents") 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_guard = os.path.join(agent_directory, GUARD_SCRIPT)
expected_audit = os.path.join(agent_directory, AUDIT_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 audit.runtime_path, expected_audit
): ):
raise ValueError( raise ValueError(
+34 -143
View File
@@ -2,107 +2,42 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import json
import os import os
import sys import sys
from typing import List, Optional, Sequence from typing import List, Optional, Sequence
from elydora._runtime_paths import resolve_agent_directory from ._runtime import (
DEFAULT_BASE_URL,
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES RuntimePaths,
from ._transaction import FileChange, file_change, source_change, write_changes 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 .base import InstallConfig
from .cline_contract import ( from .cline_contract import (
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
HookFile, HookFile,
assert_wrapper_integrity, assert_wrapper_integrity,
build_metadata, build_metadata,
build_wrapper, build_wrapper,
elydora_dir,
resolve_hook_files, resolve_hook_files,
runtime_contract, 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 .guard_template import generate_guard_script
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
PRODUCT = "Cline"
@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)
def _validate_hook_files(guard_file: HookFile, audit_file: HookFile) -> None: def _validate_hook_files(guard_file: HookFile, audit_file: HookFile) -> None:
expected = resolve_hook_files() 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 audit_file.file_path, expected.audit_path
): ):
raise ValueError("Cline installation received unexpected hook paths") raise ValueError("Cline installation received unexpected hook paths")
@@ -113,41 +48,23 @@ def preflight_cline_installation(
config: InstallConfig, config: InstallConfig,
guard_file: HookFile, guard_file: HookFile,
audit_file: HookFile, audit_file: HookFile,
) -> ClineRuntimePaths: ) -> RuntimePaths:
_validate_install_config(config) 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) _validate_hook_files(guard_file, audit_file)
paths = _agent_paths(config) paths = resolve_runtime_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id) validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
return 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 _hook_change( def _hook_change(
file: HookFile, label: str, next_source: Optional[str] file: HookFile, label: str, next_source: Optional[str]
) -> Optional[FileChange]: ) -> Optional[FileChange]:
original = file.source if file.exists else None original = file.source if file.exists else None
if file.exists and original is None: if file.exists and original is None:
raise RuntimeError(f"{label} snapshot is missing source") raise RuntimeError(f"{label} snapshot is missing source")
return source_change( return source_change(file.file_path, label, original, next_source, 0o700)
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]
def prepare_cline_installation( def prepare_cline_installation(
@@ -169,37 +86,18 @@ def prepare_cline_installation(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
changes = _present( changes = [
( *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
_runtime_change( *present([
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
),
_hook_change(guard_file, "Cline PreToolUse hook", guard_wrapper), _hook_change(guard_file, "Cline PreToolUse hook", guard_wrapper),
_hook_change(audit_file, "Cline PostToolUse hook", audit_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)) validate_hook_tree(os.path.dirname(guard_file.file_path))
return changes return changes
@@ -211,21 +109,14 @@ def commit_cline_installation(changes: Sequence[FileChange]) -> None:
def prepare_cline_uninstall( def prepare_cline_uninstall(
files: Sequence[HookFile], agent_id: str = "" files: Sequence[HookFile], agent_id: str = ""
) -> List[FileChange]: ) -> 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]] = [] changes: List[Optional[FileChange]] = []
for file in owned: for file in files:
metadata = file.metadata 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) assert_wrapper_integrity(file)
changes.append( changes.append(_hook_change(file, f"Cline {metadata.kind} hook", None))
_hook_change(file, f"Cline {metadata.kind} hook", None) return present(changes)
)
return _present(changes)
def commit_cline_uninstall(changes: Sequence[FileChange]) -> None: def commit_cline_uninstall(changes: Sequence[FileChange]) -> None:
+9 -220
View File
@@ -2,43 +2,17 @@
from __future__ import annotations from __future__ import annotations
import base64
import os import os
from typing import Any, Optional from typing import Tuple
import urllib.parse
from ._managed_files import ( from ._managed_files import physical_directory_exists, read_physical_file
MAX_CONFIG_BYTES, from ._runtime import expected_runtime_scripts, runtime_contract_exists
MAX_SECRET_BYTES, from ._strict_json import JsonObject
physical_directory_exists, from .cline_contract import AGENT_KEY, HookFile, RuntimeContract, parse_metadata
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)
)
def validate_hook_tree(hooks_directory: str) -> None: def validate_hook_tree(hooks_directory: str) -> None:
physical_directory_exists( physical_directory_exists(os.path.dirname(hooks_directory), "Cline configuration directory")
os.path.dirname(hooks_directory), "Cline configuration directory"
)
physical_directory_exists(hooks_directory, "Cline hooks 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: def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
try: return expected_runtime_scripts(AGENT_KEY, agent_id)
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 runtime_files_exist(contract: RuntimeContract) -> bool: def runtime_files_exist(contract: RuntimeContract) -> bool:
if not _valid_contract_paths(contract): return runtime_contract_exists(contract, AGENT_KEY, "Cline", _expected_scripts)
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",
]
+24 -104
View File
@@ -7,10 +7,19 @@ import json
import os import os
import re import re
import shlex import shlex
import sys
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from elydora._runtime_paths import runtime_root
from ._managed_files import FileSnapshot 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 ._strict_json import parse_json_object
from .copilot_schema import CopilotHooks, validate_hooks from .copilot_schema import CopilotHooks, validate_hooks
@@ -69,124 +78,35 @@ class RuntimeContract:
audit_path: str audit_path: str
@dataclass(frozen=True)
class _ParsedArgument:
value: str
next_index: int
@dataclass(frozen=True) @dataclass(frozen=True)
class _ManagedEntry: class _ManagedEntry:
agent_id: str agent_id: str
script_path: 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: def build_handler(script_path: str) -> JsonObject:
return { return {
"type": "command", "type": "command",
"bash": f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}", "bash": posix_source(script_path),
"powershell": ( "powershell": powershell_source(script_path),
f"& {_quote_powershell(sys.executable)} "
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
),
"timeoutSec": HOOK_TIMEOUT_SECONDS, "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: def _exact_handler_keys(handler: JsonObject) -> bool:
return set(handler) == {"type", "bash", "powershell", "timeoutSec"} return set(handler) == {"type", "bash", "powershell", "timeoutSec"}
def _is_python_executable(file_path: str) -> bool: def _parse_generated_bash(command: Any) -> Optional[Tuple[str, str]]:
return os.path.isabs(file_path) and re.fullmatch( return parse_posix_command(command) if isinstance(command, str) else None
r"(?:python|pypy)(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
os.path.basename(file_path),
re.IGNORECASE, def _parse_generated_powershell(command: Any) -> Optional[Tuple[str, str]]:
) is not None 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]: def _current_script_path(handler: JsonObject) -> Optional[str]:
@@ -201,7 +121,7 @@ def _current_script_path(handler: JsonObject) -> Optional[str]:
if ( if (
bash is None bash is None
or powershell 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[0], powershell[0])
or not same_path(bash[1], powershell[1]) or not same_path(bash[1], powershell[1])
): ):
@@ -227,7 +147,7 @@ def _prior_script_path(handler: JsonObject) -> Optional[str]:
len(arguments) != 2 len(arguments) != 2
or shlex.join(arguments) != bash_command or shlex.join(arguments) != bash_command
or powershell is None 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[0], powershell[0])
or not same_path(arguments[1], powershell[1]) or not same_path(arguments[1], powershell[1])
): ):
+38 -160
View File
@@ -3,48 +3,29 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json
import os import os
import sys 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_SOURCE_BYTES
from ._runtime import (
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES DEFAULT_BASE_URL,
from ._transaction import ( RuntimePaths,
FileChange, resolve_runtime_paths,
FilePrecondition, runtime_file_changes,
file_change,
source_change,
write_changes,
)
from .base import InstallConfig
from .copilot_contract import (
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
CopilotSources,
RenderedDocument,
runtime_root,
same_path, same_path,
) validate_install_config,
from .copilot_io import (
require_hooks_enabled,
validate_api_origin,
validate_private_key,
validate_runtime_tree, 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 .copilot_schema import validate_javascript_regexes
from .guard_template import generate_guard_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
PRODUCT = "GitHub Copilot CLI"
@dataclass(frozen=True)
class CopilotRuntimePaths:
agent_id: str
agent_directory: str
guard_path: str
audit_path: str
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -53,95 +34,25 @@ class PreparedCopilotInstallation:
preconditions: Tuple[FilePrecondition, ...] 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( def preflight_copilot_installation(
config: InstallConfig, config: InstallConfig,
sources: CopilotSources, sources: CopilotSources,
) -> CopilotRuntimePaths: ) -> RuntimePaths:
if not sources.user.file_path: if not sources.user.file_path:
raise ValueError( raise ValueError("GitHub Copilot CLI installation requires a user hook path")
"GitHub Copilot CLI installation requires a user hook path"
)
require_hooks_enabled(sources) require_hooks_enabled(sources)
hook_sources = [sources.user.hooks] hook_sources = [sources.user.hooks]
if sources.legacy is not None: if sources.legacy is not None:
hook_sources.append(sources.legacy.hooks) hook_sources.append(sources.legacy.hooks)
validate_javascript_regexes(hook_sources) validate_javascript_regexes(hook_sources)
_validate_install_config(config) validate_install_config(config, AGENT_KEY, PRODUCT)
paths = _agent_paths(config) if not os.path.isabs(sys.executable):
validate_runtime_tree(paths.agent_directory, paths.agent_id) 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 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]: def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
if not rendered.changed: if not rendered.changed:
return None 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( def _document_preconditions(
rendered: Sequence[RenderedDocument], rendered: Sequence[RenderedDocument],
) -> Tuple[FilePrecondition, ...]: ) -> Tuple[FilePrecondition, ...]:
@@ -177,16 +82,9 @@ def _document_preconditions(
) )
def _settings_preconditions( def _settings_preconditions(sources: CopilotSources) -> Tuple[FilePrecondition, ...]:
sources: CopilotSources,
) -> Tuple[FilePrecondition, ...]:
return tuple( return tuple(
FilePrecondition( FilePrecondition(item.file_path, item.label, item.snapshot, MAX_SOURCE_BYTES)
item.file_path,
item.label,
item.snapshot,
MAX_SOURCE_BYTES,
)
for item in sources.settings_preconditions 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 same_path(item.document.file_path, document.file_path)
] ]
if len(matches) != 1: if len(matches) != 1:
raise ValueError( raise ValueError("GitHub Copilot rendered source set contains unexpected paths")
"GitHub Copilot rendered source set contains unexpected paths"
)
def prepare_copilot_installation( def prepare_copilot_installation(
@@ -223,33 +119,18 @@ def prepare_copilot_installation(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
changes = _present(( document_changes = [
_runtime_change( change for change in (_document_change(item) for item in rendered)
paths.guard_path, "Elydora guard runtime", guard_script, 0o700 if change is not None
), ]
_runtime_change( changes = (
os.path.join(paths.agent_directory, "config.json"), *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
"Elydora runtime config", *document_changes,
_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),
))
preconditions = ( preconditions = (
*_settings_preconditions(sources), *_settings_preconditions(sources),
*_document_preconditions(rendered), *_document_preconditions(rendered),
@@ -257,20 +138,17 @@ def prepare_copilot_installation(
return PreparedCopilotInstallation(changes, preconditions) return PreparedCopilotInstallation(changes, preconditions)
def commit_copilot_installation( def commit_copilot_installation(prepared: PreparedCopilotInstallation) -> None:
prepared: PreparedCopilotInstallation, write_changes(prepared.changes, "Install GitHub Copilot hooks", prepared.preconditions)
) -> None:
write_changes(
prepared.changes,
"Install GitHub Copilot hooks",
prepared.preconditions,
)
def prepare_copilot_uninstall( def prepare_copilot_uninstall(
rendered: Sequence[RenderedDocument], rendered: Sequence[RenderedDocument],
) -> Tuple[FileChange, ...]: ) -> 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: def commit_copilot_uninstall(changes: Sequence[FileChange]) -> None:
+13 -193
View File
@@ -2,40 +2,29 @@
from __future__ import annotations from __future__ import annotations
import base64
from dataclasses import dataclass from dataclasses import dataclass
import os import os
from typing import Any, List, Optional, Tuple from typing import List, Optional, Tuple
import urllib.parse
from ._jsonc import parse_jsonc from ._jsonc import parse_jsonc
from ._managed_files import ( from ._managed_files import (
FileSnapshot, FileSnapshot,
MAX_CONFIG_BYTES,
MAX_SECRET_BYTES,
MAX_SOURCE_BYTES, MAX_SOURCE_BYTES,
physical_directory_exists, physical_directory_exists,
physical_file_exists,
read_physical_file, read_physical_file,
) )
from ._runtime import expected_runtime_scripts, runtime_contract_exists
from ._strict_json import JsonObject, parse_json_object from ._strict_json import JsonObject, parse_json_object
from .copilot_contract import ( from .copilot_contract import (
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT,
CONFIG_FILE, CONFIG_FILE,
GUARD_SCRIPT,
CopilotDocument, CopilotDocument,
CopilotSources, CopilotSources,
RuntimeContract, RuntimeContract,
SourcePrecondition, SourcePrecondition,
create_document, create_document,
parse_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) @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: def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
try: return expected_runtime_scripts(AGENT_KEY, agent_id)
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 runtime_files_exist(contracts: List[RuntimeContract]) -> bool: 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 ._jsonc import JsonPathPart, JsoncEditor
from ._managed_files import FileSnapshot from ._managed_files import FileSnapshot
from ._runtime import same_path
from .droid_contract import ( from .droid_contract import (
TOOL_EVENTS, TOOL_EVENTS,
DroidHookMap, DroidHookMap,
JsonObject, JsonObject,
managed_removals, managed_removals,
read_hook_map, read_hook_map,
same_path,
) )
from .droid_policy import DroidPolicyState from .droid_policy import DroidPolicyState
+17 -116
View File
@@ -12,6 +12,16 @@ import subprocess # nosec B404
import sys import sys
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple 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" AGENT_KEY = "droid"
GUARD_SCRIPT = "guard.py" GUARD_SCRIPT = "guard.py"
@@ -22,7 +32,6 @@ TOOL_EVENTS = ("PreToolUse", "PostToolUse")
_HANDLER_KEYS = {"command", "timeout", "type"} _HANDLER_KEYS = {"command", "timeout", "type"}
_GROUP_KEYS = {"hooks", "matcher"} _GROUP_KEYS = {"hooks", "matcher"}
_REGEX_TIMEOUT_SECONDS = 10 _REGEX_TIMEOUT_SECONDS = 10
_WINDOWS_EXIT_SUFFIX = "; exit $LASTEXITCODE"
_REGEX_VALIDATOR = """import fs from 'node:fs'; _REGEX_VALIDATOR = """import fs from 'node:fs';
const entries = JSON.parse(fs.readFileSync(0, 'utf8')); const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
for (const entry of entries) { for (const entry of entries) {
@@ -55,39 +64,14 @@ class ManagedRemoval:
remove_group: bool 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: def build_command(script_path: str) -> str:
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path): if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
raise ValueError( raise ValueError(
"Factory Droid hook commands require absolute executable and script paths" "Factory Droid hook commands require absolute executable and script paths"
) )
if os.name == "nt": if os.name == "nt":
return ( return powershell_source(script_path)
f"& {_quote_power_shell(sys.executable)} " return posix_source(script_path)
f"{_quote_power_shell(script_path)}{_WINDOWS_EXIT_SUFFIX}"
)
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
def build_group(script_path: str) -> JsonObject: 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]]: def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
match = re.fullmatch(r'"([^"\r\n]+)" "([^"\r\n]+)"', command) match = re.fullmatch(r'"([^"\r\n]+)" "([^"\r\n]+)"', command)
return (match.group(1), match.group(2)) if match else None return (match.group(1), match.group(2)) if match else None
@@ -301,8 +208,8 @@ def _parse_generated_command(
include_legacy: bool, include_legacy: bool,
) -> Optional[Tuple[str, str]]: ) -> Optional[Tuple[str, str]]:
if os.name != "nt": if os.name != "nt":
return _parse_two_arguments(command, 0) return parse_posix_command(command)
current = _parse_power_shell_command(command) current = parse_powershell_source(command)
return current or ( return current or (
_parse_legacy_windows_command(command) if include_legacy else None _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) parsed = _parse_generated_command(command, include_legacy)
if parsed is None or not same_path(parsed[0], sys.executable): if parsed is None or not same_path(parsed[0], sys.executable):
return None return None
script_path = parsed[1] reference = managed_script_reference(parsed[1], script_name)
if os.path.basename(script_path) != script_name: return None if reference is None else reference[0]
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
def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool: 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): for agent_id in sorted(guards):
if not any(same_agent_id(agent_id, audit_id) for audit_id in audits): if not any(same_agent_id(agent_id, audit_id) for audit_id in audits):
continue continue
root = os.path.join(elydora_dir(), agent_id) root = os.path.join(runtime_root(), agent_id)
contracts.append(RuntimeContract( contracts.append(RuntimeContract(
agent_id, agent_id,
os.path.join(root, GUARD_SCRIPT), os.path.join(root, GUARD_SCRIPT),
+44 -186
View File
@@ -3,21 +3,21 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json
import os import os
import sys import sys
from typing import Optional, Sequence, Tuple from typing import Optional, Sequence, Tuple
from elydora._runtime_paths import resolve_agent_directory from ._managed_files import MAX_SOURCE_BYTES
from ._runtime import (
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES DEFAULT_BASE_URL,
from ._transaction import ( RuntimePaths,
FileChange, resolve_runtime_paths,
FilePrecondition, runtime_file_changes,
file_change, same_path,
source_change, validate_install_config,
write_changes, validate_runtime_tree,
) )
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
from .base import InstallConfig from .base import InstallConfig
from .droid_config import ( from .droid_config import (
DroidDocument, DroidDocument,
@@ -26,30 +26,12 @@ from .droid_config import (
installation_documents, installation_documents,
source_documents, source_documents,
) )
from .droid_contract import ( from .droid_contract import AGENT_KEY, validate_javascript_regexes
AGENT_KEY, from .droid_io import require_hooks_enabled
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 .guard_template import generate_guard_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
PRODUCT = "Factory Droid"
@dataclass(frozen=True)
class DroidRuntimePaths:
agent_id: str
agent_directory: str
guard_path: str
audit_path: str
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -74,90 +56,20 @@ def _source_label(document: DroidDocument) -> str:
return "Factory Droid user hooks" 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( def preflight_droid_installation(
config: InstallConfig, config: InstallConfig,
sources: DroidSources, sources: DroidSources,
) -> DroidRuntimePaths: ) -> RuntimePaths:
require_hooks_enabled(sources) require_hooks_enabled(sources)
validate_javascript_regexes([ validate_javascript_regexes([document.hooks for document in source_documents(sources)])
document.hooks for document in source_documents(sources) validate_install_config(config, AGENT_KEY, PRODUCT)
]) if not os.path.isabs(sys.executable):
_validate_install_config(config) raise RuntimeError("Factory Droid requires an absolute Python executable path")
paths = _agent_paths(config) paths = resolve_runtime_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id) validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
return 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 _document_change(rendered: RenderedDocument) -> Optional[FileChange]: def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
if not rendered.changed: if not rendered.changed:
return None return None
@@ -173,10 +85,11 @@ def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
) )
def _present( def _document_changes(rendered: Sequence[RenderedDocument]) -> Tuple[FileChange, ...]:
changes: Sequence[Optional[FileChange]], return tuple(
) -> Tuple[FileChange, ...]: change for change in (_document_change(item) for item in rendered)
return tuple(change for change in changes if change is not None) if change is not None
)
def _validate_rendered_documents( def _validate_rendered_documents(
@@ -188,18 +101,15 @@ def _validate_rendered_documents(
raise ValueError("Factory Droid rendered source set is incomplete") raise ValueError("Factory Droid rendered source set is incomplete")
for document in expected: for document in expected:
matches = [ matches = [
item item for item in rendered
for item in rendered
if same_path(item.document.file_path, document.file_path) if same_path(item.document.file_path, document.file_path)
] ]
if len(matches) != 1: if len(matches) != 1:
raise ValueError( raise ValueError("Factory Droid rendered source set contains unexpected paths")
"Factory Droid rendered source set contains unexpected paths"
)
def _source_preconditions( def _unchanged_preconditions(
sources: DroidSources, documents: Sequence[DroidDocument],
changed_paths: Sequence[str], changed_paths: Sequence[str],
) -> Tuple[FilePrecondition, ...]: ) -> Tuple[FilePrecondition, ...]:
return tuple( return tuple(
@@ -209,11 +119,8 @@ def _source_preconditions(
document.snapshot, document.snapshot,
MAX_SOURCE_BYTES, MAX_SOURCE_BYTES,
) )
for document in source_documents(sources) for document in documents
if not any( if not any(same_path(document.file_path, changed) for changed in changed_paths)
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", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
document_changes = _present(tuple( document_changes = _document_changes(rendered)
_document_change(item) for item in rendered changes = (
)) *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
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,
)) )
changed_paths = [change.file_path for change in document_changes] changed_paths = [change.file_path for change in document_changes]
preconditions = ( preconditions = (
*_source_preconditions(sources, changed_paths), *_unchanged_preconditions(source_documents(sources), changed_paths),
*sources.policy.preconditions, *sources.policy.preconditions,
) )
return PreparedDroidInstallation(changes, tuple(preconditions)) return PreparedDroidInstallation(changes, preconditions)
def commit_droid_installation( def commit_droid_installation(prepared: PreparedDroidInstallation) -> None:
prepared: PreparedDroidInstallation, write_changes(prepared.changes, "Install Factory Droid hooks", prepared.preconditions)
) -> None:
write_changes(
prepared.changes,
"Install Factory Droid hooks",
prepared.preconditions,
)
def prepare_droid_uninstall( def prepare_droid_uninstall(
rendered: Sequence[RenderedDocument], rendered: Sequence[RenderedDocument],
) -> PreparedDroidUninstall: ) -> PreparedDroidUninstall:
changes = _present(tuple(_document_change(item) for item in rendered)) changes = _document_changes(rendered)
changed_paths = [change.file_path for change in changes] changed_paths = [change.file_path for change in changes]
preconditions = tuple( documents = [item.document for item in rendered]
FilePrecondition( return PreparedDroidUninstall(changes, _unchanged_preconditions(documents, changed_paths))
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)
def commit_droid_uninstall(prepared: PreparedDroidUninstall) -> None: def commit_droid_uninstall(prepared: PreparedDroidUninstall) -> None:
write_changes( write_changes(prepared.changes, "Uninstall Factory Droid hooks", prepared.preconditions)
prepared.changes,
"Uninstall Factory Droid hooks",
prepared.preconditions,
)
+18 -212
View File
@@ -2,21 +2,13 @@
from __future__ import annotations from __future__ import annotations
import base64
from dataclasses import dataclass from dataclasses import dataclass
import os import os
from typing import Any, Optional from typing import List, Optional, Tuple
import urllib.parse
from ._managed_files import ( from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
MAX_CONFIG_BYTES, from ._runtime import expected_runtime_scripts, runtime_contract_exists
MAX_SECRET_BYTES, from ._strict_json import JsonObject
MAX_SOURCE_BYTES,
physical_directory_exists,
physical_file_exists,
read_physical_file,
)
from ._strict_json import JsonObject, parse_json_object
from .droid_config import ( from .droid_config import (
DroidDocument, DroidDocument,
DroidSources, DroidSources,
@@ -27,18 +19,8 @@ from .droid_config import (
hook_block, hook_block,
parse_document, parse_document,
) )
from .droid_contract import ( from .droid_contract import AGENT_KEY, RuntimeContract
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
RuntimeContract,
elydora_dir,
same_agent_id,
same_path,
)
from .droid_policy import read_droid_policy from .droid_policy import read_droid_policy
from .guard_template import generate_guard_script
from .hook_template import generate_hook_script
@dataclass(frozen=True) @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: def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
try: return expected_runtime_scripts(AGENT_KEY, agent_id)
parsed = urllib.parse.urlsplit(value)
hostname = parsed.hostname
parsed.port def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
except ValueError as error: return any(
raise ValueError( runtime_contract_exists(
f"{label} must be an absolute HTTP or HTTPS URL" contract,
) from error AGENT_KEY,
invalid_character = "\\" in value or any( "Factory Droid",
character.isspace() or ord(character) < 32 for character in value _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: def display_config_path(sources: DroidSources) -> str:
+17 -171
View File
@@ -2,16 +2,23 @@
from __future__ import annotations from __future__ import annotations
import base64
from dataclasses import dataclass from dataclasses import dataclass
import ntpath
import os import os
import re import re
import shlex import shlex
import subprocess # nosec B404 - used only for legacy command rendering import subprocess
import sys import sys
from typing import Optional, Tuple 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) @dataclass(frozen=True)
class GrokRuntimeReference: class GrokRuntimeReference:
@@ -19,154 +26,14 @@ class GrokRuntimeReference:
script_path: str 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: def build_grok_command(script_path: str) -> str:
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path): if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
raise ValueError( raise ValueError(
"Grok hook commands require absolute executable and script paths" "Grok hook commands require absolute executable and script paths"
) )
if os.name == "nt": if os.name == "nt":
return _windows_command(script_path) return encoded_windows_command(script_path)
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}" return posix_source(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)
def _parse_legacy_posix_command(command: str) -> Optional[Tuple[str, str]]: 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] 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( def grok_runtime_reference(
command: str, script_name: str command: str, script_name: str
) -> Optional[GrokRuntimeReference]: ) -> Optional[GrokRuntimeReference]:
parsed = ( parsed = (
_parse_posix_command(command) parse_posix_command(command)
or _parse_windows_command(command) or parse_encoded_windows_command(command)
or _parse_legacy_posix_command(command) or _parse_legacy_posix_command(command)
or _parse_legacy_windows_command(command) or _parse_legacy_windows_command(command)
) )
if parsed is None: if parsed is None:
return None return None
executable, script_path = parsed executable, script_path = parsed
if ( if not os.path.isabs(executable) or not is_python_executable(executable):
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
):
return None return None
agent_directory = os.path.dirname(script_path) reference = managed_script_reference(script_path, script_name)
if not same_grok_path(os.path.dirname(agent_directory), runtime_root()): return None if reference is None else GrokRuntimeReference(*reference)
return None
agent_id = os.path.basename(agent_directory)
if agent_id in {"", ".", ".."}:
return None
return GrokRuntimeReference(agent_id, script_path)
+3 -4
View File
@@ -7,13 +7,12 @@ import json
import os import os
from typing import Any, Dict, List, Optional, Tuple 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 ._strict_json import JsonObject, parse_json_object
from .grok_command import ( from .grok_command import (
GrokRuntimeReference, GrokRuntimeReference,
build_grok_command, build_grok_command,
grok_runtime_reference, grok_runtime_reference,
same_grok_agent_id,
same_grok_path,
) )
@@ -183,7 +182,7 @@ def _remove_from_groups(
for handler in group["hooks"]: for handler in group["hooks"]:
reference = _managed_reference(handler, script_name) reference = _managed_reference(handler, script_name)
remove = reference is not None and ( 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: if not remove:
kept.append(handler) kept.append(handler)
@@ -286,7 +285,7 @@ def grok_runtime_contracts(hooks: GrokHooks) -> List[GrokRuntimeContract]:
failure = failures.get(key, []) failure = failures.get(key, [])
if len(guard) != 1 or len(success) != 1 or len(failure) != 1: if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
continue 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 continue
contracts.append(GrokRuntimeContract( contracts.append(GrokRuntimeContract(
agent_id=guard[0].agent_id, agent_id=guard[0].agent_id,
+25 -138
View File
@@ -2,169 +2,56 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from typing import List
import json
import os
from typing import List, Optional
from elydora._runtime_paths import resolve_agent_directory from ._runtime import (
DEFAULT_BASE_URL,
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES RuntimePaths,
from ._transaction import FileChange, file_change, source_change, write_changes present,
from .base import InstallConfig resolve_runtime_paths,
from .grok_command import runtime_root, same_grok_path runtime_file_changes,
from .grok_contract import ( validate_install_config,
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
GrokDocument,
RenderedGrokDocument,
)
from .grok_io import (
validate_api_origin,
validate_private_key,
validate_runtime_tree, 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 .guard_template import generate_guard_script
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
PRODUCT = "Grok"
@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)
def preflight_grok_installation( def preflight_grok_installation(
config: InstallConfig, document: GrokDocument config: InstallConfig, document: GrokDocument
) -> GrokRuntimePaths: ) -> RuntimePaths:
if not document.config_path: if not document.config_path:
raise ValueError("Grok installation requires a hook configuration path") raise ValueError("Grok installation requires a hook configuration path")
_validate_install_config(config) validate_install_config(config, AGENT_KEY, PRODUCT)
paths = _agent_paths(config) paths = resolve_runtime_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id) validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
return 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 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( def prepare_grok_installation(
config: InstallConfig, config: InstallConfig,
paths: GrokRuntimePaths, paths: RuntimePaths,
rendered: RenderedGrokDocument, rendered: RenderedGrokDocument,
) -> List[FileChange]: ) -> List[FileChange]:
guard_script = generate_guard_script( guard_script = generate_guard_script(AGENT_KEY, paths.agent_id, deny_protocol="grok")
AGENT_KEY, paths.agent_id, deny_protocol="grok"
)
audit_script = generate_hook_script( audit_script = generate_hook_script(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
return _present([ return [
_runtime_change( *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
paths.guard_path, "Elydora guard runtime", guard_script, 0o700 *present([rendered_change(rendered)]),
), ]
_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),
])
def commit_grok_installation(changes: List[FileChange]) -> None: 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]: 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: def commit_grok_uninstall(changes: List[FileChange]) -> None:
+14 -175
View File
@@ -2,24 +2,17 @@
from __future__ import annotations from __future__ import annotations
import base64
import os import os
from typing import Any, List, Optional from typing import List, Optional
import urllib.parse
from ._managed_files import ( from ._managed_files import physical_directory_exists, read_physical_file
MAX_CONFIG_BYTES, from ._runtime import rendered_source_change, runtime_contract_exists
MAX_SECRET_BYTES, from ._transaction import FileChange
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 .grok_contract import ( from .grok_contract import (
AGENT_KEY, AGENT_KEY,
GrokDocument, GrokDocument,
GrokRuntimeContract, GrokRuntimeContract,
RenderedGrokDocument,
create_grok_document, create_grok_document,
parse_grok_document, parse_grok_document,
) )
@@ -41,9 +34,7 @@ def grok_config_path() -> str:
def read_grok_document() -> GrokDocument: def read_grok_document() -> GrokDocument:
config_path = grok_config_path() config_path = grok_config_path()
hooks_directory = os.path.dirname(config_path) hooks_directory = os.path.dirname(config_path)
physical_directory_exists( physical_directory_exists(os.path.dirname(hooks_directory), "Grok home directory")
os.path.dirname(hooks_directory), "Grok home directory"
)
physical_directory_exists(hooks_directory, "Grok hooks directory") physical_directory_exists(hooks_directory, "Grok hooks directory")
snapshot = read_physical_file(config_path, "Grok user hooks") snapshot = read_physical_file(config_path, "Grok user hooks")
if snapshot is None: if snapshot is None:
@@ -51,168 +42,16 @@ def read_grok_document() -> GrokDocument:
return parse_grok_document(config_path, snapshot.contents) return parse_grok_document(config_path, snapshot.contents)
def validate_api_origin(value: str, label: str = "base_url") -> None: def rendered_change(rendered: RenderedGrokDocument) -> Optional[FileChange]:
try: if not rendered.changed:
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:
return None return None
return parse_json_object( return rendered_source_change(
snapshot.contents, f"Elydora runtime config at {file_path}" 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: 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 from __future__ import annotations
import base64
from dataclasses import dataclass from dataclasses import dataclass
import ntpath
import os import os
import re import re
import shlex import shlex
import subprocess # nosec B404 - used only for legacy command rendering import subprocess
import sys import sys
from typing import Optional, Tuple 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) @dataclass(frozen=True)
class KimiRuntimeReference: class KimiRuntimeReference:
@@ -19,154 +26,14 @@ class KimiRuntimeReference:
script_path: str 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: def build_kimi_command(script_path: str) -> str:
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path): if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
raise ValueError( raise ValueError(
"Kimi hook commands require absolute executable and script paths" "Kimi hook commands require absolute executable and script paths"
) )
if os.name == "nt": if os.name == "nt":
return _windows_command(script_path) return encoded_windows_command(script_path)
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}" return posix_source(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)
def _parse_legacy_posix_command(command: str) -> Optional[Tuple[str, str]]: 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] 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( def kimi_runtime_reference(
command: str, script_name: str command: str, script_name: str
) -> Optional[KimiRuntimeReference]: ) -> Optional[KimiRuntimeReference]:
parsed = ( parsed = (
_parse_posix_command(command) parse_posix_command(command)
or _parse_windows_command(command) or parse_encoded_windows_command(command)
or _parse_legacy_posix_command(command) or _parse_legacy_posix_command(command)
or _parse_legacy_windows_command(command) or _parse_legacy_windows_command(command)
) )
if parsed is None: if parsed is None:
return None return None
executable, script_path = parsed executable, script_path = parsed
if ( if not os.path.isabs(executable) or not is_python_executable(executable):
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
):
return None return None
agent_directory = os.path.dirname(script_path) reference = managed_script_reference(script_path, script_name)
if not same_kimi_path(os.path.dirname(agent_directory), runtime_root()): return None if reference is None else KimiRuntimeReference(*reference)
return None
agent_id = os.path.basename(agent_directory)
if agent_id in {"", ".", ".."}:
return None
return KimiRuntimeReference(agent_id, script_path)
+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 import aot, document, dumps, inline_table, parse, table
from tomlkit.items import AoT, Array from tomlkit.items import AoT, Array
from ._runtime import same_agent_id, same_path
from .kimi_command import ( from .kimi_command import (
KimiRuntimeReference, KimiRuntimeReference,
build_kimi_command, build_kimi_command,
kimi_runtime_reference, kimi_runtime_reference,
same_kimi_agent_id,
same_kimi_path,
) )
@@ -216,7 +215,7 @@ def remove_managed_kimi_hooks(
else None else None
) )
remove = reference is not None and ( 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: if not remove:
result.append(hook) result.append(hook)
@@ -318,7 +317,7 @@ def kimi_runtime_contracts(
failure = failures.get(key, []) failure = failures.get(key, [])
if len(guard) != 1 or len(success) != 1 or len(failure) != 1: if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
continue 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 continue
contracts.append( contracts.append(
KimiRuntimeContract( KimiRuntimeContract(
+26 -142
View File
@@ -2,121 +2,39 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import json
import os
from typing import List, Optional from typing import List, Optional
from elydora._runtime_paths import resolve_agent_directory from ._managed_files import MAX_SOURCE_BYTES
from ._runtime import (
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES DEFAULT_BASE_URL,
from ._transaction import FileChange, file_change, source_change, write_changes 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 .base import InstallConfig
from .guard_template import generate_guard_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
from .kimi_command import runtime_root, same_kimi_path from .kimi_contract import AGENT_KEY, KimiDocument, RenderedKimiDocument
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,
)
PRODUCT = "Kimi"
@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)
def preflight_kimi_installation( def preflight_kimi_installation(
config: InstallConfig, documents: List[KimiDocument] config: InstallConfig, documents: List[KimiDocument]
) -> KimiRuntimePaths: ) -> RuntimePaths:
if not documents: if not documents:
raise ValueError("Kimi installation requires at least one hook contract") raise ValueError("Kimi installation requires at least one hook contract")
_validate_install_config(config) validate_install_config(config, AGENT_KEY, PRODUCT)
paths = _agent_paths(config) paths = resolve_runtime_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id) validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
return paths return paths
def _runtime_change( def rendered_change(rendered: RenderedKimiDocument) -> Optional[FileChange]:
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]:
if not rendered.changed: if not rendered.changed:
return None return None
source = rendered.document 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( def prepare_kimi_installation(
config: InstallConfig, config: InstallConfig,
paths: KimiRuntimePaths, paths: RuntimePaths,
rendered: List[RenderedKimiDocument], rendered: List[RenderedKimiDocument],
) -> List[FileChange]: ) -> List[FileChange]:
config_changes = _present([rendered_change(item) for item in rendered])
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id) guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
audit_script = generate_hook_script( audit_script = generate_hook_script(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
runtime_changes = _present( return [
[ *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
_runtime_change( *present([rendered_change(item) for item in rendered]),
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]
def commit_kimi_installation(changes: List[FileChange]) -> None: def commit_kimi_installation(changes: List[FileChange]) -> None:
write_changes(changes, "Install Kimi hooks") write_changes(changes, "Install Kimi hooks")
def prepare_kimi_uninstall( def prepare_kimi_uninstall(rendered: List[RenderedKimiDocument]) -> List[FileChange]:
rendered: List[RenderedKimiDocument], return present([rendered_change(item) for item in rendered])
) -> List[FileChange]:
return _present([rendered_change(item) for item in rendered])
def commit_kimi_uninstall(changes: List[FileChange]) -> None: def commit_kimi_uninstall(changes: List[FileChange]) -> None:
+5 -189
View File
@@ -2,24 +2,11 @@
from __future__ import annotations from __future__ import annotations
import base64
import os import os
from typing import Any, List, Optional from typing import List
import urllib.parse
from ._managed_files import ( from ._managed_files import physical_directory_exists, read_physical_file
MAX_CONFIG_BYTES, from ._runtime import runtime_contract_exists, same_path
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 .kimi_contract import ( from .kimi_contract import (
AGENT_KEY, AGENT_KEY,
LEGACY_EVENTS, LEGACY_EVENTS,
@@ -76,7 +63,7 @@ def resolve_kimi_contracts() -> List[KimiContract]:
legacy_home = os.path.join(home, ".kimi") legacy_home = os.path.join(home, ".kimi")
stable = _stable_contract(os.path.join(stable_home, "config.toml")) stable = _stable_contract(os.path.join(stable_home, "config.toml"))
legacy = _legacy_contract(os.path.join(legacy_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] return [stable]
stable_detected = explicit_home is not None or _entry_exists( 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()] 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: 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 from __future__ import annotations
import json
import os import os
import shlex import shlex
import stat
import subprocess import subprocess
import sys import sys
import tempfile
from typing import Any, Dict, List, Optional, Tuple 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 .base import AgentPlugin, InstallConfig, PluginStatus
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
@@ -42,11 +40,11 @@ class KiroCliPlugin(AgentPlugin):
raise ValueError("agent_id is required") raise ValueError("agent_id is required")
if not guard_path: if not guard_path:
raise ValueError("guard_script_path is required") 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}") raise FileNotFoundError(f"Elydora guard runtime is missing: {guard_path}")
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent 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 {} v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") or {}
v2_hooks = _hooks_object(v2_settings, "Kiro CLI v2 agent config") v2_hooks = _hooks_object(v2_settings, "Kiro CLI v2 agent config")
current_v3_hooks = _v3_hooks(v3_settings) current_v3_hooks = _v3_hooks(v3_settings)
@@ -108,22 +106,28 @@ class KiroCliPlugin(AgentPlugin):
kid=config.get("kid", ""), kid=config.get("kid", ""),
base_url=config.get("base_url", "https://api.elydora.com"), base_url=config.get("base_url", "https://api.elydora.com"),
) )
_write_json_atomic(os.path.join(agent_dir, "config.json"), runtime_config, 0o600) write_json_atomic(
_write_text_atomic( os.path.join(agent_dir, "config.json"),
runtime_config,
0o600,
"Elydora runtime config",
)
write_text_atomic(
os.path.join(agent_dir, "private.key"), os.path.join(agent_dir, "private.key"),
config.get("private_key", ""), config.get("private_key", ""),
0o600, 0o600,
"Elydora private key",
) )
_write_text_atomic(hook_path, hook_script, 0o700) write_text_atomic(hook_path, hook_script, 0o700, "Elydora audit runtime")
_write_json_atomic(V2_AGENT_PATH, next_v2_settings, 0o600) 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) 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 v2: start with "kiro-cli --agent elydora-audit".')
print('Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.') print('Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.')
def uninstall(self, agent_id: str = "") -> None: def uninstall(self, agent_id: str = "") -> None:
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent 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") v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
next_v2: Optional[Tuple[JsonObject, bool]] = None next_v2: Optional[Tuple[JsonObject, bool]] = None
if v2_settings is not None: if v2_settings is not None:
@@ -153,22 +157,22 @@ class KiroCliPlugin(AgentPlugin):
if next_v2 is not None: if next_v2 is not None:
settings, remove = next_v2 settings, remove = next_v2
if remove: if remove:
_remove_file(V2_AGENT_PATH, "Kiro CLI v2 agent config") remove_file(V2_AGENT_PATH, "Kiro CLI v2 agent config")
else: 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: if next_v3 is not None:
settings, remove = next_v3 settings, remove = next_v3
if remove: if remove:
_remove_file(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") remove_file(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
else: 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.") print("Elydora hooks uninstalled from Kiro CLI.")
def status(self) -> PluginStatus: def status(self) -> PluginStatus:
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent 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") v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
contracts: List[HookContract] = [] contracts: List[HookContract] = []
if v2_settings is not None: if v2_settings is not None:
@@ -204,63 +208,6 @@ class KiroCliPlugin(AgentPlugin):
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details) 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: def _build_command(script_path: str) -> str:
arguments = [sys.executable, script_path] arguments = [sys.executable, script_path]
return subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments) return subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments)
@@ -385,16 +332,6 @@ def _find_v3_command(
return None 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: def _runtime_scripts_exist(contracts: List[HookContract]) -> bool:
try: try:
with os.scandir(ELYDORA_DIR) as iterator: with os.scandir(ELYDORA_DIR) as iterator:
@@ -421,10 +358,10 @@ def _runtime_scripts_exist(contracts: List[HookContract]) -> bool:
continue continue
config_path = os.path.join(entry.path, "config.json") 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: if config is None or config.get("agent_name") != AGENT_KEY:
continue continue
return _regular_file_exists( return regular_file_exists(
guard_path, "Elydora guard runtime" 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 return False
+3 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from ._runtime import same_agent_id
from .base import AgentPlugin, InstallConfig, PluginStatus from .base import AgentPlugin, InstallConfig, PluginStatus
from .kiroide_contract import ( from .kiroide_contract import (
AGENT_KEY, AGENT_KEY,
@@ -21,7 +22,6 @@ from .kiroide_installation import (
prepare_kiroide_installation, prepare_kiroide_installation,
prepare_kiroide_uninstall, prepare_kiroide_uninstall,
) )
from .kiroide_command import same_kiroide_agent_id
from .kiroide_io import ( from .kiroide_io import (
kiroide_runtime_files_exist, kiroide_runtime_files_exist,
legacy_kiroide_contract_matches_agent, legacy_kiroide_contract_matches_agent,
@@ -78,13 +78,13 @@ class KiroIdePlugin(AgentPlugin):
agent_ids: list[str] = [] agent_ids: list[str] = []
for discovered_agent_id in discovered_agent_ids: for discovered_agent_id in discovered_agent_ids:
if not any( 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 for existing_agent_id in agent_ids
): ):
agent_ids.append(discovered_agent_id) agent_ids.append(discovered_agent_id)
for managed_agent_id in agent_ids: for managed_agent_id in agent_ids:
owns_workspace_runtime = any( 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 for contract in contracts
) )
require_kiroide_workspace_owner( require_kiroide_workspace_owner(
+23 -189
View File
@@ -2,15 +2,23 @@
from __future__ import annotations from __future__ import annotations
import base64
from dataclasses import dataclass from dataclasses import dataclass
import ntpath
import os import os
import re import re
import sys import sys
from typing import Optional, Tuple 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) @dataclass(frozen=True)
@@ -19,71 +27,10 @@ class KiroIdeRuntimeReference:
script_path: str 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: def build_kiroide_command(script_path: str) -> str:
if ( if (
not os.path.isabs(sys.executable) 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) or not os.path.isabs(script_path)
): ):
raise ValueError( raise ValueError(
@@ -91,136 +38,25 @@ def build_kiroide_command(script_path: str) -> str:
"and script path" "and script path"
) )
if os.name == "nt": if os.name == "nt":
return _windows_command(script_path) return encoded_windows_command(script_path, _PREAMBLE)
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}" return posix_source(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
)
def _runtime_reference( def _runtime_reference(
executable: str, script_path: str, script_name: str executable: str, script_path: str, script_name: str
) -> Optional[KiroIdeRuntimeReference]: ) -> Optional[KiroIdeRuntimeReference]:
if ( if not os.path.isabs(executable) or not is_python_executable(executable):
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
):
return None return None
agent_directory = os.path.dirname(script_path) reference = managed_script_reference(script_path, script_name)
if not same_kiroide_path(os.path.dirname(agent_directory), runtime_root()): return None if reference is None else KiroIdeRuntimeReference(*reference)
return None
agent_id = os.path.basename(agent_directory)
if agent_id in {"", ".", ".."}:
return None
return KiroIdeRuntimeReference(agent_id, script_path)
def _parse_kiroide_command(command: str) -> Optional[Tuple[str, str]]: def _parse_kiroide_command(command: str) -> Optional[Tuple[str, str]]:
return ( if os.name == "nt":
_parse_windows_command(command) return parse_encoded_windows_command(
if os.name == "nt" command, _PREAMBLE + "& ", windows_powershell_path()
else _parse_posix_command(command) )
) return parse_posix_command(command)
def owned_kiroide_runtime_reference( def owned_kiroide_runtime_reference(
@@ -236,9 +72,7 @@ def kiroide_runtime_reference(
command: str, script_name: str command: str, script_name: str
) -> Optional[KiroIdeRuntimeReference]: ) -> Optional[KiroIdeRuntimeReference]:
parsed = _parse_kiroide_command(command) parsed = _parse_kiroide_command(command)
if parsed is None: if parsed is None or not same_path(parsed[0], sys.executable):
return None
if not same_kiroide_path(parsed[0], sys.executable):
return None return None
return _runtime_reference(parsed[0], parsed[1], script_name) 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 typing import Any, Callable, Dict, List, Optional, Tuple
from ._managed_files import FileSnapshot from ._managed_files import FileSnapshot
from ._runtime import same_agent_id, same_path
from ._strict_json import JsonObject, parse_json_object from ._strict_json import JsonObject, parse_json_object
from .kiroide_command import ( from .kiroide_command import (
KiroIdeRuntimeReference, KiroIdeRuntimeReference,
@@ -16,8 +17,6 @@ from .kiroide_command import (
legacy_audit_reference, legacy_audit_reference,
legacy_guard_reference, legacy_guard_reference,
owned_kiroide_runtime_reference, owned_kiroide_runtime_reference,
same_kiroide_agent_id,
same_kiroide_path,
) )
@@ -240,7 +239,7 @@ def _managed_pair(
if ( if (
guard is None guard is None
or audit 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 None
return (guards[0][0], guard), (audits[0][0], audit) return (guards[0][0], guard), (audits[0][0], audit)
@@ -289,7 +288,7 @@ def without_managed_kiroide_hooks(
for index, hook in enumerate(hooks): for index, hook in enumerate(hooks):
reference = owned.get(index) reference = owned.get(index)
remove = reference is not None and ( 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: if not remove:
result.append(hook) result.append(hook)
@@ -382,8 +381,8 @@ def legacy_kiroide_runtime_contract(
if ( if (
guard is None guard is None
or audit 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)
or not same_kiroide_path( or not same_path(
os.path.dirname(guard.script_path), os.path.dirname(audit.script_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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json
import os import os
from typing import List, Optional, Sequence, Tuple from typing import List, Optional, Sequence, Tuple
from elydora._runtime_paths import resolve_agent_directory, runtime_root
from ._managed_files import ( from ._managed_files import (
MAX_CONFIG_BYTES, MAX_CONFIG_BYTES,
MAX_SECRET_BYTES, MAX_SECRET_BYTES,
@@ -16,6 +13,14 @@ from ._managed_files import (
read_physical_file, read_physical_file,
) )
from ._opaque_removal import OpaqueRemovalChange 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 ( from ._transaction import (
DirectoryPrecondition, DirectoryPrecondition,
FileChange, FileChange,
@@ -34,11 +39,8 @@ from ._runtime_removal import (
from .base import InstallConfig from .base import InstallConfig
from .guard_template import generate_guard_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_script from .hook_template import generate_hook_script
from .kiroide_command import same_kiroide_agent_id, same_kiroide_path
from .kiroide_contract import ( from .kiroide_contract import (
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
RenderedKiroIdeDocument, RenderedKiroIdeDocument,
kiroide_runtime_contracts, kiroide_runtime_contracts,
) )
@@ -54,11 +56,13 @@ from .kiroide_io import (
LegacyKiroIdeDocument, LegacyKiroIdeDocument,
legacy_kiroide_contract_matches_agent, legacy_kiroide_contract_matches_agent,
require_physical_legacy_directory, require_physical_legacy_directory,
validate_api_origin,
validate_kiroide_runtime_config, validate_kiroide_runtime_config,
validate_private_key,
validate_runtime_tree, validate_runtime_tree,
) )
PRODUCT = "Kiro IDE"
@dataclass(frozen=True) @dataclass(frozen=True)
class KiroIdeRuntimePaths: class KiroIdeRuntimePaths:
agent_id: str agent_id: str
@@ -74,6 +78,8 @@ class KiroIdeRuntimePaths:
class PreparedKiroIdeInstallation: class PreparedKiroIdeInstallation:
changes: List[FileChange] changes: List[FileChange]
runtime_preconditions: List[FilePrecondition] runtime_preconditions: List[FilePrecondition]
@dataclass(frozen=True) @dataclass(frozen=True)
class PreparedKiroIdeUninstall: class PreparedKiroIdeUninstall:
changes: List[FileChange] 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: def _agent_paths(config: InstallConfig) -> KiroIdeRuntimePaths:
agent_id = config.get("agent_id", "") paths = resolve_runtime_paths(config)
if not agent_id: root = os.path.dirname(paths.agent_directory)
raise ValueError("agent_id is required")
root = runtime_root()
runtime_parent = os.path.dirname(root) 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( return KiroIdeRuntimePaths(
agent_id, paths.agent_id,
runtime_parent, runtime_parent,
root, root,
agent_directory, paths.agent_directory,
guard_path, paths.guard_path,
audit_path, paths.audit_path,
directories, 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( def preflight_kiroide_installation(
config: InstallConfig, sources: KiroIdeSources config: InstallConfig, sources: KiroIdeSources
) -> KiroIdeRuntimePaths: ) -> KiroIdeRuntimePaths:
if not sources.document.file_path: if not sources.document.file_path:
raise ValueError("Kiro IDE installation requires a workspace hook 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( 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) for contract in kiroide_runtime_contracts(sources.document.hooks)
) )
owns_global_legacy_runtime = legacy_kiroide_contract_matches_agent( owns_global_legacy_runtime = legacy_kiroide_contract_matches_agent(
@@ -229,7 +186,7 @@ def _removes_legacy(
) -> bool: ) -> bool:
return legacy.contract is not None and ( return legacy.contract is not None and (
not agent_id 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( def prepare_kiroide_installation(
config: InstallConfig, config: InstallConfig,
paths: KiroIdeRuntimePaths, paths: KiroIdeRuntimePaths,
@@ -269,7 +222,7 @@ def prepare_kiroide_installation(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
@@ -280,12 +233,11 @@ def prepare_kiroide_installation(
_runtime_change( _runtime_change(
os.path.join(paths.agent_directory, "config.json"), os.path.join(paths.agent_directory, "config.json"),
"Elydora runtime config", "Elydora runtime config",
_json_source( runtime_config_source(
_runtime_config( config,
config, paths.agent_id,
paths.agent_id, AGENT_KEY,
sources.paths.workspace_root, workspace_root=sources.paths.workspace_root,
)
), ),
0o600, 0o600,
MAX_CONFIG_BYTES, MAX_CONFIG_BYTES,
@@ -303,7 +255,7 @@ def prepare_kiroide_installation(
paths.audit_path, "Elydora audit runtime", audit_script, 0o700 paths.audit_path, "Elydora audit runtime", audit_script, 0o700
), ),
] ]
changes = _present( changes = present(
[ [
*(change for change, _condition in runtime), *(change for change, _condition in runtime),
_rendered_change(rendered), _rendered_change(rendered),
@@ -420,7 +372,7 @@ def prepare_kiroide_uninstall(
candidates = runtime_agent_ids or ((agent_id,) if agent_id else ()) candidates = runtime_agent_ids or ((agent_id,) if agent_id else ())
for runtime_agent_id in candidates: for runtime_agent_id in candidates:
if any( 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 for selected_agent_id in selected_agent_ids
): ):
continue continue
@@ -436,7 +388,7 @@ def prepare_kiroide_uninstall(
config_path, config_path,
sources.paths.workspace_root, sources.paths.workspace_root,
allow_missing_workspace_root=any( allow_missing_workspace_root=any(
same_kiroide_agent_id( same_agent_id(
contract.agent_id, runtime_agent_id contract.agent_id, runtime_agent_id
) )
for contract in kiroide_runtime_contracts( for contract in kiroide_runtime_contracts(
@@ -454,7 +406,7 @@ def prepare_kiroide_uninstall(
) )
if runtime_removal is not None: if runtime_removal is not None:
runtime_removals.append(runtime_removal) runtime_removals.append(runtime_removal)
provider_changes = _present( provider_changes = present(
[_rendered_change(rendered), _legacy_change(sources.legacy, agent_id)] [_rendered_change(rendered), _legacy_change(sources.legacy, agent_id)]
) )
return PreparedKiroIdeUninstall( return PreparedKiroIdeUninstall(
+36 -132
View File
@@ -2,11 +2,9 @@
from __future__ import annotations from __future__ import annotations
import base64
from dataclasses import dataclass from dataclasses import dataclass
import os import os
from typing import Any, List, Optional from typing import List, Optional
import urllib.parse
from elydora._runtime_paths import resolve_agent_directory, runtime_root from elydora._runtime_paths import resolve_agent_directory, runtime_root
@@ -20,10 +18,17 @@ from ._managed_files import (
read_physical_directory, read_physical_directory,
read_physical_file, 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 ._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 ( from .kiroide_contract import (
AGENT_KEY, AGENT_KEY,
CONFIG_FILE, 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: def validate_kiroide_runtime_config(
try:
parsed = urllib.parse.urlsplit(value)
hostname = parsed.hostname
parsed.port
except ValueError as error:
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
invalid_character = "\\" in value or any(
character.isspace() or ord(character) < 32 for character in value
)
if (
parsed.scheme not in ("http", "https")
or not parsed.netloc
or hostname is None
or invalid_character
):
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
if (
parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError(
f"{label} must exclude credentials, query parameters, and fragments"
)
def validate_private_key(value: str, label: str = "private_key") -> None:
try:
padded = value + "=" * ((4 - len(value) % 4) % 4)
seed = base64.b64decode(
padded.replace("-", "+").replace("_", "/"), validate=True
)
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
except (ValueError, UnicodeEncodeError) as error:
raise ValueError(
f"{label} must be a canonical 32-byte base64url value"
) from error
if len(seed) != 32 or canonical != value:
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
return value
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
snapshot = read_physical_file(
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
)
if snapshot is None:
return None
return parse_json_object(
snapshot.contents, f"Elydora runtime config at {file_path}"
)
def _validate_runtime_config(
config: JsonObject, config: JsonObject,
expected_agent_id: str, expected_agent_id: str,
config_path: str, config_path: str,
@@ -255,27 +200,19 @@ def _validate_runtime_config(
allow_missing_workspace_root: bool = False, allow_missing_workspace_root: bool = False,
allow_legacy_ownerless_config: bool = False, allow_legacy_ownerless_config: bool = False,
) -> None: ) -> None:
supported = { supported = RUNTIME_CONFIG_FIELDS | {"workspace_root"}
"org_id",
"agent_id",
"kid",
"base_url",
"token",
"agent_name",
"workspace_root",
}
extra = next((key for key in config if key not in supported), None) extra = next((key for key in config if key not in supported), None)
if extra is not None: if extra is not None:
raise ValueError( raise ValueError(
f'Elydora runtime config has unsupported field "{extra}": {config_path}' 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("org_id"), "org_id", config_path)
_require_non_empty_string(config.get("kid"), "kid", config_path) require_non_empty_string(config.get("kid"), "kid", config_path)
agent_id = _require_non_empty_string( agent_id = require_non_empty_string(
config.get("agent_id"), "agent_id", config_path config.get("agent_id"), "agent_id", config_path
) )
if ( 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 or config.get("agent_name") != AGENT_KEY
): ):
raise ValueError( raise ValueError(
@@ -289,8 +226,8 @@ def _validate_runtime_config(
if "token" in config and not ( if "token" in config and not (
legacy_ownerless_config and config.get("token") == "" legacy_ownerless_config and config.get("token") == ""
): ):
_require_non_empty_string(config.get("token"), "token", config_path) require_non_empty_string(config.get("token"), "token", config_path)
base_url = _require_non_empty_string( base_url = require_non_empty_string(
config.get("base_url"), "base_url", config_path config.get("base_url"), "base_url", config_path
) )
validate_api_origin(base_url, "Elydora runtime config base_url") 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 allow_missing_workspace_root or legacy_ownerless_config
): ):
return return
workspace_root = _require_non_empty_string( workspace_root = require_non_empty_string(
configured_workspace, "workspace_root", config_path configured_workspace, "workspace_root", config_path
) )
if not os.path.isabs(workspace_root): if not os.path.isabs(workspace_root):
raise ValueError( raise ValueError(
f"Elydora runtime config workspace_root must be absolute: {config_path}" 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( raise ValueError(
"Elydora Kiro IDE agent is bound to another workspace: " "Elydora Kiro IDE agent is bound to another workspace: "
f"{workspace_root}" 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( def validate_runtime_tree(
agent_directory: str, agent_directory: str,
agent_id: str, agent_id: str,
@@ -346,27 +264,21 @@ def validate_runtime_tree(
): ):
return return
config_path = os.path.join(agent_directory, "config.json") config_path = os.path.join(agent_directory, "config.json")
config = _read_runtime_config(config_path) config_snapshot = read_physical_file(config_path, "Elydora runtime config", MAX_CONFIG_BYTES)
artifact_exists = any( artifact_states = [
physical_file_exists(os.path.join(agent_directory, name), label) physical_file_exists(os.path.join(agent_directory, name), label)
for name, label in ( for name, label in RUNTIME_ARTIFACTS
("private.key", "Elydora private key"), ]
("guard.py", "Elydora guard runtime"), artifact_exists = any(artifact_states)
("hook.py", "Elydora audit runtime"), if config_snapshot is None:
("chain-state.json", "Elydora chain state"),
("status-cache.json", "Elydora status cache"),
("error.log", "Elydora error log"),
)
)
if config is None:
if artifact_exists: if artifact_exists:
raise ValueError( raise ValueError(
"Elydora runtime identity cannot be verified without config.json: " "Elydora runtime identity cannot be verified without config.json: "
f"{agent_directory}" f"{agent_directory}"
) )
return return
_validate_runtime_config( validate_kiroide_runtime_config(
config, parse_json_object(config_snapshot.contents, f"Elydora runtime config at {config_path}"),
agent_id, agent_id,
config_path, config_path,
workspace_root, workspace_root,
@@ -381,8 +293,8 @@ def _runtime_contract_exists(
root = runtime_root() root = runtime_root()
agent_directory = os.path.dirname(contract.guard_path) agent_directory = os.path.dirname(contract.guard_path)
if ( if (
not same_kiroide_path(os.path.dirname(agent_directory), root) not same_path(os.path.dirname(agent_directory), root)
or not same_kiroide_path( or not same_path(
contract.audit_path, os.path.join(agent_directory, "hook.py") 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") 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: if config is None or key is None or guard is None or audit is None:
return False return False
_validate_runtime_config( validate_kiroide_runtime_config(
config, config,
contract.agent_id, contract.agent_id,
config_path, config_path,
@@ -436,15 +348,7 @@ def _runtime_contract_exists(
f"its owner: {config_path}" f"its owner: {config_path}"
) )
validate_private_key(key.contents, "Elydora private key") validate_private_key(key.contents, "Elydora private key")
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id) expected_guard, expected_audit = expected_runtime_scripts(AGENT_KEY, contract.agent_id, config)
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 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 legacy: LegacyKiroIdeDocument, agent_id: str
) -> bool: ) -> bool:
contract = legacy.contract 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 contract.agent_id, agent_id
): ):
return False return False
agent_directory = resolve_agent_directory(runtime_root(), agent_id) agent_directory = resolve_agent_directory(runtime_root(), agent_id)
return same_kiroide_path( return same_path(
contract.guard_path, contract.guard_path,
os.path.join(agent_directory, "guard.py"), os.path.join(agent_directory, "guard.py"),
) and same_kiroide_path( ) and same_path(
contract.audit_path, contract.audit_path,
os.path.join(agent_directory, "hook.py"), os.path.join(agent_directory, "hook.py"),
) )
+2 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Dict from typing import Dict
from ._runtime import RuntimePaths
from .base import AgentPlugin, InstallConfig, PluginStatus from .base import AgentPlugin, InstallConfig, PluginStatus
from .letta_config import render_letta_document from .letta_config import render_letta_document
from .letta_contract import ( from .letta_contract import (
@@ -13,7 +14,6 @@ from .letta_contract import (
letta_runtime_contracts, letta_runtime_contracts,
) )
from .letta_installation import ( from .letta_installation import (
LettaRuntimePaths,
commit_letta_installation, commit_letta_installation,
commit_letta_uninstall, commit_letta_uninstall,
preflight_letta_installation, preflight_letta_installation,
@@ -24,7 +24,7 @@ from .letta_io import letta_runtime_files_exist
from .letta_sources import read_letta_sources from .letta_sources import read_letta_sources
def _installed_groups(paths: LettaRuntimePaths) -> Dict[str, JsonObject]: def _installed_groups(paths: RuntimePaths) -> Dict[str, JsonObject]:
return { return {
"PreToolUse": build_letta_group(paths.guard_path), "PreToolUse": build_letta_group(paths.guard_path),
"PostToolUse": build_letta_group(paths.audit_path), "PostToolUse": build_letta_group(paths.audit_path),
+17 -115
View File
@@ -4,11 +4,17 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import os import os
import re
import sys 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) @dataclass(frozen=True)
@@ -18,103 +24,12 @@ class LettaRuntimeReference:
executable_path: Optional[str] = None 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: def build_letta_command(script_path: str) -> str:
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path): if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
raise ValueError( raise ValueError(
"Letta Code hook commands require absolute executable and script paths" "Letta Code hook commands require absolute executable and script paths"
) )
invocation = ( return powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
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))
def _runtime_reference( def _runtime_reference(
@@ -122,30 +37,21 @@ def _runtime_reference(
script_name: str, script_name: str,
executable_path: Optional[str] = None, executable_path: Optional[str] = None,
) -> Optional[LettaRuntimeReference]: ) -> 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 return None
agent_directory = os.path.dirname(script_path) return LettaRuntimeReference(reference[0], reference[1], executable_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)
def letta_runtime_reference( def letta_runtime_reference(
command: str, script_name: str command: str, script_name: str
) -> Optional[LettaRuntimeReference]: ) -> Optional[LettaRuntimeReference]:
parsed = ( parsed = (
_parse_powershell_command(command) parse_powershell_source(command)
if os.name == "nt" if os.name == "nt"
else _parse_posix_command(command) else parse_posix_command(command)
) )
if ( if parsed is None or not os.path.isabs(parsed[0]) or not is_python_executable(parsed[0]):
parsed is None
or not os.path.isabs(parsed[0])
or not _is_python_executable(parsed[0])
):
return None return None
return _runtime_reference(parsed[1], script_name, parsed[0]) return _runtime_reference(parsed[1], script_name, parsed[0])
@@ -160,11 +66,7 @@ def letta_legacy_guard_reference(
return None return None
executable = command[1:closing] executable = command[1:closing]
script_path = command[closing + 2 :] script_path = command[closing + 2 :]
if ( if not os.path.isabs(executable) or not is_python_executable(executable) or not script_path:
not os.path.isabs(executable)
or not _is_python_executable(executable)
or not script_path
):
return None return None
return _runtime_reference(script_path, script_name, executable) return _runtime_reference(script_path, script_name, executable)
+4 -11
View File
@@ -8,16 +8,13 @@ import os
import sys import sys
from typing import Any, Dict, List, Optional, Tuple 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 ( from .letta_command import (
LettaRuntimeReference, LettaRuntimeReference,
build_letta_command, build_letta_command,
letta_legacy_audit_reference, letta_legacy_audit_reference,
letta_legacy_guard_reference, letta_legacy_guard_reference,
letta_runtime_reference, letta_runtime_reference,
same_letta_agent_id,
same_letta_path,
) )
@@ -63,10 +60,6 @@ class ManagedRemoval:
remove_group: bool remove_group: bool
def elydora_dir() -> str:
return runtime_root()
def _validate_timeout(value: JsonObject, label: str) -> None: def _validate_timeout(value: JsonObject, label: str) -> None:
if "timeout" not in value: if "timeout" not in value:
return return
@@ -222,7 +215,7 @@ def managed_letta_removals(
is not None is not None
and ( and (
agent_id is None 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: if indexes:
@@ -247,7 +240,7 @@ def _references_for_event(
if ( if (
reference is None reference is None
or reference.executable_path 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 continue
key = os.path.normcase(reference.agent_id) key = os.path.normcase(reference.agent_id)
@@ -267,7 +260,7 @@ def letta_runtime_contracts(hooks: LettaHooks) -> List[LettaRuntimeContract]:
failure = failures.get(key, []) failure = failures.get(key, [])
if len(guard) != 1 or len(post) != 1 or len(failure) != 1: if len(guard) != 1 or len(post) != 1 or len(failure) != 1:
continue 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 continue
contracts.append(LettaRuntimeContract( contracts.append(LettaRuntimeContract(
guard[0].agent_id, guard[0].agent_id,
+30 -132
View File
@@ -2,113 +2,41 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from typing import List, Optional
import json
import os
from typing import List, Optional, Sequence
from elydora._runtime_paths import resolve_agent_directory from ._managed_files import MAX_SOURCE_BYTES
from ._runtime import (
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES DEFAULT_BASE_URL,
from ._transaction import ( RuntimePaths,
FileChange, present,
FilePrecondition, resolve_runtime_paths,
file_change, runtime_file_changes,
source_change, same_path,
write_changes, validate_install_config,
validate_runtime_tree,
) )
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
from .base import InstallConfig from .base import InstallConfig
from .guard_template import generate_guard_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_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_config import RenderedLettaDocument, letta_document_label
from .letta_contract import AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT, elydora_dir from .letta_contract import AGENT_KEY
from .letta_io import validate_api_origin, validate_private_key, validate_runtime_tree
from .letta_sources import LettaSources, require_letta_hooks_enabled from .letta_sources import LettaSources, require_letta_hooks_enabled
PRODUCT = "Letta Code"
@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)
def preflight_letta_installation( def preflight_letta_installation(
config: InstallConfig, sources: LettaSources config: InstallConfig, sources: LettaSources
) -> LettaRuntimePaths: ) -> RuntimePaths:
require_letta_hooks_enabled(sources) require_letta_hooks_enabled(sources)
_validate_install_config(config) validate_install_config(config, AGENT_KEY, PRODUCT)
paths = _agent_paths(config) paths = resolve_runtime_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id) validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
return paths return paths
def _runtime_change( def _rendered_change(rendered: RenderedLettaDocument) -> Optional[FileChange]:
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]:
if not rendered.changed: if not rendered.changed:
return None return None
return source_change( 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( def prepare_letta_installation(
config: InstallConfig, config: InstallConfig,
paths: LettaRuntimePaths, paths: RuntimePaths,
rendered: RenderedLettaDocument, rendered: RenderedLettaDocument,
) -> List[FileChange]: ) -> List[FileChange]:
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id) guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
@@ -136,50 +60,28 @@ def prepare_letta_installation(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, fail_closed=True,
native_payload=True, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
return _present([ return [
_runtime_change( *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
paths.guard_path, "Elydora guard runtime", guard_script, 0o700 *present([_rendered_change(rendered)]),
), ]
_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),
])
def _read_only_preconditions( def _read_only_preconditions(
sources: LettaSources, changed_path: Optional[str] sources: LettaSources, changed_path: str
) -> List[FilePrecondition]: ) -> List[FilePrecondition]:
return [ return [
condition condition
for condition in sources.preconditions for condition in sources.preconditions
if changed_path is None if not same_path(condition.file_path, changed_path)
or not same_letta_path(condition.file_path, changed_path)
] ]
def commit_letta_installation( def commit_letta_installation(changes: List[FileChange], sources: LettaSources) -> None:
changes: List[FileChange], sources: LettaSources
) -> None:
write_changes( write_changes(
changes, changes,
"Install Letta Code hooks", "Install Letta Code hooks",
@@ -187,15 +89,11 @@ def commit_letta_installation(
) )
def prepare_letta_uninstall( def prepare_letta_uninstall(rendered: RenderedLettaDocument) -> Optional[FileChange]:
rendered: RenderedLettaDocument,
) -> Optional[FileChange]:
return _rendered_change(rendered) return _rendered_change(rendered)
def commit_letta_uninstall( def commit_letta_uninstall(change: Optional[FileChange], sources: LettaSources) -> None:
change: Optional[FileChange], sources: LettaSources
) -> None:
if change is None: if change is None:
return return
write_changes( write_changes(
+12 -193
View File
@@ -2,200 +2,19 @@
from __future__ import annotations from __future__ import annotations
import base64 from typing import List, Tuple
import os
from typing import Any, List, Optional
import urllib.parse
from ._managed_files import ( from ._runtime import expected_runtime_scripts, runtime_contract_exists
MAX_CONFIG_BYTES, from ._strict_json import JsonObject
MAX_SECRET_BYTES, from .letta_contract import AGENT_KEY, LettaRuntimeContract
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
def validate_api_origin(value: str, label: str = "base_url") -> None: def _expected_scripts(agent_id: str, config: JsonObject) -> Tuple[str, str]:
try: return expected_runtime_scripts(AGENT_KEY, agent_id, config, fail_closed=True)
parsed = urllib.parse.urlsplit(value)
hostname = parsed.hostname
parsed.port def letta_runtime_files_exist(contracts: List[LettaRuntimeContract]) -> bool:
except ValueError as error: return any(
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error runtime_contract_exists(contract, AGENT_KEY, "Letta Code", _expected_scripts)
invalid_character = "\\" in value or any( for contract in contracts
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_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 from dataclasses import dataclass
import os import os
from typing import Dict, Optional, Tuple from typing import Optional, Tuple
from ._managed_files import ( from ._managed_files import (
MAX_SOURCE_BYTES, MAX_SOURCE_BYTES,
physical_directory_exists, physical_directory_exists,
read_physical_file, read_physical_file,
) )
from ._runtime import same_path, unique_preconditions
from ._transaction import FilePrecondition from ._transaction import FilePrecondition
from .letta_command import same_letta_path
from .letta_config import ( from .letta_config import (
LettaDocument, LettaDocument,
create_letta_document, 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( def _effective_disable(
global_settings: LettaDocument, global_settings: LettaDocument,
project: LettaDocument, project: LettaDocument,
@@ -111,7 +102,7 @@ def read_letta_sources() -> LettaSources:
physical_directory_exists( physical_directory_exists(
global_directory, "Letta Code global configuration directory" 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( physical_directory_exists(
project_directory, "Letta Code project configuration directory" project_directory, "Letta Code project configuration directory"
) )
@@ -141,7 +132,7 @@ def read_letta_sources() -> LettaSources:
_effective_disable( _effective_disable(
global_settings, project, project_local, project_active 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 typing import Dict
from ._runtime import RuntimePaths
from .base import AgentPlugin, InstallConfig, PluginStatus from .base import AgentPlugin, InstallConfig, PluginStatus
from .qwen_config import render_qwen_document from .qwen_config import render_qwen_document
from .qwen_contract import ( from .qwen_contract import (
@@ -15,7 +16,6 @@ from .qwen_contract import (
qwen_runtime_contracts, qwen_runtime_contracts,
) )
from .qwen_installation import ( from .qwen_installation import (
QwenRuntimePaths,
commit_qwen_installation, commit_qwen_installation,
commit_qwen_uninstall, commit_qwen_uninstall,
preflight_qwen_installation, preflight_qwen_installation,
@@ -26,7 +26,7 @@ from .qwen_io import qwen_runtime_files_exist
from .qwen_sources import read_qwen_sources from .qwen_sources import read_qwen_sources
def _installed_groups(paths: QwenRuntimePaths) -> Dict[str, JsonObject]: def _installed_groups(paths: RuntimePaths) -> Dict[str, JsonObject]:
return { return {
"PreToolUse": build_qwen_group(paths.guard_path, GUARD_HOOK_NAME), "PreToolUse": build_qwen_group(paths.guard_path, GUARD_HOOK_NAME),
"PostToolUse": build_qwen_group(paths.audit_path, AUDIT_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 from dataclasses import dataclass
import os import os
import sys 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) @dataclass(frozen=True)
@@ -14,121 +22,23 @@ class QwenRuntimeReference:
script_path: str 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: def build_qwen_command(script_path: str) -> str:
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path): if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
raise ValueError( raise ValueError(
"Qwen Code hook commands require absolute executable and script paths" "Qwen Code hook commands require absolute executable and script paths"
) )
invocation = ( return powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
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 qwen_runtime_reference( def qwen_runtime_reference(
command: str, script_name: str command: str, script_name: str
) -> Optional[QwenRuntimeReference]: ) -> Optional[QwenRuntimeReference]:
parsed = ( parsed = (
_parse_powershell_command(command) parse_powershell_source(command)
if os.name == "nt" if os.name == "nt"
else _parse_posix_command(command) else parse_posix_command(command)
) )
if ( if parsed is None or not same_path(parsed[0], sys.executable):
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
):
return None return None
agent_directory = os.path.dirname(parsed[1]) reference = managed_script_reference(parsed[1], script_name)
runtime_root = os.path.join(os.path.expanduser("~"), ".elydora") return None if reference is None else QwenRuntimeReference(*reference)
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])
+3 -8
View File
@@ -10,12 +10,11 @@ import shutil
import subprocess # nosec B404 import subprocess # nosec B404
from typing import Any, Dict, List, Optional, Sequence, Tuple from typing import Any, Dict, List, Optional, Sequence, Tuple
from ._runtime import same_agent_id, same_path
from .qwen_command import ( from .qwen_command import (
QwenRuntimeReference, QwenRuntimeReference,
build_qwen_command, build_qwen_command,
qwen_runtime_reference, qwen_runtime_reference,
same_qwen_agent_id,
same_qwen_path,
) )
@@ -102,10 +101,6 @@ class ManagedRemoval:
remove_group: bool 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: def _optional_string(value: JsonObject, field: str, label: str) -> None:
if field in value and not isinstance(value[field], str): if field in value and not isinstance(value[field], str):
raise ValueError(f'{label} field "{field}" must be a string') raise ValueError(f'{label} field "{field}" must be a string')
@@ -352,7 +347,7 @@ def managed_qwen_removals(
is not None is not None
and ( and (
agent_id is None 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: if indexes:
@@ -398,7 +393,7 @@ def qwen_runtime_contracts(hooks: QwenHooks) -> List[QwenRuntimeContract]:
failure = failures.get(key, []) failure = failures.get(key, [])
if len(guard) != 1 or len(post) != 1 or len(failure) != 1: if len(guard) != 1 or len(post) != 1 or len(failure) != 1:
continue 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 continue
contracts.append(QwenRuntimeContract( contracts.append(QwenRuntimeContract(
guard[0].agent_id, guard[0].agent_id,
+31 -127
View File
@@ -2,107 +2,41 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from typing import List, Optional
import json
import os
from typing import List, Optional, Sequence
from elydora._runtime_paths import resolve_agent_directory from ._managed_files import MAX_SOURCE_BYTES
from ._runtime import (
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES DEFAULT_BASE_URL,
from ._transaction import FileChange, FilePrecondition, file_change, source_change, write_changes 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 .base import InstallConfig
from .guard_template import generate_guard_script from .guard_template import generate_guard_script
from .hook_template import generate_hook_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_config import RenderedQwenDocument, qwen_document_label
from .qwen_contract import AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT, elydora_dir from .qwen_contract import AGENT_KEY
from .qwen_io import validate_api_origin, validate_private_key, validate_runtime_tree
from .qwen_sources import QwenSources, require_qwen_hooks_enabled from .qwen_sources import QwenSources, require_qwen_hooks_enabled
PRODUCT = "Qwen Code"
@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)
def preflight_qwen_installation( def preflight_qwen_installation(
config: InstallConfig, sources: QwenSources config: InstallConfig, sources: QwenSources
) -> QwenRuntimePaths: ) -> RuntimePaths:
require_qwen_hooks_enabled(sources) require_qwen_hooks_enabled(sources)
_validate_install_config(config) validate_install_config(config, AGENT_KEY, PRODUCT)
paths = _agent_paths(config) paths = resolve_runtime_paths(config)
validate_runtime_tree(paths.agent_directory, paths.agent_id) validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
return paths return paths
def _runtime_change( def _rendered_change(rendered: RenderedQwenDocument) -> Optional[FileChange]:
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]:
if not rendered.changed: if not rendered.changed:
return None return None
return source_change( 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( def prepare_qwen_installation(
config: InstallConfig, config: InstallConfig,
paths: QwenRuntimePaths, paths: RuntimePaths,
rendered: RenderedQwenDocument, rendered: RenderedQwenDocument,
) -> List[FileChange]: ) -> List[FileChange]:
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id) guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
@@ -130,49 +60,27 @@ def prepare_qwen_installation(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=paths.agent_id, agent_id=paths.agent_id,
kid=config.get("kid", ""), 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, native_payload=True,
agent_name=AGENT_KEY, agent_name=AGENT_KEY,
) )
return _present([ return [
_runtime_change( *runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
paths.guard_path, "Elydora guard runtime", guard_script, 0o700 *present([_rendered_change(rendered)]),
), ]
_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),
])
def _read_only_preconditions( def _read_only_preconditions(
sources: QwenSources, changed_path: Optional[str] sources: QwenSources, changed_path: str
) -> List[FilePrecondition]: ) -> List[FilePrecondition]:
return [ return [
condition condition
for condition in sources.preconditions for condition in sources.preconditions
if changed_path is None if not same_path(condition.file_path, changed_path)
or not same_qwen_path(condition.file_path, changed_path)
] ]
def commit_qwen_installation( def commit_qwen_installation(changes: List[FileChange], sources: QwenSources) -> None:
changes: List[FileChange], sources: QwenSources
) -> None:
write_changes( write_changes(
changes, changes,
"Install Qwen Code hooks", "Install Qwen Code hooks",
@@ -180,15 +88,11 @@ def commit_qwen_installation(
) )
def prepare_qwen_uninstall( def prepare_qwen_uninstall(rendered: RenderedQwenDocument) -> Optional[FileChange]:
rendered: RenderedQwenDocument,
) -> Optional[FileChange]:
return _rendered_change(rendered) return _rendered_change(rendered)
def commit_qwen_uninstall( def commit_qwen_uninstall(change: Optional[FileChange], sources: QwenSources) -> None:
change: Optional[FileChange], sources: QwenSources
) -> None:
if change is None: if change is None:
return return
write_changes( write_changes(
+12 -192
View File
@@ -2,199 +2,19 @@
from __future__ import annotations from __future__ import annotations
import base64 from typing import List, Tuple
import os
from typing import Any, List, Optional
import urllib.parse
from ._managed_files import ( from ._runtime import expected_runtime_scripts, runtime_contract_exists
MAX_CONFIG_BYTES, from ._strict_json import JsonObject
MAX_SECRET_BYTES, from .qwen_contract import AGENT_KEY, QwenRuntimeContract
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
def validate_api_origin(value: str, label: str = "base_url") -> None: def _expected_scripts(agent_id: str, config: JsonObject) -> Tuple[str, str]:
try: return expected_runtime_scripts(AGENT_KEY, agent_id, config)
parsed = urllib.parse.urlsplit(value)
hostname = parsed.hostname
parsed.port def qwen_runtime_files_exist(contracts: List[QwenRuntimeContract]) -> bool:
except ValueError as error: return any(
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error runtime_contract_exists(contract, AGENT_KEY, "Qwen Code", _expected_scripts)
invalid_character = "\\" in value or any( for contract in contracts
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_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 typing import Dict, List, Optional, Set, Tuple
from ._dotenv import parse_dotenv 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 ._transaction import FilePrecondition
from .qwen_command import same_qwen_path
from .qwen_config import ( from .qwen_config import (
QwenDocument, QwenDocument,
create_qwen_document, create_qwen_document,
@@ -127,7 +127,7 @@ def _resolve_qwen_routing() -> _RoutingResult:
discovered_home = values.get("QWEN_HOME") discovered_home = values.get("QWEN_HOME")
if discovered_home and discovered_home != initial_qwen_home: if discovered_home and discovered_home != initial_qwen_home:
discovered_directory = _resolve_config_path(discovered_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")) read_candidate(os.path.join(discovered_directory, ".env"))
resolved_home = values.get("QWEN_HOME") resolved_home = values.get("QWEN_HOME")
return _RoutingResult( 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: def read_qwen_sources() -> QwenSources:
routing = _resolve_qwen_routing() routing = _resolve_qwen_routing()
system_path = _system_settings_path() system_path = _system_settings_path()
@@ -355,7 +346,7 @@ def read_qwen_sources() -> QwenSources:
system, system,
workspace_active and workspace_trusted, 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 token_id: str
# Deprecated alias for IssueApiTokenResponse.
IssueTokenResponse = IssueApiTokenResponse
class RotateApiTokenResponse(TypedDict): class RotateApiTokenResponse(TypedDict):
token: str token: str
expires_at: Optional[int] expires_at: Optional[int]
token_id: str token_id: str
previous_token_grace_until: int
class HealthResponse(TypedDict): 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 __future__ import annotations
from copy import deepcopy from copy import deepcopy
import json
import os import os
from pathlib import Path from pathlib import Path
import sys import sys
-1
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
from pathlib import Path from pathlib import Path
import subprocess # nosec B404 import subprocess # nosec B404
-1
View File
@@ -5,7 +5,6 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys
from typing import Any from typing import Any
import pytest 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 json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any
import pytest 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)) calls.append((path, dir_fd))
monkeypatch.setattr(_private_artifact.os, "rmdir", record_rmdir) 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") _private_artifact._remove_posix_private_directory(41, "private-container")
+25 -552
View File
@@ -1,23 +1,14 @@
from __future__ import annotations from __future__ import annotations
import argparse
import base64
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
import stat import stat
import subprocess
import sys
from typing import Any
import pytest import pytest
from elydora import cli from elydora import cli
from elydora.plugins import ( from elydora.plugins import (
_directory_removal,
_opaque_cleanup,
_opaque_removal,
_transaction, _transaction,
) )
from elydora.plugins._runtime_removal import ( from elydora.plugins._runtime_removal import (
@@ -25,76 +16,15 @@ from elydora.plugins._runtime_removal import (
finalize_runtime_removal, finalize_runtime_removal,
prepare_runtime_removal, prepare_runtime_removal,
) )
from runtime_removal_support import (
AGENT_ID,
AGENT_ID = "agent-1" AGENT_NAME,
AGENT_NAME = "opencode" prepare_runtime,
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([19]) * 32).rstrip(b"=").decode() stream_digest,
uninstall_args,
write_error_log,
def _set_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> None: write_json,
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,
]
)
@pytest.mark.parametrize("entry_kind", ["file", "directory", "symlink"]) @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, tmp_path: Path,
entry_kind: str, entry_kind: str,
) -> None: ) -> None:
_root, agent = _runtime(monkeypatch, tmp_path) _root, agent = prepare_runtime(monkeypatch, tmp_path)
unknown = agent / "user-owned" unknown = agent / "user-owned"
if entry_kind == "file": if entry_kind == "file":
unknown.write_text("preserve", encoding="utf-8") 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()) monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
with pytest.raises(OSError, match="unmanaged entries"): with pytest.raises(OSError, match="unmanaged entries"):
cli.cmd_uninstall(_uninstall_args()) cli.cmd_uninstall(uninstall_args())
assert provider_calls == [] assert provider_calls == []
assert unknown.exists() or unknown.is_symlink() 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, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
_root, agent = _runtime(monkeypatch, tmp_path, agent_name="kiroide") _root, agent = prepare_runtime(monkeypatch, tmp_path, agent_name="kiroide")
provider_calls: list[str] = [] provider_calls: list[str] = []
class Plugin: 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, "_get_plugin", lambda _name: Plugin())
monkeypatch.setattr(cli, "prepare_runtime_removal", reject_generic_prepare) 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 provider_calls == [AGENT_ID]
assert (agent / "config.json").is_file() assert (agent / "config.json").is_file()
@@ -163,11 +93,11 @@ def test_runtime_removal_rejects_config_identity_drift(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
_root, agent = _runtime(monkeypatch, tmp_path) _root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None assert plan is not None
concurrent = {"agent_id": AGENT_ID, "agent_name": "cursor"} 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"): with pytest.raises(OSError, match="changed"):
commit_runtime_removal(plan) commit_runtime_removal(plan)
@@ -181,7 +111,7 @@ def test_runtime_removal_treats_other_plugin_wrappers_as_unmanaged(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> 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" wrapper_name = "augment-guard.cmd" if os.name == "nt" else "augment-guard.sh"
user_file = agent / wrapper_name user_file = agent / wrapper_name
user_file.write_text("user-owned payload", encoding="utf-8") 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, tmp_path: Path,
swap: str, swap: str,
) -> None: ) -> None:
root, agent = _runtime(monkeypatch, tmp_path) root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None assert plan is not None
@@ -228,7 +158,7 @@ def test_runtime_removal_rejects_concurrent_creation_of_missing_managed_file(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
_root, agent = _runtime(monkeypatch, tmp_path) _root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None assert plan is not None
concurrent = agent / "status-cache.json" concurrent = agent / "status-cache.json"
@@ -247,7 +177,7 @@ def test_runtime_removal_preserves_unknown_entry_created_before_finalize(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
_root, agent = _runtime(monkeypatch, tmp_path) _root, agent = prepare_runtime(monkeypatch, tmp_path)
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
assert plan is not None assert plan is not None
_transaction.write_changes( _transaction.write_changes(
@@ -270,7 +200,7 @@ def test_runtime_removal_deletes_all_known_dynamic_files(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
root, agent = _runtime(monkeypatch, tmp_path) root, agent = prepare_runtime(monkeypatch, tmp_path)
dynamic = { dynamic = {
"chain-state.json": '{"sequence":1}\n', "chain-state.json": '{"sequence":1}\n',
"status-cache.json": '{"status":"active"}\n', "status-cache.json": '{"status":"active"}\n',
@@ -293,8 +223,8 @@ def test_runtime_removal_streams_oversized_error_log(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
root, agent = _runtime(monkeypatch, tmp_path) root, agent = prepare_runtime(monkeypatch, tmp_path)
expected_digest = _write_error_log(agent / "error.log", chunks=32) expected_digest = write_error_log(agent / "error.log", chunks=32)
assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024 assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) 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, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
_root, agent = _runtime(monkeypatch, tmp_path) _root, agent = prepare_runtime(monkeypatch, tmp_path)
error_log = agent / "error.log" 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) expected_mode = stat.S_IMODE(error_log.stat().st_mode)
original_commit = _transaction._commit original_commit = _transaction._commit
@@ -329,464 +259,7 @@ def test_oversized_error_log_rolls_back_after_later_text_failure(
commit_runtime_removal(plan) commit_runtime_removal(plan)
monkeypatch.setattr(_transaction, "_commit", original_commit) 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 stat.S_IMODE(error_log.stat().st_mode) == expected_mode
assert (agent / "config.json").is_file() assert (agent / "config.json").is_file()
assert list(agent.glob("*.opaque-rollback")) == [] 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 import pytest
from elydora.plugins import _transaction, _transaction_staging from elydora.plugins import _transaction
from elydora.plugins._managed_files import read_physical_directory, read_physical_file from elydora.plugins._managed_files import read_physical_file
from transaction_support import (
make_change,
def _write(path: Path, source: str) -> None: transaction_subprocess_environment,
path.write_bytes(source.encode("utf-8")) write_source,
)
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
def test_post_replace_precondition_failure_restores_original_target( def test_post_replace_precondition_failure_restores_original_target(
@@ -43,8 +25,8 @@ def test_post_replace_precondition_failure_restores_original_target(
) -> None: ) -> None:
target = tmp_path / "target.txt" target = tmp_path / "target.txt"
provider = tmp_path / "provider.txt" provider = tmp_path / "provider.txt"
_write(target, "old\n") write_source(target, "old\n")
_write(provider, "stable\n") write_source(provider, "stable\n")
provider_snapshot = read_physical_file(str(provider), "provider") provider_snapshot = read_physical_file(str(provider), "provider")
real_replace = _transaction._replace_physical real_replace = _transaction._replace_physical
@@ -55,7 +37,7 @@ def test_post_replace_precondition_failure_restores_original_target(
) -> None: ) -> None:
real_replace(source, destination, directory) real_replace(source, destination, directory)
if os.path.abspath(destination) == os.path.abspath(str(target)): if os.path.abspath(destination) == os.path.abspath(str(target)):
_write(provider, "concurrent\n") write_source(provider, "concurrent\n")
monkeypatch.setattr( monkeypatch.setattr(
_transaction, "_replace_physical", replace_then_change_provider _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"): with pytest.raises(OSError, match="provider changed during"):
_transaction.write_changes( _transaction.write_changes(
[_change(target, "old\n", "new\n")], [make_change(target, "old\n", "new\n")],
"provider transaction", "provider transaction",
[_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)], [_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)],
) )
@@ -78,7 +60,7 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
target = tmp_path / "target.txt" target = tmp_path / "target.txt"
_write(target, "old\n") write_source(target, "old\n")
real_capture = _transaction._capture_physical real_capture = _transaction._capture_physical
changed = False changed = False
@@ -94,14 +76,14 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected(
and destination.endswith(".rollback") and destination.endswith(".rollback")
): ):
changed = True changed = True
_write(target, "concurrent\n") write_source(target, "concurrent\n")
real_capture(source, destination, directory) real_capture(source, destination, directory)
monkeypatch.setattr(_transaction, "_capture_physical", write_before_capture) monkeypatch.setattr(_transaction, "_capture_physical", write_before_capture)
with pytest.raises(OSError, match="changed at commit boundary"): with pytest.raises(OSError, match="changed at commit boundary"):
_transaction.write_changes( _transaction.write_changes(
[_change(target, "old\n", "new\n")], [make_change(target, "old\n", "new\n")],
"commit boundary", "commit boundary",
) )
@@ -116,7 +98,7 @@ def test_existing_target_remains_visible_during_atomic_update(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
target = tmp_path / "target.txt" target = tmp_path / "target.txt"
_write(target, "old\n") write_source(target, "old\n")
replace_started = threading.Event() replace_started = threading.Event()
release_replace = threading.Event() release_replace = threading.Event()
failures: list[BaseException] = [] failures: list[BaseException] = []
@@ -135,7 +117,7 @@ def test_existing_target_remains_visible_during_atomic_update(
def update() -> None: def update() -> None:
try: try:
_transaction.write_changes( _transaction.write_changes(
[_change(target, "old\n", "new\n")], [make_change(target, "old\n", "new\n")],
"visible atomic update", "visible atomic update",
) )
except BaseException as error: 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" 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( def test_process_exit_after_atomic_replace_recovers_on_restart(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
@@ -170,7 +145,7 @@ def test_process_exit_after_atomic_replace_recovers_on_restart(
home = tmp_path / "home" home = tmp_path / "home"
home.mkdir() home.mkdir()
target = tmp_path / "target.txt" target = tmp_path / "target.txt"
_write(target, "old\n") write_source(target, "old\n")
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setenv("USERPROFILE", str(home))
script = """ script = """
@@ -193,7 +168,7 @@ _transaction.write_changes([change], "crash recovery")
completed = subprocess.run( completed = subprocess.run(
[sys.executable, "-c", script, str(target)], [sys.executable, "-c", script, str(target)],
cwd=Path(__file__).resolve().parents[1], cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home), env=transaction_subprocess_environment(home),
check=False, check=False,
) )
@@ -212,7 +187,7 @@ def test_process_exit_after_committed_marker_keeps_atomic_update(
home = tmp_path / "home" home = tmp_path / "home"
home.mkdir() home.mkdir()
target = tmp_path / "target.txt" target = tmp_path / "target.txt"
_write(target, "old\n") write_source(target, "old\n")
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setenv("USERPROFILE", str(home))
script = """ script = """
@@ -230,7 +205,7 @@ _transaction.write_changes([change], "committed crash recovery")
completed = subprocess.run( completed = subprocess.run(
[sys.executable, "-c", script, str(target)], [sys.executable, "-c", script, str(target)],
cwd=Path(__file__).resolve().parents[1], cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home), env=transaction_subprocess_environment(home),
check=False, check=False,
) )
@@ -250,8 +225,8 @@ def test_process_exit_mid_transaction_restores_all_targets(
home.mkdir() home.mkdir()
first = tmp_path / "first.txt" first = tmp_path / "first.txt"
second = tmp_path / "second.txt" second = tmp_path / "second.txt"
_write(first, "first-old\n") write_source(first, "first-old\n")
_write(second, "second-old\n") write_source(second, "second-old\n")
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setenv("USERPROFILE", str(home))
script = """ script = """
@@ -281,7 +256,7 @@ _transaction.write_changes(changes, "multi-file crash recovery")
completed = subprocess.run( completed = subprocess.run(
[sys.executable, "-c", script, str(first), str(second)], [sys.executable, "-c", script, str(first), str(second)],
cwd=Path(__file__).resolve().parents[1], cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home), env=transaction_subprocess_environment(home),
check=False, check=False,
) )
@@ -313,7 +288,7 @@ def test_process_exit_recovers_atomic_create_and_remove(
home.mkdir() home.mkdir()
target = tmp_path / "target.txt" target = tmp_path / "target.txt"
if original is not None: if original is not None:
_write(target, original) write_source(target, original)
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home)) monkeypatch.setenv("USERPROFILE", str(home))
script = """ script = """
@@ -343,7 +318,7 @@ _transaction.write_changes([change], "create-remove crash recovery")
str(exit_code), str(exit_code),
], ],
cwd=Path(__file__).resolve().parents[1], cwd=Path(__file__).resolve().parents[1],
env=_transaction_subprocess_environment(home), env=transaction_subprocess_environment(home),
check=False, check=False,
) )
@@ -366,8 +341,8 @@ def test_cross_process_writers_are_serialized(
ready = tmp_path / "ready" ready = tmp_path / "ready"
second_ready = tmp_path / "second-ready" second_ready = tmp_path / "second-ready"
release = tmp_path / "release" release = tmp_path / "release"
_write(target, "old\n") write_source(target, "old\n")
environment = _transaction_subprocess_environment(home) environment = transaction_subprocess_environment(home)
first_script = """ first_script = """
import pathlib import pathlib
import sys import sys
@@ -443,632 +418,3 @@ except OSError as error:
assert second.returncode == 19, output assert second.returncode == 19, output
assert "changed during" in output assert "changed during" in output
assert target.read_text(encoding="utf-8") == "first\n" 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)