autojanet/container/entrypoint.py
Zoë e945ef82ee
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: opencode config uses provider (singular) with npm/options structure
2026-05-30 18:12:20 -07:00

186 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", "vikunja-token", "forgejo-token", "argocd-token"]
for name in secret_names:
try:
key = "token" if name != "litellm-key" else "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 with the agent's secrets and MCP server tokens."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
litellm_key = secrets.get("litellm-key", "")
vikunja_token = secrets.get("vikunja-token", "")
forgejo_token = secrets.get("forgejo-token", "")
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,
}
}
},
"mcp": {
"vikunja": {
"type": "sse",
"url": f"{LITELLM_BASE_URL}/mcp/vikunja",
"headers": {
"x-vikunja-token": vikunja_token,
}
},
"forgejo": {
"type": "sse",
"url": f"{LITELLM_BASE_URL}/mcp/forgejo",
"headers": {
"x-forgejo-token": forgejo_token,
}
},
}
}
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()