From 45df68c14622b7c209e02a7fb945d4801578db79 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 15 Apr 2026 18:34:33 -0700 Subject: [PATCH] feat: ensure sqlite3 installation --- core/framework/loader/tool_registry.py | 12 +- core/framework/tools/queen_lifecycle_tools.py | 29 ++++ quickstart.sh | 42 ++++++ tools/coder_tools_server.py | 128 +++++++++++++++++- tools/src/aden_tools/file_ops.py | 17 ++- 5 files changed, 215 insertions(+), 13 deletions(-) diff --git a/core/framework/loader/tool_registry.py b/core/framework/loader/tool_registry.py index 47ce85b5..8347c603 100644 --- a/core/framework/loader/tool_registry.py +++ b/core/framework/loader/tool_registry.py @@ -502,12 +502,22 @@ class ToolRegistry: config["cwd"] = str(resolved_cwd) return config - # For coder_tools_server, inject --project-root so writes go to the expected workspace + # For coder_tools_server, inject --project-root so reads land + # in the expected workspace (hive repo, for framework skills + # and docs), and inject --write-root so writes land under + # ~/.hive/workspace/ instead of polluting the git checkout + # with queen-authored skills, ledgers, and scripts. Without + # the split, every ``write_file`` call from the queen landed + # in the hive repo root. if script_name and "coder_tools" in script_name: project_root = str(resolved_cwd.parent.resolve()) args = list(args) if "--project-root" not in args: args.extend(["--project-root", project_root]) + if "--write-root" not in args: + _write_root = Path.home() / ".hive" / "workspace" + _write_root.mkdir(parents=True, exist_ok=True) + args.extend(["--write-root", str(_write_root)]) config["args"] = args if os.name == "nt": diff --git a/core/framework/tools/queen_lifecycle_tools.py b/core/framework/tools/queen_lifecycle_tools.py index ad2025a5..283f15c3 100644 --- a/core/framework/tools/queen_lifecycle_tools.py +++ b/core/framework/tools/queen_lifecycle_tools.py @@ -1495,6 +1495,35 @@ def register_queen_lifecycle_tools( except OSError as e: return None, f"failed to install skill into {target}: {e}" + # Cleanup the source directory after a successful install so + # the authored skill doesn't linger as debris in the agent + # workspace (or — pre-sandbox-split — in the hive git + # checkout). Only removes paths that are OUTSIDE + # ``~/.hive/skills/`` so we never nuke the canonical install + # target or user-owned skill dirs. + try: + src_resolved = src.resolve() + skills_root_resolved = target_root.resolve() + try: + src_resolved.relative_to(skills_root_resolved) + _under_skills_root = True + except ValueError: + _under_skills_root = False + if not _under_skills_root: + _shutil.rmtree(src_resolved) + logger.info( + "create_colony: cleaned up authored skill source at %s " + "(installed to %s)", + src_resolved, + target, + ) + except OSError as e: + logger.warning( + "create_colony: failed to clean up skill source at %s (non-fatal): %s", + src, + e, + ) + return target, None async def create_colony( diff --git a/quickstart.sh b/quickstart.sh index 93be8981..1e5ce39b 100755 --- a/quickstart.sh +++ b/quickstart.sh @@ -271,6 +271,48 @@ else exit 1 fi +# Check for sqlite3 CLI (required for colony progress tracking) +echo -n " Checking for sqlite3... " +if command -v sqlite3 &> /dev/null; then + echo -e "${GREEN}ok${NC}" +else + echo -e "${YELLOW}not found${NC}" + # Attempt auto-install on common package managers + SQLITE_INSTALLED=false + if command -v apt-get &> /dev/null; then + echo -n " Installing sqlite3 via apt... " + if sudo apt-get install -y sqlite3 > /dev/null 2>&1; then + SQLITE_INSTALLED=true + fi + elif command -v brew &> /dev/null; then + echo -n " Installing sqlite3 via brew... " + if brew install sqlite > /dev/null 2>&1; then + SQLITE_INSTALLED=true + fi + elif command -v apk &> /dev/null; then + echo -n " Installing sqlite3 via apk... " + if apk add sqlite > /dev/null 2>&1; then + SQLITE_INSTALLED=true + fi + elif command -v dnf &> /dev/null; then + echo -n " Installing sqlite3 via dnf... " + if sudo dnf install -y sqlite > /dev/null 2>&1; then + SQLITE_INSTALLED=true + fi + elif command -v pacman &> /dev/null; then + echo -n " Installing sqlite3 via pacman... " + if sudo pacman -S --noconfirm sqlite > /dev/null 2>&1; then + SQLITE_INSTALLED=true + fi + fi + if [ "$SQLITE_INSTALLED" = true ]; then + echo -e "${GREEN}ok${NC}" + else + echo -e "${YELLOW} ⚠ Could not install sqlite3 automatically${NC}" + echo -e "${DIM} Install manually: apt install sqlite3 / brew install sqlite / apk add sqlite${NC}" + fi +fi + # Check for Chrome/Edge (required for GCU browser tools) echo -n " Checking for Chrome/Edge browser... " # Check common browser locations diff --git a/tools/coder_tools_server.py b/tools/coder_tools_server.py index 10816604..aeadf0a7 100644 --- a/tools/coder_tools_server.py +++ b/tools/coder_tools_server.py @@ -82,10 +82,29 @@ def _find_project_root() -> str: return os.path.dirname(os.path.abspath(__file__)) -def _resolve_path(path: str) -> str: - """Resolve path relative to PROJECT_ROOT. Raises ValueError if outside. +# When ``--write-root`` is passed on the CLI, ``WRITE_ROOT`` diverges +# from ``PROJECT_ROOT``: reads stay permissive (so the queen can +# reference framework skills, docs, and the hive repo), but writes +# are confined to the write root plus the ``~/.hive/`` escape hatch. +# Without this split, the coder-tools sandbox IS the hive git +# checkout — every queen-authored skill/ledger/script lands there as +# untracked debris, which was the 2026-04-15 incident +# (``~/aden/hive/x-rapid-reply/`` and siblings). +WRITE_ROOT: str = "" - Also allows access to ~/.hive/ directory for agent session data files. + +def _resolve_read_path(path: str) -> str: + """Resolve path for READ operations. + + Allowlist (in order): + 1. Paths under ``~/.hive/`` — agent session data, colonies, skills. + 2. Paths under ``PROJECT_ROOT`` — hive repo, for reading framework + defaults, docs, examples, etc. + 3. Relative paths — joined against ``PROJECT_ROOT`` (read-side + default; writes use ``WRITE_ROOT`` instead). + + Raises ``ValueError`` when the resolved path falls outside all + allowed roots. """ # Normalize slashes for cross-platform (e.g. exports/hi_agent from LLM) path = path.replace("/", os.sep) @@ -155,6 +174,88 @@ def _resolve_path(path: str) -> str: return resolved +def _resolve_write_path(path: str) -> str: + """Resolve path for WRITE operations. + + Stricter than the read resolver: only allows writes under: + 1. ``WRITE_ROOT`` — the agent workspace (default: ``~/.hive/workspace/`` + when ``--write-root`` is passed). + 2. ``~/.hive/`` — agent session data. + + Writes to the hive repo (``PROJECT_ROOT``) are REJECTED to keep + the git checkout clean of queen-authored debris. Relative paths + resolve against ``WRITE_ROOT``, not ``PROJECT_ROOT``. + + When ``WRITE_ROOT`` equals ``PROJECT_ROOT`` (no split configured), + this function is semantically identical to ``_resolve_read_path``. + """ + # Normalize slashes + expand ~ + path = path.replace("/", os.sep) + if path.startswith("~"): + path = os.path.expanduser(path) + + hive_dir = os.path.expanduser("~/.hive") + + if os.path.isabs(path): + resolved = os.path.abspath(path) + + # Always allow writes under ~/.hive/ + try: + if os.path.commonpath([resolved, hive_dir]) == hive_dir: + return resolved + except ValueError: + pass + + # Writes are ALSO allowed under WRITE_ROOT (the agent workspace). + try: + if os.path.commonpath([resolved, WRITE_ROOT]) == WRITE_ROOT: + return resolved + except ValueError: + pass + + # If WRITE_ROOT == PROJECT_ROOT (legacy behavior: no split), + # fall through to the read-side resolver so existing callers + # keep working unchanged. + if WRITE_ROOT == PROJECT_ROOT: + return _resolve_read_path(path) + + # Split configured AND the path isn't under WRITE_ROOT or + # ~/.hive/. Reject — this is the whole point of the split. + raise ValueError( + f"Access denied: writes must be under '{WRITE_ROOT}' or " + f"'{hive_dir}'. Path '{path}' is outside both " + "(use an absolute path under one of those roots, or a " + "relative path which will resolve under the write root)." + ) + else: + # Relative path: resolve against WRITE_ROOT, not PROJECT_ROOT. + resolved = os.path.abspath(os.path.join(WRITE_ROOT, path)) + + # Double-check the resolved absolute path is inside WRITE_ROOT or + # ~/.hive/ (covers edge cases like "../../etc/passwd" that escape). + try: + wr_common = os.path.commonpath([resolved, WRITE_ROOT]) + except ValueError: + wr_common = "" + try: + hv_common = os.path.commonpath([resolved, hive_dir]) + except ValueError: + hv_common = "" + if wr_common != WRITE_ROOT and hv_common != hive_dir: + raise ValueError( + f"Access denied: resolved write path '{resolved}' escaped the " + f"allowed roots ('{WRITE_ROOT}', '{hive_dir}')." + ) + return resolved + + +# Back-compat alias: existing call sites in this module call +# ``_resolve_path`` directly (e.g. for snapshot dirs, agent tool +# introspection). Those are all non-user-driven paths; route them +# through the read resolver. +_resolve_path = _resolve_read_path + + # ── Git snapshot system (ported from opencode's shadow git) ─────────────── @@ -1689,32 +1790,45 @@ def validate_agent_package(agent_name: str) -> str: def main() -> None: - global PROJECT_ROOT, SNAPSHOT_DIR + global PROJECT_ROOT, SNAPSHOT_DIR, WRITE_ROOT from aden_tools.file_ops import register_file_tools parser = argparse.ArgumentParser(description="Coder Tools MCP Server") parser.add_argument("--project-root", default="") + # ``--write-root`` isolates file writes from the project root so + # queen-authored skills, ledgers, and scripts don't land in the + # hive git checkout. Reads remain permissive under PROJECT_ROOT + # so framework skills, docs, and examples stay accessible. + # Defaults to PROJECT_ROOT when empty (legacy behavior). + parser.add_argument("--write-root", default="") parser.add_argument("--port", type=int, default=int(os.getenv("CODER_TOOLS_PORT", "4002"))) parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--stdio", action="store_true") args = parser.parse_args() PROJECT_ROOT = os.path.abspath(args.project_root) if args.project_root else _find_project_root() + if args.write_root: + WRITE_ROOT = os.path.abspath(os.path.expanduser(args.write_root)) + os.makedirs(WRITE_ROOT, exist_ok=True) + else: + WRITE_ROOT = PROJECT_ROOT # legacy: no split SNAPSHOT_DIR = os.path.join( os.path.expanduser("~"), ".hive", "snapshots", os.path.basename(PROJECT_ROOT), ) - logger.info(f"Project root: {PROJECT_ROOT}") + logger.info(f"Project root (reads): {PROJECT_ROOT}") + logger.info(f"Write root (writes): {WRITE_ROOT}") logger.info(f"Snapshot dir: {SNAPSHOT_DIR}") register_file_tools( mcp, - resolve_path=_resolve_path, + resolve_path=_resolve_read_path, + resolve_path_write=_resolve_write_path, before_write=None, # Git snapshot causes stdio deadlock on Windows; undo_changes limited - project_root=PROJECT_ROOT, + project_root=WRITE_ROOT, ) if args.stdio: diff --git a/tools/src/aden_tools/file_ops.py b/tools/src/aden_tools/file_ops.py index 08f93b1f..a1de2a54 100644 --- a/tools/src/aden_tools/file_ops.py +++ b/tools/src/aden_tools/file_ops.py @@ -328,6 +328,7 @@ def register_file_tools( mcp: FastMCP, *, resolve_path: Callable[[str], str] | None = None, + resolve_path_write: Callable[[str], str] | None = None, before_write: Callable[[], None] | None = None, project_root: str | None = None, ) -> None: @@ -335,12 +336,18 @@ def register_file_tools( Args: mcp: FastMCP instance to register tools on. - resolve_path: Path resolver. Default: resolve to absolute path. - Raise ValueError to reject paths (e.g. outside sandbox). + resolve_path: Path resolver for READ operations. Default: + resolve to absolute path. Raise ValueError to reject paths + (e.g. outside sandbox). + resolve_path_write: Path resolver for WRITE/EDIT operations. + Defaults to ``resolve_path`` when not provided. Split + resolvers let callers keep reads permissive (framework + skills, docs) while confining writes to an agent workspace. before_write: Hook called before write/edit operations (e.g. git snapshot). project_root: If set, search_files relativizes output paths to this root. """ _resolve = resolve_path or _default_resolve_path + _resolve_write = resolve_path_write or _resolve @mcp.tool() def read_file(path: str, offset: int = 1, limit: int = 0, hashline: bool = False) -> str: @@ -440,7 +447,7 @@ def register_file_tools( path: Absolute file path to write. content: Complete file content to write. """ - resolved = _resolve(path) + resolved = _resolve_write(path) resolved_path = Path(resolved) # Stale-edit guard: an existing file must have been read recently @@ -512,7 +519,7 @@ def register_file_tools( new_text: Replacement text. replace_all: Replace all occurrences (default: first only). """ - resolved = _resolve(path) + resolved = _resolve_write(path) if not os.path.isfile(resolved): return f"Error: File not found: {path}" @@ -829,7 +836,7 @@ def register_file_tools( return "Error: Too many edits in one call (max 100). Split into multiple calls." # 2. Read file - resolved = _resolve(path) + resolved = _resolve_write(path) if not os.path.isfile(resolved): return f"Error: File not found: {path}"