237 lines
8.3 KiB
Python
237 lines
8.3 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")
|
|
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": {
|
|
"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 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 <svc-ag-%s@ad.ctz.fyi>", 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", prompt]
|
|
log.info("Running: %s", " ".join(cmd))
|
|
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 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 (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)
|
|
|
|
# Move to In Review regardless of exit code — work was attempted
|
|
if pm_token:
|
|
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()
|