fix(python): mirror Factory Droid 0.175 hooks
This commit is contained in:
1 parent
47ef2cc691
commit
c59fcafebb
12 files changed
+2288
-750
No files matched your search
@@ -37,7 +37,7 @@ This repository publishes the open-source server, console, integration catalog,
|
||||
- Write Node Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
|
||||
- Write Go Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
|
||||
- Write Python Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, and own exact native `preToolUse`, `postToolUse`, and `postToolUseFailure` commands. Forward native success and failure payloads, retain PowerShell exit code `2`, set every handler to `failClosed` with a bounded timeout, and commit the guard, audit script, runtime identity, private key, and hook configuration as one rollback-capable transaction.
|
||||
- Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, and require `/hooks` review for the session snapshot and remote organization policy.
|
||||
- Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, require exact generated runtime sources during status checks, and require `/hooks` review for the session snapshot and remote organization policy.
|
||||
- Qwen Code `0.20.0` resolves user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
|
||||
- Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `P
|
||||
|
||||
Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration.
|
||||
|
||||
Factory Droid installation follows its active user source per event: `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json` fallback, or hooks nested in `~/.factory/settings.json`. Elydora preserves JSONC comments and formatting, leaves project and organization hooks unchanged, and runs both managed commands with absolute Python and script paths. Run `/hooks` after installation to review the changes.
|
||||
Factory Droid 0.175.0 installation resolves one active user hook source in this order: `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the `hooks` field from user `settings.local.json` and `settings.json`. Current hook files use the documented top-level `hooks` container; legacy direct event maps remain readable until Factory migrates them. Elydora writes exact `PreToolUse` and `PostToolUse` groups to that source, removes exact Elydora ownership from inactive user sources, and keeps project, folder, plugin, and organization hook definitions read-only. System, project, folder, and user `hooksDisabled` values follow Factory's extension-only precedence; system-managed `allowManagedHooksOnly` blocks a user-hook installation. Hook sources, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed commands preserve the complete native snake_case payload, propagate frozen or revoked state through exit code `2`, and use Factory's native PowerShell call operator with explicit `$LASTEXITCODE` propagation on Windows. Run `/hooks` after installation to refresh Droid's session snapshot and verify remote organization policy.
|
||||
|
||||
Qwen Code installation resolves its user settings through explicit `QWEN_HOME`, `~/.qwen/.env`, then `~/.env`. Elydora preserves comments and unrelated settings, leaves workspace settings unchanged, and uses Qwen's explicit Bash or PowerShell command contract with millisecond timeouts. Run `/hooks` after installation to review the changes.
|
||||
|
||||
|
||||
@@ -2,182 +2,112 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List
|
||||
|
||||
from ._transaction import FileChange, file_change, require_runtime, write_changes
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .droid_config import (
|
||||
additions_for,
|
||||
installation_targets,
|
||||
DroidSources,
|
||||
RenderedDocument,
|
||||
active_document,
|
||||
additions_for_target,
|
||||
effective_hooks,
|
||||
hook_block,
|
||||
installation_documents,
|
||||
render_document,
|
||||
unique_documents,
|
||||
source_documents,
|
||||
)
|
||||
from .droid_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
TOOL_EVENTS,
|
||||
JsonObject,
|
||||
build_group,
|
||||
elydora_dir,
|
||||
merge_hook_settings,
|
||||
runtime_contracts,
|
||||
validate_javascript_regexes,
|
||||
)
|
||||
from .droid_io import (
|
||||
display_config_path,
|
||||
read_sources,
|
||||
rendered_change,
|
||||
runtime_files_exist,
|
||||
from .droid_installation import (
|
||||
commit_droid_installation,
|
||||
commit_droid_uninstall,
|
||||
preflight_droid_installation,
|
||||
prepare_droid_installation,
|
||||
prepare_droid_uninstall,
|
||||
)
|
||||
from .hook_template import generate_hook_script
|
||||
from .droid_io import display_config_path, read_sources, runtime_files_exist
|
||||
|
||||
|
||||
def _agent_directory(agent_id: str) -> str:
|
||||
if (
|
||||
not agent_id
|
||||
or agent_id in {".", ".."}
|
||||
or os.path.basename(agent_id) != agent_id
|
||||
or os.path.isabs(agent_id)
|
||||
):
|
||||
raise ValueError("agent_id must be a single non-empty path segment")
|
||||
return os.path.join(elydora_dir(), agent_id)
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
def _render_installation(
|
||||
sources: DroidSources,
|
||||
guard_path: str,
|
||||
audit_path: str,
|
||||
) -> List[RenderedDocument]:
|
||||
target = active_document(sources)
|
||||
groups: Dict[str, JsonObject] = {
|
||||
"PreToolUse": build_group(guard_path),
|
||||
"PostToolUse": build_group(audit_path),
|
||||
}
|
||||
return [
|
||||
render_document(
|
||||
document,
|
||||
None,
|
||||
additions_for_target(document, target, groups),
|
||||
)
|
||||
for document in installation_documents(sources)
|
||||
]
|
||||
|
||||
|
||||
def _append_change(changes: List[FileChange], change: Optional[FileChange]) -> None:
|
||||
if change is not None:
|
||||
changes.append(change)
|
||||
def _render_uninstall(
|
||||
sources: DroidSources,
|
||||
agent_id: str,
|
||||
) -> List[RenderedDocument]:
|
||||
return [
|
||||
render_document(document, agent_id or None, {})
|
||||
for document in source_documents(sources)
|
||||
]
|
||||
|
||||
|
||||
class DroidPlugin(AgentPlugin):
|
||||
"""Install Elydora into Factory Droid's user-level hook sources."""
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_directory = _agent_directory(agent_id)
|
||||
manages_guard_runtime = True
|
||||
|
||||
def preflight_install(self, config: InstallConfig) -> None:
|
||||
sources = read_sources()
|
||||
validate_javascript_regexes([
|
||||
sources.primary.hooks if sources.primary is not None else {},
|
||||
sources.settings.hooks,
|
||||
])
|
||||
preflight_droid_installation(config, sources)
|
||||
|
||||
guard_path = config.get("guard_script_path", "")
|
||||
expected_guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
if not _same_path(guard_path, expected_guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{expected_guard_path}"
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
sources = read_sources()
|
||||
paths = preflight_droid_installation(config, sources)
|
||||
rendered = _render_installation(
|
||||
sources,
|
||||
paths.guard_path,
|
||||
paths.audit_path,
|
||||
)
|
||||
require_runtime(guard_path, "Elydora guard runtime")
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
|
||||
selected = installation_targets(sources)
|
||||
groups = {
|
||||
"PreToolUse": build_group(guard_path),
|
||||
"PostToolUse": build_group(audit_path),
|
||||
}
|
||||
documents = unique_documents([
|
||||
sources.primary,
|
||||
sources.settings if sources.settings.has_hooks_container else None,
|
||||
*(selected.targets[event] for event in TOOL_EVENTS),
|
||||
])
|
||||
rendered = [
|
||||
render_document(
|
||||
document,
|
||||
None,
|
||||
additions_for(document, selected.targets, groups),
|
||||
)
|
||||
for document in documents
|
||||
]
|
||||
|
||||
runtime_config = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
)
|
||||
changes: List[FileChange] = []
|
||||
_append_change(changes, file_change(
|
||||
os.path.join(agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
json.dumps(runtime_config, indent=2) + "\n",
|
||||
0o600,
|
||||
))
|
||||
_append_change(changes, file_change(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
))
|
||||
_append_change(changes, file_change(
|
||||
audit_path,
|
||||
"Elydora audit runtime",
|
||||
audit_script,
|
||||
0o700,
|
||||
))
|
||||
for item in rendered:
|
||||
_append_change(changes, rendered_change(item))
|
||||
write_changes(changes, "Write Factory Droid installation")
|
||||
|
||||
locations = ", ".join(
|
||||
f"{event}: {selected.targets[event].file_path}"
|
||||
for event in TOOL_EVENTS
|
||||
)
|
||||
print(f"Factory Droid: {locations}")
|
||||
prepared = prepare_droid_installation(config, sources, rendered)
|
||||
commit_droid_installation(prepared)
|
||||
print(f"Factory Droid hooks: {active_document(sources).file_path}")
|
||||
print("Factory Droid: run /hooks to review the Elydora hook changes.")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
sources = read_sources()
|
||||
documents = unique_documents([
|
||||
sources.primary,
|
||||
sources.settings if sources.settings.has_hooks_container else None,
|
||||
])
|
||||
changes: List[FileChange] = []
|
||||
for document in documents:
|
||||
rendered = render_document(document, agent_id or None, {})
|
||||
_append_change(changes, rendered_change(rendered))
|
||||
write_changes(changes, "Write Factory Droid hook sources")
|
||||
rendered = _render_uninstall(sources, agent_id)
|
||||
commit_droid_uninstall(prepare_droid_uninstall(rendered))
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
sources = read_sources()
|
||||
effective = merge_hook_settings(
|
||||
sources.primary.hooks if sources.primary is not None else None,
|
||||
sources.settings.hooks,
|
||||
)
|
||||
contracts = runtime_contracts(effective)
|
||||
contracts = runtime_contracts(effective_hooks(sources))
|
||||
blocked = hook_block(sources)
|
||||
configured = blocked is None and bool(contracts)
|
||||
installed = configured and runtime_files_exist(contracts)
|
||||
config_path = display_config_path(sources)
|
||||
if effective.get("hooksDisabled") is True:
|
||||
return PluginStatus(
|
||||
installed=False,
|
||||
agent=AGENT_KEY,
|
||||
details=f"Configured hooks are disabled: {config_path}",
|
||||
)
|
||||
if not contracts:
|
||||
return PluginStatus(
|
||||
installed=False,
|
||||
agent=AGENT_KEY,
|
||||
details="Not installed",
|
||||
)
|
||||
installed = runtime_files_exist(contracts)
|
||||
if blocked is not None:
|
||||
details = (
|
||||
f"Config: {config_path}"
|
||||
if installed
|
||||
else f"Configured at {config_path}; runtime scripts missing"
|
||||
f"Configured hooks are disabled by {blocked.field}: "
|
||||
f"{blocked.file_path}"
|
||||
)
|
||||
elif not contracts:
|
||||
details = "Not installed"
|
||||
elif installed:
|
||||
details = f"Config: {config_path}"
|
||||
else:
|
||||
details = (
|
||||
f"Configured at {config_path}; managed contract incomplete"
|
||||
)
|
||||
return PluginStatus(
|
||||
installed=installed,
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
import os
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from ._jsonc import JsonPathPart, JsoncEditor
|
||||
from ._managed_files import FileSnapshot
|
||||
from .droid_contract import (
|
||||
TOOL_EVENTS,
|
||||
DroidHookSettings,
|
||||
DroidHookMap,
|
||||
JsonObject,
|
||||
has_own,
|
||||
managed_removals,
|
||||
read_hook_settings,
|
||||
read_hook_map,
|
||||
same_path,
|
||||
)
|
||||
from ._jsonc import JsonPathPart, JsoncEditor
|
||||
from .droid_policy import DroidPolicyState
|
||||
|
||||
|
||||
OWNED_FILE_MARKER = "// Managed by Elydora"
|
||||
@@ -25,24 +28,23 @@ class DroidDocument:
|
||||
file_path: str
|
||||
exists: bool
|
||||
raw: str
|
||||
snapshot: Optional[FileSnapshot]
|
||||
root: JsonObject
|
||||
hooks: DroidHookSettings
|
||||
hooks: DroidHookMap
|
||||
base_path: Tuple[JsonPathPart, ...]
|
||||
has_hooks_container: bool
|
||||
hooks_disabled: Optional[bool]
|
||||
show_hook_output: Optional[bool]
|
||||
owned_file: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DroidSources:
|
||||
root_path: str
|
||||
primary: Optional[DroidDocument]
|
||||
root: DroidDocument
|
||||
legacy: DroidDocument
|
||||
settings: DroidDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallationTargets:
|
||||
targets: Dict[str, DroidDocument]
|
||||
created_root: Optional[DroidDocument]
|
||||
local_settings: DroidDocument
|
||||
policy: DroidPolicyState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -52,30 +54,65 @@ class RenderedDocument:
|
||||
next_source: Optional[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DroidHookBlock:
|
||||
field: str
|
||||
file_path: str
|
||||
label: str
|
||||
|
||||
|
||||
def _label(kind: str, file_path: str) -> str:
|
||||
if kind == "settings":
|
||||
return f"Factory Droid settings at {file_path}"
|
||||
if kind == "local-settings":
|
||||
return f"Factory Droid local settings at {file_path}"
|
||||
if kind == "legacy":
|
||||
return f"Factory Droid legacy hooks at {file_path}"
|
||||
return f"Factory Droid hooks at {file_path}"
|
||||
|
||||
|
||||
def _optional_boolean(root: JsonObject, field: str, label: str) -> Optional[bool]:
|
||||
value = root.get(field)
|
||||
if field in root and not isinstance(value, bool):
|
||||
raise ValueError(f'{label} field "{field}" must be a boolean')
|
||||
return value
|
||||
|
||||
|
||||
def _legacy_direct_hooks(
|
||||
root: JsonObject,
|
||||
label: str,
|
||||
) -> Tuple[DroidHookMap, Optional[bool], Optional[bool]]:
|
||||
hooks_disabled = _optional_boolean(root, "hooksDisabled", label)
|
||||
show_hook_output = _optional_boolean(root, "showHookOutput", label)
|
||||
entries = {
|
||||
key: value
|
||||
for key, value in root.items()
|
||||
if key not in {"hooksDisabled", "showHookOutput"}
|
||||
}
|
||||
return (
|
||||
read_hook_map(entries, label),
|
||||
hooks_disabled,
|
||||
show_hook_output,
|
||||
)
|
||||
|
||||
|
||||
def parse_document(
|
||||
*,
|
||||
exists: bool,
|
||||
file_path: str,
|
||||
kind: str,
|
||||
raw: str,
|
||||
snapshot: Optional[FileSnapshot] = None,
|
||||
) -> DroidDocument:
|
||||
label = _label(kind, file_path)
|
||||
editor = JsoncEditor(raw, label)
|
||||
if not isinstance(editor.value, dict):
|
||||
raise ValueError(f"{label} must contain a JSON object")
|
||||
root = editor.value
|
||||
if kind == "settings":
|
||||
has_container = has_own(root, "hooks")
|
||||
if kind in {"settings", "local-settings"}:
|
||||
has_container = "hooks" in root
|
||||
hooks = (
|
||||
read_hook_settings(root["hooks"], f'{label} field "hooks"')
|
||||
read_hook_map(root["hooks"], f'{label} field "hooks"')
|
||||
if has_container
|
||||
else {}
|
||||
)
|
||||
@@ -84,30 +121,65 @@ def parse_document(
|
||||
file_path,
|
||||
exists,
|
||||
raw,
|
||||
snapshot,
|
||||
root,
|
||||
hooks,
|
||||
("hooks",),
|
||||
has_container,
|
||||
_optional_boolean(root, "hooksDisabled", label),
|
||||
_optional_boolean(root, "showHookOutput", label),
|
||||
False,
|
||||
)
|
||||
if "hooks" in root:
|
||||
hooks = read_hook_map(root["hooks"], f'{label} field "hooks"')
|
||||
return DroidDocument(
|
||||
kind,
|
||||
file_path,
|
||||
exists,
|
||||
raw,
|
||||
snapshot,
|
||||
root,
|
||||
read_hook_settings(root, label),
|
||||
(),
|
||||
hooks,
|
||||
("hooks",),
|
||||
True,
|
||||
None,
|
||||
None,
|
||||
raw.startswith(OWNED_FILE_MARKER),
|
||||
)
|
||||
hooks, hooks_disabled, show_hook_output = _legacy_direct_hooks(root, label)
|
||||
return DroidDocument(
|
||||
kind,
|
||||
file_path,
|
||||
exists,
|
||||
raw,
|
||||
snapshot,
|
||||
root,
|
||||
hooks,
|
||||
(),
|
||||
False,
|
||||
hooks_disabled,
|
||||
show_hook_output,
|
||||
raw.startswith(OWNED_FILE_MARKER),
|
||||
)
|
||||
|
||||
|
||||
def create_settings_document(file_path: str) -> DroidDocument:
|
||||
def create_settings_document(
|
||||
file_path: str,
|
||||
kind: str = "settings",
|
||||
) -> DroidDocument:
|
||||
return parse_document(
|
||||
exists=False,
|
||||
file_path=file_path,
|
||||
kind="settings",
|
||||
kind=kind,
|
||||
raw="{}\n",
|
||||
)
|
||||
|
||||
|
||||
def create_legacy_hook_document(file_path: str) -> DroidDocument:
|
||||
return parse_document(
|
||||
exists=False,
|
||||
file_path=file_path,
|
||||
kind="legacy",
|
||||
raw="{}\n",
|
||||
)
|
||||
|
||||
@@ -117,30 +189,40 @@ def create_owned_hook_document(file_path: str) -> DroidDocument:
|
||||
exists=False,
|
||||
file_path=file_path,
|
||||
kind="hooks",
|
||||
raw=f"{OWNED_FILE_MARKER}\n{{}}\n",
|
||||
raw=f'{OWNED_FILE_MARKER}\n{{\n "hooks": {{}}\n}}\n',
|
||||
)
|
||||
|
||||
|
||||
def _hooks_from_editor(
|
||||
editor: JsoncEditor,
|
||||
document: DroidDocument,
|
||||
) -> DroidHookSettings:
|
||||
) -> DroidHookMap:
|
||||
root = editor.value
|
||||
label = _label(document.kind, document.file_path)
|
||||
if not isinstance(root, dict):
|
||||
raise ValueError(f"{_label(document.kind, document.file_path)} must be an object")
|
||||
if document.kind == "settings":
|
||||
hooks = root.get("hooks")
|
||||
return read_hook_settings(
|
||||
hooks,
|
||||
f'{_label(document.kind, document.file_path)} field "hooks"',
|
||||
)
|
||||
return read_hook_settings(root, _label(document.kind, document.file_path))
|
||||
raise ValueError(f"{label} must contain a JSON object")
|
||||
if document.kind in {"settings", "local-settings"} or "hooks" in root:
|
||||
return read_hook_map(root.get("hooks"), f'{label} field "hooks"')
|
||||
return _legacy_direct_hooks(root, label)[0]
|
||||
|
||||
|
||||
def _event_path(document: DroidDocument, event: str) -> Tuple[JsonPathPart, ...]:
|
||||
return (*document.base_path, event)
|
||||
|
||||
|
||||
def _current_document(
|
||||
document: DroidDocument,
|
||||
raw: str,
|
||||
) -> DroidDocument:
|
||||
return parse_document(
|
||||
exists=document.exists,
|
||||
file_path=document.file_path,
|
||||
kind=document.kind,
|
||||
raw=raw,
|
||||
snapshot=document.snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _remove_managed(
|
||||
editor: JsoncEditor,
|
||||
document: DroidDocument,
|
||||
@@ -160,103 +242,166 @@ def _remove_managed(
|
||||
continue
|
||||
for handler_index in sorted(removal.handler_indexes, reverse=True):
|
||||
editor.delete((*group_path, "hooks", handler_index))
|
||||
if event_removals:
|
||||
current = _hooks_from_editor(editor, document)
|
||||
if document.owned_file and event in current and not current[event]:
|
||||
if not current.get(event):
|
||||
editor.delete(_event_path(document, event))
|
||||
|
||||
|
||||
def _append_group(
|
||||
editor: JsoncEditor,
|
||||
document: DroidDocument,
|
||||
event: str,
|
||||
group: JsonObject,
|
||||
) -> None:
|
||||
current = _hooks_from_editor(editor, document)
|
||||
if event in current:
|
||||
editor.append(_event_path(document, event), group)
|
||||
else:
|
||||
editor.add_property(document.base_path, event, [group])
|
||||
|
||||
|
||||
def _hook_file_is_empty(
|
||||
document: DroidDocument,
|
||||
raw: str,
|
||||
) -> bool:
|
||||
if document.kind in {"settings", "local-settings"}:
|
||||
return False
|
||||
current = _current_document(document, raw)
|
||||
remaining_root_fields = [key for key in current.root if key != "hooks"]
|
||||
return not current.hooks and not remaining_root_fields
|
||||
|
||||
|
||||
def _has_exact_installation(
|
||||
document: DroidDocument,
|
||||
additions: Dict[str, JsonObject],
|
||||
) -> bool:
|
||||
if set(additions) != set(TOOL_EVENTS):
|
||||
return False
|
||||
removals = managed_removals(document.hooks)
|
||||
if len(removals) != len(TOOL_EVENTS):
|
||||
return False
|
||||
for event in TOOL_EVENTS:
|
||||
matches = [item for item in removals if item.event == event]
|
||||
if len(matches) != 1 or not matches[0].remove_group:
|
||||
return False
|
||||
group = document.hooks[event][matches[0].group_index]
|
||||
if group != additions[event]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def render_document(
|
||||
document: DroidDocument,
|
||||
agent_id: Optional[str],
|
||||
additions: Dict[str, JsonObject],
|
||||
) -> RenderedDocument:
|
||||
editor = JsoncEditor(
|
||||
document.raw,
|
||||
_label(document.kind, document.file_path),
|
||||
)
|
||||
if additions and _has_exact_installation(document, additions):
|
||||
return RenderedDocument(document, False, document.raw)
|
||||
editor = JsoncEditor(document.raw, _label(document.kind, document.file_path))
|
||||
_remove_managed(editor, document, agent_id)
|
||||
for event in TOOL_EVENTS:
|
||||
group = additions.get(event)
|
||||
if group is None:
|
||||
continue
|
||||
current = _hooks_from_editor(editor, document)
|
||||
if event in current:
|
||||
editor.append(_event_path(document, event), group)
|
||||
else:
|
||||
editor.add_property(document.base_path, event, [group])
|
||||
next_document = parse_document(
|
||||
exists=document.exists,
|
||||
file_path=document.file_path,
|
||||
kind=document.kind,
|
||||
raw=editor.raw,
|
||||
)
|
||||
if group is not None:
|
||||
_append_group(editor, document, event, group)
|
||||
_current_document(document, editor.raw)
|
||||
if (
|
||||
not additions
|
||||
and document.exists
|
||||
and document.owned_file
|
||||
and document.kind != "settings"
|
||||
and not next_document.hooks
|
||||
and _hook_file_is_empty(document, editor.raw)
|
||||
):
|
||||
return RenderedDocument(document, True, None)
|
||||
return RenderedDocument(
|
||||
document,
|
||||
editor.raw != document.raw,
|
||||
editor.raw,
|
||||
)
|
||||
return RenderedDocument(document, editor.raw != document.raw, editor.raw)
|
||||
|
||||
|
||||
def _event_target(
|
||||
event: str,
|
||||
sources: DroidSources,
|
||||
create_root: "_RootFactory",
|
||||
) -> DroidDocument:
|
||||
if sources.primary is not None and event in sources.primary.hooks:
|
||||
return sources.primary
|
||||
if sources.settings.has_hooks_container and event in sources.settings.hooks:
|
||||
return sources.settings
|
||||
if sources.primary is not None:
|
||||
return sources.primary
|
||||
def active_document(sources: DroidSources) -> DroidDocument:
|
||||
if sources.root.exists:
|
||||
return sources.root
|
||||
if sources.legacy.exists:
|
||||
return sources.legacy
|
||||
if sources.local_settings.has_hooks_container:
|
||||
return sources.local_settings
|
||||
if sources.settings.has_hooks_container:
|
||||
return sources.settings
|
||||
return create_root.get()
|
||||
return sources.root
|
||||
|
||||
|
||||
class _RootFactory:
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
self.document: Optional[DroidDocument] = None
|
||||
|
||||
def get(self) -> DroidDocument:
|
||||
if self.document is None:
|
||||
self.document = create_owned_hook_document(self.file_path)
|
||||
return self.document
|
||||
def effective_hooks(sources: DroidSources) -> DroidHookMap:
|
||||
return active_document(sources).hooks
|
||||
|
||||
|
||||
def installation_targets(sources: DroidSources) -> InstallationTargets:
|
||||
root = _RootFactory(sources.root_path)
|
||||
targets = {
|
||||
event: _event_target(event, sources, root)
|
||||
for event in TOOL_EVENTS
|
||||
}
|
||||
return InstallationTargets(targets, root.document)
|
||||
def hook_block(sources: DroidSources) -> Optional[DroidHookBlock]:
|
||||
managed = sources.policy.allow_managed_hooks_only_by
|
||||
if managed is not None:
|
||||
return DroidHookBlock(
|
||||
"allowManagedHooksOnly",
|
||||
managed.file_path,
|
||||
managed.label,
|
||||
)
|
||||
if sources.policy.hooks_disabled is not None:
|
||||
if sources.policy.hooks_disabled:
|
||||
origin = sources.policy.hooks_disabled_by
|
||||
if origin is None:
|
||||
raise RuntimeError("Factory Droid policy origin is missing")
|
||||
return DroidHookBlock(
|
||||
"hooksDisabled",
|
||||
origin.file_path,
|
||||
origin.label,
|
||||
)
|
||||
return None
|
||||
selected = (
|
||||
sources.local_settings
|
||||
if sources.local_settings.hooks_disabled is not None
|
||||
else sources.settings
|
||||
)
|
||||
if selected.hooks_disabled is True:
|
||||
return DroidHookBlock(
|
||||
"hooksDisabled",
|
||||
selected.file_path,
|
||||
_label(selected.kind, selected.file_path),
|
||||
)
|
||||
active = active_document(sources)
|
||||
if active.hooks_disabled is True:
|
||||
return DroidHookBlock(
|
||||
"hooksDisabled",
|
||||
active.file_path,
|
||||
_label(active.kind, active.file_path),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def additions_for(
|
||||
def source_documents(sources: DroidSources) -> List[DroidDocument]:
|
||||
return [
|
||||
sources.root,
|
||||
sources.legacy,
|
||||
sources.settings,
|
||||
sources.local_settings,
|
||||
]
|
||||
|
||||
|
||||
def installation_documents(sources: DroidSources) -> List[DroidDocument]:
|
||||
target = active_document(sources)
|
||||
candidates = [
|
||||
sources.root if sources.root.exists or target is sources.root else None,
|
||||
sources.legacy if sources.legacy.exists else None,
|
||||
sources.settings if sources.settings.has_hooks_container else None,
|
||||
(
|
||||
sources.local_settings
|
||||
if sources.local_settings.has_hooks_container
|
||||
else None
|
||||
),
|
||||
]
|
||||
unique: Dict[str, DroidDocument] = {}
|
||||
for document in candidates:
|
||||
if document is not None:
|
||||
unique[os.path.normcase(os.path.abspath(document.file_path))] = document
|
||||
return list(unique.values())
|
||||
|
||||
|
||||
def additions_for_target(
|
||||
document: DroidDocument,
|
||||
targets: Dict[str, DroidDocument],
|
||||
target: DroidDocument,
|
||||
groups: Dict[str, JsonObject],
|
||||
) -> Dict[str, JsonObject]:
|
||||
return {
|
||||
event: groups[event]
|
||||
for event in TOOL_EVENTS
|
||||
if targets[event].file_path == document.file_path
|
||||
}
|
||||
|
||||
|
||||
def unique_documents(
|
||||
documents: Sequence[Optional[DroidDocument]],
|
||||
) -> List[DroidDocument]:
|
||||
unique: Dict[str, DroidDocument] = {}
|
||||
for document in documents:
|
||||
if document is not None:
|
||||
unique[document.file_path] = document
|
||||
return list(unique.values())
|
||||
return groups if same_path(document.file_path, target.file_path) else {}
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
@@ -18,21 +19,10 @@ AUDIT_SCRIPT = "hook.py"
|
||||
HOOK_TIMEOUT_SECONDS = 10
|
||||
TOOL_EVENTS = ("PreToolUse", "PostToolUse")
|
||||
|
||||
_EVENT_NAMES = {
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Notification",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
}
|
||||
_FLAG_NAMES = {"hooksDisabled", "showHookOutput"}
|
||||
_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) {
|
||||
@@ -47,7 +37,7 @@ for (const entry of entries) {
|
||||
"""
|
||||
|
||||
JsonObject = Dict[str, Any]
|
||||
DroidHookSettings = Dict[str, Any]
|
||||
DroidHookMap = Dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -65,20 +55,22 @@ class ManagedRemoval:
|
||||
remove_group: bool
|
||||
|
||||
|
||||
def is_object(value: Any) -> bool:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def has_own(value: JsonObject, key: str) -> bool:
|
||||
return key in value
|
||||
|
||||
|
||||
def elydora_dir() -> str:
|
||||
return os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
|
||||
def _quote_windows(value: str) -> str:
|
||||
return '"' + value.replace('"', '\\"') + '"'
|
||||
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:
|
||||
@@ -86,8 +78,16 @@ def _quote_posix(value: str) -> str:
|
||||
|
||||
|
||||
def build_command(script_path: str) -> str:
|
||||
quote = _quote_windows if os.name == "nt" else _quote_posix
|
||||
return f"{quote(sys.executable)} {quote(script_path)}"
|
||||
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)}"
|
||||
|
||||
|
||||
def build_group(script_path: str) -> JsonObject:
|
||||
@@ -102,25 +102,27 @@ def build_group(script_path: str) -> JsonObject:
|
||||
|
||||
|
||||
def _validate_handler(value: Any, label: str) -> JsonObject:
|
||||
if not is_object(value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
handler = dict(value)
|
||||
if handler.get("type") != "command":
|
||||
raise ValueError(f'{label} type must be "command"')
|
||||
if not isinstance(handler.get("command"), str):
|
||||
raise ValueError(f"{label} command must be a string")
|
||||
command = handler.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError(f"{label} command must be a non-empty string")
|
||||
timeout = handler.get("timeout")
|
||||
if timeout is not None and (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
):
|
||||
raise ValueError(f"{label} timeout must be a finite number")
|
||||
raise ValueError(f"{label} timeout must be a positive finite number")
|
||||
return handler
|
||||
|
||||
|
||||
def _validate_group(value: Any, label: str) -> JsonObject:
|
||||
if not is_object(value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
group = dict(value)
|
||||
matcher = group.get("matcher")
|
||||
@@ -139,31 +141,23 @@ def _validate_group(value: Any, label: str) -> JsonObject:
|
||||
return group
|
||||
|
||||
|
||||
def read_hook_settings(value: Any, label: str) -> DroidHookSettings:
|
||||
if not is_object(value):
|
||||
def read_hook_map(value: Any, label: str) -> DroidHookMap:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} must contain a JSON object")
|
||||
settings: DroidHookSettings = dict(value)
|
||||
for key, item in value.items():
|
||||
if key in _FLAG_NAMES:
|
||||
if not isinstance(item, bool):
|
||||
raise ValueError(f'{label} field "{key}" must be a boolean')
|
||||
continue
|
||||
if key not in _EVENT_NAMES:
|
||||
raise ValueError(f'{label} contains unsupported field "{key}"')
|
||||
if not isinstance(item, list):
|
||||
raise ValueError(f'{label} field "{key}" must be an array')
|
||||
settings[key] = [
|
||||
_validate_group(group, f'{label} field "{key}"[{index}]')
|
||||
for index, group in enumerate(item)
|
||||
hooks: DroidHookMap = {}
|
||||
for event, groups in value.items():
|
||||
if not isinstance(groups, list):
|
||||
raise ValueError(f'{label} field "{event}" must be an array')
|
||||
hooks[event] = [
|
||||
_validate_group(group, f'{label} field "{event}"[{index}]')
|
||||
for index, group in enumerate(groups)
|
||||
]
|
||||
return settings
|
||||
return hooks
|
||||
|
||||
|
||||
def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]:
|
||||
def _regex_entries(hooks: DroidHookMap) -> List[JsonObject]:
|
||||
entries: List[JsonObject] = []
|
||||
for event, groups in settings.items():
|
||||
if event in _FLAG_NAMES:
|
||||
continue
|
||||
for event, groups in hooks.items():
|
||||
for index, group in enumerate(groups):
|
||||
matcher = group.get("matcher")
|
||||
if isinstance(matcher, str) and matcher not in {"", "*"}:
|
||||
@@ -182,8 +176,8 @@ def _regex_entries(settings: DroidHookSettings) -> List[JsonObject]:
|
||||
return entries
|
||||
|
||||
|
||||
def validate_javascript_regexes(settings: Sequence[DroidHookSettings]) -> None:
|
||||
entries = [entry for source in settings for entry in _regex_entries(source)]
|
||||
def validate_javascript_regexes(sources: Sequence[DroidHookMap]) -> None:
|
||||
entries = [entry for source in sources for entry in _regex_entries(source)]
|
||||
if not entries:
|
||||
return
|
||||
node_path = shutil.which("node")
|
||||
@@ -220,20 +214,24 @@ def validate_javascript_regexes(settings: Sequence[DroidHookSettings]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _read_windows_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
if start >= len(command) or command[start] != '"':
|
||||
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:index + 2] == '\\"':
|
||||
value += '"'
|
||||
index += 2
|
||||
continue
|
||||
if command[index] == '"':
|
||||
return value, index + 1
|
||||
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
|
||||
|
||||
|
||||
@@ -257,14 +255,16 @@ def _read_posix_argument(command: str, start: int) -> Optional[Tuple[str, int]]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_generated_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
reader = _read_windows_argument if os.name == "nt" else _read_posix_argument
|
||||
executable = reader(command, 0)
|
||||
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 = reader(command, executable[1] + 1)
|
||||
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]:
|
||||
@@ -272,32 +272,65 @@ def _parse_generated_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return executable[0], script[0]
|
||||
|
||||
|
||||
def _same_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
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
|
||||
|
||||
|
||||
def _parse_generated_command(
|
||||
command: str,
|
||||
include_legacy: bool,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
if os.name != "nt":
|
||||
return _parse_two_arguments(command, 0)
|
||||
current = _parse_power_shell_command(command)
|
||||
return current or (
|
||||
_parse_legacy_windows_command(command) if include_legacy else None
|
||||
)
|
||||
|
||||
|
||||
def same_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def managed_agent_id(handler: JsonObject, script_name: str) -> Optional[str]:
|
||||
def managed_agent_id(
|
||||
handler: JsonObject,
|
||||
script_name: str,
|
||||
include_legacy: bool = False,
|
||||
) -> Optional[str]:
|
||||
if set(handler) != _HANDLER_KEYS:
|
||||
return None
|
||||
if handler.get("type") != "command" or handler.get("timeout") != 10:
|
||||
if (
|
||||
handler.get("type") != "command"
|
||||
or handler.get("timeout") != HOOK_TIMEOUT_SECONDS
|
||||
):
|
||||
return None
|
||||
command = handler.get("command")
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
parsed = _parse_generated_command(command)
|
||||
if parsed is None or not _same_path(parsed[0], sys.executable):
|
||||
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()):
|
||||
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
|
||||
@@ -314,7 +347,7 @@ def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool:
|
||||
|
||||
|
||||
def managed_removals(
|
||||
settings: DroidHookSettings,
|
||||
hooks: DroidHookMap,
|
||||
agent_id: Optional[str] = None,
|
||||
) -> List[ManagedRemoval]:
|
||||
removals: List[ManagedRemoval] = []
|
||||
@@ -322,12 +355,19 @@ def managed_removals(
|
||||
("PreToolUse", GUARD_SCRIPT),
|
||||
("PostToolUse", AUDIT_SCRIPT),
|
||||
):
|
||||
for group_index, group in enumerate(settings.get(event, [])):
|
||||
for group_index, group in enumerate(hooks.get(event, [])):
|
||||
indexes = tuple(
|
||||
index
|
||||
for index, handler in enumerate(group["hooks"])
|
||||
if (
|
||||
(managed_id := managed_agent_id(handler, script_name)) is not None
|
||||
(
|
||||
managed_id := managed_agent_id(
|
||||
handler,
|
||||
script_name,
|
||||
include_legacy=True,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
and (agent_id is None or same_agent_id(managed_id, agent_id))
|
||||
)
|
||||
)
|
||||
@@ -350,9 +390,9 @@ def _managed_ids(groups: List[JsonObject], script_name: str) -> Set[str]:
|
||||
}
|
||||
|
||||
|
||||
def runtime_contracts(settings: DroidHookSettings) -> List[RuntimeContract]:
|
||||
guards = _managed_ids(settings.get("PreToolUse", []), GUARD_SCRIPT)
|
||||
audits = _managed_ids(settings.get("PostToolUse", []), AUDIT_SCRIPT)
|
||||
def runtime_contracts(hooks: DroidHookMap) -> List[RuntimeContract]:
|
||||
guards = _managed_ids(hooks.get("PreToolUse", []), GUARD_SCRIPT)
|
||||
audits = _managed_ids(hooks.get("PostToolUse", []), AUDIT_SCRIPT)
|
||||
contracts = []
|
||||
for agent_id in sorted(guards):
|
||||
if not any(same_agent_id(agent_id, audit_id) for audit_id in audits):
|
||||
@@ -364,10 +404,3 @@ def runtime_contracts(settings: DroidHookSettings) -> List[RuntimeContract]:
|
||||
os.path.join(root, AUDIT_SCRIPT),
|
||||
))
|
||||
return contracts
|
||||
|
||||
|
||||
def merge_hook_settings(
|
||||
primary: Optional[DroidHookSettings],
|
||||
fallback: Optional[DroidHookSettings],
|
||||
) -> DroidHookSettings:
|
||||
return {**(fallback or {}), **(primary or {})}
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Transactional Factory Droid hook installation."""
|
||||
|
||||
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 .base import InstallConfig
|
||||
from .droid_config import (
|
||||
DroidDocument,
|
||||
DroidSources,
|
||||
RenderedDocument,
|
||||
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 .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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedDroidInstallation:
|
||||
changes: Tuple[FileChange, ...]
|
||||
preconditions: Tuple[FilePrecondition, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedDroidUninstall:
|
||||
changes: Tuple[FileChange, ...]
|
||||
preconditions: Tuple[FilePrecondition, ...]
|
||||
|
||||
|
||||
def _source_label(document: DroidDocument) -> str:
|
||||
if document.kind == "settings":
|
||||
return "Factory Droid user settings"
|
||||
if document.kind == "local-settings":
|
||||
return "Factory Droid local settings"
|
||||
if document.kind == "legacy":
|
||||
return "Factory Droid legacy hooks"
|
||||
return "Factory Droid user hooks"
|
||||
|
||||
|
||||
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> DroidRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
agent_directory = resolve_agent_directory(elydora_dir(), agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
return DroidRuntimePaths(
|
||||
agent_id,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> None:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(
|
||||
f"Factory Droid installation requires agent_name {AGENT_KEY}"
|
||||
)
|
||||
if not os.path.isabs(sys.executable):
|
||||
raise RuntimeError(
|
||||
"Factory Droid requires an absolute Python executable path"
|
||||
)
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
_agent_paths(config)
|
||||
|
||||
|
||||
def preflight_droid_installation(
|
||||
config: InstallConfig,
|
||||
sources: DroidSources,
|
||||
) -> DroidRuntimePaths:
|
||||
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)
|
||||
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
|
||||
document = rendered.document
|
||||
return source_change(
|
||||
document.file_path,
|
||||
_source_label(document),
|
||||
document.raw if document.exists else None,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
document.snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _present(
|
||||
changes: Sequence[Optional[FileChange]],
|
||||
) -> Tuple[FileChange, ...]:
|
||||
return tuple(change for change in changes if change is not None)
|
||||
|
||||
|
||||
def _validate_rendered_documents(
|
||||
sources: DroidSources,
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> None:
|
||||
expected = installation_documents(sources)
|
||||
if len(rendered) != len(expected):
|
||||
raise ValueError("Factory Droid rendered source set is incomplete")
|
||||
for document in expected:
|
||||
matches = [
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def _source_preconditions(
|
||||
sources: DroidSources,
|
||||
changed_paths: Sequence[str],
|
||||
) -> Tuple[FilePrecondition, ...]:
|
||||
return tuple(
|
||||
FilePrecondition(
|
||||
document.file_path,
|
||||
_source_label(document),
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def prepare_droid_installation(
|
||||
config: InstallConfig,
|
||||
sources: DroidSources,
|
||||
rendered: Sequence[RenderedDocument],
|
||||
) -> PreparedDroidInstallation:
|
||||
paths = preflight_droid_installation(config, sources)
|
||||
_validate_rendered_documents(sources, 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"),
|
||||
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,
|
||||
))
|
||||
changed_paths = [change.file_path for change in document_changes]
|
||||
preconditions = (
|
||||
*_source_preconditions(sources, changed_paths),
|
||||
*sources.policy.preconditions,
|
||||
)
|
||||
return PreparedDroidInstallation(changes, tuple(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))
|
||||
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)
|
||||
|
||||
|
||||
def commit_droid_uninstall(prepared: PreparedDroidUninstall) -> None:
|
||||
write_changes(
|
||||
prepared.changes,
|
||||
"Uninstall Factory Droid hooks",
|
||||
prepared.preconditions,
|
||||
)
|
||||
@@ -1,126 +1,324 @@
|
||||
"""Factory Droid source discovery and runtime inspection."""
|
||||
"""Factory Droid source discovery and managed runtime validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
import urllib.parse
|
||||
|
||||
from ._transaction import (
|
||||
FileChange,
|
||||
read_optional,
|
||||
regular_file_exists,
|
||||
source_change,
|
||||
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 .droid_config import (
|
||||
DroidDocument,
|
||||
DroidSources,
|
||||
RenderedDocument,
|
||||
active_document,
|
||||
create_legacy_hook_document,
|
||||
create_owned_hook_document,
|
||||
create_settings_document,
|
||||
hook_block,
|
||||
parse_document,
|
||||
)
|
||||
from .droid_contract import AGENT_KEY, JsonObject, RuntimeContract, same_agent_id
|
||||
from .droid_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
RuntimeContract,
|
||||
elydora_dir,
|
||||
same_agent_id,
|
||||
same_path,
|
||||
)
|
||||
from .droid_policy import read_droid_policy
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
def _factory_paths() -> tuple[str, str, str]:
|
||||
@dataclass(frozen=True)
|
||||
class FactoryPaths:
|
||||
directory: str
|
||||
root: str
|
||||
legacy_directory: str
|
||||
legacy: str
|
||||
settings: str
|
||||
local_settings: str
|
||||
|
||||
|
||||
def _factory_paths() -> FactoryPaths:
|
||||
directory = os.path.join(os.path.expanduser("~"), ".factory")
|
||||
return (
|
||||
legacy_directory = os.path.join(directory, "hooks")
|
||||
return FactoryPaths(
|
||||
directory,
|
||||
os.path.join(directory, "hooks.json"),
|
||||
os.path.join(directory, "hooks", "hooks.json"),
|
||||
legacy_directory,
|
||||
os.path.join(legacy_directory, "hooks.json"),
|
||||
os.path.join(directory, "settings.json"),
|
||||
os.path.join(directory, "settings.local.json"),
|
||||
)
|
||||
|
||||
|
||||
def _read_document(
|
||||
file_path: str,
|
||||
kind: str,
|
||||
label: str,
|
||||
) -> Optional[DroidDocument]:
|
||||
snapshot = read_physical_file(file_path, label, MAX_SOURCE_BYTES)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_document(
|
||||
exists=True,
|
||||
file_path=file_path,
|
||||
kind=kind,
|
||||
raw=snapshot.contents,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
|
||||
def read_sources() -> DroidSources:
|
||||
root_path, legacy_path, settings_path = _factory_paths()
|
||||
root_raw = read_optional(root_path, "Factory Droid hooks")
|
||||
settings_raw = read_optional(settings_path, "Factory Droid settings")
|
||||
primary = None
|
||||
if root_raw is not None:
|
||||
primary = parse_document(
|
||||
exists=True,
|
||||
file_path=root_path,
|
||||
kind="hooks",
|
||||
raw=root_raw,
|
||||
paths = _factory_paths()
|
||||
physical_directory_exists(
|
||||
paths.directory,
|
||||
"Factory Droid user configuration directory",
|
||||
)
|
||||
else:
|
||||
legacy_raw = read_optional(legacy_path, "Factory Droid legacy hooks")
|
||||
if legacy_raw is not None:
|
||||
primary = parse_document(
|
||||
exists=True,
|
||||
file_path=legacy_path,
|
||||
kind="legacy",
|
||||
raw=legacy_raw,
|
||||
physical_directory_exists(
|
||||
paths.legacy_directory,
|
||||
"Factory Droid legacy hooks directory",
|
||||
)
|
||||
settings = (
|
||||
create_settings_document(settings_path)
|
||||
if settings_raw is None
|
||||
else parse_document(
|
||||
exists=True,
|
||||
file_path=settings_path,
|
||||
kind="settings",
|
||||
raw=settings_raw,
|
||||
root = _read_document(paths.root, "hooks", "Factory Droid user hooks")
|
||||
legacy = _read_document(
|
||||
paths.legacy,
|
||||
"legacy",
|
||||
"Factory Droid legacy hooks",
|
||||
)
|
||||
settings = _read_document(
|
||||
paths.settings,
|
||||
"settings",
|
||||
"Factory Droid user settings",
|
||||
)
|
||||
return DroidSources(root_path, primary, settings)
|
||||
|
||||
|
||||
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
if rendered.document.kind == "settings":
|
||||
label = "Factory Droid settings"
|
||||
elif rendered.document.kind == "legacy":
|
||||
label = "Factory Droid legacy hooks"
|
||||
else:
|
||||
label = "Factory Droid hooks"
|
||||
return source_change(
|
||||
rendered.document.file_path,
|
||||
label,
|
||||
rendered.document.raw if rendered.document.exists else None,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
local_settings = _read_document(
|
||||
paths.local_settings,
|
||||
"local-settings",
|
||||
"Factory Droid local settings",
|
||||
)
|
||||
return DroidSources(
|
||||
root or create_owned_hook_document(paths.root),
|
||||
legacy or create_legacy_hook_document(paths.legacy),
|
||||
settings or create_settings_document(paths.settings),
|
||||
local_settings or create_settings_document(
|
||||
paths.local_settings,
|
||||
"local-settings",
|
||||
),
|
||||
read_droid_policy(),
|
||||
)
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
raw = read_optional(file_path, "Elydora runtime config")
|
||||
if raw is None:
|
||||
return None
|
||||
def require_hooks_enabled(sources: DroidSources) -> None:
|
||||
blocked = hook_block(sources)
|
||||
if blocked is not None:
|
||||
raise ValueError(
|
||||
"Factory Droid user hooks are disabled by "
|
||||
f"{blocked.field} in {blocked.label} at {blocked.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"Failed to parse Elydora runtime config at {file_path}: {error}"
|
||||
f"{label} must be an absolute HTTP or HTTPS URL"
|
||||
) from error
|
||||
if not isinstance(value, dict):
|
||||
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"Elydora runtime config at {file_path} must contain a JSON object"
|
||||
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 runtime_files_exist(contracts: list[RuntimeContract]) -> bool:
|
||||
for contract in contracts:
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
config = _read_runtime_config(os.path.join(agent_directory, "config.json"))
|
||||
config_agent_id = config.get("agent_id") if config else None
|
||||
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 (
|
||||
config is None
|
||||
not same_agent_id(agent_id, contract.agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
or not isinstance(config_agent_id, str)
|
||||
or not same_agent_id(config_agent_id, contract.agent_id)
|
||||
):
|
||||
continue
|
||||
if regular_file_exists(
|
||||
contract.guard_path, "Elydora guard runtime"
|
||||
) and regular_file_exists(contract.audit_path, "Elydora audit runtime"):
|
||||
return True
|
||||
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:
|
||||
if sources.primary is not None:
|
||||
return sources.primary.file_path
|
||||
if sources.settings.exists and sources.settings.has_hooks_container:
|
||||
return sources.settings.file_path
|
||||
return sources.root_path
|
||||
return active_document(sources).file_path
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Factory Droid read-only hierarchy and organization policy resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ._jsonc import JsoncEditor
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
physical_directory_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._transaction import FilePrecondition
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyLocation:
|
||||
file_path: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyLayer:
|
||||
file_path: str
|
||||
label: str
|
||||
snapshot: Optional[FileSnapshot]
|
||||
hooks_disabled: Optional[bool]
|
||||
allow_managed_hooks_only: Optional[bool]
|
||||
show_hook_output: Optional[bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyOrigin:
|
||||
file_path: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DroidPolicyState:
|
||||
allow_managed_hooks_only_by: Optional[PolicyOrigin]
|
||||
hooks_disabled: Optional[bool]
|
||||
hooks_disabled_by: Optional[PolicyOrigin]
|
||||
preconditions: Tuple[FilePrecondition, ...]
|
||||
|
||||
|
||||
def _optional_boolean(root: dict, field: str, label: str) -> Optional[bool]:
|
||||
value = root.get(field)
|
||||
if field in root and not isinstance(value, bool):
|
||||
raise ValueError(f'{label} field "{field}" must be a boolean')
|
||||
return value
|
||||
|
||||
|
||||
def _managed_settings_path() -> str:
|
||||
if sys.platform == "darwin":
|
||||
return "/Library/Application Support/Factory/settings.json"
|
||||
if os.name == "nt":
|
||||
program_files = os.environ.get("ProgramFiles") or r"C:\Program Files"
|
||||
return os.path.join(program_files, "Factory", "settings.json")
|
||||
return "/etc/factory/settings.json"
|
||||
|
||||
|
||||
def _read_layer(location: PolicyLocation) -> PolicyLayer:
|
||||
physical_directory_exists(
|
||||
os.path.dirname(location.file_path),
|
||||
f"{location.label} directory",
|
||||
)
|
||||
snapshot = read_physical_file(
|
||||
location.file_path,
|
||||
location.label,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
if snapshot is None:
|
||||
return PolicyLayer(
|
||||
location.file_path,
|
||||
location.label,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
editor = JsoncEditor(
|
||||
snapshot.contents,
|
||||
f"{location.label} at {location.file_path}",
|
||||
)
|
||||
root = editor.value
|
||||
if not isinstance(root, dict):
|
||||
raise ValueError(
|
||||
f"{location.label} at {location.file_path} "
|
||||
"must contain a JSON object"
|
||||
)
|
||||
return PolicyLayer(
|
||||
location.file_path,
|
||||
location.label,
|
||||
snapshot,
|
||||
_optional_boolean(root, "hooksDisabled", location.label),
|
||||
_optional_boolean(root, "allowManagedHooksOnly", location.label),
|
||||
_optional_boolean(root, "showHookOutput", location.label),
|
||||
)
|
||||
|
||||
|
||||
def _git_root(start: str) -> Optional[str]:
|
||||
current = os.path.abspath(start)
|
||||
while True:
|
||||
marker = os.path.join(current, ".git")
|
||||
try:
|
||||
metadata = os.lstat(marker)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
pass
|
||||
except OSError as error:
|
||||
raise OSError(
|
||||
f"Inspect Factory Droid project marker at {marker}: {error}"
|
||||
) from error
|
||||
else:
|
||||
if stat.S_ISLNK(metadata.st_mode) or not (
|
||||
stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode)
|
||||
):
|
||||
raise OSError(
|
||||
f"Factory Droid project marker is not physical: {marker}"
|
||||
)
|
||||
return current
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def _project_directories(root: str, current: str) -> List[str]:
|
||||
directories = [root]
|
||||
try:
|
||||
relative = os.path.relpath(current, root)
|
||||
except ValueError:
|
||||
return directories
|
||||
if relative in {"", "."} or relative == os.pardir:
|
||||
return directories
|
||||
if relative.startswith(os.pardir + os.sep) or os.path.isabs(relative):
|
||||
return directories
|
||||
directory = root
|
||||
for segment in relative.split(os.sep):
|
||||
if not segment:
|
||||
continue
|
||||
directory = os.path.join(directory, segment)
|
||||
directories.append(directory)
|
||||
return directories
|
||||
|
||||
|
||||
def _project_locations() -> List[Tuple[PolicyLocation, PolicyLocation]]:
|
||||
current = os.path.abspath(os.getcwd())
|
||||
root = _git_root(current) or current
|
||||
locations = []
|
||||
for index, directory in enumerate(_project_directories(root, current)):
|
||||
factory = os.path.join(directory, ".factory")
|
||||
scope = "project" if index == 0 else f"folder {directory}"
|
||||
locations.append((
|
||||
PolicyLocation(
|
||||
os.path.join(factory, "settings.json"),
|
||||
f"Factory Droid {scope} settings",
|
||||
),
|
||||
PolicyLocation(
|
||||
os.path.join(factory, "settings.local.json"),
|
||||
f"Factory Droid {scope} local settings",
|
||||
),
|
||||
))
|
||||
return locations
|
||||
|
||||
|
||||
def _scope_value(layers: Tuple[PolicyLayer, PolicyLayer]) -> Optional[PolicyLayer]:
|
||||
settings, local = layers
|
||||
if local.hooks_disabled is not None:
|
||||
return local
|
||||
if settings.hooks_disabled is not None:
|
||||
return settings
|
||||
return None
|
||||
|
||||
|
||||
def read_droid_policy() -> DroidPolicyState:
|
||||
managed = _read_layer(PolicyLocation(
|
||||
_managed_settings_path(),
|
||||
"Factory Droid system-managed settings",
|
||||
))
|
||||
project_layers = [
|
||||
(_read_layer(scope[0]), _read_layer(scope[1]))
|
||||
for scope in _project_locations()
|
||||
]
|
||||
allow_managed = (
|
||||
PolicyOrigin(managed.file_path, managed.label)
|
||||
if managed.allow_managed_hooks_only is True
|
||||
else None
|
||||
)
|
||||
selected = next(
|
||||
(
|
||||
layer
|
||||
for layer in (
|
||||
managed,
|
||||
*(_scope_value(scope) for scope in project_layers),
|
||||
)
|
||||
if layer is not None and layer.hooks_disabled is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
flattened = [managed]
|
||||
for scope in project_layers:
|
||||
flattened.extend(scope)
|
||||
return DroidPolicyState(
|
||||
allow_managed,
|
||||
selected.hooks_disabled if selected is not None else None,
|
||||
(
|
||||
PolicyOrigin(selected.file_path, selected.label)
|
||||
if selected is not None
|
||||
else None
|
||||
),
|
||||
tuple(
|
||||
FilePrecondition(
|
||||
layer.file_path,
|
||||
layer.label,
|
||||
layer.snapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
)
|
||||
for layer in flattened
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess # nosec B404
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _jsonc, droid, droid_policy
|
||||
from elydora.plugins.base import InstallConfig
|
||||
|
||||
|
||||
AGENT_ID = "agent-1"
|
||||
VALID_PRIVATE_KEY = base64.urlsafe_b64encode(bytes([13]) * 32).rstrip(b"=").decode()
|
||||
JsonObject = Dict[str, Any]
|
||||
|
||||
|
||||
class DroidFixture:
|
||||
def __init__(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
root_config: Any = None,
|
||||
legacy_config: Any = None,
|
||||
settings: Any = None,
|
||||
local_settings: Any = None,
|
||||
project_settings: Any = None,
|
||||
project_local_settings: Any = None,
|
||||
base_url: str = "http://127.0.0.1:9",
|
||||
agent_id: str = AGENT_ID,
|
||||
) -> None:
|
||||
self.root_dir = tmp_path
|
||||
self.home_dir = tmp_path / "home with spaces and 'quote %DROID%"
|
||||
self.workspace_dir = tmp_path / "workspace with spaces"
|
||||
self.factory_dir = self.home_dir / ".factory"
|
||||
self.root_path = self.factory_dir / "hooks.json"
|
||||
self.legacy_path = self.factory_dir / "hooks" / "hooks.json"
|
||||
self.settings_path = self.factory_dir / "settings.json"
|
||||
self.local_settings_path = self.factory_dir / "settings.local.json"
|
||||
self.project_factory_dir = self.workspace_dir / ".factory"
|
||||
self.project_settings_path = self.project_factory_dir / "settings.json"
|
||||
self.project_local_settings_path = (
|
||||
self.project_factory_dir / "settings.local.json"
|
||||
)
|
||||
self.system_settings_path = (
|
||||
tmp_path / "managed factory" / "settings.json"
|
||||
)
|
||||
self.agent_id = agent_id
|
||||
self.agent_dir = self.home_dir / ".elydora" / agent_id
|
||||
self.guard_path = self.agent_dir / "guard.py"
|
||||
self.audit_path = self.agent_dir / "hook.py"
|
||||
self.runtime_config = self.agent_dir / "config.json"
|
||||
self.private_key_path = self.agent_dir / "private.key"
|
||||
self.workspace_dir.mkdir(parents=True)
|
||||
(self.workspace_dir / ".git").mkdir()
|
||||
self._write(self.root_path, root_config)
|
||||
self._write(self.legacy_path, legacy_config)
|
||||
self._write(self.settings_path, settings)
|
||||
self._write(self.local_settings_path, local_settings)
|
||||
self._write(self.project_settings_path, project_settings)
|
||||
self._write(self.project_local_settings_path, project_local_settings)
|
||||
monkeypatch.setenv("HOME", str(self.home_dir))
|
||||
monkeypatch.setenv("USERPROFILE", str(self.home_dir))
|
||||
monkeypatch.chdir(self.workspace_dir)
|
||||
monkeypatch.setattr(
|
||||
droid_policy,
|
||||
"_managed_settings_path",
|
||||
lambda: str(self.system_settings_path),
|
||||
)
|
||||
self.config: InstallConfig = {
|
||||
"agent_id": agent_id,
|
||||
"agent_name": "droid",
|
||||
"org_id": "org-1",
|
||||
"private_key": VALID_PRIVATE_KEY,
|
||||
"kid": "kid-1",
|
||||
"token": "token-1",
|
||||
"base_url": base_url,
|
||||
"guard_script_path": str(self.guard_path),
|
||||
}
|
||||
self.plugin = droid.DroidPlugin()
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
raw = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
|
||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||
file.write(raw)
|
||||
|
||||
def install(self, **overrides: str) -> None:
|
||||
config = InstallConfig(**self.config)
|
||||
config.update(overrides)
|
||||
self.plugin.install(config)
|
||||
|
||||
def write_system_settings(self, value: Any) -> None:
|
||||
self._write(self.system_settings_path, value)
|
||||
|
||||
|
||||
def load_jsonc(path: Path) -> JsonObject:
|
||||
value = _jsonc.parse_jsonc(path.read_text(encoding="utf-8"), str(path))
|
||||
assert isinstance(value, dict)
|
||||
return value
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
|
||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||
file.write(source)
|
||||
|
||||
|
||||
def read_raw(path: Path) -> str:
|
||||
with open(path, "r", encoding="utf-8", newline="") as file:
|
||||
return file.read()
|
||||
|
||||
|
||||
def managed_group(
|
||||
hooks: JsonObject,
|
||||
event: str,
|
||||
script_name: str,
|
||||
) -> Optional[JsonObject]:
|
||||
for group in hooks.get(event, []):
|
||||
if any(
|
||||
script_name in handler.get("command", "")
|
||||
for handler in group.get("hooks", [])
|
||||
):
|
||||
return group
|
||||
return None
|
||||
|
||||
|
||||
def managed_handler(
|
||||
hooks: JsonObject,
|
||||
event: str,
|
||||
script_name: str,
|
||||
) -> Optional[JsonObject]:
|
||||
group = managed_group(hooks, event, script_name)
|
||||
if group is None:
|
||||
return None
|
||||
return next(
|
||||
handler
|
||||
for handler in group["hooks"]
|
||||
if script_name in handler.get("command", "")
|
||||
)
|
||||
|
||||
|
||||
def assert_native_group(group: Optional[JsonObject]) -> None:
|
||||
assert group is not None
|
||||
assert sorted(group) == ["hooks", "matcher"]
|
||||
assert group["matcher"] == "*"
|
||||
assert len(group["hooks"]) == 1
|
||||
handler = group["hooks"][0]
|
||||
assert sorted(handler) == ["command", "timeout", "type"]
|
||||
assert handler["type"] == "command"
|
||||
assert handler["timeout"] == 10
|
||||
|
||||
|
||||
def run_hook(
|
||||
command: str,
|
||||
payload: str,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
argv = (
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
command,
|
||||
]
|
||||
if os.name == "nt"
|
||||
else ["/bin/sh", "-c", command]
|
||||
)
|
||||
return subprocess.run( # nosec B603
|
||||
argv,
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def assert_no_transaction_files(root: Path) -> None:
|
||||
leaked = [
|
||||
path
|
||||
for path in root.rglob("*")
|
||||
if path.suffix in {".tmp", ".rollback"}
|
||||
]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def snapshot(paths: Iterable[Path]) -> Dict[Path, str]:
|
||||
return {
|
||||
path: read_raw(path)
|
||||
for path in paths
|
||||
}
|
||||
|
||||
|
||||
def assert_snapshot(expected: Dict[Path, str]) -> None:
|
||||
for path, source in expected.items():
|
||||
assert read_raw(path) == source
|
||||
|
||||
|
||||
class _ApiHandler(BaseHTTPRequestHandler):
|
||||
def _respond(self, status: int, payload: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(payload.encode("utf-8"))
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.server.requests.append(("GET", self.path, b"")) # type: ignore[attr-defined]
|
||||
self._respond(200, '{"agent":{"status":"active"}}')
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length)
|
||||
self.server.requests.append( # type: ignore[attr-defined]
|
||||
("POST", self.path, body)
|
||||
)
|
||||
self._respond(201, '{"operation":{"accepted":true}}')
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
del args
|
||||
|
||||
|
||||
class ApiServer:
|
||||
def __init__(self) -> None:
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler)
|
||||
self.server.requests = [] # type: ignore[attr-defined]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever,
|
||||
daemon=True,
|
||||
)
|
||||
self.thread.start()
|
||||
host, port = self.server.server_address
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
|
||||
@property
|
||||
def requests(self) -> list[tuple[str, str, bytes]]:
|
||||
return self.server.requests # type: ignore[attr-defined,no-any-return]
|
||||
|
||||
def close(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.thread.join(timeout=5)
|
||||
self.server.server_close()
|
||||
@@ -1,109 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess # nosec B404
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import cli
|
||||
from elydora.plugins import _jsonc, droid, droid_io
|
||||
from elydora.plugins.base import InstallConfig
|
||||
from elydora.plugins import droid
|
||||
from elydora.plugins.registry import SUPPORTED_AGENTS
|
||||
|
||||
|
||||
AGENT_ID = "agent-1"
|
||||
JsonObject = Dict[str, Any]
|
||||
from droid_support import (
|
||||
AGENT_ID,
|
||||
VALID_PRIVATE_KEY,
|
||||
ApiServer,
|
||||
DroidFixture,
|
||||
assert_native_group,
|
||||
assert_no_transaction_files,
|
||||
load_jsonc,
|
||||
managed_group,
|
||||
managed_handler,
|
||||
read_raw,
|
||||
run_hook,
|
||||
snapshot,
|
||||
write_json,
|
||||
)
|
||||
|
||||
|
||||
class DroidFixture:
|
||||
def __init__(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
hooks: Any = None,
|
||||
legacy_hooks: Any = None,
|
||||
settings: Any = None,
|
||||
create_guard: bool = True,
|
||||
) -> None:
|
||||
self.home_dir = tmp_path / "home with spaces and 'quote"
|
||||
self.workspace_dir = self.home_dir / "workspace"
|
||||
self.factory_dir = self.home_dir / ".factory"
|
||||
self.config_path = self.factory_dir / "hooks.json"
|
||||
self.legacy_path = self.factory_dir / "hooks" / "hooks.json"
|
||||
self.settings_path = self.factory_dir / "settings.json"
|
||||
self.agent_dir = self.home_dir / ".elydora" / AGENT_ID
|
||||
self.guard_path = self.agent_dir / "guard.py"
|
||||
self.audit_path = self.agent_dir / "hook.py"
|
||||
self.runtime_config = self.agent_dir / "config.json"
|
||||
self.private_key_path = self.agent_dir / "private.key"
|
||||
self.workspace_dir.mkdir(parents=True)
|
||||
self.agent_dir.mkdir(parents=True)
|
||||
if create_guard:
|
||||
self.guard_path.write_text(
|
||||
"import sys\nsys.stdin.read()\n"
|
||||
"sys.stderr.write('Agent is frozen by Elydora.\\n')\n"
|
||||
"raise SystemExit(2)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._write(self.config_path, hooks)
|
||||
self._write(self.legacy_path, legacy_hooks)
|
||||
self._write(self.settings_path, settings)
|
||||
monkeypatch.setenv("HOME", str(self.home_dir))
|
||||
monkeypatch.setenv("USERPROFILE", str(self.home_dir))
|
||||
private_key = base64.urlsafe_b64encode(bytes([1]) * 32).rstrip(b"=").decode()
|
||||
self.config: InstallConfig = {
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_name": "droid",
|
||||
"org_id": "org-1",
|
||||
"private_key": private_key,
|
||||
"kid": "kid-1",
|
||||
"base_url": "https://api.elydora.test",
|
||||
"guard_script_path": str(self.guard_path),
|
||||
}
|
||||
self.plugin = droid.DroidPlugin()
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
raw = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
|
||||
with open(path, "w", encoding="utf-8", newline="") as file:
|
||||
file.write(raw)
|
||||
|
||||
def install(self) -> None:
|
||||
self.plugin.install(self.config)
|
||||
|
||||
|
||||
def _load(path: Path) -> JsonObject:
|
||||
value = _jsonc.parse_jsonc(path.read_text(encoding="utf-8"), str(path))
|
||||
assert isinstance(value, dict)
|
||||
return value
|
||||
|
||||
|
||||
def _managed_handler(groups: Any, script_path: Path) -> Optional[JsonObject]:
|
||||
for group in groups or []:
|
||||
for handler in group["hooks"]:
|
||||
if str(script_path) in handler.get("command", ""):
|
||||
return handler
|
||||
return None
|
||||
|
||||
|
||||
def _run_command(command: str, payload: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # nosec B602
|
||||
command,
|
||||
shell=True,
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
def _current_hooks(path: Path) -> dict[str, Any]:
|
||||
return load_jsonc(path)["hooks"]
|
||||
|
||||
|
||||
def test_droid_is_registered_in_the_sdk_and_cli() -> None:
|
||||
@@ -115,213 +43,379 @@ def test_droid_is_registered_in_the_sdk_and_cli() -> None:
|
||||
assert cli.PLUGIN_MAP["droid"] is droid.DroidPlugin
|
||||
|
||||
|
||||
def test_install_preserves_jsonc_and_uses_per_event_precedence(
|
||||
def test_install_writes_current_container_and_complete_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
hooks = """{
|
||||
// root hook source
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
output = capsys.readouterr().out
|
||||
source = fixture.root_path.read_text(encoding="utf-8")
|
||||
assert source.startswith("// Managed by Elydora\n")
|
||||
hooks = _current_hooks(fixture.root_path)
|
||||
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
|
||||
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
|
||||
guard = managed_handler(hooks, "PreToolUse", "guard.py")
|
||||
assert guard is not None
|
||||
if os.name == "nt":
|
||||
assert guard["command"].startswith("& '")
|
||||
assert guard["command"].endswith("; exit $LASTEXITCODE")
|
||||
runtime = json.loads(fixture.runtime_config.read_text(encoding="utf-8"))
|
||||
assert runtime == {
|
||||
"org_id": "org-1",
|
||||
"agent_id": AGENT_ID,
|
||||
"kid": "kid-1",
|
||||
"base_url": "http://127.0.0.1:9",
|
||||
"agent_name": "droid",
|
||||
"token": "token-1",
|
||||
}
|
||||
assert fixture.private_key_path.read_text(encoding="utf-8") == VALID_PRIVATE_KEY
|
||||
assert "AGENT_NAME = 'droid'" in fixture.guard_path.read_text(encoding="utf-8")
|
||||
assert "NATIVE_PAYLOAD = True" in fixture.audit_path.read_text(encoding="utf-8")
|
||||
assert "run /hooks" in output
|
||||
|
||||
paths = (
|
||||
fixture.root_path,
|
||||
fixture.guard_path,
|
||||
fixture.audit_path,
|
||||
fixture.runtime_config,
|
||||
fixture.private_key_path,
|
||||
)
|
||||
before = snapshot(paths)
|
||||
fixture.install()
|
||||
assert snapshot(paths) == before
|
||||
assert_no_transaction_files(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows migration contract")
|
||||
def test_install_migrates_legacy_windows_commands(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path, root_config={"hooks": {}})
|
||||
|
||||
def group(script: Path) -> dict[str, Any]:
|
||||
return {
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": f'"{sys.executable}" "{script}"',
|
||||
"timeout": 10,
|
||||
}],
|
||||
}
|
||||
|
||||
write_json(fixture.root_path, {
|
||||
"hooks": {
|
||||
"PreToolUse": [group(fixture.guard_path)],
|
||||
"PostToolUse": [group(fixture.audit_path)],
|
||||
},
|
||||
})
|
||||
fixture.install()
|
||||
hooks = _current_hooks(fixture.root_path)
|
||||
assert len(hooks["PreToolUse"]) == 1
|
||||
assert len(hooks["PostToolUse"]) == 1
|
||||
assert hooks["PreToolUse"][0]["hooks"][0]["command"].startswith("& '")
|
||||
|
||||
|
||||
def test_root_hook_file_has_whole_source_precedence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = """{
|
||||
// active root source
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
// keep root group comment
|
||||
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
|
||||
],
|
||||
"Notification": []
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
settings = """{
|
||||
// general setting
|
||||
// inactive settings source
|
||||
"theme": "dark",
|
||||
"hooks": {
|
||||
// settings fallback event
|
||||
"PostToolUse": [
|
||||
// keep settings group comment
|
||||
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
|
||||
],
|
||||
"showHookOutput": true,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
fixture = DroidFixture(monkeypatch, tmp_path, hooks=hooks, settings=settings)
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
root_config=root,
|
||||
settings=settings,
|
||||
)
|
||||
fixture.install()
|
||||
fixture.install()
|
||||
output = capsys.readouterr().out
|
||||
root_raw = fixture.config_path.read_text(encoding="utf-8")
|
||||
settings_raw = fixture.settings_path.read_text(encoding="utf-8")
|
||||
root = _load(fixture.config_path)
|
||||
user_settings = _load(fixture.settings_path)
|
||||
assert "root hook source" in root_raw
|
||||
assert "keep root group comment" in root_raw
|
||||
assert "general setting" in settings_raw
|
||||
assert "settings fallback event" in settings_raw
|
||||
assert "keep settings group comment" in settings_raw
|
||||
assert len(root["PreToolUse"]) == 2
|
||||
assert root["PreToolUse"][0]["hooks"][0]["command"] == "root-user"
|
||||
assert "PostToolUse" not in root
|
||||
assert user_settings["theme"] == "dark"
|
||||
assert len(user_settings["hooks"]["PostToolUse"]) == 2
|
||||
assert "PreToolUse" not in user_settings["hooks"]
|
||||
for groups, path in (
|
||||
(root["PreToolUse"], fixture.guard_path),
|
||||
(user_settings["hooks"]["PostToolUse"], fixture.audit_path),
|
||||
):
|
||||
handler = _managed_handler(groups, path)
|
||||
assert handler is not None
|
||||
assert sorted(handler) == ["command", "timeout", "type"]
|
||||
assert handler["type"] == "command"
|
||||
assert handler["timeout"] == 10
|
||||
assert "run /hooks" in output
|
||||
assert not (fixture.workspace_dir / ".factory" / "hooks.json").exists()
|
||||
root_source = fixture.root_path.read_text(encoding="utf-8")
|
||||
hooks = _current_hooks(fixture.root_path)
|
||||
assert "active root source" in root_source
|
||||
assert hooks["PreToolUse"][0]["hooks"][0]["command"] == "root-user"
|
||||
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
|
||||
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
|
||||
assert fixture.settings_path.read_text(encoding="utf-8") == settings
|
||||
|
||||
|
||||
def test_install_keeps_active_legacy_source(
|
||||
def test_settings_and_local_settings_follow_source_precedence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
settings = '{\r\n\t"theme": "dark",\r\n\t"hooks": {}\r\n}\r\n'
|
||||
fixture = DroidFixture(monkeypatch, tmp_path / "base", settings=settings)
|
||||
fixture.install()
|
||||
assert not fixture.root_path.exists()
|
||||
source = read_raw(fixture.settings_path)
|
||||
hooks = load_jsonc(fixture.settings_path)["hooks"]
|
||||
assert '\r\n\t\t"PreToolUse"' in source
|
||||
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
|
||||
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
|
||||
|
||||
local_fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path / "local",
|
||||
settings={"hooks": {"Notification": []}},
|
||||
local_settings={"hooks": {"SessionStart": []}},
|
||||
)
|
||||
base_before = local_fixture.settings_path.read_text(encoding="utf-8")
|
||||
local_fixture.install()
|
||||
assert local_fixture.settings_path.read_text(encoding="utf-8") == base_before
|
||||
local_hooks = load_jsonc(local_fixture.local_settings_path)["hooks"]
|
||||
assert_native_group(managed_group(local_hooks, "PreToolUse", "guard.py"))
|
||||
assert_native_group(managed_group(local_hooks, "PostToolUse", "hook.py"))
|
||||
|
||||
|
||||
def test_legacy_hook_file_stays_active_until_factory_migrates_it(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
legacy_hooks={"PreToolUse": []},
|
||||
legacy_config={
|
||||
"PreToolUse": [{
|
||||
"matcher": "Read",
|
||||
"hooks": [{"type": "command", "command": "legacy-user"}],
|
||||
}],
|
||||
},
|
||||
settings={"hooks": {"PostToolUse": []}},
|
||||
)
|
||||
settings_before = fixture.settings_path.read_text(encoding="utf-8")
|
||||
fixture.install()
|
||||
assert not fixture.config_path.exists()
|
||||
assert _managed_handler(_load(fixture.legacy_path)["PreToolUse"], fixture.guard_path)
|
||||
assert _managed_handler(
|
||||
_load(fixture.settings_path)["hooks"]["PostToolUse"],
|
||||
fixture.audit_path,
|
||||
)
|
||||
assert not fixture.root_path.exists()
|
||||
legacy = load_jsonc(fixture.legacy_path)
|
||||
assert_native_group(managed_group(legacy, "PreToolUse", "guard.py"))
|
||||
assert_native_group(managed_group(legacy, "PostToolUse", "hook.py"))
|
||||
assert fixture.settings_path.read_text(encoding="utf-8") == settings_before
|
||||
|
||||
|
||||
def test_install_reuses_settings_container_and_formatting(
|
||||
def test_guard_blocks_and_audit_preserves_native_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = '{\r\n\t"owner": "user",\r\n\t"hooks": {}\r\n}\r\n'
|
||||
fixture = DroidFixture(monkeypatch, tmp_path, settings=source)
|
||||
fixture.install()
|
||||
assert not fixture.config_path.exists()
|
||||
with open(fixture.settings_path, "r", encoding="utf-8", newline="") as file:
|
||||
raw = file.read()
|
||||
settings = _load(fixture.settings_path)
|
||||
assert "\r\n\t\t\"PreToolUse\"" in raw
|
||||
assert "\r\n\t\t\"PostToolUse\"" in raw
|
||||
assert settings["owner"] == "user"
|
||||
|
||||
|
||||
def test_commands_block_and_forward_official_input_byte_for_byte(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
capture_path = tmp_path / "captured-event.json"
|
||||
fixture.audit_path.write_text(
|
||||
"import pathlib, sys\n"
|
||||
f"pathlib.Path({str(capture_path)!r}).write_bytes(sys.stdin.buffer.read())\n",
|
||||
encoding="utf-8",
|
||||
api = ApiServer()
|
||||
try:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
base_url=api.base_url,
|
||||
)
|
||||
root = _load(fixture.config_path)
|
||||
guard = _managed_handler(root["PreToolUse"], fixture.guard_path)
|
||||
audit = _managed_handler(root["PostToolUse"], fixture.audit_path)
|
||||
fixture.install()
|
||||
hooks = _current_hooks(fixture.root_path)
|
||||
guard = managed_handler(hooks, "PreToolUse", "guard.py")
|
||||
audit = managed_handler(hooks, "PostToolUse", "hook.py")
|
||||
assert guard is not None and audit is not None
|
||||
pre_payload = json.dumps({
|
||||
write_json(fixture.agent_dir / "status-cache.json", {
|
||||
"status": "frozen",
|
||||
"cached_at": time.time(),
|
||||
})
|
||||
pre_payload = {
|
||||
"session_id": "session-1",
|
||||
"transcript_path": str(tmp_path / "transcript.jsonl"),
|
||||
"transcript_path": str(fixture.home_dir / "transcript.jsonl"),
|
||||
"cwd": str(fixture.workspace_dir),
|
||||
"permission_mode": "auto-high",
|
||||
"hook_event_name": "PreToolUse",
|
||||
"tool_name": "Execute",
|
||||
"tool_input": {"command": "echo test"},
|
||||
})
|
||||
guard_result = _run_command(guard["command"], pre_payload)
|
||||
}
|
||||
guard_result = run_hook(guard["command"], json.dumps(pre_payload))
|
||||
assert guard_result.returncode == 2
|
||||
assert "Agent is frozen by Elydora" in guard_result.stderr
|
||||
post_payload = json.dumps({
|
||||
**json.loads(pre_payload),
|
||||
assert 'Agent "droid" is frozen' in guard_result.stderr
|
||||
|
||||
post_payload = {
|
||||
**pre_payload,
|
||||
"hook_event_name": "PostToolUse",
|
||||
"tool_response": {"output": "test", "success": True},
|
||||
})
|
||||
audit_result = _run_command(audit["command"], post_payload)
|
||||
}
|
||||
audit_result = run_hook(audit["command"], json.dumps(post_payload))
|
||||
assert audit_result.returncode == 0, audit_result.stderr
|
||||
assert capture_path.read_text(encoding="utf-8") == post_payload
|
||||
request = next(item for item in api.requests if item[0] == "POST")
|
||||
operation = json.loads(request[2])
|
||||
assert operation["payload"] == post_payload
|
||||
assert operation["subject"] == {"session_id": "session-1"}
|
||||
assert operation["action"] == {"tool": "Execute"}
|
||||
finally:
|
||||
api.close()
|
||||
|
||||
|
||||
def test_status_requires_enabled_pair_and_runtime_files(
|
||||
def test_status_requires_exact_hooks_and_runtime_sources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
assert fixture.plugin.status() == {
|
||||
"installed": True,
|
||||
"agent": "droid",
|
||||
"details": f"Config: {fixture.root_path}",
|
||||
}
|
||||
fixture.audit_path.write_text("tampered\n", encoding="utf-8")
|
||||
status = fixture.plugin.status()
|
||||
assert status["installed"] is False
|
||||
assert "incomplete" in status["details"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "pattern"),
|
||||
[
|
||||
("config", "parse Elydora runtime config"),
|
||||
("key", "canonical 32-byte"),
|
||||
],
|
||||
)
|
||||
def test_status_surfaces_malformed_runtime_identity(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
mutation: str,
|
||||
pattern: str,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
target = fixture.runtime_config if mutation == "config" else fixture.private_key_path
|
||||
target.write_text("{ malformed" if mutation == "config" else "invalid", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match=pattern):
|
||||
fixture.plugin.status()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("root_config", "legacy_config", "settings", "local_settings", "pattern"),
|
||||
[
|
||||
("{ malformed", None, None, None, "parse Factory Droid hooks"),
|
||||
([], None, None, None, "JSON object"),
|
||||
('{"hooks":{"PreToolUse":[],"PreToolUse":[]}}', None, None, None, "duplicate"),
|
||||
({"hooks": {"PreToolUse": None}}, None, None, None, "must be an array"),
|
||||
({"hooks": {"PreToolUse": [None]}}, None, None, None, "must be an object"),
|
||||
(
|
||||
{"hooks": {"PreToolUse": [{"matcher": "[", "hooks": []}]}},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"regular expression",
|
||||
),
|
||||
(
|
||||
{"hooks": {"PreToolUse": [{"hooks": [{
|
||||
"type": "command",
|
||||
"command": "",
|
||||
}]}]}},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"non-empty",
|
||||
),
|
||||
(
|
||||
{"hooks": {"PreToolUse": [{"hooks": [{
|
||||
"type": "command",
|
||||
"command": "user",
|
||||
"timeout": 0,
|
||||
}]}]}},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"positive",
|
||||
),
|
||||
(None, "{ malformed", None, None, "legacy hooks"),
|
||||
(None, None, {"hooks": None}, None, "JSON object"),
|
||||
(None, None, None, {"hooksDisabled": "yes"}, "boolean"),
|
||||
],
|
||||
)
|
||||
def test_install_preserves_malformed_sources_before_writes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
root_config: Any,
|
||||
legacy_config: Any,
|
||||
settings: Any,
|
||||
local_settings: Any,
|
||||
pattern: str,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
root_config=root_config,
|
||||
legacy_config=legacy_config,
|
||||
settings=settings,
|
||||
local_settings=local_settings,
|
||||
)
|
||||
originals = {
|
||||
path: path.read_text(encoding="utf-8")
|
||||
for path in (
|
||||
fixture.root_path,
|
||||
fixture.legacy_path,
|
||||
fixture.settings_path,
|
||||
fixture.local_settings_path,
|
||||
)
|
||||
if path.exists()
|
||||
}
|
||||
with pytest.raises((ValueError, OSError), match=pattern):
|
||||
fixture.install()
|
||||
for path, source in originals.items():
|
||||
assert path.read_text(encoding="utf-8") == source
|
||||
assert not fixture.runtime_config.exists()
|
||||
assert not fixture.root_path.exists() or fixture.root_path in originals
|
||||
|
||||
|
||||
def test_unknown_events_and_extension_fields_remain_valid(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
hooks={"PreToolUse": []},
|
||||
settings={"hooks": {"PostToolUse": []}},
|
||||
root_config={
|
||||
"hooks": {
|
||||
"FutureFactoryEvent": [{
|
||||
"matcher": ".*",
|
||||
"commandRegex": "^factory",
|
||||
"owner": "user",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "user-command",
|
||||
"timeout": 1,
|
||||
"future": True,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
fixture.install()
|
||||
assert fixture.plugin.status()["installed"] is True
|
||||
fixture.audit_path.unlink()
|
||||
assert fixture.plugin.status()["installed"] is False
|
||||
root = _load(fixture.config_path)
|
||||
root["hooksDisabled"] = True
|
||||
fixture.config_path.write_text(json.dumps(root), encoding="utf-8")
|
||||
status = fixture.plugin.status()
|
||||
assert status["installed"] is False
|
||||
assert "disabled" in status["details"].lower()
|
||||
current = load_jsonc(fixture.root_path)
|
||||
assert current["hooks"]["FutureFactoryEvent"][0]["owner"] == "user"
|
||||
|
||||
|
||||
def test_uninstall_preserves_user_sources_and_exact_ownership(
|
||||
def test_uninstall_preserves_users_and_exact_ownership(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
hooks = '{\n // keep root comment\n "PreToolUse": []\n}\n'
|
||||
settings = (
|
||||
'{\n "theme": "dark",\n "hooks": {\n'
|
||||
' // keep settings comment\n "PostToolUse": []\n }\n}\n'
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
root_config={"hooks": {"Notification": []}},
|
||||
)
|
||||
fixture = DroidFixture(monkeypatch, tmp_path, hooks=hooks, settings=settings)
|
||||
fixture.install()
|
||||
fixture.plugin.uninstall("agent-10")
|
||||
assert _managed_handler(_load(fixture.config_path)["PreToolUse"], fixture.guard_path)
|
||||
uninstall_id = "AGENT-1" if os.name == "nt" else AGENT_ID
|
||||
fixture.plugin.uninstall(uninstall_id)
|
||||
root_raw = fixture.config_path.read_text(encoding="utf-8")
|
||||
settings_raw = fixture.settings_path.read_text(encoding="utf-8")
|
||||
assert "keep root comment" in root_raw
|
||||
assert "keep settings comment" in settings_raw
|
||||
assert _load(fixture.config_path)["PreToolUse"] == []
|
||||
current_settings = _load(fixture.settings_path)
|
||||
assert current_settings["hooks"]["PostToolUse"] == []
|
||||
assert current_settings["theme"] == "dark"
|
||||
|
||||
|
||||
def test_uninstall_deletes_only_an_owned_empty_hook_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
assert fixture.config_path.read_text(encoding="utf-8").startswith(
|
||||
"// Managed by Elydora"
|
||||
)
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
assert not fixture.config_path.exists()
|
||||
|
||||
|
||||
def test_uninstall_preserves_mixed_groups_and_lookalikes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
root = _load(fixture.config_path)
|
||||
group = next(
|
||||
item for item in root["PreToolUse"]
|
||||
if _managed_handler([item], fixture.guard_path)
|
||||
)
|
||||
root = load_jsonc(fixture.root_path)
|
||||
group = managed_group(root["hooks"], "PreToolUse", "guard.py")
|
||||
assert group is not None
|
||||
command = group["hooks"][0]["command"]
|
||||
group["hooks"].append({"type": "command", "command": "user-command"})
|
||||
group["owner"] = "user"
|
||||
root["PreToolUse"].extend([
|
||||
root["hooks"]["PreToolUse"].extend([
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
@@ -339,108 +433,55 @@ def test_uninstall_preserves_mixed_groups_and_lookalikes(
|
||||
}],
|
||||
},
|
||||
])
|
||||
fixture.config_path.write_text(json.dumps(root, indent=2) + "\n", encoding="utf-8")
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
remaining = _load(fixture.config_path)
|
||||
write_json(fixture.root_path, root)
|
||||
fixture.plugin.uninstall("AGENT-1" if os.name == "nt" else AGENT_ID)
|
||||
remaining = load_jsonc(fixture.root_path)
|
||||
raw = json.dumps(remaining)
|
||||
assert "user-command" in raw
|
||||
assert "guard.py.backup" in raw
|
||||
assert "agent-10" in raw
|
||||
assert remaining["PostToolUse"] == []
|
||||
assert "PostToolUse" not in remaining["hooks"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hooks", "legacy", "settings", "pattern"),
|
||||
[
|
||||
("{ malformed", None, None, "parse Factory Droid hooks"),
|
||||
("[]", None, None, "JSON object"),
|
||||
('{ "PreToolUse": [], "PreToolUse": [] }', None, None, "duplicate"),
|
||||
({"PreToolUse": None}, None, None, "must be an array"),
|
||||
({"PreToolUse": [None]}, None, None, "must be an object"),
|
||||
({"PreToolUse": [{"matcher": "[", "hooks": []}]}, None, None, "regular expression"),
|
||||
({"PreToolUse": [{"hooks": [{"type": "command", "command": 1}]}]}, None, None, "string"),
|
||||
(None, "{ malformed", None, "legacy hooks"),
|
||||
({"PreToolUse": []}, None, "{ malformed", "settings"),
|
||||
(None, None, {"hooks": None}, "must contain a JSON object"),
|
||||
],
|
||||
)
|
||||
def test_install_preserves_every_malformed_source_before_writes(
|
||||
def test_uninstall_removes_owned_empty_file_and_keeps_absence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
hooks: Any,
|
||||
legacy: Any,
|
||||
settings: Any,
|
||||
pattern: str,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
hooks=hooks,
|
||||
legacy_hooks=legacy,
|
||||
settings=settings,
|
||||
fixture = DroidFixture(monkeypatch, tmp_path / "installed")
|
||||
fixture.install()
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
assert not fixture.root_path.exists()
|
||||
|
||||
empty = DroidFixture(monkeypatch, tmp_path / "empty")
|
||||
empty.plugin.uninstall(AGENT_ID)
|
||||
assert not empty.root_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["factory", "hook", "runtime"])
|
||||
def test_install_rejects_linked_paths_before_writes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
kind: str,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
target = tmp_path / f"{kind}-target"
|
||||
target.mkdir()
|
||||
try:
|
||||
if kind == "factory":
|
||||
fixture.home_dir.mkdir(parents=True)
|
||||
fixture.factory_dir.symlink_to(target, target_is_directory=True)
|
||||
elif kind == "hook":
|
||||
fixture.factory_dir.mkdir(parents=True)
|
||||
target_file = target / "hooks.json"
|
||||
write_json(target_file, {"hooks": {}})
|
||||
fixture.root_path.symlink_to(target_file)
|
||||
else:
|
||||
fixture.home_dir.mkdir(parents=True)
|
||||
(fixture.home_dir / ".elydora").symlink_to(
|
||||
target,
|
||||
target_is_directory=True,
|
||||
)
|
||||
targets = [
|
||||
(fixture.config_path, hooks),
|
||||
(fixture.legacy_path, legacy),
|
||||
(fixture.settings_path, settings),
|
||||
]
|
||||
originals = {
|
||||
path: path.read_text(encoding="utf-8") for path, value in targets if value is not None
|
||||
}
|
||||
with pytest.raises((ValueError, RuntimeError), match=pattern):
|
||||
except OSError as error:
|
||||
pytest.skip(f"Symbolic links unavailable: {error}")
|
||||
with pytest.raises(OSError, match="physical (directory|file)"):
|
||||
fixture.install()
|
||||
for path, raw in originals.items():
|
||||
assert path.read_text(encoding="utf-8") == raw
|
||||
assert not fixture.audit_path.exists()
|
||||
assert not fixture.runtime_config.exists()
|
||||
assert not fixture.private_key_path.exists()
|
||||
|
||||
|
||||
def test_install_rejects_missing_guard_before_creating_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path, create_guard=False)
|
||||
with pytest.raises(FileNotFoundError, match="guard runtime is missing"):
|
||||
fixture.install()
|
||||
assert not fixture.config_path.exists()
|
||||
assert not fixture.audit_path.exists()
|
||||
assert not fixture.runtime_config.exists()
|
||||
|
||||
|
||||
def test_transaction_rolls_back_all_files_and_cleans_staging(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
hooks={"PreToolUse": []},
|
||||
settings={"hooks": {"PostToolUse": []}},
|
||||
)
|
||||
original_root = fixture.config_path.read_text(encoding="utf-8")
|
||||
original_settings = fixture.settings_path.read_text(encoding="utf-8")
|
||||
real_replace = os.replace
|
||||
failed = False
|
||||
|
||||
def fail_settings_commit(source: Any, destination: Any) -> None:
|
||||
nonlocal failed
|
||||
if not failed and Path(destination) == fixture.settings_path:
|
||||
failed = True
|
||||
raise OSError("simulated settings commit failure")
|
||||
real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(droid_io.os, "replace", fail_settings_commit)
|
||||
with pytest.raises(OSError, match="Write Factory Droid installation"):
|
||||
fixture.install()
|
||||
assert failed is True
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == original_root
|
||||
assert fixture.settings_path.read_text(encoding="utf-8") == original_settings
|
||||
assert not fixture.audit_path.exists()
|
||||
assert not fixture.runtime_config.exists()
|
||||
assert not fixture.private_key_path.exists()
|
||||
staging = [
|
||||
path for path in fixture.home_dir.rglob("*")
|
||||
if path.suffix in {".tmp", ".rollback"}
|
||||
]
|
||||
assert staging == []
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from droid_support import DroidFixture, write_json
|
||||
|
||||
|
||||
def test_user_settings_disable_before_runtime_creation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
settings={"hooksDisabled": True},
|
||||
)
|
||||
with pytest.raises(ValueError, match="hooksDisabled"):
|
||||
fixture.install()
|
||||
assert not fixture.runtime_config.exists()
|
||||
assert not fixture.root_path.exists()
|
||||
|
||||
|
||||
def test_user_local_settings_override_base_settings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
settings={"hooksDisabled": True},
|
||||
local_settings={"hooksDisabled": False},
|
||||
)
|
||||
fixture.install()
|
||||
assert fixture.runtime_config.exists()
|
||||
|
||||
|
||||
def test_legacy_direct_flags_remain_safety_compatible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
legacy_config={"hooksDisabled": True, "PreToolUse": []},
|
||||
settings={"hooksDisabled": False},
|
||||
)
|
||||
with pytest.raises(ValueError, match="legacy hooks"):
|
||||
fixture.install()
|
||||
assert not fixture.runtime_config.exists()
|
||||
|
||||
|
||||
def test_project_policy_has_extension_only_precedence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
blocked = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path / "blocked",
|
||||
settings={"hooksDisabled": False},
|
||||
project_settings={"hooksDisabled": True},
|
||||
)
|
||||
with pytest.raises(ValueError, match="project settings"):
|
||||
blocked.install()
|
||||
assert not blocked.runtime_config.exists()
|
||||
|
||||
allowed = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path / "allowed",
|
||||
settings={"hooksDisabled": True},
|
||||
project_settings={"hooksDisabled": False},
|
||||
)
|
||||
allowed.install()
|
||||
assert allowed.runtime_config.exists()
|
||||
|
||||
|
||||
def test_project_local_settings_override_matching_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
project_settings={"hooksDisabled": False},
|
||||
project_local_settings={"hooksDisabled": True},
|
||||
)
|
||||
with pytest.raises(ValueError, match="project local settings"):
|
||||
fixture.install()
|
||||
|
||||
|
||||
def test_project_value_precedes_deeper_folder_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
project_settings={"hooksDisabled": False},
|
||||
)
|
||||
child = fixture.workspace_dir / "packages" / "console"
|
||||
child.mkdir(parents=True)
|
||||
write_json(
|
||||
child / ".factory" / "settings.json",
|
||||
{"hooksDisabled": True},
|
||||
)
|
||||
monkeypatch.chdir(child)
|
||||
fixture.install()
|
||||
assert fixture.runtime_config.exists()
|
||||
|
||||
|
||||
def test_system_managed_policy_blocks_user_hooks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.write_system_settings({"allowManagedHooksOnly": True})
|
||||
with pytest.raises(ValueError, match="allowManagedHooksOnly"):
|
||||
fixture.install()
|
||||
assert not fixture.runtime_config.exists()
|
||||
|
||||
|
||||
def test_malformed_read_only_project_policy_is_preserved(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
project_settings="{ malformed",
|
||||
)
|
||||
before = fixture.project_settings_path.read_text(encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="project settings"):
|
||||
fixture.install()
|
||||
assert fixture.project_settings_path.read_text(encoding="utf-8") == before
|
||||
assert not fixture.runtime_config.exists()
|
||||
assert not fixture.root_path.exists()
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _transaction, droid
|
||||
from elydora.plugins.droid_config import render_document, source_documents
|
||||
from elydora.plugins.droid_installation import (
|
||||
commit_droid_installation,
|
||||
commit_droid_uninstall,
|
||||
preflight_droid_installation,
|
||||
prepare_droid_installation,
|
||||
prepare_droid_uninstall,
|
||||
)
|
||||
from elydora.plugins.droid_io import read_sources
|
||||
|
||||
from droid_support import (
|
||||
AGENT_ID,
|
||||
VALID_PRIVATE_KEY,
|
||||
DroidFixture,
|
||||
assert_no_transaction_files,
|
||||
assert_snapshot,
|
||||
load_jsonc,
|
||||
snapshot,
|
||||
write_json,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_installation(
|
||||
fixture: DroidFixture,
|
||||
**overrides: str,
|
||||
):
|
||||
config: Dict[str, Any] = dict(fixture.config)
|
||||
config.update(overrides)
|
||||
sources = read_sources()
|
||||
paths = preflight_droid_installation(config, sources)
|
||||
rendered = droid._render_installation( # noqa: SLF001
|
||||
sources,
|
||||
paths.guard_path,
|
||||
paths.audit_path,
|
||||
)
|
||||
return prepare_droid_installation(config, sources, rendered)
|
||||
|
||||
|
||||
def _managed_paths(fixture: DroidFixture) -> tuple[Path, ...]:
|
||||
return (
|
||||
fixture.guard_path,
|
||||
fixture.runtime_config,
|
||||
fixture.private_key_path,
|
||||
fixture.audit_path,
|
||||
fixture.root_path,
|
||||
)
|
||||
|
||||
|
||||
def test_install_rolls_back_runtime_after_late_hook_commit_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
prepared = _prepare_installation(fixture)
|
||||
real_replace = _transaction.os.replace
|
||||
failed = False
|
||||
|
||||
def fail_hook_commit(source: Any, destination: Any) -> None:
|
||||
nonlocal failed
|
||||
if not failed and Path(destination) == fixture.root_path:
|
||||
failed = True
|
||||
raise OSError("injected Droid hook commit failure")
|
||||
real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_hook_commit)
|
||||
with pytest.raises(OSError, match="injected Droid hook commit failure"):
|
||||
commit_droid_installation(prepared)
|
||||
assert failed is True
|
||||
for path in _managed_paths(fixture):
|
||||
assert not path.exists()
|
||||
assert_no_transaction_files(tmp_path)
|
||||
|
||||
|
||||
def test_concurrent_active_hook_replacement_prevents_runtime_commit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
before = snapshot(_managed_paths(fixture))
|
||||
prepared = _prepare_installation(fixture, org_id="org-updated")
|
||||
concurrent = '{"hooks":{"PreToolUse":[]},"owner":"concurrent"}\n'
|
||||
fixture.root_path.write_text(concurrent, encoding="utf-8")
|
||||
with pytest.raises(OSError, match="hooks changed during Install Factory Droid"):
|
||||
commit_droid_installation(prepared)
|
||||
for path, source in before.items():
|
||||
if path != fixture.root_path:
|
||||
assert path.read_text(encoding="utf-8") == source
|
||||
assert fixture.root_path.read_text(encoding="utf-8") == concurrent
|
||||
assert_no_transaction_files(tmp_path)
|
||||
|
||||
|
||||
def test_concurrent_inactive_local_source_creation_is_detected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
before = snapshot(_managed_paths(fixture))
|
||||
prepared = _prepare_installation(fixture, org_id="org-updated")
|
||||
concurrent = '{"hooksDisabled":true}\n'
|
||||
fixture.local_settings_path.write_text(concurrent, encoding="utf-8")
|
||||
with pytest.raises(OSError, match="local settings changed during Install"):
|
||||
commit_droid_installation(prepared)
|
||||
assert_snapshot(before)
|
||||
assert fixture.local_settings_path.read_text(encoding="utf-8") == concurrent
|
||||
assert_no_transaction_files(tmp_path)
|
||||
|
||||
|
||||
def test_concurrent_project_policy_change_is_detected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
project_settings={"hooksDisabled": False},
|
||||
)
|
||||
fixture.install()
|
||||
before = snapshot(_managed_paths(fixture))
|
||||
prepared = _prepare_installation(fixture, org_id="org-updated")
|
||||
concurrent = '{"hooksDisabled":true}\n'
|
||||
fixture.project_settings_path.write_text(concurrent, encoding="utf-8")
|
||||
with pytest.raises(OSError, match="project settings changed during Install"):
|
||||
commit_droid_installation(prepared)
|
||||
assert_snapshot(before)
|
||||
assert fixture.project_settings_path.read_text(encoding="utf-8") == concurrent
|
||||
assert_no_transaction_files(tmp_path)
|
||||
|
||||
|
||||
def test_same_content_replacement_is_rejected_by_physical_identity(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
before = snapshot(_managed_paths(fixture))
|
||||
sources = read_sources()
|
||||
original = fixture.root_path.read_text(encoding="utf-8")
|
||||
fixture.root_path.unlink()
|
||||
fixture.root_path.write_text(original, encoding="utf-8")
|
||||
config: Dict[str, Any] = dict(fixture.config)
|
||||
config["org_id"] = "org-updated"
|
||||
paths = preflight_droid_installation(config, sources)
|
||||
rendered = droid._render_installation( # noqa: SLF001
|
||||
sources,
|
||||
paths.guard_path,
|
||||
paths.audit_path,
|
||||
)
|
||||
prepared = prepare_droid_installation(config, sources, rendered)
|
||||
with pytest.raises(OSError, match="hooks changed during Install"):
|
||||
commit_droid_installation(prepared)
|
||||
assert fixture.root_path.read_text(encoding="utf-8") == original
|
||||
for path, source in before.items():
|
||||
if path != fixture.root_path:
|
||||
assert path.read_text(encoding="utf-8") == source
|
||||
assert_no_transaction_files(tmp_path)
|
||||
|
||||
|
||||
def test_orphaned_runtime_is_preserved_without_verifiable_identity(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.agent_dir.mkdir(parents=True)
|
||||
fixture.guard_path.write_text("orphaned guard\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="identity cannot be verified"):
|
||||
fixture.install()
|
||||
assert fixture.guard_path.read_text(encoding="utf-8") == "orphaned guard\n"
|
||||
assert not fixture.root_path.exists()
|
||||
|
||||
|
||||
def test_uninstall_rolls_back_all_hook_documents(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = DroidFixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
installed = load_jsonc(fixture.root_path)
|
||||
write_json(
|
||||
fixture.settings_path,
|
||||
{"hooks": installed["hooks"], "owner": "user"},
|
||||
)
|
||||
paths = (*_managed_paths(fixture), fixture.settings_path)
|
||||
before = snapshot(paths)
|
||||
sources = read_sources()
|
||||
rendered = [
|
||||
render_document(document, AGENT_ID, {})
|
||||
for document in source_documents(sources)
|
||||
]
|
||||
prepared = prepare_droid_uninstall(rendered)
|
||||
real_replace = _transaction.os.replace
|
||||
failed = False
|
||||
|
||||
def fail_second_source(source: Any, destination: Any) -> None:
|
||||
nonlocal failed
|
||||
if not failed and Path(destination) == fixture.settings_path:
|
||||
failed = True
|
||||
raise OSError("injected Droid uninstall failure")
|
||||
real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(_transaction.os, "replace", fail_second_source)
|
||||
with pytest.raises(OSError, match="injected Droid uninstall failure"):
|
||||
commit_droid_uninstall(prepared)
|
||||
assert failed is True
|
||||
assert_snapshot(before)
|
||||
assert_no_transaction_files(tmp_path)
|
||||
|
||||
|
||||
def test_cli_preflight_blocks_disabled_hooks_before_runtime_creation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
home_dir = tmp_path / "cli-home"
|
||||
settings_path = home_dir / ".factory" / "settings.json"
|
||||
key_path = tmp_path / "private.key"
|
||||
write_json(settings_path, {"hooksDisabled": True})
|
||||
key_path.write_text(
|
||||
VALID_PRIVATE_KEY,
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HOME": str(home_dir),
|
||||
"USERPROFILE": str(home_dir),
|
||||
}
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"elydora.cli",
|
||||
"install",
|
||||
"--agent",
|
||||
"droid",
|
||||
"--org_id",
|
||||
"org-1",
|
||||
"--agent_id",
|
||||
AGENT_ID,
|
||||
"--private_key_file",
|
||||
str(key_path),
|
||||
"--kid",
|
||||
"kid-1",
|
||||
"--base_url",
|
||||
"https://api.elydora.com",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
cwd=Path(__file__).parents[1],
|
||||
env=environment,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "hooksDisabled" in result.stderr
|
||||
assert not (home_dir / ".elydora").exists()
|
||||
Reference in new issue
Block a user