autojanet/container/entrypoint.py
Zoë 80e0421be5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: MCP servers auth via LiteLLM Bearer token, drop unused service tokens
2026-05-30 18:25:14 -07:00

189 lines
5.9 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")
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 = {}
secret_names = ["litellm-key"]
for name in secret_names:
try:
key = "key"
secrets[name] = get_secret(bao_token, f"autojanet/{role}/{name}", key)
log.info("Fetched secret: %s", name)
except Exception as e:
log.warning("Could not fetch %s: %s", name, 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", "")
# Set the LiteLLM API key as env var — opencode reads OPENAI_API_KEY for
# openai-compatible providers
os.environ["OPENAI_API_KEY"] = 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",
}
}
},
"mcp": {
"vikunja": {
"type": "remote",
"url": f"{LITELLM_BASE_URL}/mcp/vikunja",
"headers": {
"Authorization": f"Bearer {litellm_key}",
},
"enabled": True,
},
"forgejo": {
"type": "remote",
"url": f"{LITELLM_BASE_URL}/mcp/forgejo",
"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 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.
2. Fetch full task details from Vikunja if needed.
3. Complete the task using the tools available to you.
4. Move the task to Done in Vikunja when complete.
5. Open a PR if code was written.
6. Do not ask for confirmation — act autonomously within your 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 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)
prompt = build_prompt(TASK_ID, TASK_TITLE)
rc = run_opencode(prompt)
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()