autojanet/container/entrypoint.py
Zoë b35215be46
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: MCP uses single /mcp/ endpoint, not per-service paths
2026-05-30 19:20:08 -07:00

229 lines
8 KiB
Python

#!/usr/bin/env python3
"""
AutoJanet Agent Entrypoint
Bootstraps an agent container:
1. Authenticates to OpenBao via AppRole (OPENBAO_ROLE_ID + OPENBAO_SECRET_ID)
2. Fetches all secrets for AGENT_ROLE from OpenBao
3. Writes an opencode-compatible ~/.config/opencode/ environment
4. Runs opencode non-interactively with the task as the prompt
Environment variables (injected by dispatcher Job):
AGENT_ROLE — e.g. "coder"
TASK_ID — Vikunja task ID
TASK_TITLE — Vikunja task title (used as initial prompt)
OPENBAO_ADDR — e.g. "http://openbao.openbao.svc.cluster.local:8200"
OPENBAO_ROLE_ID — AppRole role_id
OPENBAO_SECRET_ID — AppRole secret_id
LITELLM_BASE_URL — e.g. "https://llm.ctz.fyi"
VIKUNJA_BASE_URL — e.g. "https://tasks.ctz.fyi"
FORGEJO_BASE_URL — e.g. "https://git.ctz.fyi"
"""
import json
import logging
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import httpx
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stdout,
)
log = logging.getLogger("entrypoint")
OPENBAO_ADDR = os.environ["OPENBAO_ADDR"]
OPENBAO_ROLE_ID = os.environ["OPENBAO_ROLE_ID"]
OPENBAO_SECRET_ID = os.environ["OPENBAO_SECRET_ID"]
AGENT_ROLE = os.environ["AGENT_ROLE"]
TASK_ID = os.environ["TASK_ID"]
TASK_TITLE = os.environ.get("TASK_TITLE", f"Task {TASK_ID}")
LITELLM_BASE_URL = os.environ.get("LITELLM_BASE_URL", "https://llm.ctz.fyi")
VIKUNJA_BASE_URL = os.environ.get("VIKUNJA_BASE_URL", "https://tasks.ctz.fyi")
FORGEJO_BASE_URL = os.environ.get("FORGEJO_BASE_URL", "https://git.ctz.fyi")
IN_REVIEW_BUCKET_ID = os.environ.get("IN_REVIEW_BUCKET_ID", "")
VIKUNJA_PROJECT_ID = os.environ.get("VIKUNJA_PROJECT_ID", "")
VIKUNJA_VIEW_ID = os.environ.get("VIKUNJA_VIEW_ID", "")
HOME = Path(os.environ.get("HOME", "/home/agent"))
CONFIG_DIR = HOME / ".config" / "opencode"
def get_openbao_token() -> str:
resp = httpx.post(
f"{OPENBAO_ADDR}/v1/auth/approle/login",
json={"role_id": OPENBAO_ROLE_ID, "secret_id": OPENBAO_SECRET_ID},
timeout=10,
)
resp.raise_for_status()
return resp.json()["auth"]["client_token"]
def get_secret(bao_token: str, path: str, key: str) -> str:
resp = httpx.get(
f"{OPENBAO_ADDR}/v1/secret/data/{path}",
headers={"X-Vault-Token": bao_token},
timeout=10,
)
resp.raise_for_status()
return resp.json()["data"]["data"][key]
def fetch_role_secrets(bao_token: str, role: str) -> dict:
"""Fetch all secrets for a role. Returns dict of secret_name -> value."""
secrets = {}
try:
resp = httpx.get(
f"{OPENBAO_ADDR}/v1/secret/data/autojanet/{role}/litellm-key",
headers={"X-Vault-Token": bao_token},
timeout=10,
)
resp.raise_for_status()
secrets["litellm-key"] = resp.json()["data"]["data"]["key"]
log.info("Fetched litellm-key")
except Exception as e:
log.warning("Could not fetch litellm-key: %s", e)
return secrets
def write_opencode_config(secrets: dict, role: str) -> None:
"""Write opencode config and set secrets as env vars for opencode to pick up."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
litellm_key = secrets.get("litellm-key", "")
config = {
"$schema": "https://opencode.ai/config.json",
"model": "litellm/copilot/claude-sonnet-4.6",
"provider": {
"litellm": {
"npm": "@ai-sdk/openai-compatible",
"name": "LiteLLM",
"options": {
"baseURL": f"{LITELLM_BASE_URL}/v1",
"apiKey": litellm_key,
},
"models": {
"copilot/claude-sonnet-4.6": {"name": "copilot/claude-sonnet-4.6"},
},
}
},
"mcp": {
"litellm": {
"type": "remote",
"url": f"{LITELLM_BASE_URL}/mcp/",
"headers": {
"Authorization": f"Bearer {litellm_key}",
},
"enabled": True,
},
}
}
config_path = CONFIG_DIR / "config.json"
config_path.write_text(json.dumps(config, indent=2))
log.info("Wrote opencode config to %s", config_path)
# Write AGENTS.md with role-specific instructions
agent_md_src = Path(f"/app/agents/{role}.agent.md")
agents_md_dst = CONFIG_DIR / "AGENTS.md"
if agent_md_src.exists():
agents_md_dst.write_text(agent_md_src.read_text())
log.info("Loaded agent instructions from %s", agent_md_src)
else:
log.warning("No agent file found at %s", agent_md_src)
def configure_git_identity() -> None:
"""Set git commit name to Janet, keeping the agent's service account email."""
try:
subprocess.run(["git", "config", "--global", "user.name", "Janet"], check=True)
subprocess.run(["git", "config", "--global", "user.email", f"svc-ag-{AGENT_ROLE}@ad.ctz.fyi"], check=True)
log.info("Git identity set: Janet <svc-ag-%s@ad.ctz.fyi>", AGENT_ROLE)
except Exception as e:
log.warning("Could not set git identity: %s", e)
def build_prompt(task_id: str, task_title: str) -> str:
return f"""You are the AutoJanet agent for role: {AGENT_ROLE}
Your current task (Vikunja task #{task_id}):
{task_title}
Instructions:
1. Read the task carefully. Fetch full task details from Vikunja if needed.
2. Complete the task using the tools available to you.
3. If you wrote code, commit with a message that includes "refs #{task_id}" so the task
is linked in Forgejo. Example: "feat: add login endpoint refs #{task_id}"
4. Open a PR on Forgejo — do not merge it yourself.
5. Leave a comment on the Vikunja task summarising what you did and linking any PR.
6. Do not mark the task as done — the entrypoint will move it to In Review when you finish.
7. Do not ask for confirmation — act autonomously within your role constraints.
"""
def run_opencode(prompt: str) -> int:
"""Run opencode non-interactively with the given prompt."""
cmd = ["opencode", "run", prompt]
log.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, check=False)
return result.returncode
def move_task_to_bucket(pm_token: str, bucket_id: str) -> None:
"""Move the task to a bucket using the kanban view endpoint (requires pm token)."""
if not all([bucket_id, VIKUNJA_PROJECT_ID, VIKUNJA_VIEW_ID]):
log.warning("Missing bucket/project/view IDs, skipping bucket move")
return
try:
resp = httpx.post(
f"{VIKUNJA_BASE_URL}/api/v1/projects/{VIKUNJA_PROJECT_ID}/views/{VIKUNJA_VIEW_ID}/buckets/{bucket_id}/tasks",
headers={"Authorization": f"Bearer {pm_token}", "Content-Type": "application/json"},
json={"task_id": int(TASK_ID)},
timeout=10,
)
resp.raise_for_status()
log.info("Moved task %s → bucket %s", TASK_ID, bucket_id)
except Exception as e:
log.warning("Failed to move task to bucket %s: %s", bucket_id, e)
def main() -> None:
log.info("Agent entrypoint: role=%s task=%s", AGENT_ROLE, TASK_ID)
bao_token = get_openbao_token()
log.info("OpenBao authenticated")
secrets = fetch_role_secrets(bao_token, AGENT_ROLE)
write_opencode_config(secrets, AGENT_ROLE)
configure_git_identity()
# Fetch pm token for bucket moves (admin operation)
pm_token = ""
try:
pm_token = get_secret(bao_token, "autojanet/pm/vikunja-token", "token")
log.info("Fetched pm vikunja-token")
except Exception as e:
log.warning("Could not fetch pm vikunja-token: %s", e)
prompt = build_prompt(TASK_ID, TASK_TITLE)
rc = run_opencode(prompt)
# Move to In Review regardless of exit code — work was attempted
if pm_token:
move_task_to_bucket(pm_token, IN_REVIEW_BUCKET_ID)
if rc != 0:
log.error("opencode exited with code %d", rc)
sys.exit(rc)
log.info("Agent completed task %s successfully", TASK_ID)
if __name__ == "__main__":
main()