Land the crash-recoverable managed-file transaction engine (journal, staging, commit, restart recovery, opaque snapshots, private artifacts, Windows DACL hardening) and the Kiro IDE 1.0 workspace hook plugin with legacy migration. Repair five defects that kept the suite red (426 failures to zero): - Anchor the transaction state directory at ~/.elydora/transactions instead of the shared system temp directory. The recovery journal now shares fate with the runtimes it repairs, survives temp cleaners, and isolates naturally under per-test fake homes. - Skip pending-transaction recovery when no state directory exists so rejected installs never create runtime directories. - Create the runtime root's parent before hardening it, matching the Go SDK's expand behavior for fresh homes. - Stop using file timestamps as identity evidence: Windows exposes mtime/ctime with delayed cross-handle visibility. Reads now prove stability through a verification re-read; snapshots compare device, inode, size, and SHA-256. - Stop comparing synthetic Windows permission bits across stat boundaries: path stats derive execute bits from file extensions while handle stats do not, which falsely flagged every managed .cmd wrapper. Snapshots store raw modes for restoration and compare portable bits. Reservation cleanup now verifies content before discarding so a concurrent writer's file at a rollback path is preserved, and the install-failure tests assert the precise contract: no agent runtime residue, with the durable state directory allowed to remain. Gates: pytest 807 passed, mypy clean, pip check clean, wheel builds.
493 lines
15 KiB
Python
493 lines
15 KiB
Python
"""Kiro IDE workspace discovery and managed runtime inspection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from dataclasses import dataclass
|
|
import os
|
|
from typing import Any, List, Optional
|
|
import urllib.parse
|
|
|
|
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
|
|
|
from ._managed_files import (
|
|
DirectorySnapshot,
|
|
FileSnapshot,
|
|
MAX_CONFIG_BYTES,
|
|
MAX_SECRET_BYTES,
|
|
physical_directory_exists,
|
|
physical_file_exists,
|
|
read_physical_directory,
|
|
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 .kiroide_command import same_kiroide_agent_id, same_kiroide_path
|
|
from .kiroide_contract import (
|
|
AGENT_KEY,
|
|
CONFIG_FILE,
|
|
LEGACY_CONFIG_FILE,
|
|
KiroIdeDocument,
|
|
KiroIdeRuntimeContract,
|
|
create_kiroide_document,
|
|
legacy_kiroide_runtime_contract,
|
|
parse_kiroide_document,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class KiroIdePaths:
|
|
workspace_root: str
|
|
kiro_directory: str
|
|
hooks_directory: str
|
|
config_path: str
|
|
legacy_config_path: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LegacyKiroIdeDocument:
|
|
exists: bool
|
|
file_path: str
|
|
raw: Optional[str] = None
|
|
snapshot: Optional[FileSnapshot] = None
|
|
contract: Optional[KiroIdeRuntimeContract] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class KiroIdeSources:
|
|
paths: KiroIdePaths
|
|
directories: KiroIdeDirectoryState
|
|
document: KiroIdeDocument
|
|
legacy: LegacyKiroIdeDocument
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class KiroIdeDirectoryState:
|
|
workspace: DirectorySnapshot
|
|
kiro: Optional[DirectorySnapshot]
|
|
hooks: Optional[DirectorySnapshot]
|
|
|
|
|
|
def resolve_kiroide_paths(
|
|
workspace_root: Optional[str] = None,
|
|
home_directory: Optional[str] = None,
|
|
) -> KiroIdePaths:
|
|
workspace = os.path.abspath(workspace_root or os.getcwd())
|
|
home = home_directory or os.path.expanduser("~")
|
|
kiro_directory = os.path.join(workspace, ".kiro")
|
|
hooks_directory = os.path.join(kiro_directory, "hooks")
|
|
return KiroIdePaths(
|
|
workspace,
|
|
kiro_directory,
|
|
hooks_directory,
|
|
os.path.join(hooks_directory, CONFIG_FILE),
|
|
os.path.join(home, ".kiro", "hooks", LEGACY_CONFIG_FILE),
|
|
)
|
|
|
|
|
|
def _inspect_workspace(paths: KiroIdePaths) -> KiroIdeDirectoryState:
|
|
workspace = read_physical_directory(
|
|
paths.workspace_root, "Kiro IDE workspace"
|
|
)
|
|
if workspace is None:
|
|
raise OSError(f"Kiro IDE workspace is missing: {paths.workspace_root}")
|
|
kiro = read_physical_directory(
|
|
paths.kiro_directory, "Kiro IDE configuration directory"
|
|
)
|
|
hooks = read_physical_directory(
|
|
paths.hooks_directory, "Kiro IDE hooks directory"
|
|
)
|
|
return KiroIdeDirectoryState(workspace, kiro, hooks)
|
|
|
|
|
|
def require_kiroide_directory_state(
|
|
paths: KiroIdePaths,
|
|
expected: KiroIdeDirectoryState,
|
|
operation: str,
|
|
) -> None:
|
|
current = _inspect_workspace(paths)
|
|
for label, path, before, after in (
|
|
(
|
|
"Kiro IDE workspace",
|
|
paths.workspace_root,
|
|
expected.workspace,
|
|
current.workspace,
|
|
),
|
|
(
|
|
"Kiro IDE configuration directory",
|
|
paths.kiro_directory,
|
|
expected.kiro,
|
|
current.kiro,
|
|
),
|
|
(
|
|
"Kiro IDE hooks directory",
|
|
paths.hooks_directory,
|
|
expected.hooks,
|
|
current.hooks,
|
|
),
|
|
):
|
|
if before != after:
|
|
raise OSError(f"{label} changed during {operation}: {path}")
|
|
|
|
|
|
def _read_document(paths: KiroIdePaths) -> KiroIdeDocument:
|
|
snapshot = read_physical_file(paths.config_path, "Kiro IDE hooks")
|
|
if snapshot is None:
|
|
return create_kiroide_document(paths.config_path)
|
|
return parse_kiroide_document(
|
|
paths.config_path,
|
|
snapshot.contents,
|
|
snapshot,
|
|
)
|
|
|
|
|
|
def _read_legacy(paths: KiroIdePaths) -> LegacyKiroIdeDocument:
|
|
snapshot = read_physical_file(paths.legacy_config_path, "legacy Kiro IDE hook")
|
|
if snapshot is None:
|
|
return LegacyKiroIdeDocument(False, paths.legacy_config_path)
|
|
return LegacyKiroIdeDocument(
|
|
True,
|
|
paths.legacy_config_path,
|
|
snapshot.contents,
|
|
snapshot,
|
|
legacy_kiroide_runtime_contract(
|
|
snapshot.contents, paths.legacy_config_path
|
|
),
|
|
)
|
|
|
|
|
|
def read_kiroide_sources() -> KiroIdeSources:
|
|
paths = resolve_kiroide_paths()
|
|
directories = _inspect_workspace(paths)
|
|
document = _read_document(paths)
|
|
legacy = _read_legacy(paths)
|
|
require_kiroide_directory_state(
|
|
paths, directories, "Kiro IDE source discovery"
|
|
)
|
|
return KiroIdeSources(paths, directories, document, legacy)
|
|
|
|
|
|
def require_physical_legacy_directory(legacy: LegacyKiroIdeDocument) -> None:
|
|
if not legacy.exists:
|
|
return
|
|
hooks_directory = os.path.dirname(legacy.file_path)
|
|
kiro_directory = os.path.dirname(hooks_directory)
|
|
if not physical_directory_exists(
|
|
kiro_directory, "legacy Kiro IDE configuration directory"
|
|
):
|
|
raise OSError(
|
|
f"Legacy Kiro IDE configuration directory is missing: {kiro_directory}"
|
|
)
|
|
if not physical_directory_exists(
|
|
hooks_directory, "legacy Kiro IDE hooks directory"
|
|
):
|
|
raise OSError(
|
|
f"Legacy Kiro IDE hooks directory is missing: {hooks_directory}"
|
|
)
|
|
|
|
|
|
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,
|
|
expected_workspace_root: str,
|
|
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",
|
|
}
|
|
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_kiroide_agent_id(agent_id, expected_agent_id)
|
|
or config.get("agent_name") != AGENT_KEY
|
|
):
|
|
raise ValueError(
|
|
"Elydora runtime identity does not match Kiro IDE hooks: "
|
|
f"{config_path}"
|
|
)
|
|
configured_workspace = config.get("workspace_root")
|
|
legacy_ownerless_config = (
|
|
allow_legacy_ownerless_config and configured_workspace is None
|
|
)
|
|
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(
|
|
config.get("base_url"), "base_url", config_path
|
|
)
|
|
validate_api_origin(base_url, "Elydora runtime config base_url")
|
|
if configured_workspace is None and (
|
|
allow_missing_workspace_root or legacy_ownerless_config
|
|
):
|
|
return
|
|
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):
|
|
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,
|
|
workspace_root: str,
|
|
allow_missing_workspace_root: bool = False,
|
|
allow_legacy_ownerless_config: bool = False,
|
|
) -> 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)
|
|
artifact_exists = any(
|
|
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:
|
|
if artifact_exists:
|
|
raise ValueError(
|
|
"Elydora runtime identity cannot be verified without config.json: "
|
|
f"{agent_directory}"
|
|
)
|
|
return
|
|
_validate_runtime_config(
|
|
config,
|
|
agent_id,
|
|
config_path,
|
|
workspace_root,
|
|
allow_missing_workspace_root,
|
|
allow_legacy_ownerless_config,
|
|
)
|
|
|
|
|
|
def _runtime_contract_exists(
|
|
contract: KiroIdeRuntimeContract, workspace_root: str
|
|
) -> bool:
|
|
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(
|
|
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")
|
|
config_snapshot = read_physical_file(
|
|
config_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
|
)
|
|
config = (
|
|
None
|
|
if config_snapshot is None
|
|
else parse_json_object(
|
|
config_snapshot.contents,
|
|
f"Elydora runtime config at {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,
|
|
workspace_root,
|
|
)
|
|
if os.name != "nt" and key.mode & 0o077:
|
|
raise ValueError(
|
|
f"Elydora private key must be accessible only by its owner: "
|
|
f"{os.path.join(agent_directory, 'private.key')}"
|
|
)
|
|
if (
|
|
os.name != "nt"
|
|
and "token" in config
|
|
and config_snapshot is not None
|
|
and config_snapshot.mode & 0o077
|
|
):
|
|
raise ValueError(
|
|
"Token-bearing Elydora runtime config must be accessible only by "
|
|
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,
|
|
)
|
|
return guard.contents == expected_guard and audit.contents == expected_audit
|
|
|
|
|
|
def kiroide_runtime_files_exist(
|
|
contracts: List[KiroIdeRuntimeContract],
|
|
workspace_root: str,
|
|
) -> bool:
|
|
return any(
|
|
_runtime_contract_exists(contract, workspace_root) for contract in contracts
|
|
)
|
|
|
|
|
|
def require_kiroide_workspace_owner(
|
|
agent_id: str,
|
|
workspace_root: str,
|
|
allow_missing_workspace_root: bool = False,
|
|
allow_legacy_ownerless_config: bool = False,
|
|
) -> None:
|
|
root = runtime_root()
|
|
agent_directory = resolve_agent_directory(root, agent_id)
|
|
validate_runtime_tree(
|
|
agent_directory,
|
|
agent_id,
|
|
workspace_root,
|
|
allow_missing_workspace_root,
|
|
allow_legacy_ownerless_config,
|
|
)
|
|
|
|
|
|
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(
|
|
contract.agent_id, agent_id
|
|
):
|
|
return False
|
|
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
|
return same_kiroide_path(
|
|
contract.guard_path,
|
|
os.path.join(agent_directory, "guard.py"),
|
|
) and same_kiroide_path(
|
|
contract.audit_path,
|
|
os.path.join(agent_directory, "hook.py"),
|
|
)
|