chore(oss): audit the server package and resync the SDK mirrors
Split the 657-line operation service into eor, submit, and verify modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already emits, and condense every multi-line comment. Mirror Node, Python, and Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation grace, and path-encoding fixes. Document which routes and response fields the self-hosted server does not provide. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
1 parent
2010175a82
commit
bc6983a62e
364 files changed
+10180
-23349
No files matched your search
@@ -19,17 +19,37 @@ from .types import (
|
||||
EAR,
|
||||
Agent,
|
||||
AgentKey,
|
||||
AgentAssignment,
|
||||
AdminEvent,
|
||||
AuthMeUser,
|
||||
CurrentOrganization,
|
||||
DependencyHealth,
|
||||
ErrorCode,
|
||||
EpochAnchor,
|
||||
MemberEntry,
|
||||
Operation,
|
||||
Receipt,
|
||||
Epoch,
|
||||
Export,
|
||||
Organization,
|
||||
User,
|
||||
WebhookEntry,
|
||||
AdminAction,
|
||||
AgentStatus,
|
||||
ExportStatus,
|
||||
INTEGRATION_TYPES,
|
||||
IntegrationType,
|
||||
KeyStatus,
|
||||
RbacRole,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
UpdateAgentRequest,
|
||||
UpdateAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
DeleteAgentResponse,
|
||||
FreezeAgentResponse,
|
||||
UnfreezeAgentResponse,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
@@ -39,14 +59,19 @@ from .types import (
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
ListExportsResponse,
|
||||
ListWebhooksResponse,
|
||||
RegisterWebhookResponse,
|
||||
ListMembersResponse,
|
||||
ListAdminEventsResponse,
|
||||
JWKSResponse,
|
||||
AuthRegisterResponse,
|
||||
AuthLoginResponse,
|
||||
GetMeResponse,
|
||||
IssueApiTokenResponse,
|
||||
IssueTokenResponse,
|
||||
RotateApiTokenResponse,
|
||||
HealthResponse,
|
||||
INTEGRATION_TYPES,
|
||||
IntegrationType,
|
||||
DeepHealthResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -65,21 +90,41 @@ __all__ = [
|
||||
"base64url_decode",
|
||||
"generate_nonce",
|
||||
"generate_uuidv7",
|
||||
"AdminAction",
|
||||
"AgentStatus",
|
||||
"ExportStatus",
|
||||
"INTEGRATION_TYPES",
|
||||
"IntegrationType",
|
||||
"KeyStatus",
|
||||
"RbacRole",
|
||||
"EOR",
|
||||
"EAR",
|
||||
"Agent",
|
||||
"AgentAssignment",
|
||||
"AgentKey",
|
||||
"AdminEvent",
|
||||
"AuthMeUser",
|
||||
"CurrentOrganization",
|
||||
"DependencyHealth",
|
||||
"ErrorCode",
|
||||
"EpochAnchor",
|
||||
"MemberEntry",
|
||||
"Operation",
|
||||
"Receipt",
|
||||
"Epoch",
|
||||
"Export",
|
||||
"Organization",
|
||||
"User",
|
||||
"WebhookEntry",
|
||||
"RegisterAgentRequest",
|
||||
"UpdateAgentRequest",
|
||||
"RegisterAgentResponse",
|
||||
"UpdateAgentResponse",
|
||||
"GetAgentResponse",
|
||||
"ListAgentsResponse",
|
||||
"DeleteAgentResponse",
|
||||
"FreezeAgentResponse",
|
||||
"UnfreezeAgentResponse",
|
||||
"SubmitOperationResponse",
|
||||
"GetOperationResponse",
|
||||
"VerifyOperationResponse",
|
||||
@@ -89,12 +134,17 @@ __all__ = [
|
||||
"CreateExportResponse",
|
||||
"GetExportResponse",
|
||||
"ListExportsResponse",
|
||||
"ListWebhooksResponse",
|
||||
"RegisterWebhookResponse",
|
||||
"ListMembersResponse",
|
||||
"ListAdminEventsResponse",
|
||||
"JWKSResponse",
|
||||
"AuthRegisterResponse",
|
||||
"AuthLoginResponse",
|
||||
"GetMeResponse",
|
||||
"IssueApiTokenResponse",
|
||||
"IssueTokenResponse",
|
||||
"RotateApiTokenResponse",
|
||||
"HealthResponse",
|
||||
"INTEGRATION_TYPES",
|
||||
"IntegrationType",
|
||||
"DeepHealthResponse",
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Request and response building shared by the sync and async clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .types import EOR
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# 32 zero bytes; equals the Backend GENESIS_CHAIN_HASH.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
DEFAULT_BASE_URL = "https://api.elydora.com"
|
||||
REQUEST_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
def build_headers(token: Optional[str]) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def error_from_response(status_code: int, body: Any, text: str) -> ElydoraError:
|
||||
error = body.get("error", {}) if isinstance(body, dict) else {}
|
||||
return ElydoraError(
|
||||
code=error.get("code", "INTERNAL_ERROR"),
|
||||
message=error.get("message") or text or "Unknown error",
|
||||
request_id=error.get("request_id", ""),
|
||||
details=error.get("details"),
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
def without_none(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {key: value for key, value in values.items() if value is not None}
|
||||
|
||||
|
||||
def register_body(
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: Optional[str],
|
||||
org_name: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
return without_none({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"display_name": display_name,
|
||||
"org_name": org_name,
|
||||
})
|
||||
|
||||
|
||||
def api_token_body(ttl_seconds: Optional[int]) -> Dict[str, Any]:
|
||||
return without_none({"ttl_seconds": ttl_seconds})
|
||||
|
||||
|
||||
def export_body(
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
format: str,
|
||||
agent_id: Optional[str],
|
||||
operation_type: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
return without_none({
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
"agent_id": agent_id,
|
||||
"operation_type": operation_type,
|
||||
})
|
||||
|
||||
|
||||
def admin_events_params(limit: Optional[int]) -> Dict[str, str]:
|
||||
return {} if limit is None else {"limit": str(limit)}
|
||||
|
||||
|
||||
def build_eor(
|
||||
*,
|
||||
org_id: str,
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
kid: str,
|
||||
ttl_ms: int,
|
||||
prev_chain_hash: str,
|
||||
operation_type: str,
|
||||
subject: Dict[str, Any],
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None],
|
||||
) -> Tuple[EOR, str]:
|
||||
"""Build and sign one EOR; returns the record and its chain hash."""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at)
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": org_id,
|
||||
"agent_id": agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": ttl_ms,
|
||||
"nonce": generate_nonce(),
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": prev_chain_hash,
|
||||
"agent_pubkey_kid": kid,
|
||||
"signature": "",
|
||||
}
|
||||
eor["signature"] = sign_eor(eor, private_key)
|
||||
return eor, chain_hash # type: ignore[return-value]
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Canonical Elydora package version."""
|
||||
|
||||
__version__ = "2.0.2"
|
||||
__version__ = "2.1.0"
|
||||
+156
-220
@@ -2,45 +2,63 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from urllib.parse import quote
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ._async_http import request_with_retries
|
||||
from ._client_common import (
|
||||
DEFAULT_BASE_URL,
|
||||
GENESIS_CHAIN_HASH,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
admin_events_params,
|
||||
api_token_body,
|
||||
build_eor,
|
||||
build_headers,
|
||||
error_from_response,
|
||||
export_body,
|
||||
register_body,
|
||||
without_none,
|
||||
)
|
||||
from ._retry import require_max_retries
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
AuthRegisterResponse,
|
||||
CreateExportResponse,
|
||||
DeepHealthResponse,
|
||||
DeleteAgentResponse,
|
||||
EOR,
|
||||
FreezeAgentResponse,
|
||||
GetAgentResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
GetMeResponse,
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueTokenResponse,
|
||||
IntegrationType,
|
||||
IssueApiTokenResponse,
|
||||
JWKSResponse,
|
||||
ListAdminEventsResponse,
|
||||
ListAgentsResponse,
|
||||
ListEpochsResponse,
|
||||
ListExportsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
RegisterWebhookResponse,
|
||||
RotateApiTokenResponse,
|
||||
SubmitOperationResponse,
|
||||
UnfreezeAgentResponse,
|
||||
UpdateAgentResponse,
|
||||
VerifyOperationResponse,
|
||||
)
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
TIMEOUT = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
class AsyncElydoraClient:
|
||||
@@ -52,8 +70,8 @@ class AsyncElydoraClient:
|
||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||
base_url: API base URL.
|
||||
ttl_ms: Time-to-live for operations in milliseconds.
|
||||
max_retries: Maximum number of retries on transient failures.
|
||||
token: Optional bearer token for authenticated endpoints.
|
||||
max_retries: Retries after the initial attempt on transient failures.
|
||||
token: Optional API token for authenticated endpoints.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -62,7 +80,7 @@ class AsyncElydoraClient:
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
*,
|
||||
base_url: str = "https://api.elydora.com",
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
ttl_ms: int = 30000,
|
||||
max_retries: int = 3,
|
||||
token: Optional[str] = None,
|
||||
@@ -74,17 +92,14 @@ class AsyncElydoraClient:
|
||||
self.ttl_ms = ttl_ms
|
||||
self.max_retries = require_max_retries(max_retries)
|
||||
self.token = token
|
||||
|
||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||
self._kid = ""
|
||||
self._kid = agent_id + "-key-1"
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
def set_kid(self, kid: str) -> None:
|
||||
"""Set the key ID used for signing operations."""
|
||||
self._kid = kid
|
||||
|
||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||
"""Set the previous chain hash for the next operation."""
|
||||
self._prev_chain_hash = prev_chain_hash
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
@@ -93,20 +108,9 @@ class AsyncElydoraClient:
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Internal HTTP helpers
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -114,51 +118,45 @@ class AsyncElydoraClient:
|
||||
*,
|
||||
json_body: Any = None,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
hdrs = headers or self._headers()
|
||||
session = await self._get_session()
|
||||
|
||||
return await request_with_retries(
|
||||
session,
|
||||
await self._get_session(),
|
||||
method,
|
||||
url,
|
||||
f"{self.base_url}{path}",
|
||||
path=path,
|
||||
max_retries=self.max_retries,
|
||||
response_handler=self._handle_response,
|
||||
json_body=json_body,
|
||||
params=params,
|
||||
headers=hdrs,
|
||||
headers=build_headers(self.token),
|
||||
)
|
||||
|
||||
async def _get_public(self, path: str, *, accept_status: Optional[int] = None) -> Any:
|
||||
session = await self._get_session()
|
||||
async with session.get(f"{self.base_url}{path}", timeout=TIMEOUT) as response:
|
||||
if accept_status is not None and response.status == accept_status:
|
||||
return await response.json()
|
||||
return await self._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
async def _handle_response(resp: aiohttp.ClientResponse) -> Any:
|
||||
if resp.status >= 400:
|
||||
try:
|
||||
body = await resp.json()
|
||||
except Exception:
|
||||
text = await resp.text()
|
||||
raise ElydoraError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=text or "Unknown error",
|
||||
status_code=resp.status,
|
||||
)
|
||||
err = body.get("error", {})
|
||||
raise ElydoraError(
|
||||
code=err.get("code", "INTERNAL_ERROR"),
|
||||
message=err.get("message", ""),
|
||||
request_id=err.get("request_id", ""),
|
||||
details=err.get("details"),
|
||||
status_code=resp.status,
|
||||
)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
body = None
|
||||
raise error_from_response(resp.status, body, await resp.text())
|
||||
if resp.status == 204:
|
||||
return None
|
||||
return await resp.json()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Auth (static methods)
|
||||
# -----------------------------------------------------------------
|
||||
@staticmethod
|
||||
async def _post_json(url: str, body: Dict[str, Any]) -> Any:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers=build_headers(None), timeout=TIMEOUT
|
||||
) as response:
|
||||
return await AsyncElydoraClient._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
async def register(
|
||||
@@ -168,99 +166,83 @@ class AsyncElydoraClient:
|
||||
display_name: Optional[str] = None,
|
||||
org_name: Optional[str] = None,
|
||||
) -> AuthRegisterResponse:
|
||||
"""Register a new user and organization.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password registration; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||
body: Dict[str, Any] = {"email": email, "password": password}
|
||||
if display_name is not None:
|
||||
body["display_name"] = display_name
|
||||
if org_name is not None:
|
||||
body["org_name"] = org_name
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
return await AsyncElydoraClient._handle_response(resp)
|
||||
return await AsyncElydoraClient._post_json(
|
||||
url, register_body(email, password, display_name, org_name)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||
"""Authenticate and receive a session token.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password login; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||
body = {"email": email, "password": password}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
return await AsyncElydoraClient._handle_response(resp)
|
||||
return await AsyncElydoraClient._post_json(url, {"email": email, "password": password})
|
||||
|
||||
async def get_me(self) -> GetMeResponse:
|
||||
"""Get the current user's profile."""
|
||||
return await self._request("GET", "/v1/auth/me")
|
||||
|
||||
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse:
|
||||
"""Issue an API token with an optional TTL."""
|
||||
body: Dict[str, Any] = {}
|
||||
if ttl_seconds is not None:
|
||||
body["ttl_seconds"] = ttl_seconds
|
||||
return await self._request("POST", "/v1/auth/token", json_body=body)
|
||||
async def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||
return await self._request(
|
||||
"POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds)
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Agent management
|
||||
# -----------------------------------------------------------------
|
||||
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||
"""Deprecated alias for issue_api_token()."""
|
||||
warnings.warn(
|
||||
"AsyncElydoraClient.issue_token() is deprecated. Use issue_api_token().",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return await self.issue_api_token(ttl_seconds=ttl_seconds)
|
||||
|
||||
async def rotate_api_token(self) -> RotateApiTokenResponse:
|
||||
return await self._request("POST", "/v1/auth/rotate", json_body={})
|
||||
|
||||
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return await self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
async def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
"""Retrieve agent details and keys."""
|
||||
return await self._request("GET", f"/v1/agents/{agent_id}")
|
||||
|
||||
async def freeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Freeze an agent."""
|
||||
await self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||
return await self._request("GET", f"/v1/agents/{quote(agent_id, safe='')}")
|
||||
|
||||
async def list_agents(self) -> ListAgentsResponse:
|
||||
"""List all agents for the organization."""
|
||||
return await self._request("GET", "/v1/agents")
|
||||
|
||||
async def unfreeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Unfreeze an agent."""
|
||||
await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
|
||||
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
"""Delete an agent."""
|
||||
return await self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||
|
||||
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
"""Revoke an agent's key."""
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/v1/agents/{agent_id}/revoke",
|
||||
json_body={"kid": kid, "reason": reason},
|
||||
async def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
|
||||
return await self._request(
|
||||
"POST", f"/v1/agents/{quote(agent_id, safe='')}/freeze", json_body={"reason": reason}
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Operations (CORE)
|
||||
# -----------------------------------------------------------------
|
||||
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
||||
return await self._request(
|
||||
"POST", f"/v1/agents/{quote(agent_id, safe='')}/unfreeze", json_body={"reason": reason}
|
||||
)
|
||||
|
||||
async def update_agent(
|
||||
self, agent_id: str, integration_type: IntegrationType
|
||||
) -> UpdateAgentResponse:
|
||||
require_integration_type(integration_type)
|
||||
return await self._request(
|
||||
"PATCH", f"/v1/agents/{quote(agent_id, safe='')}", json_body={"integration_type": integration_type}
|
||||
)
|
||||
|
||||
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
return await self._request("DELETE", f"/v1/agents/{quote(agent_id, safe='')}")
|
||||
|
||||
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
await self._request(
|
||||
"POST", f"/v1/agents/{quote(agent_id, safe='')}/revoke", json_body={"kid": kid, "reason": reason}
|
||||
)
|
||||
|
||||
def create_operation(
|
||||
self,
|
||||
@@ -269,58 +251,32 @@ class AsyncElydoraClient:
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None] = None,
|
||||
) -> EOR:
|
||||
"""Build and sign an Elydora Operation Record (EOR).
|
||||
|
||||
This is synchronous because it only performs local crypto operations.
|
||||
Call submit_operation() with the returned EOR to submit it.
|
||||
"""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
nonce = generate_nonce()
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(
|
||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
||||
"""Build and sign an EOR locally; submit it with submit_operation()."""
|
||||
eor, chain_hash = build_eor(
|
||||
org_id=self.org_id,
|
||||
agent_id=self.agent_id,
|
||||
private_key=self.private_key,
|
||||
kid=self._kid,
|
||||
ttl_ms=self.ttl_ms,
|
||||
prev_chain_hash=self._prev_chain_hash,
|
||||
operation_type=operation_type,
|
||||
subject=subject,
|
||||
action=action,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": self.org_id,
|
||||
"agent_id": self.agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": self.ttl_ms,
|
||||
"nonce": nonce,
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": self._prev_chain_hash,
|
||||
"agent_pubkey_kid": self._kid,
|
||||
"signature": "",
|
||||
}
|
||||
|
||||
eor["signature"] = sign_eor(eor, self.private_key)
|
||||
|
||||
self._prev_chain_hash = chain_hash
|
||||
|
||||
return eor # type: ignore[return-value]
|
||||
return eor
|
||||
|
||||
async def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||
"""Submit a signed EOR to the server."""
|
||||
return await self._request("POST", "/v1/operations", json_body=eor)
|
||||
|
||||
async def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||
"""Retrieve an operation by ID."""
|
||||
return await self._request("GET", f"/v1/operations/{operation_id}")
|
||||
|
||||
async def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||
"""Verify an operation's integrity."""
|
||||
return await self._request("POST", f"/v1/operations/{operation_id}/verify")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Audit
|
||||
# -----------------------------------------------------------------
|
||||
return await self._request(
|
||||
"POST", f"/v1/operations/{operation_id}/verify", json_body={}
|
||||
)
|
||||
|
||||
async def query_audit(
|
||||
self,
|
||||
@@ -333,40 +289,23 @@ class AsyncElydoraClient:
|
||||
cursor: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AuditQueryResponse:
|
||||
"""Query the tamper-evident audit log."""
|
||||
body: Dict[str, Any] = {}
|
||||
if org_id is not None:
|
||||
body["org_id"] = org_id
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
if start_time is not None:
|
||||
body["start_time"] = start_time
|
||||
if end_time is not None:
|
||||
body["end_time"] = end_time
|
||||
if cursor is not None:
|
||||
body["cursor"] = cursor
|
||||
if limit is not None:
|
||||
body["limit"] = limit
|
||||
body = without_none({
|
||||
"org_id": org_id,
|
||||
"agent_id": agent_id,
|
||||
"operation_type": operation_type,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"cursor": cursor,
|
||||
"limit": limit,
|
||||
})
|
||||
return await self._request("POST", "/v1/audit/query", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Epochs
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def list_epochs(self) -> ListEpochsResponse:
|
||||
"""List all epochs for the organization."""
|
||||
return await self._request("GET", "/v1/epochs")
|
||||
|
||||
async def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||
"""Retrieve an epoch root record."""
|
||||
return await self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Exports
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def create_export(
|
||||
self,
|
||||
start_time: int,
|
||||
@@ -375,55 +314,52 @@ class AsyncElydoraClient:
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
) -> CreateExportResponse:
|
||||
"""Create a compliance export job."""
|
||||
body: Dict[str, Any] = {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
}
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
body = export_body(start_time, end_time, format, agent_id, operation_type)
|
||||
return await self._request("POST", "/v1/exports", json_body=body)
|
||||
|
||||
async def list_exports(self) -> ListExportsResponse:
|
||||
"""List all exports for the organization."""
|
||||
return await self._request("GET", "/v1/exports")
|
||||
|
||||
async def get_export(self, export_id: str) -> GetExportResponse:
|
||||
"""Retrieve export status and download URL."""
|
||||
return await self._request("GET", f"/v1/exports/{export_id}")
|
||||
|
||||
async def download_export(self, export_id: str) -> bytes:
|
||||
"""Download an export file as raw bytes."""
|
||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
await self._handle_response(resp)
|
||||
return await resp.read()
|
||||
f"{self.base_url}/v1/exports/{export_id}/download",
|
||||
headers=build_headers(self.token),
|
||||
timeout=TIMEOUT,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
await self._handle_response(response)
|
||||
return await response.read()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JWKS
|
||||
# -----------------------------------------------------------------
|
||||
async def list_webhooks(self) -> ListWebhooksResponse:
|
||||
return await self._request("GET", "/v1/webhooks")
|
||||
|
||||
async def register_webhook(
|
||||
self, endpoint_url: str, events: List[str], secret: str
|
||||
) -> RegisterWebhookResponse:
|
||||
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
|
||||
return await self._request("POST", "/v1/webhooks", json_body=body)
|
||||
|
||||
async def delete_webhook(self, webhook_id: str) -> None:
|
||||
await self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
||||
|
||||
async def list_members(self) -> ListMembersResponse:
|
||||
return await self._request("GET", "/v1/members")
|
||||
|
||||
async def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
||||
return await self._request(
|
||||
"GET", "/v1/admin/events", params=admin_events_params(limit)
|
||||
)
|
||||
|
||||
async def get_jwks(self) -> JWKSResponse:
|
||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
return await self._handle_response(resp)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Health
|
||||
# -----------------------------------------------------------------
|
||||
return await self._get_public("/.well-known/elydora/jwks.json")
|
||||
|
||||
async def health(self) -> HealthResponse:
|
||||
"""Check API health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
return await self._handle_response(resp)
|
||||
return await self._get_public("/v1/health")
|
||||
|
||||
async def deep_health(self) -> DeepHealthResponse:
|
||||
"""503 carries the degraded report, not an error."""
|
||||
return await self._get_public("/v1/health/deep", accept_status=503)
|
||||
@@ -1,8 +1,4 @@
|
||||
"""Elydora CLI — install, uninstall, and manage agent audit hooks.
|
||||
|
||||
Entry point: ``elydora`` console script (see pyproject.toml).
|
||||
Uses only stdlib argparse — zero external dependencies for the CLI itself.
|
||||
"""
|
||||
"""Elydora CLI: install, uninstall, and inspect agent audit hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,7 +20,7 @@ from ._runtime_paths import (
|
||||
from .crypto import get_public_key_base64url
|
||||
from .plugins.base import AgentPlugin, InstallConfig
|
||||
from .plugins.registry import SUPPORTED_AGENTS, get_agent_names
|
||||
from .plugins.hook_template import generate_guard_script
|
||||
from .plugins.guard_template import generate_guard_script
|
||||
from .plugins.augment import AugmentPlugin
|
||||
from .plugins.claudecode import ClaudeCodePlugin
|
||||
from .plugins.codex import CodexPlugin
|
||||
@@ -173,17 +169,15 @@ def _discover_installed_agents_or_exit() -> list[_InstalledAgent]:
|
||||
|
||||
|
||||
def _get_plugin(agent_name: str) -> AgentPlugin:
|
||||
"""Instantiate the plugin for the given agent name."""
|
||||
cls = PLUGIN_MAP.get(agent_name)
|
||||
if cls is None:
|
||||
print(f"Error: Unknown agent '{agent_name}'.", file=sys.stderr)
|
||||
print(f"Supported agents: {', '.join(get_agent_names())}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_exit_with_error(
|
||||
f"Unknown agent {agent_name!r}. Supported agents: {', '.join(get_agent_names())}"
|
||||
)
|
||||
return cls()
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'install' subcommand."""
|
||||
recover_pending_transactions()
|
||||
agent_name: str = args.agent
|
||||
agent_dir = _resolve_agent_directory_or_exit(args.agent_id)
|
||||
@@ -197,12 +191,10 @@ def cmd_install(args: argparse.Namespace) -> None:
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
_exit_with_error(str(error))
|
||||
|
||||
# Derive public key to verify the private key is valid
|
||||
try:
|
||||
pub = get_public_key_base64url(secrets.private_key)
|
||||
except Exception as exc:
|
||||
print(f"Error: Invalid private key — {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_exit_with_error(f"Invalid private key: {exc}")
|
||||
|
||||
print(f"Verified key pair (public key: {pub[:16]}...)")
|
||||
|
||||
@@ -220,12 +212,10 @@ def cmd_install(args: argparse.Namespace) -> None:
|
||||
config["token"] = secrets.token
|
||||
plugin.preflight_install(config)
|
||||
|
||||
# Transactional adapters create and recover their own runtime directories.
|
||||
if not getattr(plugin, "manages_runtime_directories", False):
|
||||
ensure_private_directory(runtime_root())
|
||||
ensure_private_directory(agent_dir)
|
||||
|
||||
# Generate and write the guard script for adapters that use the shared runtime.
|
||||
if not plugin.manages_guard_runtime:
|
||||
guard_script = generate_guard_script(agent_name, args.agent_id)
|
||||
write_text_atomic(
|
||||
@@ -240,7 +230,6 @@ def cmd_install(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'uninstall' subcommand."""
|
||||
recover_pending_transactions()
|
||||
explicit_agent_id = getattr(args, "agent_id", None)
|
||||
elydora_dir = runtime_root()
|
||||
@@ -296,7 +285,6 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'status' subcommand."""
|
||||
recover_pending_transactions()
|
||||
print("Elydora Agent Hook Status")
|
||||
print("=" * 40)
|
||||
@@ -309,7 +297,6 @@ def cmd_status(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_agents(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'agents' subcommand."""
|
||||
print("Supported agents:")
|
||||
for name in get_agent_names():
|
||||
info = SUPPORTED_AGENTS[name]
|
||||
@@ -317,7 +304,6 @@ def cmd_agents(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argparse parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="elydora",
|
||||
description="Elydora — tamper-evident audit trail for AI agents",
|
||||
@@ -328,7 +314,6 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# install
|
||||
install_parser = subparsers.add_parser("install", help="Install audit hook for an agent")
|
||||
install_parser.add_argument("--agent", required=True, help="Agent name (e.g. claudecode, cursor)")
|
||||
install_parser.add_argument("--org_id", required=True, help="Organization ID")
|
||||
@@ -346,22 +331,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
install_parser.add_argument("--base_url", default="https://api.elydora.com", help="API base URL")
|
||||
|
||||
# uninstall
|
||||
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall audit hook for an agent")
|
||||
uninstall_parser.add_argument("--agent", required=True, help="Agent name")
|
||||
uninstall_parser.add_argument("--agent_id", default=None, help="Agent ID (if omitted, scans config files for matching agent name)")
|
||||
|
||||
# status
|
||||
subparsers.add_parser("status", help="Show installation status of all agents")
|
||||
|
||||
# agents
|
||||
subparsers.add_parser("agents", help="List supported agents")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
_reject_legacy_secret_arguments(sys.argv[1:])
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
+140
-208
@@ -2,45 +2,61 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from urllib.parse import quote
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
from ._client_common import (
|
||||
DEFAULT_BASE_URL,
|
||||
GENESIS_CHAIN_HASH,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
admin_events_params,
|
||||
api_token_body,
|
||||
build_eor,
|
||||
build_headers,
|
||||
error_from_response,
|
||||
export_body,
|
||||
register_body,
|
||||
without_none,
|
||||
)
|
||||
from ._retry import require_max_retries
|
||||
from ._sync_http import request_with_retries
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
AuthRegisterResponse,
|
||||
CreateExportResponse,
|
||||
DeepHealthResponse,
|
||||
DeleteAgentResponse,
|
||||
EOR,
|
||||
FreezeAgentResponse,
|
||||
GetAgentResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
GetMeResponse,
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueTokenResponse,
|
||||
IntegrationType,
|
||||
IssueApiTokenResponse,
|
||||
JWKSResponse,
|
||||
ListAdminEventsResponse,
|
||||
ListAgentsResponse,
|
||||
ListEpochsResponse,
|
||||
ListExportsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
RegisterWebhookResponse,
|
||||
RotateApiTokenResponse,
|
||||
SubmitOperationResponse,
|
||||
UnfreezeAgentResponse,
|
||||
UpdateAgentResponse,
|
||||
VerifyOperationResponse,
|
||||
)
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
class ElydoraClient:
|
||||
@@ -52,8 +68,8 @@ class ElydoraClient:
|
||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||
base_url: API base URL.
|
||||
ttl_ms: Time-to-live for operations in milliseconds.
|
||||
max_retries: Maximum number of retries on transient failures.
|
||||
token: Optional bearer token for authenticated endpoints.
|
||||
max_retries: Retries after the initial attempt on transient failures.
|
||||
token: Optional API token for authenticated endpoints.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -62,7 +78,7 @@ class ElydoraClient:
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
*,
|
||||
base_url: str = "https://api.elydora.com",
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
ttl_ms: int = 30000,
|
||||
max_retries: int = 3,
|
||||
token: Optional[str] = None,
|
||||
@@ -74,29 +90,16 @@ class ElydoraClient:
|
||||
self.ttl_ms = ttl_ms
|
||||
self.max_retries = require_max_retries(max_retries)
|
||||
self.token = token
|
||||
|
||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||
self._kid = ""
|
||||
self._kid = agent_id + "-key-1"
|
||||
self._session = requests.Session()
|
||||
|
||||
def set_kid(self, kid: str) -> None:
|
||||
"""Set the key ID used for signing operations."""
|
||||
self._kid = kid
|
||||
|
||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||
"""Set the previous chain hash for the next operation."""
|
||||
self._prev_chain_hash = prev_chain_hash
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Internal HTTP helpers
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -104,49 +107,46 @@ class ElydoraClient:
|
||||
*,
|
||||
json_body: Any = None,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
hdrs = headers or self._headers()
|
||||
|
||||
return request_with_retries(
|
||||
self._session,
|
||||
method,
|
||||
url,
|
||||
f"{self.base_url}{path}",
|
||||
path=path,
|
||||
max_retries=self.max_retries,
|
||||
response_handler=self._handle_response,
|
||||
json_body=json_body,
|
||||
params=params,
|
||||
headers=hdrs,
|
||||
headers=build_headers(self.token),
|
||||
)
|
||||
|
||||
def _get_public(self, path: str, *, accept_status: Optional[int] = None) -> Any:
|
||||
response = self._session.get(f"{self.base_url}{path}", timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
if accept_status is not None and response.status_code == accept_status:
|
||||
return response.json()
|
||||
return self._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
def _handle_response(resp: requests.Response) -> Any:
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
raise ElydoraError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=resp.text or "Unknown error",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
err = body.get("error", {})
|
||||
raise ElydoraError(
|
||||
code=err.get("code", "INTERNAL_ERROR"),
|
||||
message=err.get("message", resp.text),
|
||||
request_id=err.get("request_id", ""),
|
||||
details=err.get("details"),
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
except ValueError:
|
||||
body = None
|
||||
raise error_from_response(resp.status_code, body, resp.text)
|
||||
if resp.status_code == 204:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Auth (static methods)
|
||||
# -----------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _post_json(url: str, body: Dict[str, Any]) -> Any:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=build_headers(None),
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
return ElydoraClient._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
def register(
|
||||
@@ -156,93 +156,73 @@ class ElydoraClient:
|
||||
display_name: Optional[str] = None,
|
||||
org_name: Optional[str] = None,
|
||||
) -> AuthRegisterResponse:
|
||||
"""Register a new user and organization.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password registration; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||
body: Dict[str, Any] = {"email": email, "password": password}
|
||||
if display_name is not None:
|
||||
body["display_name"] = display_name
|
||||
if org_name is not None:
|
||||
body["org_name"] = org_name
|
||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
||||
return ElydoraClient._handle_response(resp)
|
||||
return ElydoraClient._post_json(url, register_body(email, password, display_name, org_name))
|
||||
|
||||
@staticmethod
|
||||
def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||
"""Authenticate and receive a session token.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password login; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||
body = {"email": email, "password": password}
|
||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
||||
return ElydoraClient._handle_response(resp)
|
||||
return ElydoraClient._post_json(url, {"email": email, "password": password})
|
||||
|
||||
def get_me(self) -> GetMeResponse:
|
||||
"""Get the current user's profile."""
|
||||
return self._request("GET", "/v1/auth/me")
|
||||
|
||||
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse:
|
||||
"""Issue an API token with an optional TTL."""
|
||||
body: Dict[str, Any] = {}
|
||||
if ttl_seconds is not None:
|
||||
body["ttl_seconds"] = ttl_seconds
|
||||
return self._request("POST", "/v1/auth/token", json_body=body)
|
||||
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))
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Agent management
|
||||
# -----------------------------------------------------------------
|
||||
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||
"""Deprecated alias for issue_api_token()."""
|
||||
warnings.warn(
|
||||
"ElydoraClient.issue_token() is deprecated. Use issue_api_token().",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.issue_api_token(ttl_seconds=ttl_seconds)
|
||||
|
||||
def rotate_api_token(self) -> RotateApiTokenResponse:
|
||||
return self._request("POST", "/v1/auth/rotate", json_body={})
|
||||
|
||||
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
"""Retrieve agent details and keys."""
|
||||
return self._request("GET", f"/v1/agents/{agent_id}")
|
||||
|
||||
def freeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Freeze an agent."""
|
||||
self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||
return self._request("GET", f"/v1/agents/{quote(agent_id, safe='')}")
|
||||
|
||||
def list_agents(self) -> ListAgentsResponse:
|
||||
"""List all agents for the organization."""
|
||||
return self._request("GET", "/v1/agents")
|
||||
|
||||
def unfreeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Unfreeze an agent."""
|
||||
self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
|
||||
return self._request("POST", f"/v1/agents/{quote(agent_id, safe='')}/freeze", json_body={"reason": reason})
|
||||
|
||||
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
"""Delete an agent."""
|
||||
return self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||
def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
||||
return self._request("POST", f"/v1/agents/{quote(agent_id, safe='')}/unfreeze", json_body={"reason": reason})
|
||||
|
||||
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
"""Revoke an agent's key."""
|
||||
self._request(
|
||||
"POST",
|
||||
f"/v1/agents/{agent_id}/revoke",
|
||||
json_body={"kid": kid, "reason": reason},
|
||||
def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
||||
require_integration_type(integration_type)
|
||||
return self._request(
|
||||
"PATCH", f"/v1/agents/{quote(agent_id, safe='')}", json_body={"integration_type": integration_type}
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Operations (CORE)
|
||||
# -----------------------------------------------------------------
|
||||
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
return self._request("DELETE", f"/v1/agents/{quote(agent_id, safe='')}")
|
||||
|
||||
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
self._request(
|
||||
"POST", f"/v1/agents/{quote(agent_id, safe='')}/revoke", json_body={"kid": kid, "reason": reason}
|
||||
)
|
||||
|
||||
def create_operation(
|
||||
self,
|
||||
@@ -251,60 +231,30 @@ class ElydoraClient:
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None] = None,
|
||||
) -> EOR:
|
||||
"""Build and sign an Elydora Operation Record (EOR).
|
||||
|
||||
This does NOT submit the operation to the server. Call submit_operation()
|
||||
with the returned EOR to submit it.
|
||||
"""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
nonce = generate_nonce()
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(
|
||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
||||
"""Build and sign an EOR locally; submit it with submit_operation()."""
|
||||
eor, chain_hash = build_eor(
|
||||
org_id=self.org_id,
|
||||
agent_id=self.agent_id,
|
||||
private_key=self.private_key,
|
||||
kid=self._kid,
|
||||
ttl_ms=self.ttl_ms,
|
||||
prev_chain_hash=self._prev_chain_hash,
|
||||
operation_type=operation_type,
|
||||
subject=subject,
|
||||
action=action,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": self.org_id,
|
||||
"agent_id": self.agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": self.ttl_ms,
|
||||
"nonce": nonce,
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": self._prev_chain_hash,
|
||||
"agent_pubkey_kid": self._kid,
|
||||
"signature": "",
|
||||
}
|
||||
|
||||
# Sign the EOR (signature field excluded from canonical form)
|
||||
eor["signature"] = sign_eor(eor, self.private_key)
|
||||
|
||||
# Update chain state
|
||||
self._prev_chain_hash = chain_hash
|
||||
|
||||
return eor # type: ignore[return-value]
|
||||
return eor
|
||||
|
||||
def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||
"""Submit a signed EOR to the server."""
|
||||
return self._request("POST", "/v1/operations", json_body=eor)
|
||||
|
||||
def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||
"""Retrieve an operation by ID."""
|
||||
return self._request("GET", f"/v1/operations/{operation_id}")
|
||||
|
||||
def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||
"""Verify an operation's integrity."""
|
||||
return self._request("POST", f"/v1/operations/{operation_id}/verify")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Audit
|
||||
# -----------------------------------------------------------------
|
||||
return self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
|
||||
|
||||
def query_audit(
|
||||
self,
|
||||
@@ -317,40 +267,23 @@ class ElydoraClient:
|
||||
cursor: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AuditQueryResponse:
|
||||
"""Query the tamper-evident audit log."""
|
||||
body: Dict[str, Any] = {}
|
||||
if org_id is not None:
|
||||
body["org_id"] = org_id
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
if start_time is not None:
|
||||
body["start_time"] = start_time
|
||||
if end_time is not None:
|
||||
body["end_time"] = end_time
|
||||
if cursor is not None:
|
||||
body["cursor"] = cursor
|
||||
if limit is not None:
|
||||
body["limit"] = limit
|
||||
body = without_none({
|
||||
"org_id": org_id,
|
||||
"agent_id": agent_id,
|
||||
"operation_type": operation_type,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"cursor": cursor,
|
||||
"limit": limit,
|
||||
})
|
||||
return self._request("POST", "/v1/audit/query", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Epochs
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def list_epochs(self) -> ListEpochsResponse:
|
||||
"""List all epochs for the organization."""
|
||||
return self._request("GET", "/v1/epochs")
|
||||
|
||||
def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||
"""Retrieve an epoch root record."""
|
||||
return self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Exports
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def create_export(
|
||||
self,
|
||||
start_time: int,
|
||||
@@ -359,50 +292,49 @@ class ElydoraClient:
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
) -> CreateExportResponse:
|
||||
"""Create a compliance export job."""
|
||||
body: Dict[str, Any] = {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
}
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
body = export_body(start_time, end_time, format, agent_id, operation_type)
|
||||
return self._request("POST", "/v1/exports", json_body=body)
|
||||
|
||||
def list_exports(self) -> ListExportsResponse:
|
||||
"""List all exports for the organization."""
|
||||
return self._request("GET", "/v1/exports")
|
||||
|
||||
def get_export(self, export_id: str) -> GetExportResponse:
|
||||
"""Retrieve export status and download URL."""
|
||||
return self._request("GET", f"/v1/exports/{export_id}")
|
||||
|
||||
def download_export(self, export_id: str) -> bytes:
|
||||
"""Download an export file as raw bytes."""
|
||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
||||
resp = self._session.get(url, headers=self._headers(), timeout=30)
|
||||
if resp.status_code >= 400:
|
||||
self._handle_response(resp)
|
||||
return resp.content
|
||||
response = self._session.get(
|
||||
f"{self.base_url}/v1/exports/{export_id}/download",
|
||||
headers=build_headers(self.token),
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
self._handle_response(response)
|
||||
return response.content
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JWKS
|
||||
# -----------------------------------------------------------------
|
||||
def list_webhooks(self) -> ListWebhooksResponse:
|
||||
return self._request("GET", "/v1/webhooks")
|
||||
|
||||
def register_webhook(
|
||||
self, endpoint_url: str, events: List[str], secret: str
|
||||
) -> RegisterWebhookResponse:
|
||||
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
|
||||
return self._request("POST", "/v1/webhooks", json_body=body)
|
||||
|
||||
def delete_webhook(self, webhook_id: str) -> None:
|
||||
self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
||||
|
||||
def list_members(self) -> ListMembersResponse:
|
||||
return self._request("GET", "/v1/members")
|
||||
|
||||
def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
||||
return self._request("GET", "/v1/admin/events", params=admin_events_params(limit))
|
||||
|
||||
def get_jwks(self) -> JWKSResponse:
|
||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
||||
resp = self._session.get(url, timeout=30)
|
||||
return self._handle_response(resp)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Health
|
||||
# -----------------------------------------------------------------
|
||||
return self._get_public("/.well-known/elydora/jwks.json")
|
||||
|
||||
def health(self) -> HealthResponse:
|
||||
"""Check API health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health"
|
||||
resp = self._session.get(url, timeout=30)
|
||||
return self._handle_response(resp)
|
||||
return self._get_public("/v1/health")
|
||||
|
||||
def deep_health(self) -> DeepHealthResponse:
|
||||
"""503 carries the degraded report, not an error."""
|
||||
return self._get_public("/v1/health/deep", accept_status=503)
|
||||
@@ -1,25 +1,18 @@
|
||||
"""Cryptographic utilities: Ed25519 signing, SHA-256, chain hash, JCS canonicalization.
|
||||
|
||||
Mirrors the backend implementation in ElydoraBackend/src/utils/crypto.ts exactly.
|
||||
"""
|
||||
"""Ed25519 signing, SHA-256, chain hash, and RFC 8785 canonicalization; mirrors the Backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from .utils import base64url_decode, base64url_encode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SHA-256
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sha256_base64url(data: Union[str, bytes]) -> str:
|
||||
"""Compute SHA-256 of data and return base64url-encoded hash."""
|
||||
if isinstance(data, str):
|
||||
@@ -28,39 +21,21 @@ def sha256_base64url(data: Union[str, bytes]) -> str:
|
||||
return base64url_encode(digest)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JCS Canonicalization (RFC 8785)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _jcs_serialize_number(value: Union[int, float]) -> str:
|
||||
"""Serialize a number per JCS / ES2015 Number serialization rules."""
|
||||
"""ES2015 number serialization: NaN and infinities become null, -0.0 becomes 0."""
|
||||
if isinstance(value, bool):
|
||||
# bool is a subclass of int in Python; handle before int check
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
# float
|
||||
if math.isnan(value) or math.isinf(value):
|
||||
return "null"
|
||||
if value == 0.0:
|
||||
# Handle -0.0 -> "0"
|
||||
return "0"
|
||||
# Use Python's repr which matches ES2015 for normal floats,
|
||||
# but we need to ensure no trailing zeros beyond what's needed.
|
||||
# json.dumps handles this correctly per the JSON spec.
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def jcs_canonicalize(value: Any) -> str:
|
||||
"""Canonicalize a value according to JCS (RFC 8785).
|
||||
|
||||
- Object keys sorted lexicographically
|
||||
- No whitespace
|
||||
- Numbers serialized using ES2015 rules
|
||||
- Strings serialized with minimal JSON escaping
|
||||
- undefined values in objects are omitted
|
||||
"""
|
||||
"""Canonicalize a value according to JCS (RFC 8785)."""
|
||||
if value is None:
|
||||
return "null"
|
||||
|
||||
@@ -78,60 +53,34 @@ def jcs_canonicalize(value: Any) -> str:
|
||||
return "[" + ",".join(elements) + "]"
|
||||
|
||||
if isinstance(value, dict):
|
||||
keys = sorted(value.keys())
|
||||
pairs = []
|
||||
for key in keys:
|
||||
v = value[key]
|
||||
if v is not None or key in value:
|
||||
# Include keys with None values (maps to JSON null),
|
||||
# but skip keys that don't exist (which won't happen with dict iteration)
|
||||
pairs.append(json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(v))
|
||||
pairs = [
|
||||
json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(value[key])
|
||||
for key in sorted(value.keys())
|
||||
]
|
||||
return "{" + ",".join(pairs) + "}"
|
||||
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_payload_hash(payload: Any) -> str:
|
||||
"""Compute SHA-256 hash of JCS-canonicalized payload, base64url encoded."""
|
||||
canonical = jcs_canonicalize(payload)
|
||||
return sha256_base64url(canonical)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chain hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_chain_hash(
|
||||
prev_chain_hash: str,
|
||||
payload_hash: str,
|
||||
operation_id: str,
|
||||
issued_at: int,
|
||||
) -> str:
|
||||
"""Compute chain hash: SHA-256(prev|payload_hash|op_id|issued_at) as base64url.
|
||||
|
||||
Matches the backend formula exactly:
|
||||
chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + str(issued_at))
|
||||
"""
|
||||
"""SHA-256 of "prev|payload_hash|operation_id|issued_at" as base64url."""
|
||||
input_str = f"{prev_chain_hash}|{payload_hash}|{operation_id}|{issued_at}"
|
||||
return sha256_base64url(input_str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ed25519 signing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
||||
"""Sign data with Ed25519 private key (32-byte seed, base64url encoded).
|
||||
|
||||
Returns a base64url-encoded 64-byte signature.
|
||||
"""
|
||||
"""Sign data with a base64url 32-byte Ed25519 seed; returns a base64url signature."""
|
||||
seed = base64url_decode(private_key_base64url)
|
||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
signature = key.sign(data)
|
||||
@@ -139,29 +88,14 @@ def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
||||
|
||||
|
||||
def get_public_key_base64url(private_key_base64url: str) -> str:
|
||||
"""Derive the Ed25519 public key from the private key seed.
|
||||
|
||||
Returns base64url-encoded 32-byte public key.
|
||||
"""
|
||||
"""Derive the base64url Ed25519 public key from a base64url seed."""
|
||||
seed = base64url_decode(private_key_base64url)
|
||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
pub = key.public_key()
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
raw_bytes = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
return base64url_encode(raw_bytes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EOR signing
|
||||
# ---------------------------------------------------------------------------
|
||||
public_key = Ed25519PrivateKey.from_private_bytes(seed).public_key()
|
||||
return base64url_encode(public_key.public_bytes(Encoding.Raw, PublicFormat.Raw))
|
||||
|
||||
|
||||
def sign_eor(eor_dict: Dict[str, Any], private_key_base64url: str) -> str:
|
||||
"""Sign an EOR by canonicalizing all fields except 'signature', then signing.
|
||||
|
||||
Returns the base64url-encoded Ed25519 signature.
|
||||
"""
|
||||
# Build the signable object: all EOR fields except 'signature'
|
||||
"""Sign the JCS form of every EOR field except signature."""
|
||||
signable = {k: v for k, v in eor_dict.items() if k != "signature"}
|
||||
canonical = jcs_canonicalize(signable)
|
||||
return sign_ed25519(private_key_base64url, canonical.encode("utf-8"))
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import stat
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from ._managed_files import same_file_metadata
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
@@ -27,16 +28,6 @@ def _regular_identity(
|
||||
raise OSError(f"{label} changed while copying: {path}")
|
||||
|
||||
|
||||
def _stable_metadata(metadata: os.stat_result) -> tuple[int, int, int]:
|
||||
# Timestamps are excluded: Windows exposes file times with delayed
|
||||
# cross-handle visibility, so they are unsound identity evidence.
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
metadata.st_size,
|
||||
)
|
||||
|
||||
|
||||
def copy_into_reserved(
|
||||
directory: PinnedDirectory,
|
||||
source_path: str,
|
||||
@@ -88,7 +79,7 @@ def copy_into_reserved(
|
||||
directory.chmod_descriptor(destination_descriptor, mode)
|
||||
os.fsync(destination_descriptor)
|
||||
after = os.fstat(source_descriptor)
|
||||
if _stable_metadata(after) != _stable_metadata(before):
|
||||
if not same_file_metadata(after, before):
|
||||
raise OSError(f"{label} changed while copying: {source_path}")
|
||||
finally:
|
||||
for descriptor in (destination_descriptor, source_descriptor):
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, Optional
|
||||
JsonObject = Dict[str, Any]
|
||||
|
||||
|
||||
def _cleanup_failed_write(path: str, label: str, cause: Exception) -> None:
|
||||
def _cleanup_failed_write(path: str, label: str, cause: BaseException) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
@@ -48,7 +48,7 @@ def write_text_atomic(path: str, content: str, mode: int, label: str) -> None:
|
||||
os.fsync(file.fileno())
|
||||
os.chmod(temporary_path, mode)
|
||||
os.replace(temporary_path, path)
|
||||
except Exception as error:
|
||||
except BaseException as error:
|
||||
if descriptor >= 0:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
@@ -59,13 +59,32 @@ def write_text_atomic(path: str, content: str, mode: int, label: str) -> None:
|
||||
f"close failed: {close_error}"
|
||||
) from error
|
||||
_cleanup_failed_write(temporary_path, label, error)
|
||||
raise OSError(f"Write {label} at {path}: {error}") from error
|
||||
if isinstance(error, Exception):
|
||||
raise OSError(f"Write {label} at {path}: {error}") from error
|
||||
raise
|
||||
|
||||
|
||||
def write_json_atomic(path: str, value: JsonObject, mode: int, label: str) -> None:
|
||||
write_text_atomic(path, json.dumps(value, indent=2) + "\n", mode, label)
|
||||
|
||||
|
||||
def read_json(path: str, label: str) -> Optional[JsonObject]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
raw = file.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"Failed to parse {label} at {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} at {path} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def remove_file(path: str, label: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -83,27 +102,3 @@ def regular_file_exists(path: str, label: str) -> bool:
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
return stat.S_ISREG(metadata.st_mode)
|
||||
|
||||
|
||||
def require_runtime(path: str, label: str) -> None:
|
||||
if not path:
|
||||
raise ValueError(f"{label} path is required")
|
||||
if not regular_file_exists(path, label):
|
||||
raise FileNotFoundError(f"{label} is missing: {path}")
|
||||
|
||||
|
||||
def read_json(path: str, label: str) -> Optional[JsonObject]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
raw = file.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"Failed to parse {label} at {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} at {path} must contain a JSON object")
|
||||
return value
|
||||
@@ -28,14 +28,7 @@ class DirectorySnapshot:
|
||||
|
||||
|
||||
def identity_mode_bits(mode: int) -> int:
|
||||
"""Collapse permission bits that are not identity evidence on Windows.
|
||||
|
||||
Windows synthesizes POSIX modes and derives execute bits from the file
|
||||
extension for path stats but not for handle stats, so the synthetic value
|
||||
cannot serve as identity there. Windows permissions are enforced through
|
||||
DACLs in _windows_security instead. Captured snapshots still store the
|
||||
raw bits because restoration re-applies them.
|
||||
"""
|
||||
"""Windows synthesizes mode bits, so they are not identity there."""
|
||||
return mode if os.name != "nt" else 0
|
||||
|
||||
|
||||
@@ -44,12 +37,8 @@ def portable_file_mode(metadata: os.stat_result) -> int:
|
||||
return identity_mode_bits(stat.S_IMODE(metadata.st_mode))
|
||||
|
||||
|
||||
def _stable_file_metadata(
|
||||
metadata: os.stat_result,
|
||||
) -> tuple[int, int, int, int]:
|
||||
# Timestamps are excluded deliberately: Windows exposes file times with
|
||||
# delayed cross-handle visibility, so they are unsound identity evidence.
|
||||
# Content changes are caught by the verification re-read instead.
|
||||
def stable_file_metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
||||
"""Identity without timestamps; Windows file times lag across handles."""
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
@@ -58,11 +47,8 @@ def _stable_file_metadata(
|
||||
)
|
||||
|
||||
|
||||
def _same_file_metadata(
|
||||
first: os.stat_result,
|
||||
second: os.stat_result,
|
||||
) -> bool:
|
||||
return _stable_file_metadata(first) == _stable_file_metadata(second)
|
||||
def same_file_metadata(first: os.stat_result, second: os.stat_result) -> bool:
|
||||
return stable_file_metadata(first) == stable_file_metadata(second)
|
||||
|
||||
|
||||
def assert_content_stable(
|
||||
@@ -73,12 +59,7 @@ def assert_content_stable(
|
||||
raw: bytes,
|
||||
maximum_bytes: int,
|
||||
) -> None:
|
||||
"""Re-read a just-read file and require identical identity and bytes.
|
||||
|
||||
This is the deterministic replacement for timestamp comparisons: a
|
||||
concurrent in-place rewrite is proven by content divergence between the
|
||||
two reads, independent of filesystem timer visibility.
|
||||
"""
|
||||
"""Re-read the file and require identical identity and bytes."""
|
||||
descriptor = opener()
|
||||
try:
|
||||
current = os.fstat(descriptor)
|
||||
@@ -110,26 +91,25 @@ def _inspect_regular_file(file_path: str, label: str) -> Optional[os.stat_result
|
||||
return metadata
|
||||
|
||||
|
||||
def read_physical_file(
|
||||
def read_file_snapshot(
|
||||
before: os.stat_result,
|
||||
opener: Callable[[int], int],
|
||||
file_path: str,
|
||||
label: str,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileSnapshot]:
|
||||
before = _inspect_regular_file(file_path, label)
|
||||
if before is None:
|
||||
return None
|
||||
maximum_bytes: int,
|
||||
) -> FileSnapshot:
|
||||
"""Read one regular file through opener and prove it stayed stable."""
|
||||
if before.st_size > maximum_bytes:
|
||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = os.open(file_path, flags)
|
||||
descriptor = opener(flags)
|
||||
after = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(after.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
if not _same_file_metadata(before, after):
|
||||
if not same_file_metadata(before, after):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
if after.st_size > maximum_bytes:
|
||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||
@@ -137,15 +117,10 @@ def read_physical_file(
|
||||
descriptor = -1
|
||||
raw = file.read(maximum_bytes + 1)
|
||||
finished = os.fstat(file.fileno())
|
||||
if not _same_file_metadata(finished, after):
|
||||
if not same_file_metadata(finished, after):
|
||||
raise OSError(f"{label} changed while reading: {file_path}")
|
||||
assert_content_stable(
|
||||
lambda: os.open(file_path, flags),
|
||||
file_path,
|
||||
label,
|
||||
after,
|
||||
raw,
|
||||
maximum_bytes,
|
||||
lambda: opener(flags), file_path, label, after, raw, maximum_bytes
|
||||
)
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
@@ -159,11 +134,19 @@ def read_physical_file(
|
||||
contents = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error
|
||||
return FileSnapshot(
|
||||
contents=contents,
|
||||
device=after.st_dev,
|
||||
inode=after.st_ino,
|
||||
mode=stat.S_IMODE(after.st_mode),
|
||||
return FileSnapshot(contents, after.st_dev, after.st_ino, stat.S_IMODE(after.st_mode))
|
||||
|
||||
|
||||
def read_physical_file(
|
||||
file_path: str,
|
||||
label: str,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileSnapshot]:
|
||||
before = _inspect_regular_file(file_path, label)
|
||||
if before is None:
|
||||
return None
|
||||
return read_file_snapshot(
|
||||
before, lambda flags: os.open(file_path, flags), file_path, label, maximum_bytes
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import stat
|
||||
from typing import Optional
|
||||
|
||||
from ._managed_files import portable_file_mode
|
||||
from ._managed_files import same_file_metadata
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
@@ -17,12 +17,7 @@ _HASH_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpaqueFileSnapshot:
|
||||
"""Content and physical identity without retaining file bytes in memory.
|
||||
|
||||
Identity is content-based (device, inode, mode, size, SHA-256). File
|
||||
timestamps are excluded because Windows exposes them with delayed
|
||||
visibility across handles, which makes them unsound identity evidence.
|
||||
"""
|
||||
"""Device, inode, mode, size, and SHA-256 without retaining the bytes."""
|
||||
|
||||
device: int
|
||||
inode: int
|
||||
@@ -31,19 +26,6 @@ class OpaqueFileSnapshot:
|
||||
sha256: str
|
||||
|
||||
|
||||
def _metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
portable_file_mode(metadata),
|
||||
metadata.st_size,
|
||||
)
|
||||
|
||||
|
||||
def _same_metadata(first: os.stat_result, second: os.stat_result) -> bool:
|
||||
return _metadata(first) == _metadata(second)
|
||||
|
||||
|
||||
def _require_regular(
|
||||
metadata: os.stat_result,
|
||||
file_path: str,
|
||||
@@ -82,7 +64,7 @@ def stream_opaque_snapshot(
|
||||
descriptor = directory.open_file(name, flags)
|
||||
opened = os.fstat(descriptor)
|
||||
_require_regular(opened, file_path, label, require_owner_only)
|
||||
if not _same_metadata(opened, before):
|
||||
if not same_file_metadata(opened, before):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
digest = hashlib.sha256()
|
||||
while True:
|
||||
@@ -92,7 +74,7 @@ def stream_opaque_snapshot(
|
||||
digest.update(chunk)
|
||||
finished = os.fstat(descriptor)
|
||||
_require_regular(finished, file_path, label, require_owner_only)
|
||||
if not _same_metadata(finished, opened):
|
||||
if not same_file_metadata(finished, opened):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
@@ -105,7 +87,7 @@ def stream_opaque_snapshot(
|
||||
except FileNotFoundError as error:
|
||||
raise OSError(f"{label} changed while hashing: {file_path}") from error
|
||||
_require_regular(visible, file_path, label, require_owner_only)
|
||||
if not _same_metadata(visible, finished):
|
||||
if not same_file_metadata(visible, finished):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
return OpaqueFileSnapshot(
|
||||
finished.st_dev,
|
||||
|
||||
@@ -10,8 +10,7 @@ from typing import Any, Optional, Tuple
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
_same_file_metadata,
|
||||
assert_content_stable,
|
||||
read_file_snapshot,
|
||||
read_physical_directory,
|
||||
)
|
||||
|
||||
@@ -381,8 +380,7 @@ class PinnedDirectory:
|
||||
"""Persist directory entries where the host exposes directory fsync."""
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
# NTFS journals atomic rename metadata. Windows directory handles do
|
||||
# not support FlushFileBuffers, while every staged file is flushed.
|
||||
# Windows directory handles do not support FlushFileBuffers.
|
||||
return
|
||||
if os.name != "posix":
|
||||
raise OSError(f"Unsupported transaction platform: {os.name}")
|
||||
@@ -410,71 +408,21 @@ class PinnedDirectory:
|
||||
label: str,
|
||||
maximum_bytes: int,
|
||||
) -> Optional[FileSnapshot]:
|
||||
file_path = self.path_for(name)
|
||||
try:
|
||||
before = self.stat_file(name)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {self.path_for(name)}: {error}") from error
|
||||
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||
if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {self.path_for(name)}")
|
||||
if before.st_size > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = self.open_file(name, flags)
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(
|
||||
before,
|
||||
opened,
|
||||
):
|
||||
raise OSError(f"{label} changed while opening: {self.path_for(name)}")
|
||||
if opened.st_size > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
with os.fdopen(descriptor, "rb") as file:
|
||||
descriptor = -1
|
||||
raw = file.read(maximum_bytes + 1)
|
||||
finished = os.fstat(file.fileno())
|
||||
if not _same_file_metadata(finished, opened):
|
||||
raise OSError(
|
||||
f"{label} changed while reading: {self.path_for(name)}"
|
||||
)
|
||||
assert_content_stable(
|
||||
lambda: self.open_file(name, flags),
|
||||
self.path_for(name),
|
||||
label,
|
||||
opened,
|
||||
raw,
|
||||
maximum_bytes,
|
||||
)
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {self.path_for(name)}: {error}") from error
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
if len(raw) > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
try:
|
||||
contents = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError(
|
||||
f"{label} at {self.path_for(name)} must contain UTF-8 text"
|
||||
) from error
|
||||
return FileSnapshot(
|
||||
contents,
|
||||
opened.st_dev,
|
||||
opened.st_ino,
|
||||
stat.S_IMODE(opened.st_mode),
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
return read_file_snapshot(
|
||||
before,
|
||||
lambda flags: self.open_file(name, flags),
|
||||
file_path,
|
||||
label,
|
||||
maximum_bytes,
|
||||
)
|
||||
|
||||
def chmod_file(
|
||||
|
||||
@@ -12,9 +12,12 @@ from ._managed_files import DirectorySnapshot, FileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
_RMDIR_SUPPORTS_DIR_FD = os.rmdir in os.supports_dir_fd
|
||||
|
||||
|
||||
def _remove_posix_private_directory(parent_descriptor: int, name: str) -> None:
|
||||
"""Remove one empty child through the platform's native *at semantics."""
|
||||
if os.rmdir not in os.supports_dir_fd:
|
||||
if not _RMDIR_SUPPORTS_DIR_FD:
|
||||
raise OSError("Host does not support identity-bound directory removal")
|
||||
os.rmdir(name, dir_fd=parent_descriptor)
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Runtime directory identity, config validation, and status checks shared by providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
|
||||
import urllib.parse
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, FilePrecondition, file_change, source_change
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
GUARD_SCRIPT = "guard.py"
|
||||
AUDIT_SCRIPT = "hook.py"
|
||||
DEFAULT_BASE_URL = "https://api.elydora.com"
|
||||
RUNTIME_CONFIG_FIELDS = frozenset(
|
||||
{"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
)
|
||||
RUNTIME_ARTIFACTS = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
class RuntimeContractLike(Protocol):
|
||||
@property
|
||||
def agent_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def guard_path(self) -> str: ...
|
||||
|
||||
@property
|
||||
def audit_path(self) -> str: ...
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_agent_id(value: Any, expected: str) -> bool:
|
||||
return isinstance(value, str) and os.path.normcase(value) == os.path.normcase(
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def managed_script_reference(
|
||||
script_path: str, script_name: str
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Return (agent_id, script_path) for an absolute ~/.elydora/<id>/<script_name>."""
|
||||
if not os.path.isabs(script_path) or os.path.basename(script_path) != script_name:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in ("", ".", ".."):
|
||||
return None
|
||||
return agent_id, script_path
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(file_path, "Elydora runtime config", MAX_CONFIG_BYTES)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(snapshot.contents, f"Elydora runtime config at {file_path}")
|
||||
|
||||
|
||||
def validate_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
agent_key: str,
|
||||
product_label: str,
|
||||
) -> None:
|
||||
extra = next((key for key in config if key not in RUNTIME_CONFIG_FIELDS), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = require_non_empty_string(config.get("agent_id"), "agent_id", config_path)
|
||||
if not same_agent_id(agent_id, expected_agent_id) or config.get("agent_name") != agent_key:
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match {product_label} hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = require_non_empty_string(config.get("base_url"), "base_url", config_path)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def runtime_config_source(
|
||||
config: InstallConfig, agent_id: str, agent_key: str, **extra: str
|
||||
) -> str:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", DEFAULT_BASE_URL),
|
||||
"agent_name": agent_key,
|
||||
**extra,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def resolve_runtime_paths(config: InstallConfig) -> RuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
if not same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
return RuntimePaths(
|
||||
agent_id, agent_directory, guard_path, os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
)
|
||||
|
||||
|
||||
def validate_install_config(
|
||||
config: InstallConfig, agent_key: str, product_label: str
|
||||
) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != agent_key:
|
||||
raise ValueError(f"{product_label} installation requires agent_name {agent_key}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
resolve_runtime_paths(config)
|
||||
|
||||
|
||||
def validate_runtime_tree(
|
||||
agent_directory: str,
|
||||
agent_id: str,
|
||||
agent_key: str,
|
||||
product_label: str,
|
||||
extra_artifacts: Tuple[Tuple[str, str], ...] = (),
|
||||
) -> None:
|
||||
"""Reject an existing agent directory that belongs to another identity."""
|
||||
if not physical_directory_exists(runtime_root(), "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(agent_directory, "Elydora agent runtime directory"):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = read_runtime_config(config_path)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in (*RUNTIME_ARTIFACTS, *extra_artifacts)
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
if (
|
||||
not same_agent_id(config.get("agent_id"), agent_id)
|
||||
or config.get("agent_name") != agent_key
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match {product_label} agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
ExpectedScripts = Callable[[str, JsonObject], Tuple[str, str]]
|
||||
|
||||
|
||||
def expected_runtime_scripts(
|
||||
agent_key: str,
|
||||
agent_id: str,
|
||||
config: Optional[JsonObject] = None,
|
||||
**hook_options: Any,
|
||||
) -> Tuple[str, str]:
|
||||
"""Guard and audit sources; identity fields come from config when given."""
|
||||
org_id, kid, base_url = (
|
||||
("", "", "")
|
||||
if config is None
|
||||
else (str(config["org_id"]), str(config["kid"]), str(config["base_url"]))
|
||||
)
|
||||
return (
|
||||
generate_guard_script(agent_key, agent_id),
|
||||
generate_hook_script(
|
||||
org_id=org_id,
|
||||
agent_id=agent_id,
|
||||
kid=kid,
|
||||
base_url=base_url,
|
||||
native_payload=True,
|
||||
agent_name=agent_key,
|
||||
**hook_options,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def runtime_contract_exists(
|
||||
contract: RuntimeContractLike,
|
||||
agent_key: str,
|
||||
product_label: str,
|
||||
expected_scripts: Optional[ExpectedScripts] = None,
|
||||
) -> bool:
|
||||
"""Strict status check: valid config and key plus runtime scripts."""
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not same_path(os.path.dirname(agent_directory), root) or not same_path(
|
||||
contract.audit_path, os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(agent_directory, "Elydora agent runtime directory"):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = read_runtime_config(config_path)
|
||||
key = read_physical_file(
|
||||
os.path.join(agent_directory, "private.key"), "Elydora private key", MAX_SECRET_BYTES
|
||||
)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
validate_runtime_config(config, contract.agent_id, config_path, agent_key, product_label)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
if expected_scripts is None:
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
expected_guard, expected_audit = expected_scripts(contract.agent_id, config)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def runtime_present(contract: RuntimeContractLike, agent_key: str) -> bool:
|
||||
"""Presence-only status check; identity mismatch reports False."""
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(agent_directory, "Elydora agent runtime directory"):
|
||||
return False
|
||||
config = read_runtime_config(os.path.join(agent_directory, "config.json"))
|
||||
if (
|
||||
config is None
|
||||
or config.get("agent_name") != agent_key
|
||||
or not same_agent_id(config.get("agent_id"), contract.agent_id)
|
||||
):
|
||||
return False
|
||||
return all(
|
||||
(
|
||||
physical_file_exists(contract.guard_path, "Elydora guard runtime"),
|
||||
physical_file_exists(contract.audit_path, "Elydora audit runtime"),
|
||||
physical_file_exists(
|
||||
os.path.join(agent_directory, "private.key"), "Elydora private key"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def runtime_file_changes(
|
||||
config: InstallConfig,
|
||||
paths: RuntimePaths,
|
||||
agent_key: str,
|
||||
guard_script: str,
|
||||
audit_script: str,
|
||||
) -> List[FileChange]:
|
||||
"""Changes for the guard, config, key, and audit runtime files."""
|
||||
return present([
|
||||
file_change(paths.guard_path, "Elydora guard runtime", guard_script, 0o700),
|
||||
file_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
runtime_config_source(config, paths.agent_id, agent_key),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
file_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
file_change(paths.audit_path, "Elydora audit runtime", audit_script, 0o700),
|
||||
])
|
||||
|
||||
|
||||
def rendered_source_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
original: Optional[str],
|
||||
next_source: Optional[str],
|
||||
) -> Optional[FileChange]:
|
||||
return source_change(file_path, label, original, next_source, 0o600, MAX_SOURCE_BYTES)
|
||||
|
||||
|
||||
def unique_preconditions(
|
||||
values: Sequence[FilePrecondition],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
result: Dict[str, FilePrecondition] = {}
|
||||
for value in values:
|
||||
result.setdefault(os.path.normcase(os.path.abspath(value.file_path)), value)
|
||||
return tuple(result.values())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Shell quoting and parsing for generated hook commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
CommandParts = Tuple[str, str]
|
||||
POWERSHELL_EXIT_SUFFIX = "; exit $LASTEXITCODE"
|
||||
_POSIX_APOSTROPHE = "'\"'\"'"
|
||||
_ENCODED_COMMAND = re.compile(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})"
|
||||
)
|
||||
_PYTHON_EXECUTABLE = re.compile(
|
||||
r"(?:python(?:[0-9]+(?:\.[0-9]+)*)?[td]?|pypy(?:[0-9]+(?:\.[0-9]+)*)?)(?:\.exe)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", _POSIX_APOSTROPHE) + "'"
|
||||
|
||||
|
||||
def quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def posix_source(script_path: str) -> str:
|
||||
return f"{quote_posix(sys.executable)} {quote_posix(script_path)}"
|
||||
|
||||
|
||||
def powershell_source(script_path: str) -> str:
|
||||
return (
|
||||
f"& {quote_powershell(sys.executable)} {quote_powershell(script_path)}"
|
||||
f"{POWERSHELL_EXIT_SUFFIX}"
|
||||
)
|
||||
|
||||
|
||||
def windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(system_root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
|
||||
|
||||
def encode_powershell_source(source: str) -> str:
|
||||
return base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
|
||||
|
||||
def encoded_windows_command(script_path: str, preamble: str = "") -> str:
|
||||
encoded = encode_powershell_source(preamble + powershell_source(script_path))
|
||||
return (
|
||||
f'"{windows_powershell_path()}" -NoLogo -NoProfile -NonInteractive '
|
||||
f"-EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def is_python_executable(file_path: str) -> bool:
|
||||
return _PYTHON_EXECUTABLE.fullmatch(os.path.basename(file_path)) is not None
|
||||
|
||||
|
||||
def is_powershell_executable(file_path: str) -> bool:
|
||||
return ntpath.isabs(file_path) and ntpath.basename(file_path).lower() == "powershell.exe"
|
||||
|
||||
|
||||
def read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(_POSIX_APOSTROPHE, index):
|
||||
value += "'"
|
||||
index += len(_POSIX_APOSTROPHE)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def parse_posix_command(command: str) -> Optional[CommandParts]:
|
||||
executable = read_posix_argument(command, 0)
|
||||
if executable is None or command[executable[1] : executable[1] + 1] != " ":
|
||||
return None
|
||||
script = read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def read_powershell_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def parse_powershell_source(source: str, prefix: str = "& ") -> Optional[CommandParts]:
|
||||
if not source.startswith(prefix):
|
||||
return None
|
||||
executable = read_powershell_argument(source, len(prefix))
|
||||
if executable is None or source[executable[1] : executable[1] + 1] != " ":
|
||||
return None
|
||||
script = read_powershell_argument(source, executable[1] + 1)
|
||||
if script is None or source[script[1] :] != POWERSHELL_EXIT_SUFFIX:
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def decode_powershell_source(encoded: str) -> Optional[str]:
|
||||
if re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", encoded) is None:
|
||||
return None
|
||||
try:
|
||||
payload = base64.b64decode(encoded, validate=True)
|
||||
if base64.b64encode(payload).decode("ascii") != encoded or len(payload) % 2:
|
||||
return None
|
||||
return payload.decode("utf-16le")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def same_windows_path(left: str, right: str) -> bool:
|
||||
return ntpath.normcase(ntpath.abspath(left)) == ntpath.normcase(ntpath.abspath(right))
|
||||
|
||||
|
||||
def parse_encoded_windows_command(
|
||||
command: str,
|
||||
prefix: str = "& ",
|
||||
powershell_path: Optional[str] = None,
|
||||
) -> Optional[CommandParts]:
|
||||
match = _ENCODED_COMMAND.fullmatch(command)
|
||||
if match is None or not is_powershell_executable(match.group(1)):
|
||||
return None
|
||||
if powershell_path is not None and not same_windows_path(match.group(1), powershell_path):
|
||||
return None
|
||||
source = decode_powershell_source(match.group(2))
|
||||
return None if source is None else parse_powershell_source(source, prefix)
|
||||
@@ -6,12 +6,7 @@ import os
|
||||
from typing import List, Optional, Sequence, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._managed_files import FileSnapshot, MAX_SOURCE_BYTES, read_physical_file
|
||||
from ._opaque_cleanup import cleanup_opaque_removal
|
||||
from ._opaque_removal import (
|
||||
OpaqueRemovalChange,
|
||||
@@ -65,15 +60,6 @@ def recover_pending_transactions() -> None:
|
||||
_recover_pending_transactions()
|
||||
|
||||
|
||||
def read_optional(
|
||||
file_path: str,
|
||||
label: str,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[str]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
return None if snapshot is None else snapshot.contents
|
||||
|
||||
|
||||
def source_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
@@ -479,14 +465,3 @@ def write_changes(
|
||||
directory_preconditions,
|
||||
opaque_removals,
|
||||
)
|
||||
|
||||
|
||||
def regular_file_exists(file_path: str, label: str) -> bool:
|
||||
return physical_file_exists(file_path, label)
|
||||
|
||||
|
||||
def require_runtime(file_path: str, label: str) -> None:
|
||||
if not file_path:
|
||||
raise ValueError(f"{label} path is required")
|
||||
if not regular_file_exists(file_path, label):
|
||||
raise FileNotFoundError(f"{label} is missing: {file_path}")
|
||||
@@ -13,6 +13,8 @@ from ._transaction_recovery import remove_committed_creation
|
||||
from ._transaction_staging import (
|
||||
assert_transaction_preconditions,
|
||||
assert_unchanged,
|
||||
read_target,
|
||||
same_identity_and_contents,
|
||||
same_snapshot,
|
||||
)
|
||||
from ._transaction_types import FileChange, StagedChange
|
||||
@@ -38,14 +40,6 @@ def _original_snapshot(change: FileChange) -> Optional[FileSnapshot]:
|
||||
)
|
||||
|
||||
|
||||
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_temporary(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
if staged.temporary_path is None:
|
||||
return None
|
||||
@@ -66,18 +60,6 @@ def _read_rollback(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
)
|
||||
|
||||
|
||||
def _same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
def _preserve_rollback(staged: StagedChange, error: Exception) -> OSError:
|
||||
if staged.rollback_path is None:
|
||||
return error if isinstance(error, OSError) else OSError(str(error))
|
||||
@@ -97,7 +79,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None:
|
||||
raise OSError(f"Missing rollback reservation for {staged.change.label}")
|
||||
capture_file(staged.change.file_path, rollback_path, staged.directory)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, original):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at commit boundary: "
|
||||
@@ -119,7 +101,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None:
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, original):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed while preparing rollback: "
|
||||
@@ -151,7 +133,7 @@ def _replace_next(
|
||||
raise OSError(f"Missing staged file for {staged.change.label}")
|
||||
replace_file(temporary_path, staged.change.file_path, staged.directory)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
expected = _original_snapshot(staged.change)
|
||||
if not same_snapshot(current, expected):
|
||||
error = OSError(
|
||||
@@ -227,7 +209,7 @@ def commit_change(
|
||||
else:
|
||||
_replace_next(staged, replace_file, after_replace)
|
||||
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, staged.committed_snapshot):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed immediately after commit: "
|
||||
@@ -251,7 +233,7 @@ def _restore_rollback_file(
|
||||
rollback = staged.rollback_snapshot
|
||||
if rollback_path is None or rollback is None:
|
||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
||||
if not same_snapshot(_read_target(staged), expected_current):
|
||||
if not same_snapshot(read_target(staged), expected_current):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
@@ -263,7 +245,7 @@ def _restore_rollback_file(
|
||||
restore_file(rollback_path, staged.change.file_path, staged.directory)
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
if not same_snapshot(_read_target(staged), expected_current):
|
||||
if not same_snapshot(read_target(staged), expected_current):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
@@ -272,7 +254,7 @@ def _restore_rollback_file(
|
||||
),
|
||||
)
|
||||
current_rollback = _read_rollback(staged)
|
||||
if not _same_identity_and_contents(current_rollback, rollback):
|
||||
if not same_identity_and_contents(current_rollback, rollback):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
@@ -288,7 +270,7 @@ def _restore_rollback_file(
|
||||
)
|
||||
atomic_replace(staged.directory, rollback_path, staged.change.file_path)
|
||||
staged.rollback_path = None
|
||||
restored = _read_target(staged)
|
||||
restored = read_target(staged)
|
||||
if (
|
||||
restored is None
|
||||
or restored.contents != expected.contents
|
||||
@@ -307,7 +289,7 @@ def rollback_change(
|
||||
) -> None:
|
||||
if not staged.committed:
|
||||
return
|
||||
current = _read_target(staged)
|
||||
current = read_target(staged)
|
||||
if not same_snapshot(current, staged.committed_snapshot):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
|
||||
@@ -13,7 +13,7 @@ from uuid import uuid4
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
_same_file_metadata,
|
||||
same_file_metadata,
|
||||
identity_mode_bits,
|
||||
)
|
||||
from ._opaque_snapshot import OpaqueFileSnapshot
|
||||
@@ -300,7 +300,7 @@ def inspect_fingerprint(
|
||||
descriptor = directory.open_file(name, flags)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(before, opened):
|
||||
if not stat.S_ISREG(opened.st_mode) or not same_file_metadata(before, opened):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
@@ -311,12 +311,12 @@ def inspect_fingerprint(
|
||||
digest.update(chunk)
|
||||
size += len(chunk)
|
||||
finished = os.fstat(descriptor)
|
||||
if not _same_file_metadata(finished, opened):
|
||||
if not same_file_metadata(finished, opened):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
visible = directory.stat_file(name)
|
||||
if not _same_file_metadata(visible, finished):
|
||||
if not same_file_metadata(visible, finished):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
return FileFingerprint(
|
||||
digest.hexdigest(),
|
||||
@@ -419,7 +419,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not _same_file_metadata(opened, metadata):
|
||||
if not same_file_metadata(opened, metadata):
|
||||
raise OSError(f"Transaction journal changed while opening: {path}")
|
||||
chunks = []
|
||||
remaining = MAX_JOURNAL_BYTES + 1
|
||||
@@ -431,7 +431,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
finished = os.fstat(descriptor)
|
||||
if not _same_file_metadata(finished, opened):
|
||||
if not same_file_metadata(finished, opened):
|
||||
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -439,7 +439,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
||||
visible = os.lstat(path)
|
||||
except FileNotFoundError as error:
|
||||
raise OSError(f"Transaction journal changed while reading: {path}") from error
|
||||
if not _same_file_metadata(visible, finished):
|
||||
if not same_file_metadata(visible, finished):
|
||||
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||
if len(raw) > MAX_JOURNAL_BYTES:
|
||||
raise ValueError("Transaction journal exceeds its size limit")
|
||||
|
||||
@@ -9,21 +9,13 @@ from uuid import uuid4
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._private_artifact import QuarantinedFile, reserve_private_path
|
||||
from ._transaction_staging import same_snapshot
|
||||
from ._transaction_staging import read_target, same_snapshot
|
||||
from ._transaction_types import StagedChange
|
||||
|
||||
|
||||
ReplaceFile = Callable[[str, str, PinnedDirectory], None]
|
||||
|
||||
|
||||
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_private_path(
|
||||
staged: StagedChange,
|
||||
file_path: str,
|
||||
@@ -36,18 +28,6 @@ def _read_private_path(
|
||||
)
|
||||
|
||||
|
||||
def _same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
def _recovery_path(staged: StagedChange) -> tuple[str, FileSnapshot]:
|
||||
basename = os.path.basename(staged.change.file_path)
|
||||
path = staged.directory.path_for(f".{basename}.{uuid4().hex}.recovery")
|
||||
@@ -63,76 +43,6 @@ def _preserve_private_path(error: Exception, file_path: str) -> OSError:
|
||||
return OSError(f"{error}; transaction data preserved at {file_path}")
|
||||
|
||||
|
||||
def _release_rollback_source(staged: StagedChange, source_path: str) -> bool:
|
||||
if staged.rollback_path is not None and os.path.normcase(
|
||||
os.path.abspath(staged.rollback_path)
|
||||
) == os.path.normcase(os.path.abspath(source_path)):
|
||||
staged.rollback_path = None
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _preserve_source_path(
|
||||
error: Exception,
|
||||
file_path: str,
|
||||
original_source: bool,
|
||||
) -> OSError:
|
||||
kind = "original content" if original_source else "transaction data"
|
||||
return OSError(f"{error}; {kind} preserved at {file_path}")
|
||||
|
||||
|
||||
def install_private_file(
|
||||
staged: StagedChange,
|
||||
source_path: str,
|
||||
expected: FileSnapshot,
|
||||
replace_file: ReplaceFile,
|
||||
) -> None:
|
||||
try:
|
||||
replace_file(source_path, staged.change.file_path, staged.directory)
|
||||
except Exception:
|
||||
raise
|
||||
captured = QuarantinedFile.capture(
|
||||
staged.directory,
|
||||
source_path,
|
||||
f"{staged.change.label} recovery source",
|
||||
)
|
||||
original_source = _release_rollback_source(staged, source_path)
|
||||
captured_snapshot = captured.text_snapshot(staged.change.maximum_bytes)
|
||||
if not same_snapshot(captured_snapshot, expected):
|
||||
try:
|
||||
captured.restore(source_path)
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else source_path
|
||||
raise OSError(
|
||||
f"{error}; concurrent data preserved at {preserved}"
|
||||
) from error
|
||||
raise OSError(
|
||||
f"{staged.change.label} recovery source changed: {source_path}; "
|
||||
f"concurrent data preserved at {source_path}"
|
||||
)
|
||||
try:
|
||||
captured.link(staged.change.file_path)
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else source_path
|
||||
raise _preserve_source_path(
|
||||
error,
|
||||
preserved,
|
||||
original_source,
|
||||
) from error
|
||||
restored = _read_target(staged)
|
||||
if not same_snapshot(restored, expected):
|
||||
preserved = captured.preserve()
|
||||
raise _preserve_source_path(
|
||||
OSError(
|
||||
f"{staged.change.label} changed while installing recovery data: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
preserved,
|
||||
original_source,
|
||||
)
|
||||
captured.discard()
|
||||
|
||||
|
||||
def capture_recovery_target(
|
||||
staged: StagedChange,
|
||||
expected: FileSnapshot,
|
||||
@@ -263,7 +173,7 @@ def remove_committed_creation(
|
||||
replace_file,
|
||||
capture_file,
|
||||
)
|
||||
if _read_target(staged) is not None:
|
||||
if read_target(staged) is not None:
|
||||
discard_private_file(staged, recovery_path, recovery_snapshot)
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed after recovery capture: "
|
||||
@@ -275,6 +185,5 @@ def remove_committed_creation(
|
||||
__all__ = [
|
||||
"capture_recovery_target",
|
||||
"discard_private_file",
|
||||
"install_private_file",
|
||||
"remove_committed_creation",
|
||||
]
|
||||
@@ -46,6 +46,26 @@ def same_snapshot(
|
||||
return current == expected
|
||||
|
||||
|
||||
def read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
def remove_optional(directory: PinnedDirectory, file_path: Optional[str]) -> None:
|
||||
if not file_path:
|
||||
return
|
||||
@@ -338,8 +358,7 @@ def _remove_exact_text(
|
||||
!= (expected.device, expected.inode)
|
||||
)
|
||||
if not changed:
|
||||
# An in-place rewrite keeps the inode, so the removal is only exact
|
||||
# once the bytes also match the owned snapshot.
|
||||
# An in-place rewrite keeps the inode; bytes must match too.
|
||||
try:
|
||||
snapshot = captured.text_snapshot(
|
||||
len(expected.contents.encode("utf-8"))
|
||||
|
||||
@@ -210,12 +210,6 @@ def _current_user_sid() -> Tuple[Any, str]:
|
||||
close_handle(int(token_value))
|
||||
|
||||
|
||||
def current_user_sid_string() -> str:
|
||||
"""Return the stable current-user SID used by the state namespace."""
|
||||
_sid, sid_text = _current_user_sid()
|
||||
return sid_text
|
||||
|
||||
|
||||
def _security_descriptor(directory: bool) -> Tuple[int, int]:
|
||||
_sid, sid_text = _current_user_sid()
|
||||
inheritance = "OICI" if directory else ""
|
||||
@@ -491,7 +485,6 @@ def verify_private_path(path: str, *, directory: bool) -> None:
|
||||
|
||||
__all__ = [
|
||||
"close_handle",
|
||||
"current_user_sid_string",
|
||||
"ensure_private_directory",
|
||||
"open_private_file",
|
||||
"verify_private_path",
|
||||
|
||||
@@ -9,6 +9,9 @@ import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._strict_json import parse_json_object
|
||||
|
||||
|
||||
@@ -69,10 +72,6 @@ def home_dir() -> str:
|
||||
return os.path.expanduser("~")
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(home_dir(), ".elydora")
|
||||
|
||||
|
||||
def resolve_config_path() -> str:
|
||||
return os.path.join(home_dir(), ".augment", "settings.json")
|
||||
|
||||
@@ -162,16 +161,6 @@ def _parse_wrapper_command(command: str) -> Optional[str]:
|
||||
return parser(command)
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _managed_agent_id(handler: JsonObject, wrapper_name: str) -> Optional[str]:
|
||||
if (
|
||||
handler.get("type") != "command"
|
||||
@@ -188,7 +177,7 @@ def _managed_agent_id(handler: JsonObject, wrapper_name: str) -> Optional[str]:
|
||||
):
|
||||
return None
|
||||
agent_directory = os.path.dirname(wrapper_path)
|
||||
if not _same_path(os.path.dirname(agent_directory), elydora_dir()):
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
@@ -324,7 +313,7 @@ def _remove_managed(
|
||||
for handler in group["hooks"]:
|
||||
managed_id = _managed_agent_id(handler, wrapper_name)
|
||||
remove = managed_id is not None and (
|
||||
not agent_id or _same_agent_id(managed_id, agent_id)
|
||||
not agent_id or same_agent_id(managed_id, agent_id)
|
||||
)
|
||||
if remove:
|
||||
changed = True
|
||||
@@ -376,12 +365,12 @@ def runtime_contracts(hooks: AugmentHooks) -> List[RuntimeContract]:
|
||||
contracts: List[RuntimeContract] = []
|
||||
for guard_id in sorted(guards, key=os.path.normcase):
|
||||
audit_id = next(
|
||||
(candidate for candidate in audits if _same_agent_id(candidate, guard_id)),
|
||||
(candidate for candidate in audits if same_agent_id(candidate, guard_id)),
|
||||
None,
|
||||
)
|
||||
if audit_id is None:
|
||||
continue
|
||||
agent_directory = os.path.join(elydora_dir(), guard_id)
|
||||
agent_directory = os.path.join(runtime_root(), guard_id)
|
||||
contracts.append(
|
||||
RuntimeContract(
|
||||
agent_id=guard_id,
|
||||
|
||||
@@ -3,107 +3,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, file_change, write_changes
|
||||
from .augment_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
AUDIT_WRAPPER,
|
||||
GUARD_WRAPPER,
|
||||
AugmentDocument,
|
||||
RenderedAugmentDocument,
|
||||
build_wrapper,
|
||||
elydora_dir,
|
||||
wrapper_paths,
|
||||
)
|
||||
from .augment_io import (
|
||||
rendered_change,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .augment_io import rendered_change
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
WRAPPER_ARTIFACTS = (
|
||||
(GUARD_WRAPPER, "Auggie guard wrapper"),
|
||||
(AUDIT_WRAPPER, "Auggie audit wrapper"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AugmentRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
class AugmentRuntimePaths(RuntimePaths):
|
||||
guard_wrapper_path: str
|
||||
audit_wrapper_path: str
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> AugmentRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not _same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
wrappers = wrapper_paths(agent_directory)
|
||||
paths = resolve_runtime_paths(config)
|
||||
wrappers = wrapper_paths(paths.agent_directory)
|
||||
return AugmentRuntimePaths(
|
||||
agent_id,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
paths.agent_id,
|
||||
paths.agent_directory,
|
||||
paths.guard_path,
|
||||
paths.audit_path,
|
||||
wrappers.guard_path,
|
||||
wrappers.audit_path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Augment Code CLI installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
def _validate_runtime_tree(paths: AugmentRuntimePaths) -> None:
|
||||
validate_runtime_tree(
|
||||
paths.agent_directory, paths.agent_id, AGENT_KEY, "Auggie", WRAPPER_ARTIFACTS
|
||||
)
|
||||
|
||||
|
||||
def preflight_augment_installation(
|
||||
@@ -111,26 +65,12 @@ def preflight_augment_installation(
|
||||
) -> AugmentRuntimePaths:
|
||||
if not document.config_path:
|
||||
raise ValueError("Augment Code CLI installation requires a settings path")
|
||||
_validate_install_config(config)
|
||||
validate_install_config(config, AGENT_KEY, "Augment Code CLI")
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
_validate_runtime_tree(paths)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_augment_installation(
|
||||
config: InstallConfig,
|
||||
paths: AugmentRuntimePaths,
|
||||
@@ -138,54 +78,35 @@ def prepare_augment_installation(
|
||||
) -> List[FileChange]:
|
||||
if not rendered.changed and rendered.document.raw is None:
|
||||
raise ValueError("Auggie hook installation did not produce a settings document")
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
_validate_runtime_tree(paths)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present(
|
||||
[
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([
|
||||
file_change(
|
||||
paths.guard_wrapper_path,
|
||||
"Auggie guard wrapper",
|
||||
build_wrapper(paths.guard_path),
|
||||
0o700,
|
||||
),
|
||||
_runtime_change(
|
||||
file_change(
|
||||
paths.audit_wrapper_path,
|
||||
"Auggie audit wrapper",
|
||||
build_wrapper(paths.audit_path),
|
||||
0o700,
|
||||
),
|
||||
rendered_change(rendered),
|
||||
]
|
||||
)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
]),
|
||||
]
|
||||
_validate_runtime_tree(paths)
|
||||
return changes
|
||||
|
||||
|
||||
@@ -193,10 +114,8 @@ def commit_augment_installation(changes: List[FileChange]) -> None:
|
||||
write_changes(changes, "Install Augment Code CLI hooks")
|
||||
|
||||
|
||||
def prepare_augment_uninstall(
|
||||
rendered: RenderedAugmentDocument,
|
||||
) -> List[FileChange]:
|
||||
return _present([rendered_change(rendered)])
|
||||
def prepare_augment_uninstall(rendered: RenderedAugmentDocument) -> List[FileChange]:
|
||||
return present([rendered_change(rendered)])
|
||||
|
||||
|
||||
def commit_augment_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,61 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._managed_files import MAX_CONFIG_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import runtime_contract_exists, same_path
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .augment_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_WRAPPER,
|
||||
GUARD_SCRIPT,
|
||||
GUARD_WRAPPER,
|
||||
AugmentDocument,
|
||||
RenderedAugmentDocument,
|
||||
RuntimeContract,
|
||||
build_wrapper,
|
||||
create_augment_document,
|
||||
elydora_dir,
|
||||
parse_augment_document,
|
||||
resolve_config_path,
|
||||
)
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def read_augment_document() -> AugmentDocument:
|
||||
config_path = resolve_config_path()
|
||||
physical_directory_exists(
|
||||
os.path.dirname(config_path), "Auggie configuration directory"
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(config_path), "Auggie configuration directory")
|
||||
snapshot = read_physical_file(config_path, "Auggie user settings", MAX_CONFIG_BYTES)
|
||||
if snapshot is None:
|
||||
return create_augment_document(config_path)
|
||||
return parse_augment_document(config_path, snapshot.contents)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedAugmentDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedAugmentDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
@@ -75,192 +50,26 @@ def write_augment_document(rendered: RenderedAugmentDocument) -> None:
|
||||
write_changes([change], "Uninstall Augment Code CLI hooks")
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not _same_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Auggie hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(file_path, "Elydora runtime config", MAX_CONFIG_BYTES)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
(GUARD_WRAPPER, "Auggie guard wrapper"),
|
||||
(AUDIT_WRAPPER, "Auggie audit wrapper"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not _same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Auggie agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: RuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
def _wrappers_exist(contract: RuntimeContract) -> bool:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not _same_path(os.path.dirname(agent_directory), root)
|
||||
or not _same_path(
|
||||
contract.audit_path, os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
)
|
||||
or not _same_path(
|
||||
contract.guard_wrapper_path,
|
||||
os.path.join(agent_directory, GUARD_WRAPPER),
|
||||
)
|
||||
or not _same_path(
|
||||
contract.audit_wrapper_path,
|
||||
os.path.join(agent_directory, AUDIT_WRAPPER),
|
||||
)
|
||||
if not same_path(
|
||||
contract.guard_wrapper_path, os.path.join(agent_directory, GUARD_WRAPPER)
|
||||
) or not same_path(
|
||||
contract.audit_wrapper_path, os.path.join(agent_directory, AUDIT_WRAPPER)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
guard_wrapper = read_physical_file(
|
||||
contract.guard_wrapper_path, "Auggie guard wrapper"
|
||||
)
|
||||
audit_wrapper = read_physical_file(
|
||||
contract.audit_wrapper_path, "Auggie audit wrapper"
|
||||
)
|
||||
if any(
|
||||
item is None
|
||||
for item in (config, key, guard, audit, guard_wrapper, audit_wrapper)
|
||||
):
|
||||
return False
|
||||
assert config is not None
|
||||
assert key is not None
|
||||
assert guard is not None
|
||||
assert audit is not None
|
||||
assert guard_wrapper is not None
|
||||
assert audit_wrapper is not None
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
guard_wrapper = read_physical_file(contract.guard_wrapper_path, "Auggie guard wrapper")
|
||||
audit_wrapper = read_physical_file(contract.audit_wrapper_path, "Auggie audit wrapper")
|
||||
return (
|
||||
bool(guard.contents)
|
||||
and bool(audit.contents)
|
||||
guard_wrapper is not None
|
||||
and audit_wrapper is not None
|
||||
and guard_wrapper.contents == build_wrapper(contract.guard_path)
|
||||
and audit_wrapper.contents == build_wrapper(contract.audit_path)
|
||||
)
|
||||
|
||||
|
||||
def augment_runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Auggie") and _wrappers_exist(contract)
|
||||
for contract in contracts
|
||||
)
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import urllib.parse
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
|
||||
|
||||
@@ -82,20 +83,6 @@ class _RuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_claude_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_claude_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _require_known_keys(
|
||||
value: JsonObject, allowed: frozenset[str], label: str
|
||||
) -> None:
|
||||
@@ -292,15 +279,8 @@ def _exact_managed_group(group: JsonObject) -> bool:
|
||||
def _runtime_reference(
|
||||
script_path: str, script_name: str
|
||||
) -> Optional[_RuntimeReference]:
|
||||
if not os.path.isabs(script_path) or os.path.basename(script_path) != script_name:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_claude_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in ("", ".", ".."):
|
||||
return None
|
||||
return _RuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else _RuntimeReference(*reference)
|
||||
|
||||
|
||||
def _legacy_reference(
|
||||
@@ -314,7 +294,7 @@ def _legacy_reference(
|
||||
if (
|
||||
match is None
|
||||
or not os.path.isabs(match.group(1))
|
||||
or not same_claude_path(match.group(1), sys.executable)
|
||||
or not same_path(match.group(1), sys.executable)
|
||||
):
|
||||
return None
|
||||
return _runtime_reference(match.group(2), script_name)
|
||||
@@ -334,7 +314,7 @@ def _managed_reference(
|
||||
and handler.get("statusMessage") == status_message
|
||||
and isinstance(handler.get("command"), str)
|
||||
and os.path.isabs(str(handler["command"]))
|
||||
and same_claude_path(str(handler["command"]), sys.executable)
|
||||
and same_path(str(handler["command"]), sys.executable)
|
||||
and isinstance(handler.get("args"), list)
|
||||
and len(handler["args"]) == 1
|
||||
and isinstance(handler["args"][0], str)
|
||||
@@ -365,7 +345,7 @@ def _remove_from_groups(
|
||||
)
|
||||
owned = reference is not None and (
|
||||
not agent_id
|
||||
or same_claude_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if owned:
|
||||
removed = True
|
||||
@@ -485,7 +465,7 @@ def claude_runtime_contracts(
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_claude_path(success[0].script_path, failure[0].script_path):
|
||||
if not same_path(success[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(ClaudeRuntimeContract(
|
||||
guard[0].agent_id,
|
||||
|
||||
@@ -2,121 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .claudecode_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
ClaudeDocument,
|
||||
RenderedClaudeDocument,
|
||||
runtime_root,
|
||||
same_claude_path,
|
||||
)
|
||||
from .claudecode_io import (
|
||||
rendered_change,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .base import InstallConfig
|
||||
from .claudecode_contract import AGENT_KEY, ClaudeDocument, RenderedClaudeDocument
|
||||
from .claudecode_io import rendered_change
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> ClaudeRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_claude_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return ClaudeRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Claude Code installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Claude Code"
|
||||
|
||||
|
||||
def preflight_claude_installation(
|
||||
config: InstallConfig, document: ClaudeDocument
|
||||
) -> ClaudeRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not document.file_path:
|
||||
raise ValueError("Claude Code installation requires a settings path")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_claude_installation(
|
||||
config: InstallConfig,
|
||||
paths: ClaudeRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedClaudeDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
@@ -124,43 +44,22 @@ def prepare_claude_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def commit_claude_installation(changes: List[FileChange]) -> None:
|
||||
write_changes(changes, "Install Claude Code hooks")
|
||||
|
||||
|
||||
def prepare_claude_uninstall(
|
||||
rendered: RenderedClaudeDocument,
|
||||
) -> List[FileChange]:
|
||||
return _present([rendered_change(rendered)])
|
||||
def prepare_claude_uninstall(rendered: RenderedClaudeDocument) -> List[FileChange]:
|
||||
return present([rendered_change(rendered)])
|
||||
|
||||
|
||||
def commit_claude_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,21 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_contract_exists
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .claudecode_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
@@ -25,9 +16,6 @@ from .claudecode_contract import (
|
||||
RenderedClaudeDocument,
|
||||
create_claude_document,
|
||||
parse_claude_document,
|
||||
runtime_root,
|
||||
same_claude_agent_id,
|
||||
same_claude_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,29 +31,21 @@ def claude_settings_path() -> str:
|
||||
|
||||
def read_claude_document() -> ClaudeDocument:
|
||||
file_path = claude_settings_path()
|
||||
physical_directory_exists(
|
||||
os.path.dirname(file_path), "Claude Code configuration directory"
|
||||
)
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Claude Code user settings", MAX_SOURCE_BYTES
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(file_path), "Claude Code configuration directory")
|
||||
snapshot = read_physical_file(file_path, "Claude Code user settings", MAX_SOURCE_BYTES)
|
||||
if snapshot is None:
|
||||
return create_claude_document(file_path)
|
||||
return parse_claude_document(file_path, snapshot.contents)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedClaudeDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedClaudeDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Claude Code user settings",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,172 +55,7 @@ def write_claude_document(rendered: RenderedClaudeDocument) -> None:
|
||||
write_changes([change], "Uninstall Claude Code hooks")
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def claude_runtime_files_exist(contracts: List[ClaudeRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Claude Code") for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_claude_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Claude Code hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_claude_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Claude Code agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: ClaudeRuntimeContract) -> bool:
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_claude_path(os.path.dirname(agent_directory), root)
|
||||
or not same_claude_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def claude_runtime_files_exist(
|
||||
contracts: List[ClaudeRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -8,7 +8,9 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
|
||||
|
||||
AGENT_KEY = "cline"
|
||||
@@ -58,20 +60,6 @@ def home_dir() -> str:
|
||||
return os.path.expanduser("~")
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(home_dir(), ".elydora")
|
||||
|
||||
|
||||
def same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def resolve_hooks_directory() -> str:
|
||||
configured = os.environ.get("CLINE_DIR", "").strip()
|
||||
cline_directory = (
|
||||
@@ -267,10 +255,10 @@ def runtime_contract(
|
||||
raise ValueError("Elydora Cline hook files contain mismatched event metadata")
|
||||
if not same_agent_id(guard.agent_id, audit.agent_id):
|
||||
raise ValueError("Elydora Cline hook files reference different agents")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), guard.agent_id)
|
||||
agent_directory = resolve_agent_directory(runtime_root(), guard.agent_id)
|
||||
expected_guard = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
expected_audit = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not _same_path(guard.runtime_path, expected_guard) or not _same_path(
|
||||
if not same_path(guard.runtime_path, expected_guard) or not same_path(
|
||||
audit.runtime_path, expected_audit
|
||||
):
|
||||
raise ValueError(
|
||||
|
||||
@@ -2,107 +2,42 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, source_change, write_changes
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .cline_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
HookFile,
|
||||
assert_wrapper_integrity,
|
||||
build_metadata,
|
||||
build_wrapper,
|
||||
elydora_dir,
|
||||
resolve_hook_files,
|
||||
runtime_contract,
|
||||
same_agent_id,
|
||||
)
|
||||
from .cline_io import (
|
||||
validate_api_origin,
|
||||
validate_hook_tree,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .cline_io import validate_hook_tree
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClineRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> ClineRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not _same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
return ClineRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Cline installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("Cline requires an absolute Python executable path")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Cline"
|
||||
|
||||
|
||||
def _validate_hook_files(guard_file: HookFile, audit_file: HookFile) -> None:
|
||||
expected = resolve_hook_files()
|
||||
if not _same_path(guard_file.file_path, expected.guard_path) or not _same_path(
|
||||
if not same_path(guard_file.file_path, expected.guard_path) or not same_path(
|
||||
audit_file.file_path, expected.audit_path
|
||||
):
|
||||
raise ValueError("Cline installation received unexpected hook paths")
|
||||
@@ -113,41 +48,23 @@ def preflight_cline_installation(
|
||||
config: InstallConfig,
|
||||
guard_file: HookFile,
|
||||
audit_file: HookFile,
|
||||
) -> ClineRuntimePaths:
|
||||
_validate_install_config(config)
|
||||
) -> RuntimePaths:
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("Cline requires an absolute Python executable path")
|
||||
_validate_hook_files(guard_file, audit_file)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _hook_change(
|
||||
file: HookFile, label: str, next_source: Optional[str]
|
||||
) -> Optional[FileChange]:
|
||||
original = file.source if file.exists else None
|
||||
if file.exists and original is None:
|
||||
raise RuntimeError(f"{label} snapshot is missing source")
|
||||
return source_change(
|
||||
file.file_path,
|
||||
label,
|
||||
original,
|
||||
next_source,
|
||||
0o700,
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
return source_change(file.file_path, label, original, next_source, 0o700)
|
||||
|
||||
|
||||
def prepare_cline_installation(
|
||||
@@ -169,37 +86,18 @@ def prepare_cline_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present(
|
||||
(
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([
|
||||
_hook_change(guard_file, "Cline PreToolUse hook", guard_wrapper),
|
||||
_hook_change(audit_file, "Cline PostToolUse hook", audit_wrapper),
|
||||
)
|
||||
)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
]),
|
||||
]
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
validate_hook_tree(os.path.dirname(guard_file.file_path))
|
||||
return changes
|
||||
|
||||
@@ -211,21 +109,14 @@ def commit_cline_installation(changes: Sequence[FileChange]) -> None:
|
||||
def prepare_cline_uninstall(
|
||||
files: Sequence[HookFile], agent_id: str = ""
|
||||
) -> List[FileChange]:
|
||||
owned = [
|
||||
file
|
||||
for file in files
|
||||
if file.metadata is not None
|
||||
and (not agent_id or same_agent_id(file.metadata.agent_id, agent_id))
|
||||
]
|
||||
changes: List[Optional[FileChange]] = []
|
||||
for file in owned:
|
||||
for file in files:
|
||||
metadata = file.metadata
|
||||
assert metadata is not None
|
||||
if metadata is None or (agent_id and not same_agent_id(metadata.agent_id, agent_id)):
|
||||
continue
|
||||
assert_wrapper_integrity(file)
|
||||
changes.append(
|
||||
_hook_change(file, f"Cline {metadata.kind} hook", None)
|
||||
)
|
||||
return _present(changes)
|
||||
changes.append(_hook_change(file, f"Cline {metadata.kind} hook", None))
|
||||
return present(changes)
|
||||
|
||||
|
||||
def commit_cline_uninstall(changes: Sequence[FileChange]) -> None:
|
||||
|
||||
@@ -2,43 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
import urllib.parse
|
||||
from typing import Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .cline_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
HookFile,
|
||||
RuntimeContract,
|
||||
elydora_dir,
|
||||
parse_metadata,
|
||||
same_agent_id,
|
||||
)
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
from ._managed_files import physical_directory_exists, read_physical_file
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .cline_contract import AGENT_KEY, HookFile, RuntimeContract, parse_metadata
|
||||
|
||||
|
||||
def validate_hook_tree(hooks_directory: str) -> None:
|
||||
physical_directory_exists(
|
||||
os.path.dirname(hooks_directory), "Cline configuration directory"
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(hooks_directory), "Cline configuration directory")
|
||||
physical_directory_exists(hooks_directory, "Cline hooks directory")
|
||||
|
||||
|
||||
@@ -63,194 +37,9 @@ def require_available_hook_file(file: HookFile) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Cline hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Cline agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _valid_contract_paths(contract: RuntimeContract) -> bool:
|
||||
return (
|
||||
_same_path(os.path.dirname(contract.agent_directory), elydora_dir())
|
||||
and _same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(contract.agent_directory, GUARD_SCRIPT),
|
||||
)
|
||||
and _same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(contract.agent_directory, AUDIT_SCRIPT),
|
||||
)
|
||||
)
|
||||
def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contract: RuntimeContract) -> bool:
|
||||
if not _valid_contract_paths(contract):
|
||||
return False
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
contract.agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(contract.agent_directory, "config.json")
|
||||
key_path = os.path.join(contract.agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if any(item is None for item in (config, key, guard, audit)):
|
||||
return False
|
||||
assert config is not None
|
||||
assert key is not None
|
||||
assert guard is not None
|
||||
assert audit is not None
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_audit = generate_hook_script(
|
||||
org_id="",
|
||||
agent_id=contract.agent_id,
|
||||
kid="",
|
||||
base_url="",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return (
|
||||
guard.contents == generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
and audit.contents == expected_audit
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"read_hook_file",
|
||||
"require_available_hook_file",
|
||||
"runtime_files_exist",
|
||||
"validate_api_origin",
|
||||
"validate_hook_tree",
|
||||
"validate_private_key",
|
||||
"validate_runtime_tree",
|
||||
]
|
||||
return runtime_contract_exists(contract, AGENT_KEY, "Cline", _expected_scripts)
|
||||
@@ -2,133 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
from elydora.utils import base64url_encode
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES
|
||||
from ._transaction import FileChange
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import write_changes
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .codex_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
AUDIT_STATUS,
|
||||
GUARD_SCRIPT,
|
||||
GUARD_STATUS,
|
||||
build_handler,
|
||||
remove_managed_hooks,
|
||||
render_document,
|
||||
runtime_contracts,
|
||||
runtime_root,
|
||||
same_path,
|
||||
)
|
||||
from .codex_io import (
|
||||
read_document,
|
||||
rendered_change,
|
||||
runtime_change,
|
||||
runtime_files_exist,
|
||||
validate_hooks_directory,
|
||||
validate_runtime_tree,
|
||||
write_codex_changes,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> tuple[str, str, str, str]:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
configured_guard = config.get("guard_script_path", "")
|
||||
if not same_path(configured_guard, guard_path):
|
||||
raise ValueError(
|
||||
f"Elydora guard runtime must use the managed agent directory: {guard_path}"
|
||||
)
|
||||
return agent_id, agent_directory, guard_path, audit_path
|
||||
|
||||
|
||||
def _validate_private_key(value: str) -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
"private_key must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or base64url_encode(seed) != value:
|
||||
raise ValueError("private_key must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Codex installation requires agent_name {AGENT_KEY}")
|
||||
_validate_private_key(config["private_key"])
|
||||
|
||||
base_url = config["base_url"]
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
has_credentials = parsed.username is not None or parsed.password is not None
|
||||
except ValueError as error:
|
||||
raise ValueError("base_url must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in base_url or any(
|
||||
character.isspace() or ord(character) < 32 for character in base_url
|
||||
)
|
||||
valid_origin = (
|
||||
parsed.scheme in ("http", "https")
|
||||
and bool(parsed.netloc)
|
||||
and hostname is not None
|
||||
and not invalid_character
|
||||
)
|
||||
if not valid_origin:
|
||||
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
|
||||
if has_credentials or parsed.query or parsed.fragment:
|
||||
raise ValueError(
|
||||
"base_url must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Codex"
|
||||
|
||||
|
||||
class CodexPlugin(AgentPlugin):
|
||||
@@ -137,74 +39,39 @@ class CodexPlugin(AgentPlugin):
|
||||
manages_guard_runtime = True
|
||||
|
||||
def preflight_install(self, config: InstallConfig) -> None:
|
||||
_validate_install_config(config)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
document = read_document()
|
||||
validate_hooks_directory(document.file_path)
|
||||
agent_id, agent_directory, _, _ = _agent_paths(config)
|
||||
validate_runtime_tree(runtime_root(), agent_directory, agent_id)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
self.preflight_install(config)
|
||||
document = read_document()
|
||||
agent_id, agent_directory, guard_path, audit_path = _agent_paths(config)
|
||||
|
||||
paths = resolve_runtime_paths(config)
|
||||
hooks = remove_managed_hooks(document.hooks)
|
||||
hooks["PreToolUse"] = [
|
||||
*hooks.get("PreToolUse", []),
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [build_handler(guard_path, GUARD_STATUS)],
|
||||
},
|
||||
{"matcher": "*", "hooks": [build_handler(paths.guard_path, GUARD_STATUS)]},
|
||||
]
|
||||
hooks["PostToolUse"] = [
|
||||
*hooks.get("PostToolUse", []),
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [build_handler(audit_path, AUDIT_STATUS)],
|
||||
},
|
||||
{"matcher": "*", "hooks": [build_handler(paths.audit_path, AUDIT_STATUS)]},
|
||||
]
|
||||
|
||||
guard_script = generate_guard_script(AGENT_KEY, agent_id)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present(
|
||||
[
|
||||
runtime_change(
|
||||
guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
runtime_change(
|
||||
os.path.join(agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
runtime_change(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
runtime_change(
|
||||
audit_path,
|
||||
"Elydora audit runtime",
|
||||
audit_script,
|
||||
0o700,
|
||||
),
|
||||
rendered_change(render_document(document, hooks)),
|
||||
]
|
||||
)
|
||||
write_codex_changes(changes, "Install Codex hooks")
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(render_document(document, hooks))]),
|
||||
]
|
||||
write_changes(changes, "Install Codex hooks")
|
||||
print(f" Codex hooks: {document.file_path}")
|
||||
print(" Codex trust: run /hooks and approve both Elydora command hooks.")
|
||||
|
||||
@@ -215,25 +82,17 @@ class CodexPlugin(AgentPlugin):
|
||||
if change is None:
|
||||
return
|
||||
validate_hooks_directory(document.file_path)
|
||||
write_codex_changes([change], "Uninstall Codex hooks")
|
||||
write_changes([change], "Uninstall Codex hooks")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
document = read_document()
|
||||
contracts = runtime_contracts(document.hooks)
|
||||
if not contracts:
|
||||
return PluginStatus(
|
||||
installed=False,
|
||||
agent=AGENT_KEY,
|
||||
details="Not installed",
|
||||
)
|
||||
return PluginStatus(installed=False, agent=AGENT_KEY, details="Not installed")
|
||||
installed = runtime_files_exist(contracts)
|
||||
details = (
|
||||
f"Config: {document.file_path}"
|
||||
if installed
|
||||
else f"Configured at {document.file_path}; runtime files missing"
|
||||
)
|
||||
return PluginStatus(
|
||||
installed=installed,
|
||||
agent=AGENT_KEY,
|
||||
details=details,
|
||||
)
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
@@ -2,17 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess # nosec B404 - used only for legacy argument rendering
|
||||
import sys
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
|
||||
|
||||
@@ -51,164 +57,16 @@ class RuntimeContract:
|
||||
audit_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_handler(script_path: str, status_message: str) -> JsonObject:
|
||||
return {
|
||||
"type": "command",
|
||||
"command": f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}",
|
||||
"commandWindows": _windows_command(script_path),
|
||||
"command": posix_source(script_path),
|
||||
"commandWindows": encoded_windows_command(script_path),
|
||||
"timeout": HOOK_TIMEOUT_SECONDS,
|
||||
"statusMessage": status_message,
|
||||
}
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if (
|
||||
executable is None
|
||||
or source[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or ntpath.basename(match.group(1)).lower() != "powershell.exe"
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
|
||||
|
||||
def _parse_legacy_commands(handler: JsonObject) -> Optional[Tuple[str, str]]:
|
||||
command = handler.get("command")
|
||||
command_windows = handler.get("commandWindows")
|
||||
@@ -228,17 +86,6 @@ def _parse_legacy_commands(handler: JsonObject) -> Optional[Tuple[str, str]]:
|
||||
return arguments[0], arguments[1]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"python(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.I,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _managed_script_path(
|
||||
handler: JsonObject,
|
||||
status_message: str,
|
||||
@@ -252,8 +99,14 @@ def _managed_script_path(
|
||||
or handler.get("statusMessage") != status_message
|
||||
):
|
||||
return None
|
||||
posix = _parse_posix_command(handler.get("command"))
|
||||
windows = _parse_windows_command(handler.get("commandWindows"))
|
||||
command = handler.get("command")
|
||||
command_windows = handler.get("commandWindows")
|
||||
posix = parse_posix_command(command) if isinstance(command, str) else None
|
||||
windows = (
|
||||
parse_encoded_windows_command(command_windows)
|
||||
if isinstance(command_windows, str)
|
||||
else None
|
||||
)
|
||||
if posix is None or windows is None:
|
||||
legacy = _parse_legacy_commands(handler)
|
||||
posix = legacy
|
||||
@@ -263,8 +116,8 @@ def _managed_script_path(
|
||||
or windows is None
|
||||
or not os.path.isabs(posix[0])
|
||||
or not os.path.isabs(posix[1])
|
||||
or not _is_python_executable(posix[0])
|
||||
or not _is_python_executable(windows[0])
|
||||
or not is_python_executable(posix[0])
|
||||
or not is_python_executable(windows[0])
|
||||
or not same_path(posix[0], windows[0])
|
||||
or not same_path(posix[1], windows[1])
|
||||
):
|
||||
@@ -278,13 +131,10 @@ def _managed_agent_id(
|
||||
status_message: str,
|
||||
) -> Optional[str]:
|
||||
script_path = _managed_script_path(handler, status_message)
|
||||
if script_path is None or os.path.basename(script_path) != script_name:
|
||||
if script_path is None:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else reference[0]
|
||||
|
||||
|
||||
def _read_hooks(value: Any, label: str) -> CodexHooks:
|
||||
@@ -339,7 +189,7 @@ def _remove_from_groups(
|
||||
for handler in group["hooks"]:
|
||||
managed_id = _managed_agent_id(handler, script_name, status_message)
|
||||
remove = managed_id is not None and (
|
||||
not agent_id or _same_agent_id(managed_id, agent_id)
|
||||
not agent_id or same_agent_id(managed_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
handlers.append(handler)
|
||||
|
||||
@@ -4,17 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_present
|
||||
from ._transaction import FileChange
|
||||
from .codex_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
@@ -26,12 +20,6 @@ from .codex_contract import (
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(value: Any, expected: str) -> bool:
|
||||
return isinstance(value, str) and os.path.normcase(value) == os.path.normcase(
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def codex_home_path() -> str:
|
||||
configured = os.environ.get("CODEX_HOME")
|
||||
if configured is None or configured == "":
|
||||
@@ -64,130 +52,19 @@ def read_document() -> CodexDocument:
|
||||
|
||||
|
||||
def validate_hooks_directory(file_path: str) -> None:
|
||||
directory = os.path.dirname(file_path)
|
||||
physical_directory_exists(directory, "Codex hooks directory")
|
||||
physical_directory_exists(os.path.dirname(file_path), "Codex hooks directory")
|
||||
|
||||
|
||||
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Codex user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
def runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
return source_change(
|
||||
file_path,
|
||||
label,
|
||||
None if snapshot is None else snapshot.contents,
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path,
|
||||
"Elydora runtime config",
|
||||
MAX_CONFIG_BYTES,
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents,
|
||||
f"Elydora runtime config at {file_path}",
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_identity(agent_directory: str, agent_id: str) -> bool:
|
||||
config_path_value = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path_value)
|
||||
artifacts = [
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
]
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return False
|
||||
if config.get("agent_name") != AGENT_KEY or not _same_agent_id(
|
||||
config.get("agent_id"),
|
||||
agent_id,
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Codex agent {agent_id}: "
|
||||
f"{config_path_value}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def validate_runtime_tree(
|
||||
runtime_root: str, agent_directory: str, agent_id: str
|
||||
) -> None:
|
||||
if not physical_directory_exists(runtime_root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
return
|
||||
validate_runtime_identity(agent_directory, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
for contract in contracts:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
continue
|
||||
config = _read_runtime_config(os.path.join(agent_directory, "config.json"))
|
||||
if (
|
||||
config is None
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
or not _same_agent_id(config.get("agent_id"), contract.agent_id)
|
||||
):
|
||||
continue
|
||||
if all(
|
||||
(
|
||||
physical_file_exists(contract.guard_path, "Elydora guard runtime"),
|
||||
physical_file_exists(contract.audit_path, "Elydora audit runtime"),
|
||||
physical_file_exists(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
),
|
||||
)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def write_codex_changes(changes: List[FileChange], label: str) -> None:
|
||||
write_changes(changes, label)
|
||||
return any(runtime_present(contract, AGENT_KEY) for contract in contracts)
|
||||
@@ -7,10 +7,19 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
is_python_executable,
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
from ._strict_json import parse_json_object
|
||||
from .copilot_schema import CopilotHooks, validate_hooks
|
||||
|
||||
@@ -69,124 +78,35 @@ class RuntimeContract:
|
||||
audit_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ManagedEntry:
|
||||
agent_id: str
|
||||
script_path: str
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_handler(script_path: str) -> JsonObject:
|
||||
return {
|
||||
"type": "command",
|
||||
"bash": f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}",
|
||||
"powershell": (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
),
|
||||
"bash": posix_source(script_path),
|
||||
"powershell": powershell_source(script_path),
|
||||
"timeoutSec": HOOK_TIMEOUT_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_generated_bash(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or command[executable.next_index:executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_generated_powershell(command: Any) -> Optional[Tuple[str, str]]:
|
||||
if not isinstance(command, str) or not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if executable is None or command[executable.next_index:executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable.next_index + 1)
|
||||
if script is None or command[script.next_index:] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _exact_handler_keys(handler: JsonObject) -> bool:
|
||||
return set(handler) == {"type", "bash", "powershell", "timeoutSec"}
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return os.path.isabs(file_path) and re.fullmatch(
|
||||
r"(?:python|pypy)(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
) is not None
|
||||
def _parse_generated_bash(command: Any) -> Optional[Tuple[str, str]]:
|
||||
return parse_posix_command(command) if isinstance(command, str) else None
|
||||
|
||||
|
||||
def _parse_generated_powershell(command: Any) -> Optional[Tuple[str, str]]:
|
||||
return parse_powershell_source(command) if isinstance(command, str) else None
|
||||
|
||||
|
||||
def _absolute_python(file_path: str) -> bool:
|
||||
return os.path.isabs(file_path) and is_python_executable(file_path)
|
||||
|
||||
|
||||
def _current_script_path(handler: JsonObject) -> Optional[str]:
|
||||
@@ -201,7 +121,7 @@ def _current_script_path(handler: JsonObject) -> Optional[str]:
|
||||
if (
|
||||
bash is None
|
||||
or powershell is None
|
||||
or not _is_python_executable(bash[0])
|
||||
or not _absolute_python(bash[0])
|
||||
or not same_path(bash[0], powershell[0])
|
||||
or not same_path(bash[1], powershell[1])
|
||||
):
|
||||
@@ -227,7 +147,7 @@ def _prior_script_path(handler: JsonObject) -> Optional[str]:
|
||||
len(arguments) != 2
|
||||
or shlex.join(arguments) != bash_command
|
||||
or powershell is None
|
||||
or not _is_python_executable(arguments[0])
|
||||
or not _absolute_python(arguments[0])
|
||||
or not same_path(arguments[0], powershell[0])
|
||||
or not same_path(arguments[1], powershell[1])
|
||||
):
|
||||
|
||||
@@ -3,48 +3,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import (
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
file_change,
|
||||
source_change,
|
||||
write_changes,
|
||||
)
|
||||
from .base import InstallConfig
|
||||
from .copilot_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
CopilotSources,
|
||||
RenderedDocument,
|
||||
runtime_root,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
)
|
||||
from .copilot_io import (
|
||||
require_hooks_enabled,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .copilot_contract import AGENT_KEY, CopilotSources, RenderedDocument
|
||||
from .copilot_io import require_hooks_enabled
|
||||
from .copilot_schema import validate_javascript_regexes
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CopilotRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
PRODUCT = "GitHub Copilot CLI"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,95 +34,25 @@ class PreparedCopilotInstallation:
|
||||
preconditions: Tuple[FilePrecondition, ...]
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> CopilotRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return CopilotRuntimePaths(
|
||||
agent_id,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"GitHub Copilot CLI installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError(
|
||||
"GitHub Copilot CLI requires an absolute Python executable path"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
|
||||
|
||||
def preflight_copilot_installation(
|
||||
config: InstallConfig,
|
||||
sources: CopilotSources,
|
||||
) -> CopilotRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not sources.user.file_path:
|
||||
raise ValueError(
|
||||
"GitHub Copilot CLI installation requires a user hook path"
|
||||
)
|
||||
raise ValueError("GitHub Copilot CLI installation requires a user hook path")
|
||||
require_hooks_enabled(sources)
|
||||
hook_sources = [sources.user.hooks]
|
||||
if sources.legacy is not None:
|
||||
hook_sources.append(sources.legacy.hooks)
|
||||
validate_javascript_regexes(hook_sources)
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("GitHub Copilot CLI requires an absolute Python executable path")
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, "Copilot")
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
@@ -156,12 +67,6 @@ def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
)
|
||||
|
||||
|
||||
def _present(
|
||||
changes: Sequence[Optional[FileChange]],
|
||||
) -> Tuple[FileChange, ...]:
|
||||
return tuple(change for change in changes if change is not None)
|
||||
|
||||
|
||||
def _document_preconditions(
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
@@ -177,16 +82,9 @@ def _document_preconditions(
|
||||
)
|
||||
|
||||
|
||||
def _settings_preconditions(
|
||||
sources: CopilotSources,
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
def _settings_preconditions(sources: CopilotSources) -> Tuple[FilePrecondition, ...]:
|
||||
return tuple(
|
||||
FilePrecondition(
|
||||
item.file_path,
|
||||
item.label,
|
||||
item.snapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
FilePrecondition(item.file_path, item.label, item.snapshot, MAX_SOURCE_BYTES)
|
||||
for item in sources.settings_preconditions
|
||||
)
|
||||
|
||||
@@ -206,9 +104,7 @@ def _validate_rendered_documents(
|
||||
if same_path(item.document.file_path, document.file_path)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
"GitHub Copilot rendered source set contains unexpected paths"
|
||||
)
|
||||
raise ValueError("GitHub Copilot rendered source set contains unexpected paths")
|
||||
|
||||
|
||||
def prepare_copilot_installation(
|
||||
@@ -223,33 +119,18 @@ def prepare_copilot_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
changes = _present((
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
*(_document_change(item) for item in rendered),
|
||||
))
|
||||
document_changes = [
|
||||
change for change in (_document_change(item) for item in rendered)
|
||||
if change is not None
|
||||
]
|
||||
changes = (
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*document_changes,
|
||||
)
|
||||
preconditions = (
|
||||
*_settings_preconditions(sources),
|
||||
*_document_preconditions(rendered),
|
||||
@@ -257,20 +138,17 @@ def prepare_copilot_installation(
|
||||
return PreparedCopilotInstallation(changes, preconditions)
|
||||
|
||||
|
||||
def commit_copilot_installation(
|
||||
prepared: PreparedCopilotInstallation,
|
||||
) -> None:
|
||||
write_changes(
|
||||
prepared.changes,
|
||||
"Install GitHub Copilot hooks",
|
||||
prepared.preconditions,
|
||||
)
|
||||
def commit_copilot_installation(prepared: PreparedCopilotInstallation) -> None:
|
||||
write_changes(prepared.changes, "Install GitHub Copilot hooks", prepared.preconditions)
|
||||
|
||||
|
||||
def prepare_copilot_uninstall(
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> Tuple[FileChange, ...]:
|
||||
return _present(tuple(_document_change(item) for item in rendered))
|
||||
return tuple(
|
||||
change for change in (_document_change(item) for item in rendered)
|
||||
if change is not None
|
||||
)
|
||||
|
||||
|
||||
def commit_copilot_uninstall(changes: Sequence[FileChange]) -> None:
|
||||
|
||||
@@ -2,40 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any, List, Optional, Tuple
|
||||
import urllib.parse
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ._jsonc import parse_jsonc
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .copilot_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
CONFIG_FILE,
|
||||
GUARD_SCRIPT,
|
||||
CopilotDocument,
|
||||
CopilotSources,
|
||||
RuntimeContract,
|
||||
SourcePrecondition,
|
||||
create_document,
|
||||
parse_document,
|
||||
runtime_root,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
)
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -216,186 +205,17 @@ def require_hooks_enabled(sources: CopilotSources) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"{label} must be an absolute HTTP or HTTPS URL"
|
||||
) from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject,
|
||||
contract: RuntimeContract,
|
||||
config_path: str,
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_string(config.get("agent_id"), "agent_id", config_path)
|
||||
if (
|
||||
not same_agent_id(agent_id, contract.agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Copilot hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_string(config.get("base_url"), "base_url", config_path)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Copilot agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _valid_contract_paths(contract: RuntimeContract) -> bool:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
return (
|
||||
same_path(os.path.dirname(agent_directory), runtime_root())
|
||||
and same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(agent_directory, GUARD_SCRIPT),
|
||||
)
|
||||
and same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(agent_directory, AUDIT_SCRIPT),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: RuntimeContract) -> bool:
|
||||
if not _valid_contract_paths(contract):
|
||||
return False
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id="",
|
||||
agent_id=contract.agent_id,
|
||||
kid="",
|
||||
base_url="",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(
|
||||
runtime_contract_exists(
|
||||
contract,
|
||||
AGENT_KEY,
|
||||
"Copilot",
|
||||
_expected_scripts,
|
||||
)
|
||||
for contract in contracts
|
||||
)
|
||||
@@ -2,78 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
from elydora.utils import base64url_encode
|
||||
|
||||
from ._transaction import FileChange
|
||||
from ._managed_files import physical_directory_exists
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import write_changes
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .cursor_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
build_handler,
|
||||
remove_managed_hooks,
|
||||
render_document,
|
||||
runtime_contracts,
|
||||
runtime_root,
|
||||
same_path,
|
||||
)
|
||||
from .cursor_io import (
|
||||
read_document,
|
||||
rendered_change,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
require_runtime_directory,
|
||||
runtime_change,
|
||||
runtime_files_exist,
|
||||
validate_config_directory,
|
||||
validate_runtime_identity,
|
||||
write_cursor_changes,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token", "")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> tuple[str, str, str]:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
configured_guard = config.get("guard_script_path", "")
|
||||
if not same_path(configured_guard, guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return agent_id, agent_directory, guard_path
|
||||
PRODUCT = "Cursor"
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
@@ -83,27 +41,14 @@ def _validate_install_config(config: InstallConfig) -> None:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Cursor installation requires agent_name {AGENT_KEY}")
|
||||
private_key = config["private_key"]
|
||||
try:
|
||||
padded = private_key + "=" * ((4 - len(private_key) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
"private_key must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or base64url_encode(seed) != private_key:
|
||||
raise ValueError("private_key must be a canonical 32-byte base64url value")
|
||||
base_url = config.get("base_url", "https://api.elydora.com")
|
||||
validate_private_key(config["private_key"])
|
||||
base_url = config.get("base_url", DEFAULT_BASE_URL)
|
||||
if not isinstance(base_url, str):
|
||||
raise ValueError("base_url must be a string")
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
|
||||
token = config.get("token", "")
|
||||
if not isinstance(token, str):
|
||||
if not isinstance(config.get("token", ""), str):
|
||||
raise ValueError("token must be a string")
|
||||
|
||||
|
||||
@@ -116,58 +61,32 @@ class CursorPlugin(AgentPlugin):
|
||||
_validate_install_config(config)
|
||||
read_document()
|
||||
validate_config_directory()
|
||||
agent_id, agent_directory, guard_path = _agent_paths(config)
|
||||
if not physical_directory_exists(runtime_root()):
|
||||
return
|
||||
if not physical_directory_exists(agent_directory):
|
||||
return
|
||||
identity_exists = validate_runtime_identity(
|
||||
os.path.join(agent_directory, "config.json"),
|
||||
agent_id,
|
||||
)
|
||||
runtime_exists = False
|
||||
for file_path, label in (
|
||||
(guard_path, "Elydora guard runtime"),
|
||||
(os.path.join(agent_directory, AUDIT_SCRIPT), "Elydora audit runtime"),
|
||||
(os.path.join(agent_directory, "private.key"), "Elydora private key"),
|
||||
(os.path.join(agent_directory, "chain-state.json"), "Elydora chain state"),
|
||||
(os.path.join(agent_directory, "status-cache.json"), "Elydora status cache"),
|
||||
(os.path.join(agent_directory, "error.log"), "Elydora error log"),
|
||||
):
|
||||
runtime_exists = physical_file_exists(file_path, label) or runtime_exists
|
||||
if runtime_exists and not identity_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
self.preflight_install(config)
|
||||
document = read_document()
|
||||
agent_id, agent_directory, guard_path = _agent_paths(config)
|
||||
require_runtime_directory(agent_directory)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
|
||||
runtime_config_path = os.path.join(agent_directory, "config.json")
|
||||
validate_runtime_identity(runtime_config_path, agent_id)
|
||||
paths = resolve_runtime_paths(config)
|
||||
if not physical_directory_exists(
|
||||
paths.agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
raise FileNotFoundError(
|
||||
f"Elydora agent runtime directory is missing: {paths.agent_directory}"
|
||||
)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
hooks = remove_managed_hooks(document.hooks)
|
||||
hooks["preToolUse"] = [
|
||||
*hooks.get("preToolUse", []),
|
||||
build_handler(guard_path),
|
||||
]
|
||||
hooks["postToolUse"] = [
|
||||
*hooks.get("postToolUse", []),
|
||||
build_handler(audit_path),
|
||||
]
|
||||
hooks["preToolUse"] = [*hooks.get("preToolUse", []), build_handler(paths.guard_path)]
|
||||
hooks["postToolUse"] = [*hooks.get("postToolUse", []), build_handler(paths.audit_path)]
|
||||
hooks["postToolUseFailure"] = [
|
||||
*hooks.get("postToolUseFailure", []),
|
||||
build_handler(audit_path),
|
||||
build_handler(paths.audit_path),
|
||||
]
|
||||
hook_script = generate_hook_script(
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
success_output="{}\n",
|
||||
fail_closed=True,
|
||||
native_payload=True,
|
||||
@@ -175,67 +94,34 @@ class CursorPlugin(AgentPlugin):
|
||||
)
|
||||
guard_script = generate_guard_script(
|
||||
AGENT_KEY,
|
||||
agent_id,
|
||||
paths.agent_id,
|
||||
success_output='{"permission":"allow"}\n',
|
||||
fail_closed=True,
|
||||
deny_protocol="cursor",
|
||||
)
|
||||
changes = _present([
|
||||
runtime_change(
|
||||
guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
runtime_change(
|
||||
runtime_config_path,
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, agent_id)),
|
||||
0o600,
|
||||
),
|
||||
runtime_change(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
),
|
||||
runtime_change(
|
||||
audit_path,
|
||||
"Elydora audit runtime",
|
||||
hook_script,
|
||||
0o700,
|
||||
),
|
||||
rendered_change(render_document(document, hooks)),
|
||||
])
|
||||
write_cursor_changes(changes, "Install Cursor hooks")
|
||||
changes = [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(render_document(document, hooks))]),
|
||||
]
|
||||
write_changes(changes, "Install Cursor hooks")
|
||||
print(f" Cursor hooks: {document.file_path}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
document = read_document()
|
||||
hooks = remove_managed_hooks(document.hooks, agent_id)
|
||||
change = rendered_change(render_document(document, hooks))
|
||||
write_cursor_changes(
|
||||
[] if change is None else [change],
|
||||
"Uninstall Cursor hooks",
|
||||
)
|
||||
if change is not None:
|
||||
write_changes([change], "Uninstall Cursor hooks")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
document = read_document()
|
||||
contracts = runtime_contracts(document.hooks)
|
||||
if not contracts:
|
||||
return PluginStatus(
|
||||
installed=False,
|
||||
agent=AGENT_KEY,
|
||||
details="Not installed",
|
||||
)
|
||||
return PluginStatus(installed=False, agent=AGENT_KEY, details="Not installed")
|
||||
installed = runtime_files_exist(contracts)
|
||||
details = (
|
||||
f"Config: {document.file_path}"
|
||||
if installed
|
||||
else f"Configured at {document.file_path}; runtime files missing"
|
||||
)
|
||||
return PluginStatus(
|
||||
installed=installed,
|
||||
agent=AGENT_KEY,
|
||||
details=details,
|
||||
)
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
@@ -7,8 +7,17 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
|
||||
|
||||
@@ -43,42 +52,8 @@ class RuntimeContract:
|
||||
audit_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_handler(script_path: str) -> JsonObject:
|
||||
if os.name == "nt":
|
||||
command = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
else:
|
||||
command = f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
command = powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
|
||||
return {
|
||||
"command": command,
|
||||
"timeout": HOOK_TIMEOUT_SECONDS,
|
||||
@@ -86,73 +61,6 @@ def build_handler(script_path: str) -> JsonObject:
|
||||
}
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str,
|
||||
start: int,
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable.next_index + 1)
|
||||
if script is None or command[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _legacy_script_path(command: str) -> Optional[str]:
|
||||
quoted = re.fullmatch(r'"([^"\r\n]+)" ([^\r\n]+)', command)
|
||||
if quoted and same_path(quoted.group(1), sys.executable):
|
||||
@@ -169,9 +77,9 @@ def _managed_script_path(handler: JsonObject) -> Optional[str]:
|
||||
and handler.get("failClosed") is True
|
||||
):
|
||||
parsed = (
|
||||
_parse_powershell_command(handler["command"])
|
||||
parse_powershell_source(handler["command"])
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(handler["command"])
|
||||
else parse_posix_command(handler["command"])
|
||||
)
|
||||
if (
|
||||
parsed is not None
|
||||
@@ -185,18 +93,12 @@ def _managed_script_path(handler: JsonObject) -> Optional[str]:
|
||||
return _legacy_script_path(handler["command"])
|
||||
|
||||
|
||||
def _managed_agent_id(
|
||||
handler: JsonObject,
|
||||
script_name: str,
|
||||
) -> Optional[str]:
|
||||
def _managed_agent_id(handler: JsonObject, script_name: str) -> Optional[str]:
|
||||
script_path = _managed_script_path(handler)
|
||||
if script_path is None or os.path.basename(script_path) != script_name:
|
||||
if script_path is None:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else reference[0]
|
||||
|
||||
|
||||
def _read_hooks(value: Any, label: str) -> CursorHooks:
|
||||
@@ -256,7 +158,7 @@ def remove_managed_hooks(
|
||||
for handler in result.get(event, []):
|
||||
managed_id = _managed_agent_id(handler, script_name)
|
||||
remove = managed_id is not None and (
|
||||
not agent_id or _same_agent_id(managed_id, agent_id)
|
||||
not agent_id or same_agent_id(managed_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
handlers.append(handler)
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_present
|
||||
from ._transaction import FileChange
|
||||
from .cursor_contract import (
|
||||
AGENT_KEY,
|
||||
CursorDocument,
|
||||
JsonObject,
|
||||
RenderedDocument,
|
||||
RuntimeContract,
|
||||
create_document,
|
||||
parse_document,
|
||||
parse_json_object,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,180 +25,28 @@ def config_path() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".cursor", CONFIG_FILE)
|
||||
|
||||
|
||||
def _read_optional_physical(file_path: str, label: str) -> Optional[str]:
|
||||
try:
|
||||
metadata = os.lstat(file_path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", newline="") as file:
|
||||
return file.read()
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
|
||||
|
||||
def read_document() -> CursorDocument:
|
||||
file_path = config_path()
|
||||
raw = _read_optional_physical(file_path, "Cursor user hooks")
|
||||
return create_document(file_path) if raw is None else parse_document(file_path, raw)
|
||||
snapshot = read_physical_file(file_path, "Cursor user hooks", MAX_SOURCE_BYTES)
|
||||
if snapshot is None:
|
||||
return create_document(file_path)
|
||||
return parse_document(file_path, snapshot.contents)
|
||||
|
||||
|
||||
def validate_config_directory() -> None:
|
||||
directory = os.path.dirname(config_path())
|
||||
try:
|
||||
metadata = os.lstat(directory)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect Cursor hooks directory at {directory}: {error}") from error
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"Cursor hooks directory is not a physical directory: {directory}")
|
||||
physical_directory_exists(os.path.dirname(config_path()), "Cursor hooks directory")
|
||||
|
||||
|
||||
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Cursor user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
)
|
||||
|
||||
|
||||
def runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
) -> Optional[FileChange]:
|
||||
original = _read_optional_physical(file_path, label)
|
||||
return source_change(file_path, label, original, next_source, mode)
|
||||
|
||||
|
||||
def physical_file_exists(file_path: str, label: str) -> bool:
|
||||
try:
|
||||
metadata = os.lstat(file_path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
return True
|
||||
|
||||
|
||||
def require_runtime(file_path: str, label: str) -> None:
|
||||
if not file_path:
|
||||
raise ValueError(f"{label} path is required")
|
||||
if not physical_file_exists(file_path, label):
|
||||
raise FileNotFoundError(f"{label} is missing: {file_path}")
|
||||
|
||||
|
||||
def physical_directory_exists(directory: str) -> bool:
|
||||
try:
|
||||
metadata = os.lstat(directory)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(
|
||||
f"Inspect Elydora agent runtime directory at {directory}: {error}"
|
||||
) from error
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(
|
||||
f"Elydora agent runtime path is not a physical directory: {directory}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def require_runtime_directory(directory: str) -> None:
|
||||
if not physical_directory_exists(directory):
|
||||
raise FileNotFoundError(
|
||||
f"Elydora agent runtime directory is missing: {directory}"
|
||||
)
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
raw = _read_optional_physical(file_path, "Elydora runtime config")
|
||||
if raw is None:
|
||||
return None
|
||||
return parse_json_object(raw, f"Elydora runtime config at {file_path}")
|
||||
|
||||
|
||||
def validate_runtime_identity(
|
||||
file_path: str,
|
||||
agent_id: str,
|
||||
) -> bool:
|
||||
config = _read_runtime_config(file_path)
|
||||
if config is None:
|
||||
return False
|
||||
if config.get("agent_name") != AGENT_KEY or not _same_agent_id(
|
||||
config.get("agent_id"), agent_id
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Cursor agent {agent_id}: "
|
||||
f"{file_path}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _same_agent_id(value: Any, expected: str) -> bool:
|
||||
return isinstance(value, str) and os.path.normcase(value) == os.path.normcase(
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
for contract in contracts:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(agent_directory):
|
||||
continue
|
||||
config_path_value = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path_value)
|
||||
if (
|
||||
config is None
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
or not _same_agent_id(config.get("agent_id"), contract.agent_id)
|
||||
):
|
||||
continue
|
||||
if physical_file_exists(
|
||||
contract.guard_path,
|
||||
"Elydora guard runtime",
|
||||
) and physical_file_exists(
|
||||
contract.audit_path,
|
||||
"Elydora audit runtime",
|
||||
) and physical_file_exists(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_physical_directory(directory: str, label: str) -> None:
|
||||
try:
|
||||
os.makedirs(directory, mode=0o700, exist_ok=True)
|
||||
metadata = os.lstat(directory)
|
||||
except OSError as error:
|
||||
raise OSError(f"Prepare {label} directory at {directory}: {error}") from error
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} directory is not a physical directory: {directory}")
|
||||
|
||||
|
||||
def write_cursor_changes(changes: List[FileChange], label: str) -> None:
|
||||
effective = [change for change in changes if change.original != change.next_source]
|
||||
if not effective:
|
||||
return
|
||||
directories = {
|
||||
os.path.normcase(os.path.abspath(os.path.dirname(change.file_path))):
|
||||
os.path.dirname(change.file_path)
|
||||
for change in effective
|
||||
}
|
||||
for directory in directories.values():
|
||||
_ensure_physical_directory(directory, label)
|
||||
write_changes(effective, label)
|
||||
return any(runtime_present(contract, AGENT_KEY) for contract in contracts)
|
||||
@@ -8,13 +8,13 @@ from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from ._jsonc import JsonPathPart, JsoncEditor
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._runtime import same_path
|
||||
from .droid_contract import (
|
||||
TOOL_EVENTS,
|
||||
DroidHookMap,
|
||||
JsonObject,
|
||||
managed_removals,
|
||||
read_hook_map,
|
||||
same_path,
|
||||
)
|
||||
from .droid_policy import DroidPolicyState
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ import subprocess # nosec B404
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import managed_script_reference, same_agent_id, same_path
|
||||
from ._shell_command import (
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
|
||||
|
||||
AGENT_KEY = "droid"
|
||||
GUARD_SCRIPT = "guard.py"
|
||||
@@ -22,7 +32,6 @@ TOOL_EVENTS = ("PreToolUse", "PostToolUse")
|
||||
_HANDLER_KEYS = {"command", "timeout", "type"}
|
||||
_GROUP_KEYS = {"hooks", "matcher"}
|
||||
_REGEX_TIMEOUT_SECONDS = 10
|
||||
_WINDOWS_EXIT_SUFFIX = "; exit $LASTEXITCODE"
|
||||
_REGEX_VALIDATOR = """import fs from 'node:fs';
|
||||
const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
for (const entry of entries) {
|
||||
@@ -55,39 +64,14 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_power_shell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def build_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Factory Droid hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return (
|
||||
f"& {_quote_power_shell(sys.executable)} "
|
||||
f"{_quote_power_shell(script_path)}{_WINDOWS_EXIT_SUFFIX}"
|
||||
)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
return powershell_source(script_path)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def build_group(script_path: str) -> JsonObject:
|
||||
@@ -214,83 +198,6 @@ def validate_javascript_regexes(sources: Sequence[DroidHookMap]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _read_power_shell_argument(
|
||||
command: str,
|
||||
start: int,
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
_POSIX_APOSTROPHE = "'\"'\"'"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(_POSIX_APOSTROPHE, index):
|
||||
value += "'"
|
||||
index += len(_POSIX_APOSTROPHE)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_two_arguments(
|
||||
command: str,
|
||||
start: int,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, start)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
if not executable[0] or not script[0]:
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _parse_power_shell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_power_shell_argument(command, 2)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_power_shell_argument(command, executable[1] + 1)
|
||||
if (
|
||||
script is None
|
||||
or command[script[1]:] != _WINDOWS_EXIT_SUFFIX
|
||||
or not executable[0]
|
||||
or not script[0]
|
||||
):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(r'"([^"\r\n]+)" "([^"\r\n]+)"', command)
|
||||
return (match.group(1), match.group(2)) if match else None
|
||||
@@ -301,8 +208,8 @@ def _parse_generated_command(
|
||||
include_legacy: bool,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
if os.name != "nt":
|
||||
return _parse_two_arguments(command, 0)
|
||||
current = _parse_power_shell_command(command)
|
||||
return parse_posix_command(command)
|
||||
current = parse_powershell_source(command)
|
||||
return current or (
|
||||
_parse_legacy_windows_command(command) if include_legacy else None
|
||||
)
|
||||
@@ -326,14 +233,8 @@ def managed_agent_id(
|
||||
parsed = _parse_generated_command(command, include_legacy)
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
script_path = parsed[1]
|
||||
if os.path.basename(script_path) != script_name:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_path(os.path.dirname(agent_directory), elydora_dir()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
return agent_id if agent_id not in {"", ".", ".."} else None
|
||||
reference = managed_script_reference(parsed[1], script_name)
|
||||
return None if reference is None else reference[0]
|
||||
|
||||
|
||||
def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool:
|
||||
@@ -397,7 +298,7 @@ def runtime_contracts(hooks: DroidHookMap) -> List[RuntimeContract]:
|
||||
for agent_id in sorted(guards):
|
||||
if not any(same_agent_id(agent_id, audit_id) for audit_id in audits):
|
||||
continue
|
||||
root = os.path.join(elydora_dir(), agent_id)
|
||||
root = os.path.join(runtime_root(), agent_id)
|
||||
contracts.append(RuntimeContract(
|
||||
agent_id,
|
||||
os.path.join(root, GUARD_SCRIPT),
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import (
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
file_change,
|
||||
source_change,
|
||||
write_changes,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .droid_config import (
|
||||
DroidDocument,
|
||||
@@ -26,30 +26,12 @@ from .droid_config import (
|
||||
installation_documents,
|
||||
source_documents,
|
||||
)
|
||||
from .droid_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
elydora_dir,
|
||||
same_path,
|
||||
validate_javascript_regexes,
|
||||
)
|
||||
from .droid_io import (
|
||||
require_hooks_enabled,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .droid_contract import AGENT_KEY, validate_javascript_regexes
|
||||
from .droid_io import require_hooks_enabled
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DroidRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
PRODUCT = "Factory Droid"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -74,90 +56,20 @@ def _source_label(document: DroidDocument) -> str:
|
||||
return "Factory Droid user hooks"
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> DroidRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return DroidRuntimePaths(
|
||||
agent_id,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Factory Droid installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError(
|
||||
"Factory Droid requires an absolute Python executable path"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
|
||||
|
||||
def preflight_droid_installation(
|
||||
config: InstallConfig,
|
||||
sources: DroidSources,
|
||||
) -> DroidRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
require_hooks_enabled(sources)
|
||||
validate_javascript_regexes([
|
||||
document.hooks for document in source_documents(sources)
|
||||
])
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_javascript_regexes([document.hooks for document in source_documents(sources)])
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError("Factory Droid requires an absolute Python executable path")
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
@@ -173,10 +85,11 @@ def _document_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
)
|
||||
|
||||
|
||||
def _present(
|
||||
changes: Sequence[Optional[FileChange]],
|
||||
) -> Tuple[FileChange, ...]:
|
||||
return tuple(change for change in changes if change is not None)
|
||||
def _document_changes(rendered: Sequence[RenderedDocument]) -> Tuple[FileChange, ...]:
|
||||
return tuple(
|
||||
change for change in (_document_change(item) for item in rendered)
|
||||
if change is not None
|
||||
)
|
||||
|
||||
|
||||
def _validate_rendered_documents(
|
||||
@@ -188,18 +101,15 @@ def _validate_rendered_documents(
|
||||
raise ValueError("Factory Droid rendered source set is incomplete")
|
||||
for document in expected:
|
||||
matches = [
|
||||
item
|
||||
for item in rendered
|
||||
item for item in rendered
|
||||
if same_path(item.document.file_path, document.file_path)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
"Factory Droid rendered source set contains unexpected paths"
|
||||
)
|
||||
raise ValueError("Factory Droid rendered source set contains unexpected paths")
|
||||
|
||||
|
||||
def _source_preconditions(
|
||||
sources: DroidSources,
|
||||
def _unchanged_preconditions(
|
||||
documents: Sequence[DroidDocument],
|
||||
changed_paths: Sequence[str],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
return tuple(
|
||||
@@ -209,11 +119,8 @@ def _source_preconditions(
|
||||
document.snapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
for document in source_documents(sources)
|
||||
if not any(
|
||||
same_path(document.file_path, changed)
|
||||
for changed in changed_paths
|
||||
)
|
||||
for document in documents
|
||||
if not any(same_path(document.file_path, changed) for changed in changed_paths)
|
||||
)
|
||||
|
||||
|
||||
@@ -229,84 +136,35 @@ def prepare_droid_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
document_changes = _present(tuple(
|
||||
_document_change(item) for item in rendered
|
||||
))
|
||||
changes = _present((
|
||||
_runtime_change(
|
||||
paths.guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path,
|
||||
"Elydora audit runtime",
|
||||
audit_script,
|
||||
0o700,
|
||||
),
|
||||
document_changes = _document_changes(rendered)
|
||||
changes = (
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*document_changes,
|
||||
))
|
||||
)
|
||||
changed_paths = [change.file_path for change in document_changes]
|
||||
preconditions = (
|
||||
*_source_preconditions(sources, changed_paths),
|
||||
*_unchanged_preconditions(source_documents(sources), changed_paths),
|
||||
*sources.policy.preconditions,
|
||||
)
|
||||
return PreparedDroidInstallation(changes, tuple(preconditions))
|
||||
return PreparedDroidInstallation(changes, preconditions)
|
||||
|
||||
|
||||
def commit_droid_installation(
|
||||
prepared: PreparedDroidInstallation,
|
||||
) -> None:
|
||||
write_changes(
|
||||
prepared.changes,
|
||||
"Install Factory Droid hooks",
|
||||
prepared.preconditions,
|
||||
)
|
||||
def commit_droid_installation(prepared: PreparedDroidInstallation) -> None:
|
||||
write_changes(prepared.changes, "Install Factory Droid hooks", prepared.preconditions)
|
||||
|
||||
|
||||
def prepare_droid_uninstall(
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> PreparedDroidUninstall:
|
||||
changes = _present(tuple(_document_change(item) for item in rendered))
|
||||
changes = _document_changes(rendered)
|
||||
changed_paths = [change.file_path for change in changes]
|
||||
preconditions = tuple(
|
||||
FilePrecondition(
|
||||
item.document.file_path,
|
||||
_source_label(item.document),
|
||||
item.document.snapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
for item in rendered
|
||||
if not any(
|
||||
same_path(item.document.file_path, changed)
|
||||
for changed in changed_paths
|
||||
)
|
||||
)
|
||||
return PreparedDroidUninstall(changes, preconditions)
|
||||
documents = [item.document for item in rendered]
|
||||
return PreparedDroidUninstall(changes, _unchanged_preconditions(documents, changed_paths))
|
||||
|
||||
|
||||
def commit_droid_uninstall(prepared: PreparedDroidUninstall) -> None:
|
||||
write_changes(
|
||||
prepared.changes,
|
||||
"Uninstall Factory Droid hooks",
|
||||
prepared.preconditions,
|
||||
)
|
||||
write_changes(prepared.changes, "Uninstall Factory Droid hooks", prepared.preconditions)
|
||||
@@ -2,21 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .droid_config import (
|
||||
DroidDocument,
|
||||
DroidSources,
|
||||
@@ -27,18 +19,8 @@ from .droid_config import (
|
||||
hook_block,
|
||||
parse_document,
|
||||
)
|
||||
from .droid_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
RuntimeContract,
|
||||
elydora_dir,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
)
|
||||
from .droid_contract import AGENT_KEY, RuntimeContract
|
||||
from .droid_policy import read_droid_policy
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -128,196 +110,20 @@ def require_hooks_enabled(sources: DroidSources) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"{label} must be an absolute HTTP or HTTPS URL"
|
||||
) from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id)
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(
|
||||
contract,
|
||||
AGENT_KEY,
|
||||
"Factory Droid",
|
||||
_expected_scripts,
|
||||
)
|
||||
for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject,
|
||||
contract: RuntimeContract,
|
||||
config_path: str,
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": '
|
||||
f"{config_path}"
|
||||
)
|
||||
_require_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_string(config.get("agent_id"), "agent_id", config_path)
|
||||
if (
|
||||
not same_agent_id(agent_id, contract.agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Factory Droid hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_string(config.get("base_url"), "base_url", config_path)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path,
|
||||
"Elydora runtime config",
|
||||
MAX_CONFIG_BYTES,
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents,
|
||||
f"Elydora runtime config at {file_path}",
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
(GUARD_SCRIPT, "Elydora guard runtime"),
|
||||
(AUDIT_SCRIPT, "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Factory Droid agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _valid_contract_paths(contract: RuntimeContract) -> bool:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
return (
|
||||
same_path(os.path.dirname(agent_directory), elydora_dir())
|
||||
and same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(agent_directory, GUARD_SCRIPT),
|
||||
)
|
||||
and same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(agent_directory, AUDIT_SCRIPT),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: RuntimeContract) -> bool:
|
||||
if not _valid_contract_paths(contract):
|
||||
return False
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id="",
|
||||
agent_id=contract.agent_id,
|
||||
kid="",
|
||||
base_url="",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def runtime_files_exist(contracts: list[RuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
|
||||
|
||||
def display_config_path(sources: DroidSources) -> str:
|
||||
|
||||
@@ -2,14 +2,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._runtime import managed_script_reference, same_path
|
||||
from ._shell_command import (
|
||||
decode_powershell_source,
|
||||
encode_powershell_source,
|
||||
is_powershell_executable,
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
quote_powershell,
|
||||
read_powershell_argument,
|
||||
windows_powershell_path,
|
||||
)
|
||||
|
||||
_ENCODED_FLAGS = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeminiRuntimeReference:
|
||||
@@ -17,163 +31,33 @@ class GeminiRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
def same_gemini_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_gemini_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot")
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f"& {_quote_powershell(_windows_powershell_path())} "
|
||||
"-NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
||||
f"{encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_gemini_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Gemini CLI hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if executable is None or executable[1] >= len(source):
|
||||
return None
|
||||
if source[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable[1] + 1)
|
||||
if script is None or source[script[1] :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
encoded = encode_powershell_source(powershell_source(script_path))
|
||||
return f"& {quote_powershell(windows_powershell_path())}{_ENCODED_FLAGS}{encoded}"
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
powershell = _read_powershell_argument(command, 2)
|
||||
if (
|
||||
powershell is None
|
||||
or not ntpath.isabs(powershell[0])
|
||||
or ntpath.basename(powershell[0]).lower() != "powershell.exe"
|
||||
):
|
||||
powershell = read_powershell_argument(command, 2)
|
||||
if powershell is None or not is_powershell_executable(powershell[0]):
|
||||
return None
|
||||
prefix = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
||||
if not command.startswith(prefix, powershell[1]):
|
||||
if not command.startswith(_ENCODED_FLAGS, powershell[1]):
|
||||
return None
|
||||
encoded = command[powershell[1] + len(prefix) :]
|
||||
if re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", encoded) is None:
|
||||
return None
|
||||
try:
|
||||
payload = base64.b64decode(encoded, validate=True)
|
||||
if base64.b64encode(payload).decode("ascii") != encoded:
|
||||
return None
|
||||
if len(payload) % 2:
|
||||
return None
|
||||
source = payload.decode("utf-16le")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
source = decode_powershell_source(command[powershell[1] + len(_ENCODED_FLAGS) :])
|
||||
return None if source is None else parse_powershell_source(source)
|
||||
|
||||
|
||||
def _parse_legacy_command(
|
||||
command: str, script_name: str
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
def _parse_legacy_command(command: str, script_name: str) -> Optional[Tuple[str, str]]:
|
||||
if script_name == "guard.py":
|
||||
match = re.fullmatch(r'"([^"\r\n]+)" ([^\r\n]+)', command)
|
||||
if match is None or not same_gemini_path(match.group(1), sys.executable):
|
||||
if match is None or not same_path(match.group(1), sys.executable):
|
||||
return None
|
||||
return match.group(1), match.group(2)
|
||||
if "\r" in command or "\n" in command:
|
||||
@@ -186,21 +70,10 @@ def gemini_runtime_reference(
|
||||
script_name: str,
|
||||
include_legacy: bool = False,
|
||||
) -> Optional[GeminiRuntimeReference]:
|
||||
parsed = _parse_posix_command(command) or _parse_windows_command(command)
|
||||
parsed = parse_posix_command(command) or _parse_windows_command(command)
|
||||
if parsed is None and include_legacy:
|
||||
parsed = _parse_legacy_command(command, script_name)
|
||||
if (
|
||||
parsed is None
|
||||
or not same_gemini_path(parsed[0], sys.executable)
|
||||
or not os.path.isabs(parsed[1])
|
||||
or os.path.basename(parsed[1]) != script_name
|
||||
):
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(parsed[1])
|
||||
runtime_root = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
if not same_gemini_path(os.path.dirname(agent_directory), runtime_root):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return GeminiRuntimeReference(agent_id, parsed[1])
|
||||
reference = managed_script_reference(parsed[1], script_name)
|
||||
return None if reference is None else GeminiRuntimeReference(*reference)
|
||||
@@ -7,11 +7,11 @@ import math
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ._runtime import same_agent_id
|
||||
from .gemini_command import (
|
||||
GeminiRuntimeReference,
|
||||
build_gemini_command,
|
||||
gemini_runtime_reference,
|
||||
same_gemini_agent_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,10 +63,6 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def _optional_string(value: JsonObject, field: str, label: str) -> None:
|
||||
if field in value and not isinstance(value[field], str):
|
||||
raise ValueError(f'{label} field "{field}" must be a string')
|
||||
@@ -286,7 +282,7 @@ def managed_gemini_removals(
|
||||
is not None
|
||||
and (
|
||||
agent_id is None
|
||||
or same_gemini_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
)
|
||||
if indexes:
|
||||
|
||||
@@ -2,159 +2,58 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .gemini_command import same_gemini_path
|
||||
from .gemini_config import GeminiDocument, RenderedGeminiDocument
|
||||
from .gemini_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
elydora_dir,
|
||||
)
|
||||
from .gemini_io import (
|
||||
rendered_change,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .base import InstallConfig
|
||||
from .gemini_config import GeminiDocument, RenderedGeminiDocument
|
||||
from .gemini_contract import AGENT_KEY
|
||||
from .gemini_io import rendered_change
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeminiRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> GeminiRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_gemini_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return GeminiRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Gemini CLI installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Gemini CLI"
|
||||
|
||||
|
||||
def preflight_gemini_installation(
|
||||
config: InstallConfig, document: GeminiDocument
|
||||
) -> GeminiRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not document.file_path:
|
||||
raise ValueError("Gemini CLI installation requires a settings path")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_gemini_installation(
|
||||
config: InstallConfig,
|
||||
paths: GeminiRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedGeminiDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(
|
||||
AGENT_KEY,
|
||||
paths.agent_id,
|
||||
success_output="{}\n",
|
||||
)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id, success_output="{}\n")
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
success_output="{}\n",
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def commit_gemini_installation(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,34 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .gemini_command import same_gemini_agent_id, same_gemini_path
|
||||
from ._managed_files import MAX_SOURCE_BYTES, physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_contract_exists
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .gemini_config import (
|
||||
GeminiDocument,
|
||||
RenderedGeminiDocument,
|
||||
create_gemini_document,
|
||||
parse_gemini_document,
|
||||
)
|
||||
from .gemini_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
GeminiRuntimeContract,
|
||||
elydora_dir,
|
||||
)
|
||||
from .gemini_contract import AGENT_KEY, CONFIG_FILE, GeminiRuntimeContract
|
||||
|
||||
|
||||
def gemini_configuration_directory() -> str:
|
||||
@@ -44,33 +29,21 @@ def gemini_settings_path() -> str:
|
||||
|
||||
def read_gemini_document() -> GeminiDocument:
|
||||
file_path = gemini_settings_path()
|
||||
physical_directory_exists(
|
||||
os.path.dirname(file_path), "Gemini CLI configuration directory"
|
||||
)
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Gemini CLI user settings", MAX_SOURCE_BYTES
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(file_path), "Gemini CLI configuration directory")
|
||||
snapshot = read_physical_file(file_path, "Gemini CLI user settings", MAX_SOURCE_BYTES)
|
||||
if snapshot is None:
|
||||
return create_gemini_document(file_path)
|
||||
return parse_gemini_document(
|
||||
exists=True,
|
||||
file_path=file_path,
|
||||
raw=snapshot.contents,
|
||||
)
|
||||
return parse_gemini_document(exists=True, file_path=file_path, raw=snapshot.contents)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedGeminiDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedGeminiDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
return rendered_source_change(
|
||||
rendered.document.file_path,
|
||||
"Gemini CLI user settings",
|
||||
rendered.document.raw if rendered.document.exists else None,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,171 +53,7 @@ def write_gemini_document(rendered: RenderedGeminiDocument) -> None:
|
||||
write_changes([change], "Uninstall Gemini CLI hooks")
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def gemini_runtime_files_exist(contracts: List[GeminiRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Gemini CLI") for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_gemini_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Gemini CLI hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_gemini_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Gemini CLI agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: GeminiRuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_gemini_path(os.path.dirname(agent_directory), root)
|
||||
or not same_gemini_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def gemini_runtime_files_exist(
|
||||
contracts: List[GeminiRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess # nosec B404 - used only for legacy command rendering
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._runtime import managed_script_reference
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrokRuntimeReference:
|
||||
@@ -19,154 +26,14 @@ class GrokRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_grok_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_grok_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_grok_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Grok hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or command[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if executable is None or source[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or ntpath.basename(match.group(1)).lower() != "powershell.exe"
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
return encoded_windows_command(script_path)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _parse_legacy_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
@@ -193,40 +60,19 @@ def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return arguments[0], arguments[1]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"python(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def grok_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[GrokRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_posix_command(command)
|
||||
or _parse_windows_command(command)
|
||||
parse_posix_command(command)
|
||||
or parse_encoded_windows_command(command)
|
||||
or _parse_legacy_posix_command(command)
|
||||
or _parse_legacy_windows_command(command)
|
||||
)
|
||||
if parsed is None:
|
||||
return None
|
||||
executable, script_path = parsed
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not os.path.isabs(script_path)
|
||||
or not _is_python_executable(executable)
|
||||
or os.path.basename(script_path) != script_name
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_grok_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return GrokRuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else GrokRuntimeReference(*reference)
|
||||
@@ -7,13 +7,12 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .grok_command import (
|
||||
GrokRuntimeReference,
|
||||
build_grok_command,
|
||||
grok_runtime_reference,
|
||||
same_grok_agent_id,
|
||||
same_grok_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -183,7 +182,7 @@ def _remove_from_groups(
|
||||
for handler in group["hooks"]:
|
||||
reference = _managed_reference(handler, script_name)
|
||||
remove = reference is not None and (
|
||||
not agent_id or same_grok_agent_id(reference.agent_id, agent_id)
|
||||
not agent_id or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
kept.append(handler)
|
||||
@@ -286,7 +285,7 @@ def grok_runtime_contracts(hooks: GrokHooks) -> List[GrokRuntimeContract]:
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_grok_path(success[0].script_path, failure[0].script_path):
|
||||
if not same_path(success[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(GrokRuntimeContract(
|
||||
agent_id=guard[0].agent_id,
|
||||
|
||||
@@ -2,168 +2,56 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .grok_command import runtime_root, same_grok_path
|
||||
from .grok_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
GrokDocument,
|
||||
RenderedGrokDocument,
|
||||
)
|
||||
from .grok_io import (
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from ._transaction import FileChange, write_changes
|
||||
from .base import InstallConfig
|
||||
from .grok_contract import AGENT_KEY, GrokDocument, RenderedGrokDocument
|
||||
from .grok_io import rendered_change
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrokRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> GrokRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_grok_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return GrokRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Grok installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Grok"
|
||||
|
||||
|
||||
def preflight_grok_installation(
|
||||
config: InstallConfig, document: GrokDocument
|
||||
) -> GrokRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not document.config_path:
|
||||
raise ValueError("Grok installation requires a hook configuration path")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedGrokDocument,
|
||||
) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
rendered.document.config_path,
|
||||
"Grok user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_grok_installation(
|
||||
config: InstallConfig,
|
||||
paths: GrokRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedGrokDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(
|
||||
AGENT_KEY, paths.agent_id, deny_protocol="grok"
|
||||
)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id, deny_protocol="grok")
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def commit_grok_installation(changes: List[FileChange]) -> None:
|
||||
@@ -171,7 +59,7 @@ def commit_grok_installation(changes: List[FileChange]) -> None:
|
||||
|
||||
|
||||
def prepare_grok_uninstall(rendered: RenderedGrokDocument) -> List[FileChange]:
|
||||
return _present([rendered_change(rendered)])
|
||||
return present([rendered_change(rendered)])
|
||||
|
||||
|
||||
def commit_grok_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,24 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .grok_command import runtime_root, same_grok_agent_id, same_grok_path
|
||||
from ._managed_files import physical_directory_exists, read_physical_file
|
||||
from ._runtime import rendered_source_change, runtime_contract_exists
|
||||
from ._transaction import FileChange
|
||||
from .grok_contract import (
|
||||
AGENT_KEY,
|
||||
GrokDocument,
|
||||
GrokRuntimeContract,
|
||||
RenderedGrokDocument,
|
||||
create_grok_document,
|
||||
parse_grok_document,
|
||||
)
|
||||
@@ -41,9 +34,7 @@ def grok_config_path() -> str:
|
||||
def read_grok_document() -> GrokDocument:
|
||||
config_path = grok_config_path()
|
||||
hooks_directory = os.path.dirname(config_path)
|
||||
physical_directory_exists(
|
||||
os.path.dirname(hooks_directory), "Grok home directory"
|
||||
)
|
||||
physical_directory_exists(os.path.dirname(hooks_directory), "Grok home directory")
|
||||
physical_directory_exists(hooks_directory, "Grok hooks directory")
|
||||
snapshot = read_physical_file(config_path, "Grok user hooks")
|
||||
if snapshot is None:
|
||||
@@ -51,168 +42,16 @@ def read_grok_document() -> GrokDocument:
|
||||
return parse_grok_document(config_path, snapshot.contents)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_grok_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Grok hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
def rendered_change(rendered: RenderedGrokDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
return rendered_source_change(
|
||||
rendered.document.config_path,
|
||||
"Grok user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.next_source,
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_grok_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Grok agent {agent_id}: "
|
||||
f"{config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: GrokRuntimeContract) -> bool:
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_grok_path(os.path.dirname(agent_directory), root)
|
||||
or not same_grok_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def grok_runtime_files_exist(contracts: List[GrokRuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(runtime_contract_exists(contract, AGENT_KEY, "Grok") for contract in contracts)
|
||||
@@ -5,11 +5,8 @@ from __future__ import annotations
|
||||
import sys
|
||||
|
||||
from .error_log_template import ERROR_LOG_RUNTIME
|
||||
from .guard_template import generate_guard_script
|
||||
from .runtime_reader_template import PROTECTED_RUNTIME_READER
|
||||
|
||||
__all__ = ["generate_guard_script", "generate_hook_script"]
|
||||
|
||||
|
||||
def generate_hook_script(
|
||||
*,
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess # nosec B404 - used only for legacy command rendering
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._runtime import managed_script_reference
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiRuntimeReference:
|
||||
@@ -19,154 +26,14 @@ class KimiRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def runtime_root() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def same_kimi_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_kimi_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_kimi_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Kimi hook commands require absolute executable and script paths"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or command[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
if not source.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, 2)
|
||||
if executable is None or source[executable.next_index : executable.next_index + 1] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or ntpath.basename(match.group(1)).lower() != "powershell.exe"
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
return encoded_windows_command(script_path)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _parse_legacy_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
@@ -193,40 +60,19 @@ def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return arguments[0], arguments[1]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"python(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def kimi_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[KimiRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_posix_command(command)
|
||||
or _parse_windows_command(command)
|
||||
parse_posix_command(command)
|
||||
or parse_encoded_windows_command(command)
|
||||
or _parse_legacy_posix_command(command)
|
||||
or _parse_legacy_windows_command(command)
|
||||
)
|
||||
if parsed is None:
|
||||
return None
|
||||
executable, script_path = parsed
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not os.path.isabs(script_path)
|
||||
or not _is_python_executable(executable)
|
||||
or os.path.basename(script_path) != script_name
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_kimi_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return KimiRuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else KimiRuntimeReference(*reference)
|
||||
@@ -10,12 +10,11 @@ from typing import Any, Dict, FrozenSet, List, Optional, Tuple
|
||||
from tomlkit import aot, document, dumps, inline_table, parse, table
|
||||
from tomlkit.items import AoT, Array
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from .kimi_command import (
|
||||
KimiRuntimeReference,
|
||||
build_kimi_command,
|
||||
kimi_runtime_reference,
|
||||
same_kimi_agent_id,
|
||||
same_kimi_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -216,7 +215,7 @@ def remove_managed_kimi_hooks(
|
||||
else None
|
||||
)
|
||||
remove = reference is not None and (
|
||||
not agent_id or same_kimi_agent_id(reference.agent_id, agent_id)
|
||||
not agent_id or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
result.append(hook)
|
||||
@@ -318,7 +317,7 @@ def kimi_runtime_contracts(
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(success) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_kimi_path(success[0].script_path, failure[0].script_path):
|
||||
if not same_path(success[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(
|
||||
KimiRuntimeContract(
|
||||
|
||||
@@ -2,120 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, file_change, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .hook_template import generate_guard_script, generate_hook_script
|
||||
from .kimi_command import runtime_root, same_kimi_path
|
||||
from .kimi_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
KimiDocument,
|
||||
RenderedKimiDocument,
|
||||
)
|
||||
from .kimi_io import (
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kimi_contract import AGENT_KEY, KimiDocument, RenderedKimiDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> KimiRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
configured_guard = config.get("guard_script_path", "")
|
||||
if not same_kimi_path(configured_guard, guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return KimiRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Kimi installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Kimi"
|
||||
|
||||
|
||||
def preflight_kimi_installation(
|
||||
config: InstallConfig, documents: List[KimiDocument]
|
||||
) -> KimiRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
if not documents:
|
||||
raise ValueError("Kimi installation requires at least one hook contract")
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(
|
||||
file_path,
|
||||
label,
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def rendered_change(
|
||||
rendered: RenderedKimiDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def rendered_change(rendered: RenderedKimiDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
source = rendered.document
|
||||
@@ -129,66 +48,32 @@ def rendered_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: List[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_kimi_installation(
|
||||
config: InstallConfig,
|
||||
paths: KimiRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: List[RenderedKimiDocument],
|
||||
) -> List[FileChange]:
|
||||
config_changes = _present([rendered_change(item) for item in rendered])
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
runtime_changes = _present(
|
||||
[
|
||||
_runtime_change(
|
||||
paths.guard_path,
|
||||
"Elydora guard runtime",
|
||||
guard_script,
|
||||
0o700,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path,
|
||||
"Elydora audit runtime",
|
||||
audit_script,
|
||||
0o700,
|
||||
),
|
||||
]
|
||||
)
|
||||
return [*runtime_changes, *config_changes]
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([rendered_change(item) for item in rendered]),
|
||||
]
|
||||
|
||||
|
||||
def commit_kimi_installation(changes: List[FileChange]) -> None:
|
||||
write_changes(changes, "Install Kimi hooks")
|
||||
|
||||
|
||||
def prepare_kimi_uninstall(
|
||||
rendered: List[RenderedKimiDocument],
|
||||
) -> List[FileChange]:
|
||||
return _present([rendered_change(item) for item in rendered])
|
||||
def prepare_kimi_uninstall(rendered: List[RenderedKimiDocument]) -> List[FileChange]:
|
||||
return present([rendered_change(item) for item in rendered])
|
||||
|
||||
|
||||
def commit_kimi_uninstall(changes: List[FileChange]) -> None:
|
||||
|
||||
@@ -2,24 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .kimi_command import (
|
||||
runtime_root,
|
||||
same_kimi_agent_id,
|
||||
same_kimi_path,
|
||||
)
|
||||
from ._managed_files import physical_directory_exists, read_physical_file
|
||||
from ._runtime import runtime_contract_exists, same_path
|
||||
from .kimi_contract import (
|
||||
AGENT_KEY,
|
||||
LEGACY_EVENTS,
|
||||
@@ -76,7 +63,7 @@ def resolve_kimi_contracts() -> List[KimiContract]:
|
||||
legacy_home = os.path.join(home, ".kimi")
|
||||
stable = _stable_contract(os.path.join(stable_home, "config.toml"))
|
||||
legacy = _legacy_contract(os.path.join(legacy_home, "config.toml"))
|
||||
if same_kimi_path(stable.config_path, legacy.config_path):
|
||||
if same_path(stable.config_path, legacy.config_path):
|
||||
return [stable]
|
||||
|
||||
stable_detected = explicit_home is not None or _entry_exists(
|
||||
@@ -101,176 +88,5 @@ def read_kimi_documents() -> List[KimiDocument]:
|
||||
return [_read_kimi_document(contract) for contract in resolve_kimi_contracts()]
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"),
|
||||
validate=True,
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_kimi_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match Kimi hooks: {config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path,
|
||||
"Elydora runtime config",
|
||||
MAX_CONFIG_BYTES,
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents,
|
||||
f"Elydora runtime config at {file_path}",
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = [
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
]
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_kimi_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config identity does not match Kimi agent {agent_id}: "
|
||||
f"{config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: KimiRuntimeContract) -> bool:
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_kimi_path(os.path.dirname(agent_directory), root)
|
||||
or not same_kimi_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
return bool(guard.contents) and bool(audit.contents)
|
||||
|
||||
|
||||
def kimi_runtime_files_exist(contracts: List[KimiRuntimeContract]) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
return any(runtime_contract_exists(contract, AGENT_KEY, "Kimi") for contract in contracts)
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ._file_io import read_json, regular_file_exists, remove_file, write_json_atomic, write_text_atomic
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
@@ -42,11 +40,11 @@ class KiroCliPlugin(AgentPlugin):
|
||||
raise ValueError("agent_id is required")
|
||||
if not guard_path:
|
||||
raise ValueError("guard_script_path is required")
|
||||
if not _regular_file_exists(guard_path, "Elydora guard runtime"):
|
||||
if not regular_file_exists(guard_path, "Elydora guard runtime"):
|
||||
raise FileNotFoundError(f"Elydora guard runtime is missing: {guard_path}")
|
||||
|
||||
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config") or {}
|
||||
v3_settings = _read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") or {}
|
||||
v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config") or {}
|
||||
v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config") or {}
|
||||
v2_hooks = _hooks_object(v2_settings, "Kiro CLI v2 agent config")
|
||||
current_v3_hooks = _v3_hooks(v3_settings)
|
||||
|
||||
@@ -108,22 +106,28 @@ class KiroCliPlugin(AgentPlugin):
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
_write_json_atomic(os.path.join(agent_dir, "config.json"), runtime_config, 0o600)
|
||||
_write_text_atomic(
|
||||
write_json_atomic(
|
||||
os.path.join(agent_dir, "config.json"),
|
||||
runtime_config,
|
||||
0o600,
|
||||
"Elydora runtime config",
|
||||
)
|
||||
write_text_atomic(
|
||||
os.path.join(agent_dir, "private.key"),
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
"Elydora private key",
|
||||
)
|
||||
_write_text_atomic(hook_path, hook_script, 0o700)
|
||||
_write_json_atomic(V2_AGENT_PATH, next_v2_settings, 0o600)
|
||||
_write_json_atomic(V3_HOOKS_PATH, next_v3_settings, 0o600)
|
||||
write_text_atomic(hook_path, hook_script, 0o700, "Elydora audit runtime")
|
||||
write_json_atomic(V2_AGENT_PATH, next_v2_settings, 0o600, "Kiro CLI v2 agent config")
|
||||
write_json_atomic(V3_HOOKS_PATH, next_v3_settings, 0o600, "Kiro CLI v3 hooks config")
|
||||
|
||||
print('Kiro CLI v2: start with "kiro-cli --agent elydora-audit".')
|
||||
print('Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.')
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = _read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
|
||||
next_v2: Optional[Tuple[JsonObject, bool]] = None
|
||||
if v2_settings is not None:
|
||||
@@ -153,22 +157,22 @@ class KiroCliPlugin(AgentPlugin):
|
||||
if next_v2 is not None:
|
||||
settings, remove = next_v2
|
||||
if remove:
|
||||
_remove_file(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
remove_file(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
else:
|
||||
_write_json_atomic(V2_AGENT_PATH, settings, 0o600)
|
||||
write_json_atomic(V2_AGENT_PATH, settings, 0o600, "Kiro CLI v2 agent config")
|
||||
|
||||
if next_v3 is not None:
|
||||
settings, remove = next_v3
|
||||
if remove:
|
||||
_remove_file(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
remove_file(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
else:
|
||||
_write_json_atomic(V3_HOOKS_PATH, settings, 0o600)
|
||||
write_json_atomic(V3_HOOKS_PATH, settings, 0o600, "Kiro CLI v3 hooks config")
|
||||
|
||||
print("Elydora hooks uninstalled from Kiro CLI.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
v2_settings = _read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = _read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
v2_settings = read_json(V2_AGENT_PATH, "Kiro CLI v2 agent config")
|
||||
v3_settings = read_json(V3_HOOKS_PATH, "Kiro CLI v3 hooks config")
|
||||
contracts: List[HookContract] = []
|
||||
|
||||
if v2_settings is not None:
|
||||
@@ -204,63 +208,6 @@ class KiroCliPlugin(AgentPlugin):
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
|
||||
|
||||
def _read_json(path: str, label: str) -> Optional[JsonObject]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
raw = file.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"Failed to parse {label} at {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} at {path} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _write_text_atomic(path: str, content: str, mode: int) -> None:
|
||||
directory = os.path.dirname(path) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
descriptor, temporary_path = tempfile.mkstemp(
|
||||
dir=directory,
|
||||
prefix=f".{os.path.basename(path)}.",
|
||||
suffix=".tmp",
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as file:
|
||||
file.write(content)
|
||||
os.chmod(temporary_path, mode)
|
||||
os.replace(temporary_path, path)
|
||||
except BaseException as write_error:
|
||||
try:
|
||||
os.remove(temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as cleanup_error:
|
||||
raise RuntimeError(
|
||||
f"Write {path} failed and temporary file cleanup also failed: {cleanup_error}"
|
||||
) from write_error
|
||||
raise
|
||||
|
||||
|
||||
def _write_json_atomic(path: str, value: JsonObject, mode: int) -> None:
|
||||
_write_text_atomic(path, json.dumps(value, indent=2) + "\n", mode)
|
||||
|
||||
|
||||
def _remove_file(path: str, label: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as error:
|
||||
raise OSError(f"Remove {label} at {path}: {error}") from error
|
||||
|
||||
|
||||
def _build_command(script_path: str) -> str:
|
||||
arguments = [sys.executable, script_path]
|
||||
return subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments)
|
||||
@@ -385,16 +332,6 @@ def _find_v3_command(
|
||||
return None
|
||||
|
||||
|
||||
def _regular_file_exists(path: str, label: str) -> bool:
|
||||
try:
|
||||
metadata = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {path}: {error}") from error
|
||||
return stat.S_ISREG(metadata.st_mode)
|
||||
|
||||
|
||||
def _runtime_scripts_exist(contracts: List[HookContract]) -> bool:
|
||||
try:
|
||||
with os.scandir(ELYDORA_DIR) as iterator:
|
||||
@@ -421,10 +358,10 @@ def _runtime_scripts_exist(contracts: List[HookContract]) -> bool:
|
||||
continue
|
||||
|
||||
config_path = os.path.join(entry.path, "config.json")
|
||||
config = _read_json(config_path, "Elydora runtime config")
|
||||
config = read_json(config_path, "Elydora runtime config")
|
||||
if config is None or config.get("agent_name") != AGENT_KEY:
|
||||
continue
|
||||
return _regular_file_exists(
|
||||
return regular_file_exists(
|
||||
guard_path, "Elydora guard runtime"
|
||||
) and _regular_file_exists(hook_path, "Elydora audit runtime")
|
||||
) and regular_file_exists(hook_path, "Elydora audit runtime")
|
||||
return False
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._runtime import same_agent_id
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
@@ -21,7 +22,6 @@ from .kiroide_installation import (
|
||||
prepare_kiroide_installation,
|
||||
prepare_kiroide_uninstall,
|
||||
)
|
||||
from .kiroide_command import same_kiroide_agent_id
|
||||
from .kiroide_io import (
|
||||
kiroide_runtime_files_exist,
|
||||
legacy_kiroide_contract_matches_agent,
|
||||
@@ -78,13 +78,13 @@ class KiroIdePlugin(AgentPlugin):
|
||||
agent_ids: list[str] = []
|
||||
for discovered_agent_id in discovered_agent_ids:
|
||||
if not any(
|
||||
same_kiroide_agent_id(discovered_agent_id, existing_agent_id)
|
||||
same_agent_id(discovered_agent_id, existing_agent_id)
|
||||
for existing_agent_id in agent_ids
|
||||
):
|
||||
agent_ids.append(discovered_agent_id)
|
||||
for managed_agent_id in agent_ids:
|
||||
owns_workspace_runtime = any(
|
||||
same_kiroide_agent_id(contract.agent_id, managed_agent_id)
|
||||
same_agent_id(contract.agent_id, managed_agent_id)
|
||||
for contract in contracts
|
||||
)
|
||||
require_kiroide_workspace_owner(
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
from ._runtime import managed_script_reference, same_path
|
||||
from ._shell_command import (
|
||||
encoded_windows_command,
|
||||
is_python_executable,
|
||||
parse_encoded_windows_command,
|
||||
parse_posix_command,
|
||||
posix_source,
|
||||
windows_powershell_path,
|
||||
)
|
||||
|
||||
_PREAMBLE = "$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; "
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,71 +27,10 @@ class KiroIdeRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def same_kiroide_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_kiroide_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _same_windows_path(left: str, right: str) -> bool:
|
||||
return ntpath.normcase(ntpath.abspath(left)) == ntpath.normcase(
|
||||
ntpath.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
"$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; "
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_kiroide_command(script_path: str) -> str:
|
||||
if (
|
||||
not os.path.isabs(sys.executable)
|
||||
or not _is_python_executable(sys.executable)
|
||||
or not is_python_executable(sys.executable)
|
||||
or not os.path.isabs(script_path)
|
||||
):
|
||||
raise ValueError(
|
||||
@@ -91,136 +38,25 @@ def build_kiroide_command(script_path: str) -> str:
|
||||
"and script path"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
prefix = "$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; & "
|
||||
if not source.startswith(prefix):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, len(prefix))
|
||||
if (
|
||||
executable is None
|
||||
or source[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or not _same_windows_path(match.group(1), _windows_powershell_path())
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"(?:python(?:[0-9]+(?:\.[0-9]+)*)?[td]?|"
|
||||
r"pypy(?:[0-9]+(?:\.[0-9]+)*)?)(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
return encoded_windows_command(script_path, _PREAMBLE)
|
||||
return posix_source(script_path)
|
||||
|
||||
|
||||
def _runtime_reference(
|
||||
executable: str, script_path: str, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not _is_python_executable(executable)
|
||||
or not os.path.isabs(script_path)
|
||||
or os.path.basename(script_path) != script_name
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_kiroide_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return KiroIdeRuntimeReference(agent_id, script_path)
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
return None if reference is None else KiroIdeRuntimeReference(*reference)
|
||||
|
||||
|
||||
def _parse_kiroide_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return (
|
||||
_parse_windows_command(command)
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(command)
|
||||
)
|
||||
if os.name == "nt":
|
||||
return parse_encoded_windows_command(
|
||||
command, _PREAMBLE + "& ", windows_powershell_path()
|
||||
)
|
||||
return parse_posix_command(command)
|
||||
|
||||
|
||||
def owned_kiroide_runtime_reference(
|
||||
@@ -236,9 +72,7 @@ def kiroide_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
parsed = _parse_kiroide_command(command)
|
||||
if parsed is None:
|
||||
return None
|
||||
if not same_kiroide_path(parsed[0], sys.executable):
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
return _runtime_reference(parsed[0], parsed[1], script_name)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .kiroide_command import (
|
||||
KiroIdeRuntimeReference,
|
||||
@@ -16,8 +17,6 @@ from .kiroide_command import (
|
||||
legacy_audit_reference,
|
||||
legacy_guard_reference,
|
||||
owned_kiroide_runtime_reference,
|
||||
same_kiroide_agent_id,
|
||||
same_kiroide_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -240,7 +239,7 @@ def _managed_pair(
|
||||
if (
|
||||
guard is None
|
||||
or audit is None
|
||||
or not same_kiroide_agent_id(guard.agent_id, audit.agent_id)
|
||||
or not same_agent_id(guard.agent_id, audit.agent_id)
|
||||
):
|
||||
return None
|
||||
return (guards[0][0], guard), (audits[0][0], audit)
|
||||
@@ -289,7 +288,7 @@ def without_managed_kiroide_hooks(
|
||||
for index, hook in enumerate(hooks):
|
||||
reference = owned.get(index)
|
||||
remove = reference is not None and (
|
||||
not agent_id or same_kiroide_agent_id(reference.agent_id, agent_id)
|
||||
not agent_id or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
result.append(hook)
|
||||
@@ -382,8 +381,8 @@ def legacy_kiroide_runtime_contract(
|
||||
if (
|
||||
guard is None
|
||||
or audit is None
|
||||
or not same_kiroide_agent_id(guard.agent_id, audit.agent_id)
|
||||
or not same_kiroide_path(
|
||||
or not same_agent_id(guard.agent_id, audit.agent_id)
|
||||
or not same_path(
|
||||
os.path.dirname(guard.script_path), os.path.dirname(audit.script_path)
|
||||
)
|
||||
):
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
@@ -16,6 +13,14 @@ from ._managed_files import (
|
||||
read_physical_file,
|
||||
)
|
||||
from ._opaque_removal import OpaqueRemovalChange
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_config_source,
|
||||
same_agent_id,
|
||||
validate_install_config,
|
||||
)
|
||||
from ._transaction import (
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
@@ -34,11 +39,8 @@ from ._runtime_removal import (
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kiroide_command import same_kiroide_agent_id, same_kiroide_path
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
RenderedKiroIdeDocument,
|
||||
kiroide_runtime_contracts,
|
||||
)
|
||||
@@ -54,11 +56,13 @@ from .kiroide_io import (
|
||||
LegacyKiroIdeDocument,
|
||||
legacy_kiroide_contract_matches_agent,
|
||||
require_physical_legacy_directory,
|
||||
validate_api_origin,
|
||||
validate_kiroide_runtime_config,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
|
||||
PRODUCT = "Kiro IDE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeRuntimePaths:
|
||||
agent_id: str
|
||||
@@ -74,6 +78,8 @@ class KiroIdeRuntimePaths:
|
||||
class PreparedKiroIdeInstallation:
|
||||
changes: List[FileChange]
|
||||
runtime_preconditions: List[FilePrecondition]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKiroIdeUninstall:
|
||||
changes: List[FileChange]
|
||||
@@ -92,79 +98,30 @@ def _require_runtime_state(
|
||||
)
|
||||
|
||||
|
||||
def _runtime_config(
|
||||
config: InstallConfig, agent_id: str, workspace_root: str
|
||||
) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
"workspace_root": workspace_root,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> KiroIdeRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
root = runtime_root()
|
||||
paths = resolve_runtime_paths(config)
|
||||
root = os.path.dirname(paths.agent_directory)
|
||||
runtime_parent = os.path.dirname(root)
|
||||
agent_directory = resolve_agent_directory(root, agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_kiroide_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
directories = inspect_runtime_directories(
|
||||
runtime_parent, root, agent_directory
|
||||
)
|
||||
return KiroIdeRuntimePaths(
|
||||
agent_id,
|
||||
paths.agent_id,
|
||||
runtime_parent,
|
||||
root,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
directories,
|
||||
paths.agent_directory,
|
||||
paths.guard_path,
|
||||
paths.audit_path,
|
||||
inspect_runtime_directories(runtime_parent, root, paths.agent_directory),
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> KiroIdeRuntimePaths:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Kiro IDE installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
return _agent_paths(config)
|
||||
|
||||
|
||||
def preflight_kiroide_installation(
|
||||
config: InstallConfig, sources: KiroIdeSources
|
||||
) -> KiroIdeRuntimePaths:
|
||||
if not sources.document.file_path:
|
||||
raise ValueError("Kiro IDE installation requires a workspace hook path")
|
||||
paths = _validate_install_config(config)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = _agent_paths(config)
|
||||
owns_workspace_runtime = any(
|
||||
same_kiroide_agent_id(contract.agent_id, paths.agent_id)
|
||||
same_agent_id(contract.agent_id, paths.agent_id)
|
||||
for contract in kiroide_runtime_contracts(sources.document.hooks)
|
||||
)
|
||||
owns_global_legacy_runtime = legacy_kiroide_contract_matches_agent(
|
||||
@@ -229,7 +186,7 @@ def _removes_legacy(
|
||||
) -> bool:
|
||||
return legacy.contract is not None and (
|
||||
not agent_id
|
||||
or same_kiroide_agent_id(legacy.contract.agent_id, agent_id)
|
||||
or same_agent_id(legacy.contract.agent_id, agent_id)
|
||||
)
|
||||
|
||||
|
||||
@@ -252,10 +209,6 @@ def _legacy_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_kiroide_installation(
|
||||
config: InstallConfig,
|
||||
paths: KiroIdeRuntimePaths,
|
||||
@@ -269,7 +222,7 @@ def prepare_kiroide_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
@@ -280,12 +233,11 @@ def prepare_kiroide_installation(
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(
|
||||
_runtime_config(
|
||||
config,
|
||||
paths.agent_id,
|
||||
sources.paths.workspace_root,
|
||||
)
|
||||
runtime_config_source(
|
||||
config,
|
||||
paths.agent_id,
|
||||
AGENT_KEY,
|
||||
workspace_root=sources.paths.workspace_root,
|
||||
),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
@@ -303,7 +255,7 @@ def prepare_kiroide_installation(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
]
|
||||
changes = _present(
|
||||
changes = present(
|
||||
[
|
||||
*(change for change, _condition in runtime),
|
||||
_rendered_change(rendered),
|
||||
@@ -420,7 +372,7 @@ def prepare_kiroide_uninstall(
|
||||
candidates = runtime_agent_ids or ((agent_id,) if agent_id else ())
|
||||
for runtime_agent_id in candidates:
|
||||
if any(
|
||||
same_kiroide_agent_id(runtime_agent_id, selected_agent_id)
|
||||
same_agent_id(runtime_agent_id, selected_agent_id)
|
||||
for selected_agent_id in selected_agent_ids
|
||||
):
|
||||
continue
|
||||
@@ -436,7 +388,7 @@ def prepare_kiroide_uninstall(
|
||||
config_path,
|
||||
sources.paths.workspace_root,
|
||||
allow_missing_workspace_root=any(
|
||||
same_kiroide_agent_id(
|
||||
same_agent_id(
|
||||
contract.agent_id, runtime_agent_id
|
||||
)
|
||||
for contract in kiroide_runtime_contracts(
|
||||
@@ -454,7 +406,7 @@ def prepare_kiroide_uninstall(
|
||||
)
|
||||
if runtime_removal is not None:
|
||||
runtime_removals.append(runtime_removal)
|
||||
provider_changes = _present(
|
||||
provider_changes = present(
|
||||
[_rendered_change(rendered), _legacy_change(sources.legacy, agent_id)]
|
||||
)
|
||||
return PreparedKiroIdeUninstall(
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
@@ -20,10 +18,17 @@ from ._managed_files import (
|
||||
read_physical_directory,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._runtime import (
|
||||
RUNTIME_ARTIFACTS,
|
||||
RUNTIME_CONFIG_FIELDS,
|
||||
expected_runtime_scripts,
|
||||
require_non_empty_string,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
validate_api_origin,
|
||||
validate_private_key,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kiroide_command import same_kiroide_agent_id, same_kiroide_path
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
@@ -187,67 +192,7 @@ def require_physical_legacy_directory(legacy: LegacyKiroIdeDocument) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
def validate_kiroide_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
@@ -255,27 +200,19 @@ def _validate_runtime_config(
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
supported = {
|
||||
"org_id",
|
||||
"agent_id",
|
||||
"kid",
|
||||
"base_url",
|
||||
"token",
|
||||
"agent_name",
|
||||
"workspace_root",
|
||||
}
|
||||
supported = RUNTIME_CONFIG_FIELDS | {"workspace_root"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_kiroide_agent_id(agent_id, expected_agent_id)
|
||||
not same_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
@@ -289,8 +226,8 @@ def _validate_runtime_config(
|
||||
if "token" in config and not (
|
||||
legacy_ownerless_config and config.get("token") == ""
|
||||
):
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
@@ -298,39 +235,20 @@ def _validate_runtime_config(
|
||||
allow_missing_workspace_root or legacy_ownerless_config
|
||||
):
|
||||
return
|
||||
workspace_root = _require_non_empty_string(
|
||||
workspace_root = require_non_empty_string(
|
||||
configured_workspace, "workspace_root", config_path
|
||||
)
|
||||
if not os.path.isabs(workspace_root):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config workspace_root must be absolute: {config_path}"
|
||||
)
|
||||
if not same_kiroide_path(workspace_root, expected_workspace_root):
|
||||
if not same_path(workspace_root, expected_workspace_root):
|
||||
raise ValueError(
|
||||
"Elydora Kiro IDE agent is bound to another workspace: "
|
||||
f"{workspace_root}"
|
||||
)
|
||||
|
||||
|
||||
def validate_kiroide_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
expected_workspace_root: str,
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
"""Validate a runtime config already read through a pinned directory."""
|
||||
_validate_runtime_config(
|
||||
config,
|
||||
expected_agent_id,
|
||||
config_path,
|
||||
expected_workspace_root,
|
||||
allow_missing_workspace_root,
|
||||
allow_legacy_ownerless_config,
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(
|
||||
agent_directory: str,
|
||||
agent_id: str,
|
||||
@@ -346,27 +264,21 @@ def validate_runtime_tree(
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifact_exists = any(
|
||||
config_snapshot = read_physical_file(config_path, "Elydora runtime config", MAX_CONFIG_BYTES)
|
||||
artifact_states = [
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
)
|
||||
if config is None:
|
||||
for name, label in RUNTIME_ARTIFACTS
|
||||
]
|
||||
artifact_exists = any(artifact_states)
|
||||
if config_snapshot is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
_validate_runtime_config(
|
||||
config,
|
||||
validate_kiroide_runtime_config(
|
||||
parse_json_object(config_snapshot.contents, f"Elydora runtime config at {config_path}"),
|
||||
agent_id,
|
||||
config_path,
|
||||
workspace_root,
|
||||
@@ -381,8 +293,8 @@ def _runtime_contract_exists(
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_kiroide_path(os.path.dirname(agent_directory), root)
|
||||
or not same_kiroide_path(
|
||||
not same_path(os.path.dirname(agent_directory), root)
|
||||
or not same_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
@@ -414,7 +326,7 @@ def _runtime_contract_exists(
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(
|
||||
validate_kiroide_runtime_config(
|
||||
config,
|
||||
contract.agent_id,
|
||||
config_path,
|
||||
@@ -436,15 +348,7 @@ def _runtime_contract_exists(
|
||||
f"its owner: {config_path}"
|
||||
)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id=str(config["org_id"]),
|
||||
agent_id=contract.agent_id,
|
||||
kid=str(config["kid"]),
|
||||
base_url=str(config["base_url"]),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
expected_guard, expected_audit = expected_runtime_scripts(AGENT_KEY, contract.agent_id, config)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
@@ -478,15 +382,15 @@ def legacy_kiroide_contract_matches_agent(
|
||||
legacy: LegacyKiroIdeDocument, agent_id: str
|
||||
) -> bool:
|
||||
contract = legacy.contract
|
||||
if contract is None or not same_kiroide_agent_id(
|
||||
if contract is None or not same_agent_id(
|
||||
contract.agent_id, agent_id
|
||||
):
|
||||
return False
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
return same_kiroide_path(
|
||||
return same_path(
|
||||
contract.guard_path,
|
||||
os.path.join(agent_directory, "guard.py"),
|
||||
) and same_kiroide_path(
|
||||
) and same_path(
|
||||
contract.audit_path,
|
||||
os.path.join(agent_directory, "hook.py"),
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from ._runtime import RuntimePaths
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .letta_config import render_letta_document
|
||||
from .letta_contract import (
|
||||
@@ -13,7 +14,6 @@ from .letta_contract import (
|
||||
letta_runtime_contracts,
|
||||
)
|
||||
from .letta_installation import (
|
||||
LettaRuntimePaths,
|
||||
commit_letta_installation,
|
||||
commit_letta_uninstall,
|
||||
preflight_letta_installation,
|
||||
@@ -24,7 +24,7 @@ from .letta_io import letta_runtime_files_exist
|
||||
from .letta_sources import read_letta_sources
|
||||
|
||||
|
||||
def _installed_groups(paths: LettaRuntimePaths) -> Dict[str, JsonObject]:
|
||||
def _installed_groups(paths: RuntimePaths) -> Dict[str, JsonObject]:
|
||||
return {
|
||||
"PreToolUse": build_letta_group(paths.guard_path),
|
||||
"PostToolUse": build_letta_group(paths.audit_path),
|
||||
|
||||
@@ -4,11 +4,17 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
from ._runtime import managed_script_reference
|
||||
from ._shell_command import (
|
||||
is_python_executable,
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -18,103 +24,12 @@ class LettaRuntimeReference:
|
||||
executable_path: Optional[str] = None
|
||||
|
||||
|
||||
def same_letta_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_letta_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_letta_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Letta Code hook commands require absolute executable and script paths"
|
||||
)
|
||||
invocation = (
|
||||
f"{_quote_powershell(sys.executable)} {_quote_powershell(script_path)}"
|
||||
if os.name == "nt"
|
||||
else f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
)
|
||||
return f"& {invocation}; exit $LASTEXITCODE" if os.name == "nt" else invocation
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable[1] + 1)
|
||||
if script is None or command[script[1] :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return bool(re.fullmatch(r"python(?:3(?:\.\d+)?)?(?:\.exe)?", os.path.basename(file_path), re.IGNORECASE))
|
||||
return powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
|
||||
|
||||
|
||||
def _runtime_reference(
|
||||
@@ -122,30 +37,21 @@ def _runtime_reference(
|
||||
script_name: str,
|
||||
executable_path: Optional[str] = None,
|
||||
) -> Optional[LettaRuntimeReference]:
|
||||
if not os.path.isabs(script_path) or os.path.basename(script_path) != script_name:
|
||||
reference = managed_script_reference(script_path, script_name)
|
||||
if reference is None:
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_letta_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return LettaRuntimeReference(agent_id, script_path, executable_path)
|
||||
return LettaRuntimeReference(reference[0], reference[1], executable_path)
|
||||
|
||||
|
||||
def letta_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[LettaRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_powershell_command(command)
|
||||
parse_powershell_source(command)
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(command)
|
||||
else parse_posix_command(command)
|
||||
)
|
||||
if (
|
||||
parsed is None
|
||||
or not os.path.isabs(parsed[0])
|
||||
or not _is_python_executable(parsed[0])
|
||||
):
|
||||
if parsed is None or not os.path.isabs(parsed[0]) or not is_python_executable(parsed[0]):
|
||||
return None
|
||||
return _runtime_reference(parsed[1], script_name, parsed[0])
|
||||
|
||||
@@ -160,11 +66,7 @@ def letta_legacy_guard_reference(
|
||||
return None
|
||||
executable = command[1:closing]
|
||||
script_path = command[closing + 2 :]
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not _is_python_executable(executable)
|
||||
or not script_path
|
||||
):
|
||||
if not os.path.isabs(executable) or not is_python_executable(executable) or not script_path:
|
||||
return None
|
||||
return _runtime_reference(script_path, script_name, executable)
|
||||
|
||||
|
||||
@@ -8,16 +8,13 @@ import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from .letta_command import (
|
||||
LettaRuntimeReference,
|
||||
build_letta_command,
|
||||
letta_legacy_audit_reference,
|
||||
letta_legacy_guard_reference,
|
||||
letta_runtime_reference,
|
||||
same_letta_agent_id,
|
||||
same_letta_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,10 +60,6 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return runtime_root()
|
||||
|
||||
|
||||
def _validate_timeout(value: JsonObject, label: str) -> None:
|
||||
if "timeout" not in value:
|
||||
return
|
||||
@@ -222,7 +215,7 @@ def managed_letta_removals(
|
||||
is not None
|
||||
and (
|
||||
agent_id is None
|
||||
or same_letta_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
)
|
||||
if indexes:
|
||||
@@ -247,7 +240,7 @@ def _references_for_event(
|
||||
if (
|
||||
reference is None
|
||||
or reference.executable_path is None
|
||||
or not same_letta_path(reference.executable_path, sys.executable)
|
||||
or not same_path(reference.executable_path, sys.executable)
|
||||
):
|
||||
continue
|
||||
key = os.path.normcase(reference.agent_id)
|
||||
@@ -267,7 +260,7 @@ def letta_runtime_contracts(hooks: LettaHooks) -> List[LettaRuntimeContract]:
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(post) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_letta_path(post[0].script_path, failure[0].script_path):
|
||||
if not same_path(post[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(LettaRuntimeContract(
|
||||
guard[0].agent_id,
|
||||
|
||||
@@ -2,113 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Sequence
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import (
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
file_change,
|
||||
source_change,
|
||||
write_changes,
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .letta_command import same_letta_path
|
||||
from .letta_config import RenderedLettaDocument, letta_document_label
|
||||
from .letta_contract import AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT, elydora_dir
|
||||
from .letta_io import validate_api_origin, validate_private_key, validate_runtime_tree
|
||||
from .letta_contract import AGENT_KEY
|
||||
from .letta_sources import LettaSources, require_letta_hooks_enabled
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LettaRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> LettaRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_letta_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return LettaRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Letta Code installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Letta Code"
|
||||
|
||||
|
||||
def preflight_letta_installation(
|
||||
config: InstallConfig, sources: LettaSources
|
||||
) -> LettaRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
require_letta_hooks_enabled(sources)
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _rendered_change(
|
||||
rendered: RenderedLettaDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def _rendered_change(rendered: RenderedLettaDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
@@ -122,13 +50,9 @@ def _rendered_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_letta_installation(
|
||||
config: InstallConfig,
|
||||
paths: LettaRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedLettaDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
@@ -136,50 +60,28 @@ def prepare_letta_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
fail_closed=True,
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
_rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([_rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def _read_only_preconditions(
|
||||
sources: LettaSources, changed_path: Optional[str]
|
||||
sources: LettaSources, changed_path: str
|
||||
) -> List[FilePrecondition]:
|
||||
return [
|
||||
condition
|
||||
for condition in sources.preconditions
|
||||
if changed_path is None
|
||||
or not same_letta_path(condition.file_path, changed_path)
|
||||
if not same_path(condition.file_path, changed_path)
|
||||
]
|
||||
|
||||
|
||||
def commit_letta_installation(
|
||||
changes: List[FileChange], sources: LettaSources
|
||||
) -> None:
|
||||
def commit_letta_installation(changes: List[FileChange], sources: LettaSources) -> None:
|
||||
write_changes(
|
||||
changes,
|
||||
"Install Letta Code hooks",
|
||||
@@ -187,15 +89,11 @@ def commit_letta_installation(
|
||||
)
|
||||
|
||||
|
||||
def prepare_letta_uninstall(
|
||||
rendered: RenderedLettaDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def prepare_letta_uninstall(rendered: RenderedLettaDocument) -> Optional[FileChange]:
|
||||
return _rendered_change(rendered)
|
||||
|
||||
|
||||
def commit_letta_uninstall(
|
||||
change: Optional[FileChange], sources: LettaSources
|
||||
) -> None:
|
||||
def commit_letta_uninstall(change: Optional[FileChange], sources: LettaSources) -> None:
|
||||
if change is None:
|
||||
return
|
||||
write_changes(
|
||||
|
||||
@@ -2,200 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .letta_command import same_letta_agent_id, same_letta_path
|
||||
from .letta_contract import AGENT_KEY, LettaRuntimeContract, elydora_dir
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .letta_contract import AGENT_KEY, LettaRuntimeContract
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def _expected_scripts(agent_id: str, config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id, config, fail_closed=True)
|
||||
|
||||
|
||||
def letta_runtime_files_exist(contracts: List[LettaRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Letta Code", _expected_scripts)
|
||||
for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_letta_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Letta Code hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_letta_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Letta Code agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: LettaRuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_letta_path(os.path.dirname(agent_directory), root)
|
||||
or not same_letta_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id=str(config["org_id"]),
|
||||
agent_id=contract.agent_id,
|
||||
kid=str(config["kid"]),
|
||||
base_url=str(config["base_url"]),
|
||||
fail_closed=True,
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def letta_runtime_files_exist(
|
||||
contracts: List[LettaRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -4,15 +4,15 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._runtime import same_path, unique_preconditions
|
||||
from ._transaction import FilePrecondition
|
||||
from .letta_command import same_letta_path
|
||||
from .letta_config import (
|
||||
LettaDocument,
|
||||
create_letta_document,
|
||||
@@ -76,15 +76,6 @@ def _source_precondition(document: LettaDocument) -> FilePrecondition:
|
||||
)
|
||||
|
||||
|
||||
def _deduplicate_preconditions(
|
||||
values: list[FilePrecondition],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
result: Dict[str, FilePrecondition] = {}
|
||||
for value in values:
|
||||
result.setdefault(_comparison_path(value.file_path), value)
|
||||
return tuple(result.values())
|
||||
|
||||
|
||||
def _effective_disable(
|
||||
global_settings: LettaDocument,
|
||||
project: LettaDocument,
|
||||
@@ -111,7 +102,7 @@ def read_letta_sources() -> LettaSources:
|
||||
physical_directory_exists(
|
||||
global_directory, "Letta Code global configuration directory"
|
||||
)
|
||||
if not same_letta_path(project_directory, global_directory):
|
||||
if not same_path(project_directory, global_directory):
|
||||
physical_directory_exists(
|
||||
project_directory, "Letta Code project configuration directory"
|
||||
)
|
||||
@@ -141,7 +132,7 @@ def read_letta_sources() -> LettaSources:
|
||||
_effective_disable(
|
||||
global_settings, project, project_local, project_active
|
||||
),
|
||||
_deduplicate_preconditions(preconditions),
|
||||
unique_preconditions(preconditions),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from ._runtime import RuntimePaths
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .qwen_config import render_qwen_document
|
||||
from .qwen_contract import (
|
||||
@@ -15,7 +16,6 @@ from .qwen_contract import (
|
||||
qwen_runtime_contracts,
|
||||
)
|
||||
from .qwen_installation import (
|
||||
QwenRuntimePaths,
|
||||
commit_qwen_installation,
|
||||
commit_qwen_uninstall,
|
||||
preflight_qwen_installation,
|
||||
@@ -26,7 +26,7 @@ from .qwen_io import qwen_runtime_files_exist
|
||||
from .qwen_sources import read_qwen_sources
|
||||
|
||||
|
||||
def _installed_groups(paths: QwenRuntimePaths) -> Dict[str, JsonObject]:
|
||||
def _installed_groups(paths: RuntimePaths) -> Dict[str, JsonObject]:
|
||||
return {
|
||||
"PreToolUse": build_qwen_group(paths.guard_path, GUARD_HOOK_NAME),
|
||||
"PostToolUse": build_qwen_group(paths.audit_path, AUDIT_HOOK_NAME),
|
||||
|
||||
@@ -5,7 +5,15 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
from ._runtime import managed_script_reference, same_path
|
||||
from ._shell_command import (
|
||||
parse_posix_command,
|
||||
parse_powershell_source,
|
||||
posix_source,
|
||||
powershell_source,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -14,121 +22,23 @@ class QwenRuntimeReference:
|
||||
script_path: str
|
||||
|
||||
|
||||
def same_qwen_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_qwen_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def build_qwen_command(script_path: str) -> str:
|
||||
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
||||
raise ValueError(
|
||||
"Qwen Code hook commands require absolute executable and script paths"
|
||||
)
|
||||
invocation = (
|
||||
f"{_quote_powershell(sys.executable)} {_quote_powershell(script_path)}"
|
||||
if os.name == "nt"
|
||||
else f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
)
|
||||
return f"& {invocation}; exit $LASTEXITCODE" if os.name == "nt" else invocation
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return value, index + 1
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_posix_argument(command, executable[1] + 1)
|
||||
if script is None or script[1] != len(command):
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return value, index + 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
if not command.startswith("& "):
|
||||
return None
|
||||
executable = _read_powershell_argument(command, 2)
|
||||
if executable is None or executable[1] >= len(command):
|
||||
return None
|
||||
if command[executable[1]] != " ":
|
||||
return None
|
||||
script = _read_powershell_argument(command, executable[1] + 1)
|
||||
if script is None or command[script[1] :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable[0], script[0]
|
||||
return powershell_source(script_path) if os.name == "nt" else posix_source(script_path)
|
||||
|
||||
|
||||
def qwen_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[QwenRuntimeReference]:
|
||||
parsed = (
|
||||
_parse_powershell_command(command)
|
||||
parse_powershell_source(command)
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(command)
|
||||
else parse_posix_command(command)
|
||||
)
|
||||
if (
|
||||
parsed is None
|
||||
or not same_qwen_path(parsed[0], sys.executable)
|
||||
or not os.path.isabs(parsed[1])
|
||||
or os.path.basename(parsed[1]) != script_name
|
||||
):
|
||||
if parsed is None or not same_path(parsed[0], sys.executable):
|
||||
return None
|
||||
agent_directory = os.path.dirname(parsed[1])
|
||||
runtime_root = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
if not same_qwen_path(os.path.dirname(agent_directory), runtime_root):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return QwenRuntimeReference(agent_id, parsed[1])
|
||||
reference = managed_script_reference(parsed[1], script_name)
|
||||
return None if reference is None else QwenRuntimeReference(*reference)
|
||||
@@ -10,12 +10,11 @@ import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from ._runtime import same_agent_id, same_path
|
||||
from .qwen_command import (
|
||||
QwenRuntimeReference,
|
||||
build_qwen_command,
|
||||
qwen_runtime_reference,
|
||||
same_qwen_agent_id,
|
||||
same_qwen_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -102,10 +101,6 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def _optional_string(value: JsonObject, field: str, label: str) -> None:
|
||||
if field in value and not isinstance(value[field], str):
|
||||
raise ValueError(f'{label} field "{field}" must be a string')
|
||||
@@ -352,7 +347,7 @@ def managed_qwen_removals(
|
||||
is not None
|
||||
and (
|
||||
agent_id is None
|
||||
or same_qwen_agent_id(reference.agent_id, agent_id)
|
||||
or same_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
)
|
||||
if indexes:
|
||||
@@ -398,7 +393,7 @@ def qwen_runtime_contracts(hooks: QwenHooks) -> List[QwenRuntimeContract]:
|
||||
failure = failures.get(key, [])
|
||||
if len(guard) != 1 or len(post) != 1 or len(failure) != 1:
|
||||
continue
|
||||
if not same_qwen_path(post[0].script_path, failure[0].script_path):
|
||||
if not same_path(post[0].script_path, failure[0].script_path):
|
||||
continue
|
||||
contracts.append(QwenRuntimeContract(
|
||||
guard[0].agent_id,
|
||||
|
||||
@@ -2,107 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Sequence
|
||||
from typing import List, Optional
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory
|
||||
|
||||
from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||
from ._transaction import FileChange, FilePrecondition, file_change, source_change, write_changes
|
||||
from ._managed_files import MAX_SOURCE_BYTES
|
||||
from ._runtime import (
|
||||
DEFAULT_BASE_URL,
|
||||
RuntimePaths,
|
||||
present,
|
||||
resolve_runtime_paths,
|
||||
runtime_file_changes,
|
||||
same_path,
|
||||
validate_install_config,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
from ._transaction import FileChange, FilePrecondition, source_change, write_changes
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .qwen_command import same_qwen_path
|
||||
from .qwen_config import RenderedQwenDocument, qwen_document_label
|
||||
from .qwen_contract import AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT, elydora_dir
|
||||
from .qwen_io import validate_api_origin, validate_private_key, validate_runtime_tree
|
||||
from .qwen_contract import AGENT_KEY
|
||||
from .qwen_sources import QwenSources, require_qwen_hooks_enabled
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QwenRuntimePaths:
|
||||
agent_id: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> QwenRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_qwen_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return QwenRuntimePaths(agent_id, agent_directory, guard_path, audit_path)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Qwen Code installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
PRODUCT = "Qwen Code"
|
||||
|
||||
|
||||
def preflight_qwen_installation(
|
||||
config: InstallConfig, sources: QwenSources
|
||||
) -> QwenRuntimePaths:
|
||||
) -> RuntimePaths:
|
||||
require_qwen_hooks_enabled(sources)
|
||||
_validate_install_config(config)
|
||||
paths = _agent_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id)
|
||||
validate_install_config(config, AGENT_KEY, PRODUCT)
|
||||
paths = resolve_runtime_paths(config)
|
||||
validate_runtime_tree(paths.agent_directory, paths.agent_id, AGENT_KEY, PRODUCT)
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
) -> Optional[FileChange]:
|
||||
return file_change(file_path, label, next_source, mode, maximum_bytes)
|
||||
|
||||
|
||||
def _rendered_change(
|
||||
rendered: RenderedQwenDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def _rendered_change(rendered: RenderedQwenDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
@@ -116,13 +50,9 @@ def _rendered_change(
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_qwen_installation(
|
||||
config: InstallConfig,
|
||||
paths: QwenRuntimePaths,
|
||||
paths: RuntimePaths,
|
||||
rendered: RenderedQwenDocument,
|
||||
) -> List[FileChange]:
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
@@ -130,49 +60,27 @@ def prepare_qwen_installation(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
base_url=config.get("base_url", DEFAULT_BASE_URL),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return _present([
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(_runtime_config(config, paths.agent_id)),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
_rendered_change(rendered),
|
||||
])
|
||||
return [
|
||||
*runtime_file_changes(config, paths, AGENT_KEY, guard_script, audit_script),
|
||||
*present([_rendered_change(rendered)]),
|
||||
]
|
||||
|
||||
|
||||
def _read_only_preconditions(
|
||||
sources: QwenSources, changed_path: Optional[str]
|
||||
sources: QwenSources, changed_path: str
|
||||
) -> List[FilePrecondition]:
|
||||
return [
|
||||
condition
|
||||
for condition in sources.preconditions
|
||||
if changed_path is None
|
||||
or not same_qwen_path(condition.file_path, changed_path)
|
||||
if not same_path(condition.file_path, changed_path)
|
||||
]
|
||||
|
||||
|
||||
def commit_qwen_installation(
|
||||
changes: List[FileChange], sources: QwenSources
|
||||
) -> None:
|
||||
def commit_qwen_installation(changes: List[FileChange], sources: QwenSources) -> None:
|
||||
write_changes(
|
||||
changes,
|
||||
"Install Qwen Code hooks",
|
||||
@@ -180,15 +88,11 @@ def commit_qwen_installation(
|
||||
)
|
||||
|
||||
|
||||
def prepare_qwen_uninstall(
|
||||
rendered: RenderedQwenDocument,
|
||||
) -> Optional[FileChange]:
|
||||
def prepare_qwen_uninstall(rendered: RenderedQwenDocument) -> Optional[FileChange]:
|
||||
return _rendered_change(rendered)
|
||||
|
||||
|
||||
def commit_qwen_uninstall(
|
||||
change: Optional[FileChange], sources: QwenSources
|
||||
) -> None:
|
||||
def commit_qwen_uninstall(change: Optional[FileChange], sources: QwenSources) -> None:
|
||||
if change is None:
|
||||
return
|
||||
write_changes(
|
||||
|
||||
@@ -2,199 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
from typing import List, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .qwen_command import same_qwen_agent_id, same_qwen_path
|
||||
from .qwen_contract import AGENT_KEY, QwenRuntimeContract, elydora_dir
|
||||
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
||||
from ._strict_json import JsonObject
|
||||
from .qwen_contract import AGENT_KEY, QwenRuntimeContract
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
def _expected_scripts(agent_id: str, config: JsonObject) -> Tuple[str, str]:
|
||||
return expected_runtime_scripts(AGENT_KEY, agent_id, config)
|
||||
|
||||
|
||||
def qwen_runtime_files_exist(contracts: List[QwenRuntimeContract]) -> bool:
|
||||
return any(
|
||||
runtime_contract_exists(contract, AGENT_KEY, "Qwen Code", _expected_scripts)
|
||||
for contract in contracts
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
value: Any, field: str, config_path: str
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"Elydora runtime config {field} is invalid: {config_path}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject, expected_agent_id: str, config_path: str
|
||||
) -> None:
|
||||
supported = {"org_id", "agent_id", "kid", "base_url", "token", "agent_name"}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_qwen_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Qwen Code hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
if "token" in config:
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(agent_directory: str, agent_id: str) -> None:
|
||||
root = elydora_dir()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifacts = (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in artifacts
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
configured_id = config.get("agent_id")
|
||||
if (
|
||||
not isinstance(configured_id, str)
|
||||
or not same_qwen_agent_id(configured_id, agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime config identity does not match Qwen Code agent "
|
||||
f"{agent_id}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(contract: QwenRuntimeContract) -> bool:
|
||||
root = elydora_dir()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_qwen_path(os.path.dirname(agent_directory), root)
|
||||
or not same_qwen_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
key_path = os.path.join(agent_directory, "private.key")
|
||||
config = _read_runtime_config(config_path)
|
||||
key = read_physical_file(key_path, "Elydora private key", MAX_SECRET_BYTES)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(config, contract.agent_id, config_path)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id=str(config["org_id"]),
|
||||
agent_id=contract.agent_id,
|
||||
kid=str(config["kid"]),
|
||||
base_url=str(config["base_url"]),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def qwen_runtime_files_exist(
|
||||
contracts: List[QwenRuntimeContract],
|
||||
) -> bool:
|
||||
return any(_runtime_contract_exists(contract) for contract in contracts)
|
||||
@@ -10,9 +10,9 @@ import tempfile
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ._dotenv import parse_dotenv
|
||||
from ._managed_files import MAX_SOURCE_BYTES, FileSnapshot, read_physical_file
|
||||
from ._managed_files import MAX_SOURCE_BYTES, read_physical_file
|
||||
from ._runtime import same_path, unique_preconditions
|
||||
from ._transaction import FilePrecondition
|
||||
from .qwen_command import same_qwen_path
|
||||
from .qwen_config import (
|
||||
QwenDocument,
|
||||
create_qwen_document,
|
||||
@@ -127,7 +127,7 @@ def _resolve_qwen_routing() -> _RoutingResult:
|
||||
discovered_home = values.get("QWEN_HOME")
|
||||
if discovered_home and discovered_home != initial_qwen_home:
|
||||
discovered_directory = _resolve_config_path(discovered_home)
|
||||
if not same_qwen_path(discovered_directory, initial_directory):
|
||||
if not same_path(discovered_directory, initial_directory):
|
||||
read_candidate(os.path.join(discovered_directory, ".env"))
|
||||
resolved_home = values.get("QWEN_HOME")
|
||||
return _RoutingResult(
|
||||
@@ -288,15 +288,6 @@ def _source_precondition(document: QwenDocument) -> FilePrecondition:
|
||||
)
|
||||
|
||||
|
||||
def _deduplicate_preconditions(
|
||||
values: List[FilePrecondition],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
result: Dict[str, FilePrecondition] = {}
|
||||
for value in values:
|
||||
result.setdefault(_comparison_path(value.file_path), value)
|
||||
return tuple(result.values())
|
||||
|
||||
|
||||
def read_qwen_sources() -> QwenSources:
|
||||
routing = _resolve_qwen_routing()
|
||||
system_path = _system_settings_path()
|
||||
@@ -355,7 +346,7 @@ def read_qwen_sources() -> QwenSources:
|
||||
system,
|
||||
workspace_active and workspace_trusted,
|
||||
),
|
||||
preconditions=_deduplicate_preconditions(preconditions),
|
||||
preconditions=unique_preconditions(preconditions),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+157
-42
@@ -1,7 +1,4 @@
|
||||
"""Type definitions matching the Elydora backend API contract.
|
||||
|
||||
All types use TypedDict for structural typing, compatible with Python 3.10+.
|
||||
"""
|
||||
"""TypedDict definitions matching the Elydora Backend API contract."""
|
||||
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -12,10 +9,6 @@ else:
|
||||
from typing_extensions import Literal, NotRequired, TypedDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums (as Literal types)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AgentStatus = Literal["active", "frozen", "revoked"]
|
||||
KeyStatus = Literal["active", "retired", "revoked"]
|
||||
ExportStatus = Literal["queued", "running", "done", "failed"]
|
||||
@@ -52,11 +45,33 @@ INTEGRATION_TYPES: Tuple[IntegrationType, ...] = (
|
||||
"gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen",
|
||||
"enterprise", "gui", "sdk", "other",
|
||||
)
|
||||
AdminAction = Literal[
|
||||
"agent.register",
|
||||
"agent.update",
|
||||
"agent.freeze",
|
||||
"agent.unfreeze",
|
||||
"agent.revoke",
|
||||
"agent.delete",
|
||||
"key.revoke",
|
||||
"export.create",
|
||||
"org.create",
|
||||
"org.update",
|
||||
"member.invite",
|
||||
"invitation.cancel",
|
||||
"member.remove",
|
||||
"member.role_change",
|
||||
"agent.assign",
|
||||
"agent.unassign",
|
||||
"webhook.register",
|
||||
"webhook.delete",
|
||||
]
|
||||
ErrorCode = Literal[
|
||||
"INVALID_SIGNATURE",
|
||||
"UNKNOWN_AGENT",
|
||||
"KEY_REVOKED",
|
||||
"KEY_RETIRED",
|
||||
"AGENT_FROZEN",
|
||||
"AGENT_REVOKED",
|
||||
"TTL_EXPIRED",
|
||||
"REPLAY_DETECTED",
|
||||
"PREV_HASH_MISMATCH",
|
||||
@@ -69,18 +84,13 @@ ErrorCode = Literal[
|
||||
"VALIDATION_ERROR",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Agent(TypedDict):
|
||||
agent_id: str
|
||||
org_id: str
|
||||
display_name: str
|
||||
responsible_entity: str
|
||||
integration_type: IntegrationType
|
||||
status: AgentStatus
|
||||
integration_type: IntegrationType
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
@@ -136,6 +146,8 @@ class Epoch(TypedDict):
|
||||
class Organization(TypedDict):
|
||||
org_id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
ba_org_id: Optional[str]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
@@ -161,11 +173,6 @@ class Export(TypedDict):
|
||||
completed_at: Optional[int]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EOR(TypedDict):
|
||||
"""Elydora Operation Record."""
|
||||
op_version: Literal["1.0"]
|
||||
@@ -201,11 +208,6 @@ class EAR(TypedDict):
|
||||
elydora_signature: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API request types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegisterAgentKeyParam(TypedDict):
|
||||
kid: str
|
||||
public_key: str
|
||||
@@ -220,21 +222,6 @@ class RegisterAgentRequest(TypedDict):
|
||||
responsible_entity: NotRequired[str]
|
||||
|
||||
|
||||
class AuditQueryParams(TypedDict, total=False):
|
||||
org_id: str
|
||||
agent_id: str
|
||||
operation_type: str
|
||||
start_time: int
|
||||
end_time: int
|
||||
cursor: str
|
||||
limit: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API response types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegisterAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
keys: List[AgentKey]
|
||||
@@ -252,6 +239,7 @@ class SubmitOperationResponse(TypedDict):
|
||||
class GetOperationResponse(TypedDict, total=False):
|
||||
operation: Operation
|
||||
receipt: Receipt
|
||||
payload: Any
|
||||
|
||||
|
||||
class VerifyChecks(TypedDict, total=False):
|
||||
@@ -273,9 +261,16 @@ class AuditQueryResponse(TypedDict):
|
||||
total_count: int
|
||||
|
||||
|
||||
class EpochAnchor(TypedDict, total=False):
|
||||
tsa_url: str
|
||||
anchored_at: int
|
||||
tsa_token: str
|
||||
root_hash: str
|
||||
|
||||
|
||||
class GetEpochResponse(TypedDict, total=False):
|
||||
epoch: Epoch
|
||||
anchor: Dict[str, Any]
|
||||
anchor: EpochAnchor
|
||||
|
||||
|
||||
class ListEpochsResponse(TypedDict):
|
||||
@@ -327,17 +322,137 @@ class DeleteAgentResponse(TypedDict):
|
||||
deleted: bool
|
||||
|
||||
|
||||
class AuthMeUser(TypedDict):
|
||||
user_id: str
|
||||
org_id: Optional[str]
|
||||
email: str
|
||||
display_name: str
|
||||
role: RbacRole
|
||||
status: Literal["active", "suspended"]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
onboarding_completed: bool
|
||||
|
||||
|
||||
class CurrentOrganization(TypedDict):
|
||||
org_id: Optional[str]
|
||||
ba_org_id: Optional[str]
|
||||
role: RbacRole
|
||||
|
||||
|
||||
class GetMeResponse(TypedDict):
|
||||
user: User
|
||||
user: AuthMeUser
|
||||
current_organization: CurrentOrganization
|
||||
|
||||
|
||||
class IssueTokenResponse(TypedDict):
|
||||
class IssueApiTokenResponse(TypedDict):
|
||||
token: str
|
||||
expires_at: Optional[int]
|
||||
token_id: str
|
||||
|
||||
|
||||
# Deprecated alias for IssueApiTokenResponse.
|
||||
IssueTokenResponse = IssueApiTokenResponse
|
||||
|
||||
|
||||
class RotateApiTokenResponse(TypedDict):
|
||||
token: str
|
||||
expires_at: Optional[int]
|
||||
token_id: str
|
||||
previous_token_grace_until: int
|
||||
|
||||
|
||||
class HealthResponse(TypedDict):
|
||||
status: str
|
||||
version: str
|
||||
protocol_version: str
|
||||
capabilities: Dict[str, str]
|
||||
timestamp: int
|
||||
|
||||
|
||||
class AdminEvent(TypedDict):
|
||||
event_id: str
|
||||
org_id: str
|
||||
actor: str
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
details: Optional[str]
|
||||
created_at: int
|
||||
|
||||
|
||||
class AgentAssignment(TypedDict):
|
||||
id: str
|
||||
agent_id: str
|
||||
user_id: str
|
||||
org_id: str
|
||||
assigned_by: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class WebhookEntry(TypedDict):
|
||||
webhook_id: str
|
||||
org_id: str
|
||||
endpoint_url: str
|
||||
events: List[str]
|
||||
status: Literal["active", "disabled"]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class MemberEntry(TypedDict):
|
||||
member_id: str
|
||||
user_id: str
|
||||
role: str
|
||||
joined_at: str
|
||||
email: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class UpdateAgentRequest(TypedDict):
|
||||
integration_type: IntegrationType
|
||||
|
||||
|
||||
class UpdateAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
|
||||
|
||||
class FreezeAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
previous_status: AgentStatus
|
||||
|
||||
|
||||
class UnfreezeAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
previous_status: AgentStatus
|
||||
|
||||
|
||||
class ListWebhooksResponse(TypedDict):
|
||||
webhooks: List[WebhookEntry]
|
||||
|
||||
|
||||
class RegisterWebhookResponse(TypedDict):
|
||||
webhook: WebhookEntry
|
||||
|
||||
|
||||
class ListMembersResponse(TypedDict):
|
||||
members: List[MemberEntry]
|
||||
|
||||
|
||||
class ListAdminEventsResponse(TypedDict):
|
||||
events: List[AdminEvent]
|
||||
|
||||
|
||||
class DependencyHealth(TypedDict):
|
||||
status: Literal["ok", "failed"]
|
||||
latency_ms: int
|
||||
contract_phase: NotRequired[Literal["expand", "contract"]]
|
||||
|
||||
|
||||
class DeepHealthResponse(TypedDict):
|
||||
status: Literal["healthy", "degraded"]
|
||||
version: str
|
||||
protocol_version: str
|
||||
capabilities: Dict[str, str]
|
||||
timestamp: int
|
||||
dependencies: Dict[str, DependencyHealth]
|
||||
@@ -28,27 +28,12 @@ def generate_nonce() -> str:
|
||||
|
||||
|
||||
def generate_uuidv7() -> str:
|
||||
"""Generate a UUIDv7 (time-ordered, random) as a string.
|
||||
|
||||
Layout (RFC 9562):
|
||||
- 48-bit Unix timestamp in milliseconds
|
||||
- 4-bit version (0b0111)
|
||||
- 12-bit random
|
||||
- 2-bit variant (0b10)
|
||||
- 62-bit random
|
||||
"""
|
||||
"""UUIDv7 (RFC 9562): 48-bit ms timestamp, version 7, variant 10, 74 random bits."""
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
|
||||
# 48-bit timestamp
|
||||
ts_bytes = struct.pack(">Q", timestamp_ms)[2:] # last 6 bytes of 8-byte big-endian
|
||||
|
||||
# 2 bytes: version (4 bits = 0x7) + 12 random bits
|
||||
rand_a = struct.unpack(">H", os.urandom(2))[0]
|
||||
rand_a = (rand_a & 0x0FFF) | 0x7000 # set version nibble
|
||||
|
||||
# 8 bytes: variant (2 bits = 0b10) + 62 random bits
|
||||
ts_bytes = struct.pack(">Q", timestamp_ms)[2:]
|
||||
rand_a = (struct.unpack(">H", os.urandom(2))[0] & 0x0FFF) | 0x7000
|
||||
rand_b = bytearray(os.urandom(8))
|
||||
rand_b[0] = (rand_b[0] & 0x3F) | 0x80 # set variant bits
|
||||
rand_b[0] = (rand_b[0] & 0x3F) | 0x80
|
||||
|
||||
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
|
||||
|
||||
|
||||
Reference in new issue
Block a user