feat(python): durable transaction subsystem and Kiro IDE 1.0 adapter
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.
This commit is contained in:
1 parent
dce7d1288a
commit
e37418c489
53 files changed
+11855
-531
No files matched your search
@@ -0,0 +1,498 @@
|
||||
"""Transactional Kiro IDE workspace hook installation."""
|
||||
|
||||
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,
|
||||
MAX_SOURCE_BYTES,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._opaque_removal import OpaqueRemovalChange
|
||||
from ._transaction import (
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
cleanup_created_directories,
|
||||
source_change,
|
||||
write_changes,
|
||||
)
|
||||
from ._strict_json import JsonObject
|
||||
from ._runtime_removal import (
|
||||
KIRO_IDE_RUNTIME_FILES,
|
||||
PreparedRuntimeRemoval,
|
||||
finalize_runtime_removal,
|
||||
prepare_runtime_removal,
|
||||
)
|
||||
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,
|
||||
)
|
||||
from .kiroide_directories import (
|
||||
KiroIdeRuntimeDirectoryState,
|
||||
inspect_runtime_directories,
|
||||
pin_runtime_directories,
|
||||
pin_workspace_directories,
|
||||
require_runtime_directory_state,
|
||||
)
|
||||
from .kiroide_io import (
|
||||
KiroIdeSources,
|
||||
LegacyKiroIdeDocument,
|
||||
legacy_kiroide_contract_matches_agent,
|
||||
require_physical_legacy_directory,
|
||||
validate_api_origin,
|
||||
validate_kiroide_runtime_config,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeRuntimePaths:
|
||||
agent_id: str
|
||||
runtime_parent: str
|
||||
runtime_root: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
directories: KiroIdeRuntimeDirectoryState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKiroIdeInstallation:
|
||||
changes: List[FileChange]
|
||||
runtime_preconditions: List[FilePrecondition]
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKiroIdeUninstall:
|
||||
changes: List[FileChange]
|
||||
runtime_removals: List[PreparedRuntimeRemoval]
|
||||
|
||||
|
||||
def _require_runtime_state(
|
||||
paths: KiroIdeRuntimePaths, operation: str
|
||||
) -> None:
|
||||
require_runtime_directory_state(
|
||||
paths.runtime_parent,
|
||||
paths.runtime_root,
|
||||
paths.agent_directory,
|
||||
paths.directories,
|
||||
operation,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
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,
|
||||
runtime_parent,
|
||||
root,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
directories,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
owns_workspace_runtime = any(
|
||||
same_kiroide_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(
|
||||
sources.legacy, paths.agent_id
|
||||
)
|
||||
validate_runtime_tree(
|
||||
paths.agent_directory,
|
||||
paths.agent_id,
|
||||
sources.paths.workspace_root,
|
||||
allow_missing_workspace_root=owns_workspace_runtime,
|
||||
allow_legacy_ownerless_config=owns_global_legacy_runtime,
|
||||
)
|
||||
_require_runtime_state(paths, "Kiro IDE installation preflight")
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
enforce_mode: bool = False,
|
||||
) -> Tuple[Optional[FileChange], Optional[FilePrecondition]]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
change = source_change(
|
||||
file_path,
|
||||
label,
|
||||
None if snapshot is None else snapshot.contents,
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
expected_snapshot=snapshot,
|
||||
enforce_mode=enforce_mode,
|
||||
)
|
||||
precondition = (
|
||||
None
|
||||
if change is not None
|
||||
else FilePrecondition(file_path, label, snapshot, maximum_bytes)
|
||||
)
|
||||
return change, precondition
|
||||
|
||||
|
||||
def _rendered_change(
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
rendered.document.file_path,
|
||||
"Kiro IDE hooks",
|
||||
rendered.document.raw if rendered.document.exists else None,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
expected_snapshot=rendered.document.snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _removes_legacy(
|
||||
legacy: LegacyKiroIdeDocument, agent_id: str = ""
|
||||
) -> bool:
|
||||
return legacy.contract is not None and (
|
||||
not agent_id
|
||||
or same_kiroide_agent_id(legacy.contract.agent_id, agent_id)
|
||||
)
|
||||
|
||||
|
||||
def _legacy_change(
|
||||
legacy: LegacyKiroIdeDocument, agent_id: str = ""
|
||||
) -> Optional[FileChange]:
|
||||
if not _removes_legacy(legacy, agent_id):
|
||||
return None
|
||||
if legacy.raw is None:
|
||||
raise OSError(f"Legacy Kiro IDE hook source is missing: {legacy.file_path}")
|
||||
require_physical_legacy_directory(legacy)
|
||||
return source_change(
|
||||
legacy.file_path,
|
||||
"legacy Kiro IDE hook",
|
||||
legacy.raw,
|
||||
None,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
expected_snapshot=legacy.snapshot,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
) -> PreparedKiroIdeInstallation:
|
||||
operation = "Kiro IDE installation preparation"
|
||||
_require_runtime_state(paths, operation)
|
||||
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"),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
runtime = [
|
||||
_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,
|
||||
sources.paths.workspace_root,
|
||||
)
|
||||
),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
enforce_mode=os.name != "nt",
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
enforce_mode=os.name != "nt",
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
]
|
||||
changes = _present(
|
||||
[
|
||||
*(change for change, _condition in runtime),
|
||||
_rendered_change(rendered),
|
||||
_legacy_change(sources.legacy, paths.agent_id),
|
||||
]
|
||||
)
|
||||
_require_runtime_state(paths, operation)
|
||||
return PreparedKiroIdeInstallation(
|
||||
changes,
|
||||
[
|
||||
condition
|
||||
for _change, condition in runtime
|
||||
if condition is not None
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _provider_preconditions(
|
||||
sources: KiroIdeSources,
|
||||
changes: List[FileChange],
|
||||
) -> List[FilePrecondition]:
|
||||
changed = {
|
||||
os.path.normcase(os.path.abspath(change.file_path)) for change in changes
|
||||
}
|
||||
values = (
|
||||
(
|
||||
sources.document.file_path,
|
||||
"Kiro IDE hooks",
|
||||
sources.document.snapshot,
|
||||
),
|
||||
(
|
||||
sources.legacy.file_path,
|
||||
"legacy Kiro IDE hook",
|
||||
sources.legacy.snapshot,
|
||||
),
|
||||
)
|
||||
return [
|
||||
FilePrecondition(file_path, label, snapshot, MAX_SOURCE_BYTES)
|
||||
for file_path, label, snapshot in values
|
||||
if os.path.normcase(os.path.abspath(file_path)) not in changed
|
||||
]
|
||||
|
||||
|
||||
def _commit_kiroide_changes(
|
||||
changes: List[FileChange],
|
||||
sources: KiroIdeSources,
|
||||
operation: str,
|
||||
runtime_paths: Optional[KiroIdeRuntimePaths] = None,
|
||||
runtime_preconditions: Sequence[FilePrecondition] = (),
|
||||
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
||||
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
||||
create_workspace_directories: Optional[bool] = None,
|
||||
) -> None:
|
||||
directories = []
|
||||
try:
|
||||
directories.extend(
|
||||
pin_workspace_directories(
|
||||
sources,
|
||||
operation,
|
||||
bool(changes)
|
||||
if create_workspace_directories is None
|
||||
else create_workspace_directories,
|
||||
)
|
||||
)
|
||||
if runtime_paths is not None:
|
||||
directories.extend(
|
||||
pin_runtime_directories(
|
||||
runtime_paths.runtime_parent,
|
||||
runtime_paths.runtime_root,
|
||||
runtime_paths.agent_directory,
|
||||
runtime_paths.directories,
|
||||
operation,
|
||||
bool(changes),
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
failures = cleanup_created_directories(directories)
|
||||
suffix = f"; recovery failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"{operation}: {error}{suffix}") from error
|
||||
write_changes(
|
||||
changes,
|
||||
operation,
|
||||
[
|
||||
*_provider_preconditions(sources, changes),
|
||||
*runtime_preconditions,
|
||||
],
|
||||
[*directories, *directory_preconditions],
|
||||
opaque_removals,
|
||||
)
|
||||
|
||||
|
||||
def commit_kiroide_installation(
|
||||
prepared: PreparedKiroIdeInstallation,
|
||||
sources: KiroIdeSources,
|
||||
paths: KiroIdeRuntimePaths,
|
||||
) -> None:
|
||||
_commit_kiroide_changes(
|
||||
prepared.changes,
|
||||
sources,
|
||||
"Install Kiro IDE hooks",
|
||||
paths,
|
||||
prepared.runtime_preconditions,
|
||||
)
|
||||
|
||||
|
||||
def prepare_kiroide_uninstall(
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
agent_id: str = "",
|
||||
runtime_agent_ids: Sequence[str] = (),
|
||||
) -> PreparedKiroIdeUninstall:
|
||||
runtime_removals = []
|
||||
selected_agent_ids: List[str] = []
|
||||
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)
|
||||
for selected_agent_id in selected_agent_ids
|
||||
):
|
||||
continue
|
||||
selected_agent_ids.append(runtime_agent_id)
|
||||
allow_legacy_ownerless_config = legacy_kiroide_contract_matches_agent(
|
||||
sources.legacy, runtime_agent_id
|
||||
)
|
||||
|
||||
def validate_config(config: JsonObject, config_path: str) -> None:
|
||||
validate_kiroide_runtime_config(
|
||||
config,
|
||||
runtime_agent_id,
|
||||
config_path,
|
||||
sources.paths.workspace_root,
|
||||
allow_missing_workspace_root=any(
|
||||
same_kiroide_agent_id(
|
||||
contract.agent_id, runtime_agent_id
|
||||
)
|
||||
for contract in kiroide_runtime_contracts(
|
||||
sources.document.hooks
|
||||
)
|
||||
),
|
||||
allow_legacy_ownerless_config=allow_legacy_ownerless_config,
|
||||
)
|
||||
|
||||
runtime_removal = prepare_runtime_removal(
|
||||
runtime_agent_id,
|
||||
AGENT_KEY,
|
||||
config_validator=validate_config,
|
||||
managed_files=KIRO_IDE_RUNTIME_FILES,
|
||||
)
|
||||
if runtime_removal is not None:
|
||||
runtime_removals.append(runtime_removal)
|
||||
provider_changes = _present(
|
||||
[_rendered_change(rendered), _legacy_change(sources.legacy, agent_id)]
|
||||
)
|
||||
return PreparedKiroIdeUninstall(
|
||||
[
|
||||
*provider_changes,
|
||||
*(
|
||||
change
|
||||
for runtime_removal in runtime_removals
|
||||
for change in runtime_removal.changes
|
||||
),
|
||||
],
|
||||
runtime_removals,
|
||||
)
|
||||
|
||||
|
||||
def commit_kiroide_uninstall(
|
||||
prepared: PreparedKiroIdeUninstall, sources: KiroIdeSources
|
||||
) -> None:
|
||||
_commit_kiroide_changes(
|
||||
prepared.changes,
|
||||
sources,
|
||||
"Uninstall Kiro IDE hooks",
|
||||
runtime_preconditions=tuple(
|
||||
condition
|
||||
for removal in prepared.runtime_removals
|
||||
for condition in removal.file_preconditions
|
||||
),
|
||||
directory_preconditions=tuple(
|
||||
condition
|
||||
for removal in prepared.runtime_removals
|
||||
for condition in removal.directory_preconditions
|
||||
),
|
||||
opaque_removals=tuple(
|
||||
change
|
||||
for removal in prepared.runtime_removals
|
||||
for change in removal.opaque_removals
|
||||
),
|
||||
create_workspace_directories=False,
|
||||
)
|
||||
for removal in prepared.runtime_removals:
|
||||
finalize_runtime_removal(removal)
|
||||
Reference in new issue
Block a user