188 lines
6 KiB
Python
188 lines
6 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 and set secrets as env vars for opencode to pick up."""
|
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
vikunja_token = secrets.get("vikunja-token", "")
|
|
forgejo_token = secrets.get("forgejo-token", "")
|
|
|
|
# Set the LiteLLM API key as env var — opencode reads OPENAI_API_KEY for
|
|
# openai-compatible providers, or the provider-specific env var
|
|
os.environ["OPENAI_API_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",
|
|
}
|
|
}
|
|
},
|
|
"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()
|