From 955455d704d3ac2c9a00357dd88f23573ddfa40a Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 8 Jan 2026 16:45:25 +0100 Subject: [PATCH] Git integration and Project workspace fixes --- CLAUDE.md | 27 +++ agents/prompts/roles/cell_pm.md | 29 ++- agents/prompts/roles/main_pm.md | 14 +- alembic/versions/001_initial_schema.py | 2 + docker-compose.yaml | 260 ++++++++++++++++++++++++ docker-compose.yml | 3 + docs/rag/architecture/workspaces.md | 11 + docs/rag/roles/main-pm.md | 12 +- docs/rag/tools/project-tools.md | 12 +- docs/rag/troubleshooting/git-errors.md | 16 ++ docs/rag/workflows/pr-creation.md | 6 + roboco/api/routes/git.py | 32 ++- roboco/api/routes/project.py | 9 +- roboco/api/routes/tasks.py | 17 ++ roboco/api/schemas/project.py | 25 ++- roboco/config.py | 4 + roboco/db/tables.py | 3 + roboco/mcp/project_server.py | 11 +- roboco/mcp/tasks/handlers/claim.py | 81 +++++++- roboco/mcp/tasks/handlers/management.py | 12 ++ roboco/mcp/tasks/handlers/work.py | 8 +- roboco/models/project.py | 19 ++ roboco/runtime/orchestrator.py | 5 + roboco/services/git.py | 67 +++++- roboco/services/project.py | 105 +++++++++- roboco/services/workspace.py | 121 +++++++++-- roboco/utils/__init__.py | 10 + roboco/utils/crypto.py | 110 ++++++++++ 28 files changed, 976 insertions(+), 55 deletions(-) create mode 100644 docker-compose.yaml create mode 100644 roboco/utils/crypto.py diff --git a/CLAUDE.md b/CLAUDE.md index 8b3e6346..c35f102b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,29 @@ When a developer claims a git-enabled task, a **WorkSession** is created that tr - PR number/URL when created - Merge status and who merged +### Git Credentials + +Git authentication is managed **per-project** through encrypted GitHub PATs: + +- **Each project stores its own git token** - no global fallback +- **Tokens are encrypted at rest** using Fernet symmetric encryption +- **API never exposes tokens** - only returns `has_git_token: boolean` +- **Self-service via UI** - users set/update tokens in project settings + +**Project fields:** +| Field | Description | +|-------|-------------| +| `git_token_encrypted` | Fernet-encrypted GitHub PAT (DB column) | +| `has_git_token` | Boolean indicator for API responses | + +**Token flow:** +1. User creates project in UI, enters GitHub PAT +2. Token encrypted and stored in `projects.git_token_encrypted` +3. WorkspaceService decrypts token when cloning repos +4. GitService decrypts token for PR operations (gh CLI) + +**HTTPS URLs require tokens** - attempting to clone without a token will raise `WorkspaceError`. + ## Task Lifecycle ### Task States @@ -320,6 +343,10 @@ ROBOCO_DATABASE_NAME=roboco ROBOCO_REDIS_HOST=localhost ROBOCO_REDIS_PORT=6379 +# Security (REQUIRED) +# Generate with: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())' +ROBOCO_ENCRYPTION_KEY= + # Workspaces ROBOCO_WORKSPACES_ROOT=/data/workspaces ROBOCO_WORKSPACE_AUTO_CLONE=true diff --git a/agents/prompts/roles/cell_pm.md b/agents/prompts/roles/cell_pm.md index 0d51d953..6dd79a89 100644 --- a/agents/prompts/roles/cell_pm.md +++ b/agents/prompts/roles/cell_pm.md @@ -63,15 +63,34 @@ roboco_git_create_branch(project_slug, task_id, branch_type, parent_branch) ``` - **branch_type**: `feature`, `bug`, `chore`, `docs`, `hotfix` -- **parent_branch**: Parent task's branch (or `main` for top-level) +- **parent_branch**: Parent task's branch (or project's default branch for top-level) - Branch naming: `{type}/{team}/{task_id}` (auto-generated) **Example:** ``` -roboco_git_create_branch("roboco", "abc123", "feature", "main") +roboco_git_create_branch("roboco", "abc123", "feature") # Creates: feature/backend/abc123 ``` +**Branch Hierarchy (when using hierarchical branching):** +Branches are typically created top-down: +1. Main PM creates root branch from default branch +2. You create subtask branch from root branch +3. Dev works on subsubtask branch from your branch + +**When creating a subtask branch:** +- Check if parent task has a branch set +- If so, use parent's branch as `parent_branch` parameter +- If branching from default branch directly, that works too + +**Branch hierarchy example:** +``` +default (main/master/etc) + └─ feature/backend/ROOT123 ← Main PM's branch + └─ feature/backend/ROOT123/SUB456 ← Your branch + └─ feature/backend/ROOT123/SUB456/DEV789 ← Dev branch +``` + ### 6. ACTIVATE `roboco_task_activate()` moves backlog → pending. Now visible to devs. @@ -175,6 +194,12 @@ When ALL subtasks done: reflect + complete your task. 6. **Pause after delegating** - Don't spin waiting 7. **Reflect before complete** - `roboco_journal_reflect()` required +**Task Delegation Options:** +- `roboco_task_assign()` - Reassign an existing task to a different agent +- `roboco_task_create(parent_task_id=...)` - Create a subtask under your task + +For coordination tasks where you're managing work, subtasks are often cleaner for tracking. + ## CEO Escalation For major tasks, escalate to CEO instead of completing directly: diff --git a/agents/prompts/roles/main_pm.md b/agents/prompts/roles/main_pm.md index a717a587..ad5d05e6 100644 --- a/agents/prompts/roles/main_pm.md +++ b/agents/prompts/roles/main_pm.md @@ -30,13 +30,23 @@ Claim → read full description → plan breakdown across cells → start → jo ### 3. CREATE PARENT BRANCH (Git Tasks) **For tasks with `requires_git=True`:** ``` -roboco_git_create_branch(project_slug, task_id, branch_type, "main") +roboco_git_create_branch(project_slug, task_id, branch_type) ``` -- Creates parent branch from `main` +- Creates parent branch from the project's default branch (e.g., `main`, `master`) - Cell PM subtask branches will fork from this - Example: `feature/cross/abc123` for cross-cell work +**Branch Hierarchy for Git Tasks:** +- For hierarchical branching: default branch → Your branch → Cell PM branch → Dev branch +- If using hierarchical branches, create root branch before Cell PMs create theirs +- Cell PM branches fork from your branch (set as `parent_branch`) + +**Typical order for hierarchical branching:** +1. Create your root branch from project's default branch +2. Create cell tasks, Cell PMs create branches from your branch +3. Devs create branches from Cell PM branches + ### 4. CREATE GROUP Use `roboco_group_create()` in each relevant cell channel. Cell PMs need groups to create sessions. diff --git a/alembic/versions/001_initial_schema.py b/alembic/versions/001_initial_schema.py index 0b78b8a8..c5a7303a 100644 --- a/alembic/versions/001_initial_schema.py +++ b/alembic/versions/001_initial_schema.py @@ -105,6 +105,8 @@ def upgrade() -> None: sa.Column( "protected_branches", postgresql.ARRAY(sa.String()), server_default="{}" ), + # Git Authentication (encrypted) + sa.Column("git_token_encrypted", sa.Text(), nullable=True), # CI/CD Commands (optional) sa.Column("test_command", sa.String(500), nullable=True), sa.Column("lint_command", sa.String(500), nullable=True), diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..2e31b3e7 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,260 @@ +services: + # ========================================================================== + # PostgreSQL - Primary Database with pgvector for RAG + # ========================================================================== + postgres: + image: pgvector/pgvector:pg16 + container_name: roboco-postgres + restart: unless-stopped + environment: + POSTGRES_USER: roboco + POSTGRES_PASSWORD: roboco + POSTGRES_DB: roboco + ports: + - "15432:5432" + volumes: + - ${ROBOCO_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"] + interval: 10s + timeout: 5s + retries: 5 + + # ========================================================================== + # Redis - Cache, Sessions, Event Bus + # ========================================================================== + redis: + image: redis:8-alpine + container_name: roboco-redis + restart: unless-stopped + command: redis-server --appendonly yes + ports: + - "16379:6379" + volumes: + - ${ROBOCO_DATA_DIR:-./data}/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # ========================================================================== + # Ollama - Local LLM and Embedding Server + # ========================================================================== + ollama: + image: ollama/ollama:latest + container_name: roboco-ollama + restart: unless-stopped + environment: + OLLAMA_API_KEY: ${OLLAMA_API_KEY} + ports: + - "11435:11434" + volumes: + - ${ROBOCO_DATA_DIR:-./data}/ollama:/root/.ollama + healthcheck: + # Use ollama CLI (guaranteed available) to check if server is responding + test: ["CMD", "ollama", "list"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # Ollama model puller - pulls required models on startup + # Uses streaming curl to wait for full model download + ollama-init: + image: curlimages/curl:latest + container_name: roboco-ollama-init + depends_on: + ollama: + condition: service_healthy + restart: "no" + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + echo "=== Pulling embedding model (embeddinggemma:300m) ===" + # Ollama /api/pull streams JSON lines until complete - consume full stream + # Note: $$ escapes $ for docker-compose variable substitution + curl -sN http://ollama:11434/api/pull -d '{"name":"embeddinggemma:300m"}' | while read -r line; do + status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) + [ -n "$$status" ] && echo " $$status" + done + echo "=== Pulling LLM model (glm-4.7:cloud) ===" + curl -sN http://ollama:11434/api/pull -d '{"name":"glm-4.7:cloud"}' | while read -r line; do + status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) + [ -n "$$status" ] && echo " $$status" + done + echo "=== Verifying models are available ===" + curl -sf http://ollama:11434/api/tags | grep -q "embeddinggemma" && echo " embeddinggemma: OK" + curl -sf http://ollama:11434/api/tags | grep -q "glm-4.7" && echo " glm-4.7: OK" + echo "=== All models ready! ===" + + # ========================================================================== + # Agent Base Image Builder (specialized images built on-demand by orchestrator) + # ========================================================================== + agent-base-image: + build: + context: . + dockerfile: docker/agent-base.Dockerfile + image: roboco-agent-base + container_name: roboco-agent-base-builder + entrypoint: ["/bin/sh", "-c", "echo 'Agent base image built successfully'"] + restart: "no" + + # ========================================================================== + # Agent PM Image Builder (specialized image built on-demand by orchestrator) + # ========================================================================== + agent-pm-image: + build: + context: . + dockerfile: docker/agent-pm.Dockerfile + image: roboco-agent-pm + entrypoint: ["/bin/sh", "-c", "echo 'Agent PM image built'"] + restart: "no" + depends_on: + - agent-base-image + + # ========================================================================== + # Agent Backend Dev Image Builder (specialized image built on-demand by orchestrator) + # ========================================================================== + agent-dev-be-image: + build: + context: . + dockerfile: docker/agent-dev-be.Dockerfile + image: roboco-agent-dev-be + entrypoint: ["/bin/sh", "-c", "echo 'Agent Backend Dev image built'"] + restart: "no" + depends_on: + - agent-base-image + + # ========================================================================== + # Agent Frontend Dev Image Builder (specialized image built on-demand by orchestrator) + # ========================================================================== + agent-dev-fe-image: + build: + context: . + dockerfile: docker/agent-dev-fe.Dockerfile + image: roboco-agent-dev-fe + entrypoint: ["/bin/sh", "-c", "echo 'Agent Frontend Dev image built'"] + restart: "no" + depends_on: + - agent-base-image + + # ========================================================================== + # Agent Backend QA Image Builder (specialized image built on-demand by orchestrator) + # ========================================================================== + agent-qa-be-image: + build: + context: . + dockerfile: docker/agent-qa-be.Dockerfile + image: roboco-agent-qa-be + entrypoint: ["/bin/sh", "-c", 'echo "Agent Backend QA image built"'] + restart: "no" + depends_on: + - agent-base-image + + # ========================================================================== + # Agent Frontend QA Image Builder (specialized image built on-demand by orchestrator) + # ========================================================================== + agent-qa-fe-image: + build: + context: . + dockerfile: docker/agent-qa-fe.Dockerfile + image: roboco-agent-qa-fe + entrypoint: ["/bin/sh", "-c", 'echo "Agent Frontend QA image built"'] + restart: "no" + depends_on: + - agent-base-image + + # ========================================================================== + # Agent UX/UI Dev Image Builder (specialized image built on-demand by orchestrator) + # ========================================================================== + agent-ux-image: + build: + context: . + dockerfile: docker/agent-ux.Dockerfile + image: roboco-agent-ux + entrypoint: ["/bin/sh", "-c", 'echo "Agent UX/UI image built"'] + restart: "no" + depends_on: + - agent-base-image + + # ========================================================================== + # Agent Documenter Image Builder (specialized image built on-demand by orchestrator) + # ========================================================================== + agent-doc-image: + build: + context: . + dockerfile: docker/agent-doc.Dockerfile + image: roboco-agent-doc + entrypoint: ["/bin/sh", "-c", 'echo "Agent Documenter image built"'] + restart: "no" + depends_on: + - agent-base-image + + # ========================================================================== + # Orchestrator - API Server + Agent Spawner + # ========================================================================== + orchestrator: + build: + context: . + dockerfile: docker/orchestrator.Dockerfile + image: roboco-orchestrator + container_name: roboco-orchestrator + restart: unless-stopped + ports: + - "8000:8000" + environment: + # Database (use container name, not localhost) + ROBOCO_DATABASE_HOST: roboco-postgres + ROBOCO_DATABASE_PORT: 5432 + ROBOCO_DATABASE_USER: roboco + ROBOCO_DATABASE_PASSWORD: roboco + ROBOCO_DATABASE_NAME: roboco + # Redis (use container name) + ROBOCO_REDIS_HOST: roboco-redis + ROBOCO_REDIS_PORT: 6379 + # API + ROBOCO_HOST: 0.0.0.0 + ROBOCO_PORT: 8000 + ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required} + # Ollama (use container name) + ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1 + ROBOCO_LOCAL_LLM_MODEL: glm-4.7:cloud + ROBOCO_DEFAULT_EMBEDDING_MODEL: embeddinggemma:300m + ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434 + # Host paths for spawning agent containers (required for Docker-in-Docker) + # IMPORTANT: These must be ABSOLUTE paths on the host filesystem + ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco} + ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude} + ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data} + volumes: + # Docker socket - allows spawning agent containers + - /var/run/docker.sock:/var/run/docker.sock + # Claude Code auth - mount your ~/.claude directory + - ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude + # Shared config directory for MCP configs (writable) + - ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs + # Generated prompts directory - composed at runtime from layers + - ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated + # Per-agent Claude settings (generated at spawn time) + - ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings + # Agent workspaces (git clones) - persisted across restarts + - ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ollama: + condition: service_healthy + ollama-init: + condition: service_completed_successfully + agent-base-image: + condition: service_completed_successfully + # Default agents to spawn (override in .env or command line) + # command: ["--spawn", "main-pm", "be-dev-1", "be-qa"] + +networks: + default: + name: roboco_default diff --git a/docker-compose.yml b/docker-compose.yml index d96a2121..2e31b3e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -217,6 +217,7 @@ services: # API ROBOCO_HOST: 0.0.0.0 ROBOCO_PORT: 8000 + ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required} # Ollama (use container name) ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1 ROBOCO_LOCAL_LLM_MODEL: glm-4.7:cloud @@ -238,6 +239,8 @@ services: - ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated # Per-agent Claude settings (generated at spawn time) - ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings + # Agent workspaces (git clones) - persisted across restarts + - ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces depends_on: postgres: condition: service_healthy diff --git a/docs/rag/architecture/workspaces.md b/docs/rag/architecture/workspaces.md index 182b974f..27ad79c2 100644 --- a/docs/rag/architecture/workspaces.md +++ b/docs/rag/architecture/workspaces.md @@ -76,3 +76,14 @@ roboco_workspace_status(project_slug="roboco") Path resolved automatically: `{workspaces_root}/{project}/{team}/{agent}/` If `auto_clone=True` and workspace doesn't exist, it's created on first access. + +## Authentication + +HTTPS repositories require a GitHub PAT configured on the project: + +- **Token configured**: Auto-clone works, git operations succeed +- **Token missing**: Error "Project requires a git token for HTTPS repositories" + +**If you see this error**: Contact your PM to configure the project's git token. + +PMs use `roboco_project_update(slug, git_token="...")` to set credentials. diff --git a/docs/rag/roles/main-pm.md b/docs/rag/roles/main-pm.md index acd16f41..809bff34 100644 --- a/docs/rag/roles/main-pm.md +++ b/docs/rag/roles/main-pm.md @@ -113,11 +113,19 @@ Register new git repositories: roboco_project_create( name="New Project", slug="new-project", - git_url="git@github.com:org/repo.git", - assigned_cell="backend" + git_url="https://github.com/org/repo.git", + assigned_cell="backend", + git_token="ghp_xxxx..." # Required for HTTPS repos ) ``` +**IMPORTANT:** Include `git_token` (GitHub PAT with `repo` scope) for HTTPS repositories. Without it, workspace creation and git operations will fail. + +To update or rotate tokens: +```python +roboco_project_update(slug="new-project", git_token="ghp_newtoken...") +``` + Create tasks with project: ```python diff --git a/docs/rag/tools/project-tools.md b/docs/rag/tools/project-tools.md index babd86b5..0390da0d 100644 --- a/docs/rag/tools/project-tools.md +++ b/docs/rag/tools/project-tools.md @@ -19,7 +19,9 @@ Returns projects you have access to (cell-scoped for non-PMs). roboco_project_get(slug="roboco") ``` -Returns: `name`, `git_url`, `assigned_cell`, `default_branch`, `test_command`, etc. +Returns: `name`, `git_url`, `assigned_cell`, `default_branch`, `has_git_token`, `test_command`, etc. + +**Note:** `has_git_token` indicates if authentication is configured (required for HTTPS repos). ## Create Project (PM+ Only) @@ -27,8 +29,9 @@ Returns: `name`, `git_url`, `assigned_cell`, `default_branch`, `test_command`, e roboco_project_create( name="RoboCo Panel", slug="roboco-panel", - git_url="git@github.com:org/roboco-panel.git", + git_url="https://github.com/org/roboco-panel.git", assigned_cell="frontend", + git_token="ghp_xxxx...", # GitHub PAT with repo scope default_branch="main", test_command="pnpm test", lint_command="pnpm lint" @@ -37,11 +40,14 @@ roboco_project_create( **Who can create:** Main PM, Board, CEO +**IMPORTANT:** `git_token` is **required** for HTTPS repositories. Without it, workspace creation and git operations will fail. + ## Update Project ```python roboco_project_update( slug="roboco-panel", + git_token="ghp_newtoken...", # Update/rotate token test_command="pnpm test:ci", lint_command="pnpm lint:fix" ) @@ -51,6 +57,8 @@ roboco_project_update( - CEO, Main PM: Any project - Cell PM: Own cell's projects only +**Token rotation:** Pass `git_token` to update credentials. Pass empty string to clear. + ## Workspace Tools ### Ensure Workspace diff --git a/docs/rag/troubleshooting/git-errors.md b/docs/rag/troubleshooting/git-errors.md index 6aeb75b9..3af020c4 100644 --- a/docs/rag/troubleshooting/git-errors.md +++ b/docs/rag/troubleshooting/git-errors.md @@ -1,5 +1,21 @@ # Git Error Troubleshooting +## Missing Git Token + +**Error**: "Project requires a git token for HTTPS repositories" + +**Cause**: No GitHub PAT configured for this project + +**Solutions**: +1. Open project settings in UI +2. Add GitHub token (Personal Access Token) +3. Token needs `repo` scope for clone/push/PR + +**Notes**: +- Each project requires its own token (no global fallback) +- Tokens are encrypted at rest +- Token never exposed in API responses + ## Workspace Not Found **Error**: "Workspace does not exist" diff --git a/docs/rag/workflows/pr-creation.md b/docs/rag/workflows/pr-creation.md index 578078e7..95e7866f 100644 --- a/docs/rag/workflows/pr-creation.md +++ b/docs/rag/workflows/pr-creation.md @@ -54,6 +54,12 @@ In `awaiting_documentation`: Task advances to `awaiting_pm_review` when BOTH are done. +## Prerequisites + +- **Git token configured**: Project must have a GitHub PAT set +- Token must have `repo` scope for PR creation +- If missing, error: "Project has no git token configured" + ## Before Creating PR 1. Push all commits: `roboco_git_push()` diff --git a/roboco/api/routes/git.py b/roboco/api/routes/git.py index e72dfe06..9be657f7 100644 --- a/roboco/api/routes/git.py +++ b/roboco/api/routes/git.py @@ -96,9 +96,15 @@ async def get_git_status( try: workspace = await git_service.get_workspace(project_slug, agent.agent_id) - current_branch, has_changes, staged, unstaged, untracked, ahead, behind = ( - await git_service.get_status(workspace) - ) + ( + current_branch, + has_changes, + staged, + unstaged, + untracked, + ahead, + behind, + ) = await git_service.get_status(workspace) except ServiceError as e: raise _translate_error(e) from e @@ -273,9 +279,13 @@ async def create_commit( try: workspace = await git_service.get_workspace(data.project_slug, agent.agent_id) - commit_hash, message, files_changed, insertions, deletions = ( - await git_service.create_commit(workspace, agent.agent_id, data) - ) + ( + commit_hash, + message, + files_changed, + insertions, + deletions, + ) = await git_service.create_commit(workspace, agent.agent_id, data) except ServiceError as e: raise _translate_error(e) from e @@ -406,9 +416,13 @@ async def create_pull_request( try: workspace = await git_service.get_workspace(data.project_slug, agent.agent_id) - pr_number, pr_url, title, source_branch, target_branch = ( - await git_service.create_pull_request(workspace, data) - ) + ( + pr_number, + pr_url, + title, + source_branch, + target_branch, + ) = await git_service.create_pull_request(workspace, data) except ServiceError as e: raise _translate_error(e) from e diff --git a/roboco/api/routes/project.py b/roboco/api/routes/project.py index 66deb597..27875075 100644 --- a/roboco/api/routes/project.py +++ b/roboco/api/routes/project.py @@ -146,14 +146,20 @@ async def create_project( service = get_project_service(db) + # If protected_branches wasn't provided, default to just the default_branch + protected_branches = data.protected_branches + if protected_branches is None: + protected_branches = [data.default_branch] + # Convert request to service model create_data = ProjectCreate( name=data.name, slug=data.slug, git_url=data.git_url, default_branch=data.default_branch, - protected_branches=data.protected_branches, + protected_branches=protected_branches, assigned_cell=data.assigned_cell, + git_token=data.git_token, test_command=data.test_command, lint_command=data.lint_command, format_command=data.format_command, @@ -218,6 +224,7 @@ async def update_project( default_branch=data.default_branch, protected_branches=data.protected_branches, assigned_cell=data.assigned_cell, + git_token=data.git_token, test_command=data.test_command, lint_command=data.lint_command, format_command=data.format_command, diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index 2aa3c7e2..903c1a79 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -93,6 +93,19 @@ async def create_task( detail="Not authorized to create tasks", ) + # Validate: git tasks require project_id + if data.requires_git and not data.project_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": { + "code": "PROJECT_REQUIRED", + "message": "Tasks with requires_git=True must have project_id set", + "hint": "Specify project_id or set requires_git=False", + } + }, + ) + service = get_task_service(db) req = TaskCreateRequest( title=data.title, @@ -109,6 +122,10 @@ async def create_task( status=data.status, sequence=data.sequence, # Task ordering within siblings dependency_ids=data.dependency_ids, # Dependencies for claim filtering + # Git configuration + task_type=data.task_type, + requires_git=data.requires_git, + project_id=data.project_id, ) task = await service.create(req) await db.commit() diff --git a/roboco/api/schemas/project.py b/roboco/api/schemas/project.py index c606dd1c..d299b5fa 100644 --- a/roboco/api/schemas/project.py +++ b/roboco/api/schemas/project.py @@ -33,6 +33,9 @@ class ProjectResponse(BaseModel): protected_branches: list[str] assigned_cell: Team + # Git authentication status (token never exposed, only boolean) + has_git_token: bool = False + # Optional commands test_command: str | None = None lint_command: str | None = None @@ -63,9 +66,11 @@ class ProjectSummaryResponse(BaseModel): id: UUID name: str slug: str + git_url: str assigned_cell: Team is_active: bool has_workspace: bool = False + has_git_token: bool = False class Config: """Pydantic config.""" @@ -85,9 +90,18 @@ class ProjectCreateRequest(BaseModel): slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$") git_url: str default_branch: str = "main" - protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"]) + protected_branches: list[str] | None = Field( + default=None, + description="Branches to protect. Defaults to [default_branch].", + ) assigned_cell: Team + # Git authentication (will be encrypted and stored securely) + git_token: str | None = Field( + default=None, + description="GitHub PAT for clone/push/PR (stored encrypted, never returned)", + ) + # Optional commands test_command: str | None = None lint_command: str | None = None @@ -105,6 +119,12 @@ class ProjectUpdateRequest(BaseModel): protected_branches: list[str] | None = None assigned_cell: Team | None = None + # Git authentication (empty string clears token, None leaves unchanged) + git_token: str | None = Field( + default=None, + description="GitHub PAT (empty string clears, None leaves unchanged)", + ) + # Commands test_command: str | None = None lint_command: str | None = None @@ -144,6 +164,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse: default_branch=str(default_branch) if default_branch else "main", protected_branches=list(project.protected_branches or []), assigned_cell=project.assigned_cell, + has_git_token=bool(project.git_token_encrypted), test_command=project.test_command, lint_command=project.lint_command, format_command=project.format_command, @@ -165,7 +186,9 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse: id=typing_cast("UUID", project.id), name=str(project.name), slug=str(project.slug), + git_url=str(project.git_url), assigned_cell=project.assigned_cell, is_active=bool(project.is_active), has_workspace=bool(project.workspace_path), + has_git_token=bool(project.git_token_encrypted), ) diff --git a/roboco/config.py b/roboco/config.py index 8d7da6c8..93161dc3 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -198,6 +198,10 @@ class Settings(BaseSettings): min_length=32, description="Secret key for JWT signing", ) + encryption_key: str = Field( + default="", + description="Fernet encryption key for secrets.", + ) access_token_expire_minutes: int = Field(default=60 * 24, ge=1) # 24 hours algorithm: str = "HS256" diff --git a/roboco/db/tables.py b/roboco/db/tables.py index a76b7587..9fc20f6b 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -300,6 +300,9 @@ class ProjectTable(Base): protected_branches: Mapped[list[str]] = mapped_column( ARRAY(String), default=lambda: ["main", "master"] ) + git_token_encrypted: Mapped[str | None] = mapped_column( + Text, nullable=True + ) # Fernet-encrypted GitHub PAT # CI/CD Commands (optional) test_command: Mapped[str | None] = mapped_column(String(500), nullable=True) diff --git a/roboco/mcp/project_server.py b/roboco/mcp/project_server.py index 722aa4bc..0f059f43 100644 --- a/roboco/mcp/project_server.py +++ b/roboco/mcp/project_server.py @@ -345,7 +345,10 @@ async def _handle_workspace_ensure( "branch": git_status.get("current_branch"), "has_uncommitted": git_status.get("has_changes", False), }, - "guidance": "Workspace ready. Use git tools to make changes.", + "guidance": ( + "Workspace ready. Use roboco_git_* MCP tools for git operations. " + f"Direct filesystem access at /data/workspaces/{project_slug}/..." + ), } @@ -384,7 +387,11 @@ async def _handle_workspace_status( "staged_files": git_status.get("staged_files", []), "unstaged_files": git_status.get("unstaged_files", []), }, - "guidance": "Use git tools to make changes.", + "guidance": ( + "Workspace ready. Use roboco_git_* MCP tools for git operations " + "(commit, push, branch, etc). Direct filesystem access is available " + f"at /data/workspaces/{project_slug}/... for your agent." + ), } diff --git a/roboco/mcp/tasks/handlers/claim.py b/roboco/mcp/tasks/handlers/claim.py index 59963fcb..7c020e06 100644 --- a/roboco/mcp/tasks/handlers/claim.py +++ b/roboco/mcp/tasks/handlers/claim.py @@ -6,6 +6,8 @@ Handler for claiming tasks. from typing import Any +from starlette import status + from roboco.agents_config import get_agent_role from roboco.mcp.tasks import format_task_response from roboco.mcp.tasks.handlers._helpers import ( @@ -56,6 +58,51 @@ async def _is_pre_assigned_to_agent( return agent_uuid is not None and str(assigned_to) == agent_uuid +async def _validate_branch_hierarchy( + client: ApiClient, task: dict[str, Any] +) -> dict[str, Any] | None: + """Walk up task hierarchy ensuring all git-enabled ancestors have branches. + + Returns error dict if hierarchy is incomplete, None if valid. + Branch hierarchy must be created from root down: + - Main PM creates root branch + - Cell PM creates subtask branch + - Developer works on subsubtask branch + """ + current_parent_id = task.get("parent_task_id") + ancestors_checked: list[str] = [] + + while current_parent_id: + parent_resp = await client.get(f"/tasks/{current_parent_id}") + if parent_resp.status_code != status.HTTP_200_OK: + # Can't fetch parent - may have been deleted, allow to proceed + break + + parent = parent_resp.json() + parent_title = parent.get("title", str(current_parent_id)[:8]) + ancestors_checked.append(parent_title) + + # Check if parent is a git task missing its branch + if parent.get("requires_git") and not parent.get("branch_name"): + return format_error_response( + "BRANCH_HIERARCHY_INCOMPLETE", + f"Parent task '{parent_title}' has no branch yet.", + { + "missing_branch_task": str(current_parent_id), + "ancestors_checked": ancestors_checked, + }, + hint=( + "Branch hierarchy must be created from root down. " + "Main PM creates root branch, Cell PM creates subtask branch. " + "Ask your PM to create the parent branch first." + ), + ) + + current_parent_id = parent.get("parent_task_id") + + return None + + async def _execute_claim( client: ApiClient, task_id: str, agent_id: str ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: @@ -72,6 +119,29 @@ async def _execute_claim( return claimed, None +async def _validate_git_requirements( + client: ApiClient, task: dict[str, Any], task_id: str +) -> dict[str, Any] | None: + """Validate git-related requirements. Returns error or None.""" + if not task.get("requires_git"): + return None + + # Branch must exist for git tasks + if not task.get("branch_name"): + return format_error_response( + "BRANCH_REQUIRED", + "Cannot claim git task - PM must create branch first.", + {"task_id": task_id, "requires_git": True}, + hint="PM should call roboco_git_create_branch() before developer can claim", + ) + + # Validate full branch hierarchy for git tasks with parent + if task.get("parent_task_id"): + return await _validate_branch_hierarchy(client, task) + + return None + + async def handle_task_claim( client: ApiClient, task_id: str, agent_id: str ) -> dict[str, Any]: @@ -104,14 +174,9 @@ async def handle_task_claim( if error := await validate_task_claimable(task, agent_role, agent_id, client): return error - # Validate branch exists for git tasks (PM must create branch first) - if task.get("requires_git") and not task.get("branch_name"): - return format_error_response( - "BRANCH_REQUIRED", - "Cannot claim git task - PM must create branch first.", - {"task_id": task_id, "requires_git": True}, - hint="PM should call roboco_git_create_branch() before developer can claim", - ) + # Validate git requirements (branch exists, hierarchy valid) + if error := await _validate_git_requirements(client, task, task_id): + return error # Execute the claim claimed_task, error = await _execute_claim(client, task_id, agent_id) diff --git a/roboco/mcp/tasks/handlers/management.py b/roboco/mcp/tasks/handlers/management.py index 03affe44..ae3da0f9 100644 --- a/roboco/mcp/tasks/handlers/management.py +++ b/roboco/mcp/tasks/handlers/management.py @@ -451,6 +451,15 @@ async def handle_task_assign( if assign_error or assigned_task is None: return assign_error or format_error_response("ASSIGN_FAILED", "No task") + # Warning if reassigning a claimed task (not an error, just informational) + reassign_warning = "" + if task.get("status") == "claimed" and task.get("claimed_by"): + reassign_warning = ( + "\n\n⚠️ Note: This task was already claimed. If you're delegating work, " + "consider using roboco_task_create(parent_task_id=...) to create a subtask " + "for better tracking and branch hierarchy management." + ) + guidance = ( f"Task assigned to {input_data.assignee} and set to pending. " "Orchestrator will spawn them to claim and work on it." @@ -461,6 +470,9 @@ async def handle_task_assign( if role_warning: guidance += f"\n\n⚠️ ROLE WARNING: {role_warning}" + # Add reassign warning if applicable + guidance += reassign_warning + return format_task_response(assigned_task, "ASSIGNED", guidance) diff --git a/roboco/mcp/tasks/handlers/work.py b/roboco/mcp/tasks/handlers/work.py index 674b2b9d..8119261c 100644 --- a/roboco/mcp/tasks/handlers/work.py +++ b/roboco/mcp/tasks/handlers/work.py @@ -160,10 +160,14 @@ async def handle_task_start( # Build guidance based on whether git checkout happened guidance = "Task started. Work through your plan step by step:\n" if requires_git and branch_name: - guidance += f"✓ Checked out branch: {branch_name}\n\n" + guidance += ( + f"✓ Checked out branch: {branch_name}\n" + f" Workspace: /data/workspaces/{project_slug}/...\n" + " Use roboco_git_* tools for git operations.\n\n" + ) guidance += ( "1. Implement each sub-task\n" - "2. Commit frequently with clear messages\n" + "2. Use roboco_git_commit() to commit frequently\n" "3. Call roboco_task_progress to update status\n" "4. If blocked, call roboco_task_block immediately\n" "5. When done, call roboco_task_submit_verification" diff --git a/roboco/models/project.py b/roboco/models/project.py index 129e42f6..7e098d16 100644 --- a/roboco/models/project.py +++ b/roboco/models/project.py @@ -77,6 +77,12 @@ class Project(TimestampMixin): default=None, description="Specific agents allowed (None = all in cell)" ) + # Git Authentication (token stored encrypted, never exposed) + has_git_token: bool = Field( + default=False, + description="Whether a git token is configured (token never exposed)", + ) + # Runtime State workspace_path: str | None = Field( default=None, @@ -102,6 +108,12 @@ class ProjectCreate(RobocoBase): protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"]) assigned_cell: Team + # Git authentication (will be encrypted and stored securely) + git_token: str | None = Field( + default=None, + description="GitHub PAT for clone/push/PR operations (stored encrypted)", + ) + # Optional commands test_command: str | None = None lint_command: str | None = None @@ -117,6 +129,13 @@ class ProjectUpdate(RobocoBase): git_url: str | None = None default_branch: str | None = None protected_branches: list[str] | None = None + + # Git authentication (empty string clears token, None leaves unchanged) + git_token: str | None = Field( + default=None, + description="GitHub PAT (empty string clears, None leaves unchanged)", + ) + test_command: str | None = None lint_command: str | None = None format_command: str | None = None diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 17d014e9..bb916815 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -655,6 +655,7 @@ class AgentOrchestrator: # Running inside orchestrator container - use host paths blueprints_host = f"{PROJECT_HOST_PATH}/agents/blueprints" docs_host = f"{PROJECT_HOST_PATH}/docs" + workspaces_host = f"{DATA_HOST_PATH}/workspaces" claude_host = CLAUDE_AUTH_HOST_PATH mcp_config_host = ( f"{DATA_HOST_PATH}/mcp-configs/{config.mcp_config_path.name}" @@ -674,6 +675,7 @@ class AgentOrchestrator: # Running directly on host blueprints_host = str(self.blueprints_dir.absolute()) docs_host = str(self.blueprints_dir.parent / "docs") + workspaces_host = str(Path(settings.workspaces_root)) claude_host = CLAUDE_AUTH_HOST_PATH mcp_config_host = str(config.mcp_config_path) # Generated prompts in temp dir @@ -721,6 +723,9 @@ class AgentOrchestrator: # - All other roles get read-only access "-v", f"{docs_host}:/app/docs{'' if config.agent_id in ALL_DOCS else ':ro'}", + # Mount workspaces directory for git operations + "-v", + f"{workspaces_host}:/data/workspaces", # Mount MCP config "-v", f"{mcp_config_host}:/app/mcp-config.json:ro", diff --git a/roboco/services/git.py b/roboco/services/git.py index f2bc715e..f3c36cff 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -6,6 +6,7 @@ All business logic for git commands, commit templates, PR generation. """ import asyncio +import os import subprocess from pathlib import Path from typing import ClassVar @@ -49,6 +50,24 @@ from roboco.templates.git.pr_root import CommitInfo as PRCommitInfo # Git command timeout in seconds _GIT_TIMEOUT = 30 + +def _get_gh_env(token: str | None = None) -> dict[str, str]: + """ + Get environment variables for gh CLI commands. + + Args: + token: Project-specific GitHub PAT (required for gh operations) + + Returns: + Environment dict with GITHUB_TOKEN set + """ + env = os.environ.copy() + if token: + env["GITHUB_TOKEN"] = token + env["GH_TOKEN"] = token + return env + + # Expected number of parts in various git outputs _REV_LIST_PARTS = 2 @@ -285,9 +304,7 @@ class GitService(BaseService): commit_hash = parts[0] if parts else "unknown" # Get stats - stat_result = await self._run_git( - workspace, ["diff", "--stat", "HEAD~1..HEAD"] - ) + stat_result = await self._run_git(workspace, ["diff", "--stat", "HEAD~1..HEAD"]) insertions, deletions, files_changed = self._parse_commit_stats( stat_result.stdout ) @@ -335,6 +352,25 @@ class GitService(BaseService): project = await project_service.get_by_slug(request.project_slug) base_branch = str(project.default_branch) if project else "main" + # Validate parent branch exists on remote (unless it's the default branch) + project_service = get_project_service(self.session) + project = await project_service.get_by_slug(request.project_slug) + default_branch = str(project.default_branch) if project else "main" + + if base_branch != default_branch: + # Parent is not the default branch - verify it exists on remote + result = await self._run_git( + workspace, + ["ls-remote", "--heads", "origin", base_branch], + check=False, + ) + if not result.stdout.strip(): + raise ValidationError( + f"Parent branch '{base_branch}' does not exist on remote. " + f"The parent task's branch must be created and pushed first. " + f"Ensure Main PM/Cell PM created their branches before this task." + ) + # Create and push branch await self._run_git(workspace, ["checkout", base_branch]) await self._run_git(workspace, ["pull", "origin", base_branch]) @@ -498,11 +534,21 @@ class GitService(BaseService): source_branch = await self.get_current_branch(workspace) - # Determine target branch + # Determine target branch and get project token project_service = get_project_service(self.session) project = await project_service.get_by_slug(request.project_slug) default_branch = str(project.default_branch) if project else "main" + # Get decrypted token from project (required for PR creation) + git_token = await project_service.get_decrypted_token_by_slug( + request.project_slug + ) + if not git_token: + raise GitError( + f"Project '{request.project_slug}' has no git token configured. " + "Configure a GitHub PAT in the project settings to create PRs." + ) + if request.is_root_pr: target_branch = default_branch elif task.parent_task_id: @@ -531,7 +577,7 @@ class GitService(BaseService): pr_title = pr_title or build_pr_title_internal(internal_ctx) pr_body = pr_body or build_pr_body_internal(internal_ctx, api_base) - # Create PR using gh CLI + # Create PR using gh CLI with project token def _create_pr() -> subprocess.CompletedProcess[str]: return subprocess.run( [ @@ -552,6 +598,7 @@ class GitService(BaseService): text=True, timeout=_GIT_TIMEOUT, check=True, + env=_get_gh_env(git_token), ) try: @@ -575,6 +622,14 @@ class GitService(BaseService): Returns: (target_branch, merge_commit) """ + # Get project token for gh CLI + project_service = get_project_service(self.session) + git_token = await project_service.get_decrypted_token_by_slug(project_slug) + if not git_token: + raise GitError( + f"Project '{project_slug}' has no git token configured. " + "Configure a GitHub PAT in the project settings to merge PRs." + ) def _merge_pr() -> subprocess.CompletedProcess[str]: return subprocess.run( @@ -591,6 +646,7 @@ class GitService(BaseService): text=True, timeout=_GIT_TIMEOUT, check=True, + env=_get_gh_env(git_token), ) try: @@ -599,7 +655,6 @@ class GitService(BaseService): raise GitCommandError("gh pr merge", e.stderr or e.stdout or "") from e # Get target branch - project_service = get_project_service(self.session) project = await project_service.get_by_slug(project_slug) target_branch = str(project.default_branch) if project else "main" diff --git a/roboco/services/project.py b/roboco/services/project.py index 03028b6b..93c26001 100644 --- a/roboco/services/project.py +++ b/roboco/services/project.py @@ -15,6 +15,7 @@ from roboco.db.tables import ProjectTable from roboco.models.base import Team from roboco.models.project import ProjectCreate, ProjectUpdate from roboco.services.base import BaseService, ConflictError, NotFoundError +from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token class ProjectService(BaseService): @@ -60,6 +61,15 @@ class ProjectService(BaseService): resource_type="project", ) + # Encrypt git token if provided + encrypted_token = None + if data.git_token: + try: + encrypted_token = encrypt_token(data.git_token) + except EncryptionError as e: + self.log.error("Failed to encrypt git token", error=str(e)) + raise + project = ProjectTable( name=data.name, slug=data.slug, @@ -67,6 +77,7 @@ class ProjectService(BaseService): default_branch=data.default_branch, protected_branches=data.protected_branches, assigned_cell=data.assigned_cell, + git_token_encrypted=encrypted_token, test_command=data.test_command, lint_command=data.lint_command, format_command=data.format_command, @@ -83,6 +94,7 @@ class ProjectService(BaseService): project_id=str(project.id), slug=data.slug, git_url=data.git_url, + has_git_token=bool(encrypted_token), cell=data.assigned_cell.value if isinstance(data.assigned_cell, Team) else data.assigned_cell, @@ -129,18 +141,42 @@ class ProjectService(BaseService): if not project: return None - # Apply updates for non-None fields - update_data = data.model_dump(exclude_unset=True, exclude_none=True) + # Handle git_token specially (empty string clears, None leaves unchanged) + token_updated = False + if data.git_token is not None: + if data.git_token == "": + # Clear the token + project.git_token_encrypted = None + token_updated = True + self.log.info("Git token cleared", project_id=str(project_id)) + else: + # Encrypt and set new token + try: + project.git_token_encrypted = encrypt_token(data.git_token) + token_updated = True + self.log.info("Git token updated", project_id=str(project_id)) + except EncryptionError as e: + self.log.error("Failed to encrypt git token", error=str(e)) + raise + + # Apply updates for non-None fields (excluding git_token which we handled) + update_data = data.model_dump( + exclude_unset=True, exclude_none=True, exclude={"git_token"} + ) for key, value in update_data.items(): if hasattr(project, key): setattr(project, key, value) await self.session.flush() + updated_fields = list(update_data.keys()) + if token_updated: + updated_fields.append("git_token") + self.log.info( "Project updated", project_id=str(project_id), - updates=list(update_data.keys()), + updates=updated_fields, ) return project @@ -287,6 +323,69 @@ class ProjectService(BaseService): ) return project + # ========================================================================= + # GIT TOKEN MANAGEMENT + # ========================================================================= + + async def get_decrypted_token(self, project_id: UUID) -> str | None: + """ + Get the decrypted git token for a project. + + Used by WorkspaceService and GitService for git operations. + The token is decrypted on-demand and should not be cached. + + Args: + project_id: Project to get token for + + Returns: + Decrypted token or None if no token is set + + Raises: + EncryptionError: If decryption fails (key mismatch, corrupted data) + """ + project = await self.get(project_id) + if not project or not project.git_token_encrypted: + return None + + try: + return decrypt_token(project.git_token_encrypted) + except EncryptionError: + self.log.error( + "Failed to decrypt git token", + project_id=str(project_id), + error="encryption_key_mismatch_or_corrupted", + ) + raise + + async def get_decrypted_token_by_slug(self, slug: str) -> str | None: + """ + Get the decrypted git token for a project by slug. + + Convenience method for services that work with project slugs. + + Args: + slug: Project slug + + Returns: + Decrypted token or None if no token is set + + Raises: + EncryptionError: If decryption fails + """ + project = await self.get_by_slug(slug) + if not project or not project.git_token_encrypted: + return None + + try: + return decrypt_token(project.git_token_encrypted) + except EncryptionError: + self.log.error( + "Failed to decrypt git token", + project_slug=slug, + error="encryption_key_mismatch_or_corrupted", + ) + raise + # ========================================================================= # ACCESS CONTROL # ========================================================================= diff --git a/roboco/services/workspace.py b/roboco/services/workspace.py index bc739b97..5b36262d 100644 --- a/roboco/services/workspace.py +++ b/roboco/services/workspace.py @@ -19,6 +19,7 @@ Example: """ import asyncio +import re import subprocess from pathlib import Path from uuid import UUID @@ -26,13 +27,42 @@ from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession from roboco.config import settings -from roboco.db.tables import AgentTable, ProjectTable +from roboco.db.tables import AgentTable from roboco.logging import get_logger from roboco.models.base import Team logger = get_logger(__name__) +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("/")[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.""" @@ -145,7 +175,27 @@ class WorkspaceService: Raises: WorkspaceError: If workspace creation fails """ - workspace = await self.resolve_workspace(project_slug, agent_id) + from sqlalchemy import select + + from roboco.services.project import get_project_service + + # Look up agent for workspace path and git identity + 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}") + + # Compute workspace path + team = agent.team if agent.team else Team.BACKEND + workspace = self.get_workspace_path(project_slug, team, agent.slug) # Check if already exists if (workspace / ".git").exists(): @@ -156,21 +206,34 @@ class WorkspaceService: ) return workspace - # Get git URL if not provided - if not git_url: - from sqlalchemy import select + # Get git URL and token from project + 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}") - result = await self.session.execute( - select(ProjectTable).where(ProjectTable.slug == project_slug) - ) - project = result.scalar_one_or_none() - 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 - # Clone the repository - await self._clone_repo(workspace, git_url, default_branch) + # Get decrypted token from project (per-project token, no global fallback) + git_token = await project_service.get_decrypted_token_by_slug(project_slug) + + # Validate token is set for HTTPS URLs (no global fallback) + 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." + ) + + # Clone the repository with agent identity + await self._clone_repo( + workspace, + git_url, + default_branch, + git_token, + agent=agent, + ) return workspace async def _clone_repo( @@ -178,6 +241,8 @@ class WorkspaceService: 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. @@ -186,6 +251,8 @@ class WorkspaceService: 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 @@ -193,11 +260,16 @@ class WorkspaceService: # 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, + 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]: @@ -208,7 +280,7 @@ class WorkspaceService: "--branch", default_branch, "--single-branch", - git_url, + auth_url, str(workspace), ], capture_output=True, @@ -217,8 +289,27 @@ class WorkspaceService: check=True, ) + def _configure_git() -> None: + """Configure git author info based on agent identity.""" + 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, + ) + try: await asyncio.to_thread(_do_clone) + await asyncio.to_thread(_configure_git) logger.info( "Repository cloned successfully", workspace=str(workspace), diff --git a/roboco/utils/__init__.py b/roboco/utils/__init__.py index 6bf49050..48645ed7 100644 --- a/roboco/utils/__init__.py +++ b/roboco/utils/__init__.py @@ -5,8 +5,18 @@ Common utility functions and helpers. """ from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid_list +from roboco.utils.crypto import ( + EncryptionError, + decrypt_token, + encrypt_token, + is_encryption_configured, +) __all__ = [ + "EncryptionError", + "decrypt_token", + "encrypt_token", + "is_encryption_configured", "require_uuid", "to_python_uuid", "to_python_uuid_list", diff --git a/roboco/utils/crypto.py b/roboco/utils/crypto.py new file mode 100644 index 00000000..05b96534 --- /dev/null +++ b/roboco/utils/crypto.py @@ -0,0 +1,110 @@ +""" +Cryptographic utilities for encrypting sensitive data at rest. + +Uses Fernet symmetric encryption with a master key from settings. +""" + +from cryptography.fernet import Fernet, InvalidToken + +from roboco.config import settings +from roboco.logging import get_logger + +logger = get_logger(__name__) + + +class EncryptionError(Exception): + """Raised when encryption/decryption operations fail.""" + + pass + + +def _get_fernet() -> Fernet: + """ + Get Fernet instance with master encryption key. + + Raises: + EncryptionError: If encryption key is not configured + """ + if not settings.encryption_key: + raise EncryptionError( + "ROBOCO_ENCRYPTION_KEY is not configured. " + "Generate one with: python -c 'from cryptography.fernet " + "import Fernet; print(Fernet.generate_key().decode())'" + ) + try: + return Fernet(settings.encryption_key.encode()) + except Exception as e: + raise EncryptionError(f"Invalid encryption key format: {e}") from e + + +def encrypt_token(token: str) -> str: + """ + Encrypt a token using Fernet symmetric encryption. + + Args: + token: The plaintext token to encrypt + + Returns: + Base64-encoded encrypted token string + + Raises: + EncryptionError: If encryption fails + """ + if not token: + raise EncryptionError("Cannot encrypt empty token") + + try: + fernet = _get_fernet() + encrypted = fernet.encrypt(token.encode()) + return encrypted.decode() + except EncryptionError: + raise + except Exception as e: + logger.error("Token encryption failed", error=str(e)) + raise EncryptionError(f"Failed to encrypt token: {e}") from e + + +def decrypt_token(encrypted: str) -> str: + """ + Decrypt a token that was encrypted with encrypt_token(). + + Args: + encrypted: Base64-encoded encrypted token string + + Returns: + The original plaintext token + + Raises: + EncryptionError: If decryption fails (wrong key, corrupted data, etc.) + """ + if not encrypted: + raise EncryptionError("Cannot decrypt empty value") + + try: + fernet = _get_fernet() + decrypted = fernet.decrypt(encrypted.encode()) + return decrypted.decode() + except InvalidToken as e: + logger.error( + "Token decryption failed - encryption key may have changed", + error="InvalidToken", + ) + raise EncryptionError( + "Unable to decrypt token - encryption key may have changed" + ) from e + except EncryptionError: + raise + except Exception as e: + logger.error("Token decryption failed", error=str(e)) + raise EncryptionError(f"Failed to decrypt token: {e}") from e + + +def is_encryption_configured() -> bool: + """Check if encryption is properly configured.""" + if not settings.encryption_key: + return False + try: + _get_fernet() + return True + except EncryptionError: + return False