mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Smoke run 3 fired the same workspace.py warning ~9x per run: 'ensure_workspace: refresh fetch returned non-zero' stderr: 'fatal: could not read Username for https://github.com' This is EXPECTED behavior, not a bug. The docstring on _fetch_origin_best_effort explains that credentials are deliberately scrubbed from .git/config after the initial clone (part of the secret- exfiltration mitigation) and refresh fetches are best-effort. For private repos the auth-fail is the documented outcome. The original A4 spec proposed re-injecting the PAT -- that would have violated _assert_no_pat_leak and the URL-scrub mitigation. Re-scoped to: silence the known-benign signature at DEBUG, keep WARNING for genuine failures (network errors, broken remotes, repo-not-found). No behavior change. No security boundary touched. Just log level. Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md A4 (re-scoped 2026-05-12 after investigation showed the original spec proposed reintroducing a documented security regression).
852 lines
32 KiB
Python
852 lines
32 KiB
Python
"""
|
|
Workspace Service
|
|
|
|
Manages multi-agent workspaces for git operations.
|
|
|
|
Each agent gets their own workspace (git clone) for a project, allowing
|
|
parallel development without conflicts:
|
|
|
|
/data/workspaces/
|
|
└── {project-slug}/
|
|
└── {team}/
|
|
└── {agent-slug}/
|
|
└── [git repo files]
|
|
|
|
Example:
|
|
/data/workspaces/roboco/backend/be-dev-1/
|
|
/data/workspaces/roboco/backend/be-dev-2/
|
|
/data/workspaces/roboco/frontend/fe-dev-1/
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from roboco.config import settings
|
|
from roboco.db.tables import AgentTable
|
|
from roboco.logging import get_logger
|
|
from roboco.models.base import Team
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Agent container runs the `agent` user created in agent-base.Dockerfile.
|
|
# Debian's `useradd -m` defaults to uid 1000 when that uid is free.
|
|
# Overridable via env so operators can customize if they rebuild agent-base
|
|
# with a different id.
|
|
_AGENT_UID = int(os.environ.get("ROBOCO_AGENT_UID", "1000"))
|
|
_AGENT_GID = int(os.environ.get("ROBOCO_AGENT_GID", "1000"))
|
|
|
|
|
|
def _chown_entry(entry: str) -> bool:
|
|
"""Chown a single entry; return True on success (or already correct)."""
|
|
try:
|
|
st = Path(entry).stat()
|
|
if st.st_uid != _AGENT_UID or st.st_gid != _AGENT_GID:
|
|
os.chown(entry, _AGENT_UID, _AGENT_GID)
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _make_owner_and_group_rw(entry: str) -> None:
|
|
"""Best-effort chmod ensuring owner+group have rw (+x for dirs).
|
|
|
|
NAS volumes with POSIX ACL inheritance can land cloned files with
|
|
owner=0 (e.g. `.git/config` arriving as `----rw----`). POSIX permission
|
|
rules check the OWNER bits when the caller IS the owner — group bits
|
|
only apply to non-owners — so an agent-owned file with empty owner
|
|
perms is unreadable to the agent even though group has rw. We must
|
|
set owner perms explicitly. chmod always respects the caller's
|
|
capabilities; if chown failed earlier (we're not root), we still
|
|
can't chmod files we don't own, so this is best-effort by design.
|
|
"""
|
|
import stat as _stat
|
|
|
|
try:
|
|
st = Path(entry).stat()
|
|
new_mode = (
|
|
st.st_mode | _stat.S_IRUSR | _stat.S_IWUSR | _stat.S_IRGRP | _stat.S_IWGRP
|
|
)
|
|
if _stat.S_ISDIR(st.st_mode):
|
|
new_mode |= _stat.S_IXUSR | _stat.S_IXGRP
|
|
if new_mode != st.st_mode:
|
|
Path(entry).chmod(new_mode)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _ensure_agent_owned(workspace: Path) -> None:
|
|
"""Recursively chown + group-write a workspace for the agent user.
|
|
|
|
Orchestrator runs as root so anything it clones or writes is root-owned.
|
|
Agent containers run as uid 1000 and must be able to create
|
|
.git/index.lock, refs, packed-refs, and new source files — otherwise
|
|
every git operation (and even plain file writes) fails with
|
|
"Permission denied". Called after clone and on every ensure_workspace
|
|
so legacy (pre-fix) workspaces get repaired.
|
|
|
|
Two defenses (both cheap, both idempotent):
|
|
1. chown every entry to (AGENT_UID, AGENT_GID). On setups where user
|
|
namespaces silently remap or reject the chown (some NAS / rootless
|
|
docker configs), we log the failure instead of swallowing it — so
|
|
when writes still fail from the agent, we can actually see why.
|
|
2. chmod g+w on every file/dir. If chown doesn't take effect, having
|
|
the group writable (and with AGENT_GID) is enough for uid 1000 to
|
|
write, provided agent is in that group. Belt + suspenders.
|
|
|
|
The previous fast-path (skip walk if top-level stat already matches)
|
|
was unsafe: a root-owned file deep under an agent-owned top dir would
|
|
not get repaired, which is exactly how README.md ends up root:root
|
|
even after ensure_workspace runs.
|
|
"""
|
|
failed_chowns = 0
|
|
for root, dirs, files in os.walk(workspace):
|
|
entries = (
|
|
root,
|
|
*[str(Path(root) / d) for d in dirs],
|
|
*[str(Path(root) / f) for f in files],
|
|
)
|
|
for entry in entries:
|
|
if not _chown_entry(entry):
|
|
failed_chowns += 1
|
|
_make_owner_and_group_rw(entry)
|
|
|
|
if failed_chowns:
|
|
logger.warning(
|
|
"Some chowns failed during ensure_agent_owned — "
|
|
"agent writes may still fail. Check docker user-namespace "
|
|
"config or run agents as root on this host.",
|
|
workspace=str(workspace),
|
|
failures=failed_chowns,
|
|
)
|
|
|
|
|
|
# Per (project_slug, agent_slug) async lock to serialize concurrent
|
|
# ensure_workspace calls in the same orchestrator process. Prevents two
|
|
# coroutines from both passing the ".git exists?" check and then both
|
|
# trying to clone into the same directory.
|
|
_ENSURE_WORKSPACE_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
|
|
|
|
|
|
def _ensure_lock_for(project_slug: str, agent_slug: str) -> asyncio.Lock:
|
|
"""Return the asyncio.Lock for a (project, agent) pair, creating lazily."""
|
|
key = (project_slug, agent_slug)
|
|
lock = _ENSURE_WORKSPACE_LOCKS.get(key)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
_ENSURE_WORKSPACE_LOCKS[key] = lock
|
|
return lock
|
|
|
|
|
|
def _inject_token_into_url(git_url: str, token: str | None) -> str:
|
|
"""
|
|
Inject GitHub PAT into HTTPS git URL for authentication.
|
|
|
|
Args:
|
|
git_url: Original git URL (SSH or HTTPS)
|
|
token: GitHub PAT (if None, returns original URL)
|
|
|
|
Returns:
|
|
URL with embedded token for HTTPS, or original URL for SSH
|
|
|
|
Example:
|
|
https://github.com/org/repo.git -> https://TOKEN@github.com/org/repo.git
|
|
"""
|
|
if not token:
|
|
return git_url
|
|
|
|
# Only inject for HTTPS URLs
|
|
if not git_url.startswith("https://"):
|
|
return git_url
|
|
|
|
# Check if token already present
|
|
if "@" in git_url.split("//")[1].split("/", maxsplit=1)[0]:
|
|
return git_url
|
|
|
|
# Inject token: https://github.com -> https://TOKEN@github.com
|
|
return re.sub(r"^https://", f"https://{token}@", git_url)
|
|
|
|
|
|
class WorkspaceError(Exception):
|
|
"""Raised when workspace operations fail."""
|
|
|
|
pass
|
|
|
|
|
|
class WorkspaceService:
|
|
"""
|
|
Service for managing agent workspaces.
|
|
|
|
Workspaces follow the structure:
|
|
{workspaces_root}/{project_slug}/{team}/{agent_slug}/
|
|
|
|
This allows:
|
|
- Multiple agents to work on the same project in parallel
|
|
- Each agent has their own git working tree
|
|
- Agents can be on different branches simultaneously
|
|
- No file locking conflicts between agents
|
|
"""
|
|
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
self.root = Path(settings.workspaces_root)
|
|
|
|
def get_workspace_path(
|
|
self,
|
|
project_slug: str,
|
|
team: Team | str,
|
|
agent_slug: str,
|
|
) -> Path:
|
|
"""
|
|
Compute the workspace path for an agent on a project.
|
|
|
|
Args:
|
|
project_slug: Project identifier (e.g., 'roboco')
|
|
team: Agent's team (e.g., Team.BACKEND or 'backend')
|
|
agent_slug: Agent identifier (e.g., 'be-dev-1')
|
|
|
|
Returns:
|
|
Path to the workspace directory
|
|
|
|
Raises:
|
|
WorkspaceError: If team is None (would produce a literal
|
|
"None" segment otherwise — see agents_config.AGENT_TEAM_MAP
|
|
for the canonical team for each agent).
|
|
|
|
Example:
|
|
>>> get_workspace_path('roboco', Team.BACKEND, 'be-dev-1')
|
|
Path('/data/workspaces/roboco/backend/be-dev-1')
|
|
"""
|
|
if team is None:
|
|
raise WorkspaceError(
|
|
f"Cannot resolve workspace path for {agent_slug}: team is None. "
|
|
"Add the agent to AGENT_TEAM_MAP in roboco/agents_config.py."
|
|
)
|
|
team_str = team.value if isinstance(team, Team) else str(team)
|
|
return self.root / project_slug / team_str / agent_slug
|
|
|
|
async def resolve_workspace(
|
|
self,
|
|
project_slug: str,
|
|
agent_id: UUID | str,
|
|
) -> Path:
|
|
"""
|
|
Resolve workspace path from project slug and agent ID.
|
|
|
|
Looks up the agent to get team and slug, then computes path.
|
|
|
|
Args:
|
|
project_slug: Project identifier
|
|
agent_id: Agent UUID or slug
|
|
|
|
Returns:
|
|
Path to the workspace directory
|
|
|
|
Raises:
|
|
WorkspaceError: If agent not found
|
|
"""
|
|
from sqlalchemy import select
|
|
|
|
# Look up agent
|
|
agent_id_str = str(agent_id)
|
|
|
|
# Try by UUID first, then by slug
|
|
query = select(AgentTable)
|
|
try:
|
|
agent_uuid = UUID(agent_id_str)
|
|
query = query.where(AgentTable.id == agent_uuid)
|
|
except ValueError:
|
|
query = query.where(AgentTable.slug == agent_id_str)
|
|
|
|
result = await self.session.execute(query)
|
|
agent = result.scalar_one_or_none()
|
|
|
|
if not agent:
|
|
raise WorkspaceError(f"Agent not found: {agent_id}")
|
|
|
|
team = agent.team if agent.team else Team.BACKEND
|
|
return self.get_workspace_path(project_slug, team, agent.slug)
|
|
|
|
async def _lookup_agent_or_raise(self, agent_id: UUID | str) -> AgentTable:
|
|
"""Find an agent by UUID or slug; raise WorkspaceError if missing."""
|
|
from sqlalchemy import select
|
|
|
|
agent_id_str = str(agent_id)
|
|
query = select(AgentTable)
|
|
try:
|
|
agent_uuid = UUID(agent_id_str)
|
|
query = query.where(AgentTable.id == agent_uuid)
|
|
except ValueError:
|
|
query = query.where(AgentTable.slug == agent_id_str)
|
|
|
|
result = await self.session.execute(query)
|
|
agent = result.scalar_one_or_none()
|
|
if not agent:
|
|
raise WorkspaceError(f"Agent not found: {agent_id}")
|
|
return agent
|
|
|
|
@staticmethod
|
|
def _is_workspace_healthy(workspace: Path) -> bool:
|
|
"""`.git` exists and has HEAD + objects (not a stub clone)."""
|
|
git_dir = workspace / ".git"
|
|
return (
|
|
git_dir.exists()
|
|
and (git_dir / "HEAD").exists()
|
|
and (git_dir / "objects").exists()
|
|
)
|
|
|
|
@staticmethod
|
|
async def _fetch_origin_best_effort(workspace: Path, project_slug: str) -> None:
|
|
"""Refresh `origin`'s refs into a healthy clone. Never raises.
|
|
|
|
Called from `ensure_workspace`'s healthy short-circuit so that a
|
|
respawned PM/Doc reads fresh `origin/<branch>` refs instead of
|
|
whatever the previous spawn left on disk. We deliberately omit a
|
|
positional refspec — `git fetch origin` (no args after `origin`)
|
|
updates every branch under `refs/remotes/origin/`, which is what
|
|
downstream `git diff origin/<branch>` and `git log origin/<branch>`
|
|
readers want.
|
|
|
|
No `-c http.extraheader=…` token injection: the orchestrator did
|
|
the original clone with a token but `_configure_git()` already
|
|
scrubbed it from `.git/config`, and the *fetch* path here runs
|
|
from inside the orchestrator container against the credential-
|
|
stripped remote URL. Public repos and refresh-only fetches succeed
|
|
without auth; auth-protected refreshes will surface their stderr
|
|
in the warning log without aborting workspace setup.
|
|
|
|
Timeout uses `workspace_refresh_fetch_timeout_seconds` (default
|
|
60s), NOT `workspace_clone_timeout` (300s) — a refresh transfers
|
|
small deltas, so 300s of blocking on every spawn against a hung
|
|
remote is operationally bad.
|
|
"""
|
|
|
|
def _do_fetch() -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["git", "fetch", "origin"],
|
|
cwd=str(workspace),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=settings.workspace_refresh_fetch_timeout_seconds,
|
|
check=False,
|
|
)
|
|
|
|
try:
|
|
result = await asyncio.to_thread(_do_fetch)
|
|
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
logger.warning(
|
|
"ensure_workspace: refresh fetch failed",
|
|
workspace=str(workspace),
|
|
project=project_slug,
|
|
error=str(exc),
|
|
)
|
|
return
|
|
|
|
if result.returncode != 0:
|
|
stderr = result.stderr.strip()
|
|
# The credential-less refresh fetch is expected to fail for private
|
|
# repos — see this method's docstring. Downgrade the known-benign
|
|
# auth-failure signature to DEBUG so it doesn't pollute every
|
|
# monitor / log scrape during a smoke run. Genuine failures
|
|
# (network errors, broken remotes) still surface at WARNING.
|
|
is_expected_auth_fail = (
|
|
"could not read Username" in stderr
|
|
or "Authentication failed" in stderr
|
|
or "remote: Repository not found" in stderr
|
|
)
|
|
log = logger.debug if is_expected_auth_fail else logger.warning
|
|
log(
|
|
"ensure_workspace: refresh fetch returned non-zero",
|
|
workspace=str(workspace),
|
|
project=project_slug,
|
|
stderr=stderr,
|
|
expected_auth_fail=is_expected_auth_fail,
|
|
)
|
|
|
|
@staticmethod
|
|
async def _resolve_git_token(
|
|
project_service: Any, project_slug: str, git_url: str
|
|
) -> str | None:
|
|
"""Decrypt the project's git token; raise WorkspaceError on failure."""
|
|
from roboco.utils.crypto import EncryptionError
|
|
|
|
try:
|
|
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
|
|
except EncryptionError as e:
|
|
raise WorkspaceError(
|
|
f"Failed to decrypt git token for project '{project_slug}'. "
|
|
"The ROBOCO_ENCRYPTION_KEY may have been rotated or the "
|
|
"stored token is corrupted. Re-set the project token."
|
|
) from e
|
|
|
|
if git_url.startswith("https://") and not git_token:
|
|
raise WorkspaceError(
|
|
f"Project '{project_slug}' requires a git token for HTTPS clone. "
|
|
"Configure a GitHub PAT in the project settings."
|
|
)
|
|
return cast("str | None", git_token)
|
|
|
|
async def ensure_workspace(
|
|
self,
|
|
project_slug: str,
|
|
agent_id: UUID | str,
|
|
git_url: str | None = None,
|
|
default_branch: str = "main",
|
|
) -> Path:
|
|
"""
|
|
Ensure workspace exists, cloning if necessary.
|
|
|
|
Protects against:
|
|
- Partial clones (directory exists but `.git` does not) — cleans up
|
|
the incomplete directory before re-cloning.
|
|
- Concurrent callers — per (project, agent) asyncio.Lock serializes
|
|
ensure_workspace calls so two coroutines can't both try to clone
|
|
into the same directory.
|
|
|
|
Args:
|
|
project_slug: Project identifier
|
|
agent_id: Agent UUID or slug
|
|
git_url: Git URL to clone (fetched from project if not provided)
|
|
default_branch: Default branch to checkout
|
|
|
|
Returns:
|
|
Path to the workspace directory
|
|
|
|
Raises:
|
|
WorkspaceError: If workspace creation fails
|
|
"""
|
|
from roboco.services.project import get_project_service
|
|
|
|
agent = await self._lookup_agent_or_raise(agent_id)
|
|
team = agent.team if agent.team else Team.BACKEND
|
|
workspace = self.get_workspace_path(project_slug, team, agent.slug)
|
|
|
|
lock = _ensure_lock_for(project_slug, agent.slug)
|
|
async with lock:
|
|
# Healthy clone — nothing to do except make sure it's still
|
|
# owned by the agent user. Orchestrator restarts or older
|
|
# clones (pre-ownership-fix) may leave root-owned trees that
|
|
# break every subsequent write from inside the agent container.
|
|
#
|
|
# `.git` existing is necessary but NOT sufficient — a failed
|
|
# clone can leave behind a stub .git/ with only FETCH_HEAD and
|
|
# no HEAD/objects, which looks "healthy" to a naive check but
|
|
# breaks every subsequent fetch/checkout ("origin/<branch> is
|
|
# not a commit"). Require HEAD + objects/ as the real signal.
|
|
if self._is_workspace_healthy(workspace):
|
|
await asyncio.to_thread(_ensure_agent_owned, workspace)
|
|
# Audit H26: a healthy clone short-circuit USED to return
|
|
# immediately, so a respawned PM/Doc could be reading
|
|
# arbitrarily stale refs (whatever was on disk from the
|
|
# last spawn). Fetch every entry so `git diff origin/...`
|
|
# reflects what's actually on the remote. Best-effort —
|
|
# network blips and offline mode must not break workspace
|
|
# setup; checkout is unchanged.
|
|
await self._fetch_origin_best_effort(workspace, project_slug)
|
|
# Re-chown so the agent user can still write into .git
|
|
# after our root-side fetch updated refs/objects. Mirrors
|
|
# the pattern in `fetch_branch_for_inspection` — without
|
|
# this, new pack files under .git/objects/pack/ and ref
|
|
# updates under .git/refs/remotes/origin/ land root-owned
|
|
# and undo the chown we just ran above.
|
|
await asyncio.to_thread(_ensure_agent_owned, workspace)
|
|
logger.debug(
|
|
"Workspace already exists",
|
|
workspace=str(workspace),
|
|
project=project_slug,
|
|
)
|
|
return workspace
|
|
|
|
# Partial clone: directory exists but `.git` is missing or
|
|
# a stub. git clone refuses to clone into a non-empty
|
|
# directory, so remove it first instead of letting the next
|
|
# clone fail.
|
|
if workspace.exists():
|
|
logger.warning(
|
|
"Removing partial/stub workspace before re-clone",
|
|
workspace=str(workspace),
|
|
project=project_slug,
|
|
had_git_dir=(workspace / ".git").exists(),
|
|
)
|
|
shutil.rmtree(workspace)
|
|
|
|
project_service = get_project_service(self.session)
|
|
project = await project_service.get_by_slug(project_slug)
|
|
if not project:
|
|
raise WorkspaceError(f"Project not found: {project_slug}")
|
|
|
|
if not git_url:
|
|
git_url = project.git_url
|
|
default_branch = project.default_branch or default_branch
|
|
|
|
git_token = await self._resolve_git_token(
|
|
project_service, project_slug, git_url
|
|
)
|
|
|
|
await self._clone_repo(
|
|
workspace,
|
|
git_url,
|
|
default_branch,
|
|
git_token,
|
|
agent=agent,
|
|
)
|
|
return workspace
|
|
|
|
async def _clone_repo(
|
|
self,
|
|
workspace: Path,
|
|
git_url: str,
|
|
default_branch: str,
|
|
git_token: str | None = None,
|
|
agent: AgentTable | None = None,
|
|
) -> None:
|
|
"""
|
|
Clone a git repository to the workspace.
|
|
|
|
Args:
|
|
workspace: Target directory
|
|
git_url: Git URL to clone
|
|
default_branch: Branch to checkout
|
|
git_token: GitHub PAT for authentication (per-project)
|
|
agent: Agent for git identity (name/email in commits)
|
|
|
|
Raises:
|
|
WorkspaceError: If clone fails
|
|
"""
|
|
# Create parent directories
|
|
workspace.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Inject project-specific token for HTTPS URLs
|
|
auth_url = _inject_token_into_url(git_url, git_token)
|
|
|
|
# Log without exposing token
|
|
logger.info(
|
|
"Cloning repository",
|
|
workspace=str(workspace),
|
|
git_url=git_url, # Log original URL, not auth URL
|
|
branch=default_branch,
|
|
using_token=bool(git_token and auth_url != git_url),
|
|
)
|
|
|
|
def _do_clone() -> subprocess.CompletedProcess[str]:
|
|
# Do NOT pass --single-branch. Agents work on feature branches
|
|
# pushed by peers; QA and documenter need to `git fetch` those
|
|
# branches after a dev pushes. --single-branch locks the remote
|
|
# refspec to `+refs/heads/{default_branch}:refs/remotes/origin/
|
|
# {default_branch}`, so subsequent fetches silently ignore every
|
|
# other branch. The symptom is QA doing `checkout origin/
|
|
# feature/...` and seeing "not a commit" even though the branch
|
|
# is on GitHub. --no-tags keeps the clone light.
|
|
return subprocess.run(
|
|
[
|
|
"git",
|
|
"clone",
|
|
"--branch",
|
|
default_branch,
|
|
"--no-tags",
|
|
auth_url,
|
|
str(workspace),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=settings.workspace_clone_timeout,
|
|
check=True,
|
|
)
|
|
|
|
def _configure_git() -> None:
|
|
"""Configure git author info + scrub embedded PAT from remote URL.
|
|
|
|
The clone URL carries the PAT for authentication (`https://TOKEN@
|
|
github.com/...`), which `git clone` then writes into
|
|
`.git/config`. Leaving it there lets anyone with read access to
|
|
the workspace — including the agent inside its container — read
|
|
the token and exfiltrate or use it directly against GitHub,
|
|
bypassing the orchestrator's git service.
|
|
|
|
We keep push/fetch working by letting the orchestrator inject
|
|
the token just-in-time at the subprocess level (`-c
|
|
http.extraheader='Authorization: bearer TOKEN'`) when it needs
|
|
to hit origin; see GitService.
|
|
"""
|
|
name = agent.name if agent else "RoboCo Agent"
|
|
slug = agent.slug if agent else "agent"
|
|
|
|
subprocess.run(
|
|
["git", "config", "user.name", name],
|
|
cwd=str(workspace),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.email", f"{slug}@agents.roboco.dev"],
|
|
cwd=str(workspace),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
# Disable filesystem mode tracking. The workspace volumes live
|
|
# on the NAS (`/volume1/...`), which has POSIX ACL inheritance
|
|
# that gives every cloned file the executable bit. With the
|
|
# default `core.fileMode = true`, git treats every tracked
|
|
# file as modified the moment it's cloned, and `task_start`'s
|
|
# clean-tree check refuses to checkout the feature branch.
|
|
subprocess.run(
|
|
["git", "config", "core.fileMode", "false"],
|
|
cwd=str(workspace),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
# Scrub embedded credentials from the remote URL.
|
|
if git_token and auth_url != git_url:
|
|
subprocess.run(
|
|
["git", "remote", "set-url", "origin", git_url],
|
|
cwd=str(workspace),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
def _assert_no_pat_leak() -> None:
|
|
"""Fail-fast if a PAT ended up anywhere under .git/ on disk.
|
|
|
|
Belt-and-suspenders: if the scrub above ever regresses (e.g. a
|
|
refactor skips `remote set-url`, or git starts writing the auth
|
|
URL to a new file), this catches it before the agent container
|
|
gets mounted on the workspace. The whole workspace is removed
|
|
on failure — a leaked workspace is unrecoverable.
|
|
"""
|
|
git_dir = workspace / ".git"
|
|
if not git_dir.exists():
|
|
return
|
|
leaked_in: list[Path] = []
|
|
for path in git_dir.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
try:
|
|
data = path.read_bytes()
|
|
except OSError:
|
|
continue
|
|
# Token shapes: classic (ghp_…), fine-grained (github_pat_…),
|
|
# x-access-token URL pattern. Checking bytes avoids UTF-8
|
|
# decode errors on pack files / binary blobs.
|
|
if (
|
|
b"ghp_" in data
|
|
or b"github_pat_" in data
|
|
or b"x-access-token:" in data
|
|
):
|
|
leaked_in.append(path.relative_to(workspace))
|
|
if leaked_in:
|
|
shutil.rmtree(workspace, ignore_errors=True)
|
|
raise WorkspaceError(
|
|
f"PAT leak detected under .git/ after clone: {leaked_in}. "
|
|
"Workspace destroyed. Check _configure_git() scrub step."
|
|
)
|
|
|
|
try:
|
|
await asyncio.to_thread(_do_clone)
|
|
await asyncio.to_thread(_configure_git)
|
|
await asyncio.to_thread(_assert_no_pat_leak)
|
|
# Transfer ownership to the agent user so the agent can write
|
|
# into .git/ and the working tree from inside its container.
|
|
await asyncio.to_thread(_ensure_agent_owned, workspace)
|
|
logger.info(
|
|
"Repository cloned successfully",
|
|
workspace=str(workspace),
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
raise WorkspaceError(
|
|
f"Failed to clone repository: {e.stderr or e.stdout}"
|
|
) from e
|
|
except subprocess.TimeoutExpired as e:
|
|
raise WorkspaceError(
|
|
f"Clone timed out after {settings.workspace_clone_timeout}s"
|
|
) from e
|
|
|
|
async def workspace_exists(
|
|
self,
|
|
project_slug: str,
|
|
agent_id: UUID | str,
|
|
) -> bool:
|
|
"""Check if a workspace exists for the given project and agent."""
|
|
try:
|
|
workspace = await self.resolve_workspace(project_slug, agent_id)
|
|
return (workspace / ".git").exists()
|
|
except WorkspaceError:
|
|
return False
|
|
|
|
async def list_workspaces(self, project_slug: str) -> list[dict]:
|
|
"""
|
|
List all workspaces for a project.
|
|
|
|
Returns:
|
|
List of workspace info dicts with team, agent, and path
|
|
"""
|
|
project_dir = self.root / project_slug
|
|
if not project_dir.exists():
|
|
return []
|
|
|
|
workspaces = []
|
|
for team_dir in project_dir.iterdir():
|
|
if not team_dir.is_dir():
|
|
continue
|
|
for agent_dir in team_dir.iterdir():
|
|
if not agent_dir.is_dir():
|
|
continue
|
|
if (agent_dir / ".git").exists():
|
|
workspaces.append(
|
|
{
|
|
"team": team_dir.name,
|
|
"agent": agent_dir.name,
|
|
"path": str(agent_dir),
|
|
"exists": True,
|
|
}
|
|
)
|
|
return workspaces
|
|
|
|
# =========================================================================
|
|
# GATEWAY (CONTENT_ACTIONS) BACKFILL
|
|
# =========================================================================
|
|
|
|
async def _resolve_branch_to_project_slug(self, branch_name: str) -> str:
|
|
"""Look up the task that owns `branch_name` and return its project slug.
|
|
|
|
Raises WorkspaceError when no task references the branch or the
|
|
project record is missing — fetching a phantom branch would
|
|
silently no-op otherwise.
|
|
"""
|
|
from sqlalchemy import select
|
|
|
|
from roboco.db.tables import TaskTable
|
|
from roboco.services.project import get_project_service
|
|
|
|
result = await self.session.execute(
|
|
select(TaskTable).where(TaskTable.branch_name == branch_name).limit(1)
|
|
)
|
|
task = result.scalar_one_or_none()
|
|
if task is None:
|
|
raise WorkspaceError(f"No task references branch {branch_name!r}")
|
|
project_service = get_project_service(self.session)
|
|
project = await project_service.get(UUID(str(task.project_id)))
|
|
if project is None:
|
|
raise WorkspaceError(
|
|
f"Task {task.id} for branch {branch_name!r} has no project"
|
|
)
|
|
return str(project.slug)
|
|
|
|
async def fetch_branch_for_inspection(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
branch_name: str,
|
|
) -> Path:
|
|
"""Fetch `branch_name` into the inspecting agent's workspace.
|
|
|
|
QA / Documenter / PM agents need to read a developer's branch from
|
|
their own workspace before diffing. This adapter:
|
|
|
|
1. Resolves the project from the branch (via the owning task).
|
|
2. Ensures a healthy workspace for `agent_id` on that project
|
|
(clones if missing — same path as the agent's first claim).
|
|
3. Runs `git fetch origin <branch>` with the project token so the
|
|
branch ref is locally available for `git diff`.
|
|
|
|
Returns the workspace path so the caller can chain checkout/diff
|
|
operations if needed.
|
|
"""
|
|
from roboco.services.project import get_project_service
|
|
|
|
project_slug = await self._resolve_branch_to_project_slug(branch_name)
|
|
workspace = await self.ensure_workspace(
|
|
project_slug=project_slug,
|
|
agent_id=agent_id,
|
|
)
|
|
|
|
from roboco.utils.crypto import EncryptionError
|
|
|
|
project_service = get_project_service(self.session)
|
|
project = await project_service.get_by_slug(project_slug)
|
|
git_token: str | None = None
|
|
if project is not None:
|
|
try:
|
|
git_token = await project_service.get_decrypted_token_by_slug(
|
|
project_slug
|
|
)
|
|
except EncryptionError:
|
|
# Token-decrypt failure (rotated key / corrupted record) is
|
|
# non-fatal here: a public branch fetch still works without
|
|
# auth, and a real auth failure surfaces from git below.
|
|
git_token = None
|
|
|
|
prefix: list[str] = []
|
|
if git_token:
|
|
import base64
|
|
|
|
basic = base64.b64encode(f"x-access-token:{git_token}".encode()).decode()
|
|
prefix = ["-c", f"http.extraheader=Authorization: Basic {basic}"]
|
|
|
|
def _do_fetch() -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["git", *prefix, "fetch", "origin", branch_name],
|
|
cwd=str(workspace),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=settings.workspace_clone_timeout,
|
|
check=False,
|
|
)
|
|
|
|
result = await asyncio.to_thread(_do_fetch)
|
|
if result.returncode != 0:
|
|
logger.warning(
|
|
"fetch_branch_for_inspection: fetch returned non-zero",
|
|
branch=branch_name,
|
|
workspace=str(workspace),
|
|
stderr=result.stderr.strip(),
|
|
)
|
|
# Re-chown so the agent user can still write into .git after our
|
|
# root-side fetch updated refs/objects.
|
|
await asyncio.to_thread(_ensure_agent_owned, workspace)
|
|
return workspace
|
|
|
|
async def delete_workspace(
|
|
self,
|
|
project_slug: str,
|
|
agent_id: UUID | str,
|
|
) -> bool:
|
|
"""
|
|
Delete a workspace (use with caution).
|
|
|
|
Args:
|
|
project_slug: Project identifier
|
|
agent_id: Agent UUID or slug
|
|
|
|
Returns:
|
|
True if deleted, False if didn't exist
|
|
"""
|
|
import shutil
|
|
|
|
workspace = await self.resolve_workspace(project_slug, agent_id)
|
|
if not workspace.exists():
|
|
return False
|
|
|
|
logger.warning(
|
|
"Deleting workspace",
|
|
workspace=str(workspace),
|
|
)
|
|
|
|
def _do_delete() -> None:
|
|
shutil.rmtree(workspace)
|
|
|
|
await asyncio.to_thread(_do_delete)
|
|
return True
|
|
|
|
|
|
def get_workspace_service(session: AsyncSession) -> WorkspaceService:
|
|
"""Factory function to get workspace service."""
|
|
return WorkspaceService(session)
|