#!/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 ", 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", "--print-logs", prompt] log.info("Running: opencode run --print-logs ") 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 post_task_comment(pm_token: str, rc: int) -> None: """Post a completion comment on the Vikunja task.""" if rc == 0: body = f"✅ **{AGENT_ROLE}** agent completed this task and moved it to In Review." else: body = f"⚠️ **{AGENT_ROLE}** agent finished with exit code {rc}. Task moved to In Review for manual check." try: resp = httpx.put( f"{VIKUNJA_BASE_URL}/api/v1/tasks/{TASK_ID}/comments", headers={"Authorization": f"Bearer {pm_token}", "Content-Type": "application/json"}, json={"comment": body}, timeout=10, ) resp.raise_for_status() log.info("Posted completion comment on task %s", TASK_ID) except Exception as e: log.warning("Failed to post task comment: %s", 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 and comments (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) # Always post comment and move bucket regardless of exit code if pm_token: post_task_comment(pm_token, rc) 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()