Loom / worktree.py
deploy-space action
deploy 6158a00 (c)
b972454
Raw
History Blame Contribute Delete
19.1 kB
"""Git worktree lifecycle for the Conscious multi-agent system (Tier 3, Phase 2).
Each agent gets an isolated ``git worktree`` on the shared repo: a separate
working directory sharing the repo's ``.git`` object store. No clone, no
duplication. N agents on one repo with zero file clobbering.
Layout (TIER3_PLAN.md §12)::
<workspace_sandbox>/
├── .brain/ # canonical brain (shared)
├── .git/ # shared object store
├── ... (main working tree)
└── .worktrees/
└── <agent-id>/ # one worktree per agent
├── .brain/ -> ../../.brain # symlink to shared brain
└── ... (agent's isolated working tree)
Tier 1 safety (non-negotiable): worktree operations never touch the remote
(no push/fetch), so no token is needed. The main repo's ``origin`` is already
token-free (Tier 1's ``_strip_remote_token``). Pushes from worktrees go
through ``github_integration.push_to_remote`` — token only in argv.
This module is stdlib-only (subprocess + pathlib). It does NOT import
github_integration (avoids a circular import; the merge path returns a
result that the caller — conscious_routes — uses to decide whether to push).
Security: all user-controlled inputs (agent_id, filenames, resolution keys)
are validated against strict allowlists + path-containment checks before any
filesystem operation. See ``_validate_agent_id`` and ``_safe_join``.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# security helpers
# ---------------------------------------------------------------------------
# Agent IDs are generated by conscious_db._gen_id() as uuid4().hex[:16],
# so they're always 16 lowercase hex chars. But we accept the broader
# cuid-style ids the TS preview generates too. Reject anything with /, ..,
# spaces, or shell metachars — this closes the worktree-creation /
# branch-name / rm -rf path-traversal vectors (audit C6/C7/H1/H2).
_AGENT_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def _validate_agent_id(agent_id: str) -> str:
"""Reject agent IDs that could escape the worktree path or inject refnames.
Raises ValueError if the id contains anything outside [A-Za-z0-9_-] or is
longer than 64 chars. This is the single chokepoint that closes:
- worktree creation at attacker-chosen paths (audit H1)
- branch name injection via ``conscious/<agent_id>`` (audit H2)
- ``rm -rf`` / ``shutil.rmtree`` on arbitrary dirs (audit C6/C7)
"""
if not agent_id or not _AGENT_ID_RE.match(agent_id):
raise ValueError(f"invalid agent_id (rejected by allowlist): {agent_id!r}")
if ".." in agent_id:
raise ValueError(f"agent_id contains '..': {agent_id!r}")
return agent_id
def _safe_join(base: Path, user_input: str) -> Path:
"""Join ``user_input`` onto ``base`` and verify the result stays inside ``base``.
Closes the path-traversal vector where ``inputs.filename = "../../etc/evil"``
or a conflict-resolution key escapes the sandbox. Rejects:
- absolute paths (``/etc/passwd``)
- ``..`` segments that resolve outside ``base``
- NUL bytes
"""
if not user_input or "\x00" in user_input:
raise ValueError("invalid path: empty or contains NUL byte")
if os.path.isabs(user_input):
raise ValueError(f"absolute paths not allowed: {user_input!r}")
target = (base / user_input).resolve()
base_resolved = base.resolve()
# the target must be inside base (base itself is ok for dirs)
try:
target.relative_to(base_resolved)
except ValueError:
raise ValueError(
f"path escapes sandbox: {user_input!r} resolves outside {base_resolved}")
return target
def _sanitize_git_stderr(stderr: str) -> str:
"""Redact potential tokens / auth URLs from git stderr before surfacing.
Guards against the case where git echoes a configured remote URL (which
could briefly contain a token during the clone window). Patterns:
- ``https://<token>@host`` → ``https://***@host``
- ``ghp_<36 chars>`` → ``ghp_***``
- ``github_pat_<82 chars>`` → ``github_pat_***``
- ``hf_<30+ chars>`` → ``hf_***``
- ``AKIA<16 chars>`` → ``AKIA***``
"""
import re as _re
s = stderr
s = _re.sub(r"https://[^@/\s]+@", "https://***@", s)
s = _re.sub(r"\bghp_[A-Za-z0-9]{36}\b", "ghp_***", s)
s = _re.sub(r"\bgithub_pat_[A-Za-z0-9_]{82}\b", "github_pat_***", s)
s = _re.sub(r"\bhf_[A-Za-z0-9]{30,}\b", "hf_***", s)
s = _re.sub(r"\bAKIA[0-9A-Z]{16}\b", "AKIA***", s)
return s[:400]
# ---------------------------------------------------------------------------
# low-level git helper
# ---------------------------------------------------------------------------
def _git(sandbox: str | Path, *args: str, timeout: int = 60) -> str:
"""Run a git command in ``sandbox``. Returns stdout. Raises on failure.
Security: stderr is sanitized via ``_sanitize_git_stderr`` before being
included in the error message, so tokens / auth URLs never leak into
exceptions (which can flow into HTTP 500 bodies).
"""
cmd = ["git", "-C", str(sandbox)] + list(args)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
# sanitize BOTH the args (in case an auth URL was passed) and stderr
safe_args = _sanitize_git_stderr(" ".join(args))
safe_stderr = _sanitize_git_stderr(result.stderr.strip())
raise RuntimeError(
f"git {safe_args} failed (exit {result.returncode}): "
f"{safe_stderr or _sanitize_git_stderr(result.stdout.strip())}"
)
return result.stdout
def _is_git_repo(sandbox: Path) -> bool:
try:
_git(sandbox, "rev-parse", "--git-dir")
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# main repo init
# ---------------------------------------------------------------------------
def init_main_repo(sandbox: Path, base_branch: str = "main") -> None:
"""Ensure the sandbox is a git repo with at least one commit on ``base_branch``.
Idempotent. If the sandbox isn't a repo, init one + create an initial
commit (empty or a README) so worktrees have a base to branch from.
If it's already a repo, ensure there's at least one commit (worktrees
can't be created from an empty repo).
"""
sandbox.mkdir(parents=True, exist_ok=True)
if not _is_git_repo(sandbox):
_git(sandbox, "init", "-b", base_branch)
# minimal identity so the initial commit succeeds in headless Spaces
try:
_git(sandbox, "config", "user.email", "conscious@loom.local")
_git(sandbox, "config", "user.name", "Conscious")
except Exception:
pass
# ensure at least one commit exists
try:
_git(sandbox, "rev-parse", "HEAD")
except Exception:
readme = sandbox / "README.md"
if not readme.exists():
readme.write_text(
f"# {sandbox.name}\n\nShared workspace for the Conscious multi-agent system.\n",
encoding="utf-8",
)
_git(sandbox, "add", "README.md")
_git(sandbox, "commit", "-m", "initial commit", "--allow-empty")
# rename to base_branch if currently on something else (e.g. master)
try:
current = _git(sandbox, "rev-parse", "--abbrev-ref", "HEAD").strip()
if current != base_branch:
_git(sandbox, "branch", "-M", base_branch)
except Exception:
pass
# ---------------------------------------------------------------------------
# worktree lifecycle
# ---------------------------------------------------------------------------
def worktree_path_for(sandbox: Path, agent_id: str) -> Path:
_validate_agent_id(agent_id)
return sandbox / ".worktrees" / agent_id
def branch_name_for(agent_id: str) -> str:
_validate_agent_id(agent_id)
return f"conscious/{agent_id}"
def create_worktree(sandbox: Path, agent_id: str,
base_branch: str = "main") -> Path:
"""Create a git worktree for ``agent_id`` at ``<sandbox>/.worktrees/<agent_id>``.
Creates a new branch ``conscious/<agent_id>`` off ``base_branch``.
Sets up the ``.brain/`` symlink so the worktree shares the canonical brain.
Returns the worktree path.
Raises if the worktree already exists or the branch already exists.
The caller (conscious_routes) is responsible for rolling back the agent
DB row if this raises.
"""
_validate_agent_id(agent_id) # defense-in-depth (worktree_path_for also checks)
init_main_repo(sandbox, base_branch)
wt = worktree_path_for(sandbox, agent_id)
if wt.exists():
raise RuntimeError(f"worktree already exists: {wt}")
branch = branch_name_for(agent_id)
# `git worktree add -b <branch> <path> <base>` — base must be a valid ref
_git(sandbox, "worktree", "add", "-b", branch, str(wt), base_branch)
# symlink .brain/ → ../../.brain (relative, survives path layout)
brain_link = wt / ".brain"
if not brain_link.exists():
try:
os.symlink("../../.brain", str(brain_link))
except OSError:
# symlink creation can fail on some filesystems; not fatal —
# the agent can still read .brain/ via the main sandbox path.
pass
return wt
def remove_worktree(sandbox: Path, agent_id: str) -> None:
"""Remove an agent's worktree + delete its branch. Idempotent."""
_validate_agent_id(agent_id) # prevents rm -rf on arbitrary paths
wt = worktree_path_for(sandbox, agent_id)
branch = branch_name_for(agent_id)
if wt.exists():
try:
_git(sandbox, "worktree", "remove", "--force", str(wt))
except Exception:
# if the worktree is locked or busy, prune the metadata
try:
_git(sandbox, "worktree", "prune")
except Exception:
pass
# last resort: rm the dir
import shutil
shutil.rmtree(wt, ignore_errors=True)
try:
_git(sandbox, "branch", "-D", branch)
except Exception:
pass # branch may not exist (already deleted / never created)
def list_worktrees(sandbox: Path) -> list[dict]:
"""List all worktrees. Returns [{path, branch, head, bare}]."""
if not _is_git_repo(sandbox):
return []
try:
out = _git(sandbox, "worktree", "list", "--porcelain")
except Exception:
return []
items: list[dict] = []
cur: dict | None = None
for line in out.splitlines():
if not line.strip():
if cur:
items.append(cur)
cur = None
continue
key, _, val = line.partition(" ")
if key == "worktree":
cur = {"path": val}
elif cur is not None:
if key == "HEAD":
cur["head"] = val
elif key == "branch":
cur["branch"] = val
elif key == "bare":
cur["bare"] = val == "true"
if cur:
items.append(cur)
return items
def prune_worktrees(sandbox: Path) -> None:
"""Prune orphaned worktree metadata (worktrees whose dirs were deleted externally)."""
if not _is_git_repo(sandbox):
return
try:
_git(sandbox, "worktree", "prune")
except Exception:
pass
# ---------------------------------------------------------------------------
# merge agent branch → main
# ---------------------------------------------------------------------------
def agent_branch_status(sandbox: Path, agent_id: str,
base_branch: str = "main") -> dict:
"""Return how the agent's branch diverges from base: {ahead, behind, files_changed, diff_summary}."""
branch = branch_name_for(agent_id)
try:
ahead_behind = _git(
sandbox, "rev-list", "--left-right", "--count",
f"{base_branch}...{branch}").strip().split()
ahead = int(ahead_behind[1]) if len(ahead_behind) >= 2 else 0
behind = int(ahead_behind[0]) if len(ahead_behind) >= 2 else 0
except Exception:
ahead = behind = 0
try:
diff = _git(sandbox, "diff", "--name-status", f"{base_branch}..{branch}").strip()
files = [line.split("\t", 1) for line in diff.splitlines() if line.strip()]
except Exception:
files = []
return {
"branch": branch,
"ahead": ahead,
"behind": behind,
"files_changed": [{"status": f[0], "path": f[1] if len(f) > 1 else ""}
for f in files],
}
def merge_agent_branch(sandbox: Path, agent_id: str,
base_branch: str = "main") -> dict:
"""Merge ``conscious/<agent_id>`` into ``base_branch``.
Returns one of:
- ``{"status": "clean", "merged_files": [...], "merge_commit": "<sha>"}``
- ``{"status": "conflicts", "conflicts": [{"file", "ours", "theirs"}], "aborted": True}``
On conflict: the merge is **aborted** (``git merge --abort``) so the main
branch stays clean. The caller (conscious_routes) creates one proposal per
conflicting file for the orchestrator to resolve. The orchestrator then
calls ``apply_resolution_and_merge`` to write the resolved content onto the
agent's branch and retry the merge.
This is the Q3 "merging is a judgment call, not a deterministic function"
invariant from TIER3_PLAN §1 — conflicts surface to the orchestrator, never
auto-resolved.
"""
branch = branch_name_for(agent_id)
# ensure we're on base_branch in the main worktree
_git(sandbox, "checkout", base_branch)
# attempt the merge
result = subprocess.run(
["git", "-C", str(sandbox), "merge", "--no-ff", "--no-commit", "-m",
f"[conscious] merge agent {agent_id}", branch],
capture_output=True, text=True, timeout=60,
)
if result.returncode == 0:
# clean merge — finalize the commit
_git(sandbox, "commit", "--no-edit")
sha = _git(sandbox, "rev-parse", "HEAD").strip()
files = _git(sandbox, "diff", "--name-only", "HEAD~1..HEAD").strip().splitlines()
return {"status": "clean", "merged_files": files, "merge_commit": sha}
# conflict — extract conflicting files + abort
conflicts: list[dict] = []
try:
status = _git(sandbox, "status", "--porcelain")
for line in status.splitlines():
if line.startswith(("UU", "AA", "DU", "UD", "AU", "UA", "DD")):
file = line[3:].strip()
# path-containment check before reading (audit M4)
try:
ours_path = _safe_join(sandbox, file)
theirs_path = _safe_join(worktree_path_for(sandbox, agent_id), file)
ours = _safe_read(ours_path)
theirs = _safe_read(theirs_path)
except ValueError:
# file path escapes sandbox — skip it (don't read arbitrary files)
ours = "(path skipped — outside sandbox)"
theirs = "(path skipped — outside sandbox)"
conflicts.append({"file": file, "ours": ours, "theirs": theirs})
except Exception:
pass
# abort the merge so base_branch stays clean
try:
_git(sandbox, "merge", "--abort")
except Exception:
pass
return {"status": "conflicts", "conflicts": conflicts, "aborted": True}
def apply_resolution_and_merge(sandbox: Path, agent_id: str,
resolutions: dict[str, str],
base_branch: str = "main") -> dict:
"""Apply the orchestrator's resolutions and complete the merge.
Resolves directly on the main worktree: starts the merge (which will
conflict again), overwrites each conflicted file with the orchestrator's
chosen content, stages it, then completes the merge commit. This is the
standard "resolve conflicts during merge" flow.
"""
branch = branch_name_for(agent_id)
_git(sandbox, "checkout", base_branch)
# start the merge (will conflict — expected)
import subprocess
subprocess.run(
["git", "-C", str(sandbox), "merge", "--no-ff", "--no-commit", "-m",
f"[conscious] merge agent {agent_id} (resolved)", branch],
capture_output=True, text=True, timeout=60)
# write resolutions to the main worktree — each file path validated via _safe_join
for file, content in resolutions.items():
target = _safe_join(sandbox, file) # path-containment check (audit C4)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
_git(sandbox, "add", "--", file)
# complete the merge commit
try:
_git(sandbox, "commit", "--no-edit")
except Exception:
try:
_git(sandbox, "commit", "--no-edit", "--allow-empty")
except Exception:
try:
_git(sandbox, "merge", "--abort")
except Exception:
pass
return {"status": "conflicts", "conflicts": [], "aborted": True,
"error": "could not complete the merge commit after resolution"}
sha = _git(sandbox, "rev-parse", "HEAD").strip()
files = _git(sandbox, "diff", "--name-only", "HEAD~1..HEAD").strip().splitlines()
return {"status": "clean", "merged_files": files, "merge_commit": sha}
def _safe_read(p: Path) -> str:
try:
return p.read_text(encoding="utf-8")[:4000]
except Exception:
return ""
# ---------------------------------------------------------------------------
# main-branch file listing (for smoke-test verification)
# ---------------------------------------------------------------------------
def list_files_on_branch(sandbox: Path, branch: str = "main",
prefix: str = "") -> list[str]:
"""List files on ``branch`` (optionally under ``prefix``). Excludes .git/.brain/.worktrees."""
try:
out = _git(sandbox, "ls-tree", "-r", "--name-only", branch)
except Exception:
return []
files = []
for line in out.splitlines():
if not line.strip():
continue
if line.startswith((".git/", ".worktrees/", ".brain/")):
continue
if prefix and not line.startswith(prefix):
continue
files.append(line)
return files
def read_file_on_branch(sandbox: Path, file: str,
branch: str = "main") -> str:
"""Read a file's content at ``branch`` HEAD."""
try:
return _git(sandbox, "show", f"{branch}:{file}")
except Exception:
return ""