Merge branch 'feature/colony-sqlite' into feature/clean-context
This commit is contained in:
+121
-7
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user