Merge branch 'feature/colony-sqlite' into feature/clean-context

This commit is contained in:
Timothy
2026-04-17 04:12:35 -07:00
44 changed files with 3414 additions and 492 deletions
+121 -7
View File
@@ -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)
@@ -153,6 +172,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) ───────────────
@@ -1637,32 +1738,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:
+12 -5
View File
@@ -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
@@ -509,7 +516,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}"
@@ -815,7 +822,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}"
+47 -3
View File
@@ -96,15 +96,59 @@ def register_advanced_tools(mcp: FastMCP) -> None:
profile: str | None = None,
) -> dict:
"""
Execute JavaScript in the browser context.
ESCAPE HATCH execute raw JavaScript. USE ONLY as a last
resort. 99% of browser automation does NOT need this tool.
Before reaching for it, try a semantic tool first:
- browser_click / browser_click_coordinate for clicks
- browser_type(use_insert_text=True) for text input
- browser_screenshot + browser_get_rect for locating elements
- browser_shadow_query for shadow-DOM selectors
- browser_get_text / browser_get_attribute for reading state
ANTI-PATTERNS stop and switch tools if you notice yourself:
1. Calling browser_evaluate 2+ times in a row to guess at
selectors. Each attempt costs ~30 tokens of JS + a full
LLM round-trip. After 2 empty results, the selector
strategy is wrong pivot to browser_screenshot +
browser_click_coordinate. The screenshot + coord path
works on shadow DOM, iframes, and React-obfuscated
class names indifferently.
2. Writing a walk(root) recursive shadow-DOM traversal
function. Use browser_shadow_query it does the
traversal in C++ via CDP's querySelector, not in JS.
3. Calling document.execCommand('insertText', ...) to type
into Lexical / contenteditable. Use
browser_type(use_insert_text=True, text='...') instead.
It handles the click-then-focus-then-insert sequence
with built-in retries.
4. Trying to read a nested iframe's contentDocument. That
usually fails (cross-origin or late hydration). Use
browser_screenshot to see it, then browser_click_coordinate.
LEGITIMATE uses (when nothing semantic fits):
- Reading a computed style, window size, or scroll position
that no tool exposes.
- Firing a one-shot site-specific API call (e.g. an analytics
beacon the test needs).
- Stripping an onbeforeunload handler that blocks navigation.
- Probing for shadow roots whose existence is conditional.
Args:
script: JavaScript code to execute
script: JavaScript code to execute. Keep it small. If you
need to traverse the DOM, prefer browser_shadow_query.
tab_id: Chrome tab ID (default: active tab)
profile: Browser profile name (default: "default")
Returns:
Dict with evaluation result
Dict with evaluation result. On a "find X" script that
returns [] or null: do NOT retry with a different
selector take a screenshot and switch to coordinates.
"""
bridge = get_bridge()
if not bridge or not bridge.is_connected: