autojanet/container/entrypoint.py
Zoë 90f80d9220
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: opencode run takes prompt as positional arg not --prompt-file
2026-05-30 18:03:28 -07:00

181 lines
5.7 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 = {
"model": f"litellm/copilot/claude-sonnet-4.6",
"providers": {
"litellm": {
"apiKey": litellm_key,
"baseURL": f"{LITELLM_BASE_URL}/v1",
}
},
"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()