6dc7e72ac8
agentd webhook-serve (agentd/webhook.py, stdlib): verifies X-Plane-Signature
(HMAC-SHA256 hex of the body, constant-time; 401 on missing/bad), routes by
data.project to a configured tenant (others 202), coalesces deliveries within
3 s into one tick, runs one more tick when deliveries arrive during a tick,
and retries every 15 s for up to 2 min while the lock is held (tick command
exit 75: flock -E 75 in the cron scripts, cli tick on Busy). Ticks run in a
systemd scope so restarting the receiver never kills one. Log:
<log_dir>/webhook.log; GET /healthz.
Config: webhook section (key names only, PLANE_WEBHOOK_SECRET scrubbed from
child envs like the Plane tokens), notify: {kind: none} as a no-op hook
called on every state move; the unused per-project webhook_secret_key is
gone (Plane webhooks are workspace-level). systemd unit for the LXC and
tools/plane-webhook.py to create/rotate the Plane webhook as the workspace
admin (secret straight into the Vault, never printed).
216 lines
9.5 KiB
Python
216 lines
9.5 KiB
Python
"""Load and validate the agentd YAML configuration.
|
|
|
|
The config only ever holds Vault *key names* (`token_key`). The token itself is read from the environment
|
|
variable of the same name, which `agent-access.py exec --secret-env KEY=KEY` injects into the process."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from . import ROOT
|
|
|
|
SCHEMA_PATH = ROOT / "config" / "schema.json"
|
|
DEFAULT_PATHS = (ROOT / "config" / "agentd.local.yaml", ROOT / "config" / "agentd.yaml")
|
|
|
|
|
|
class ConfigError(Exception):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------- minimal JSON-schema validator (fallback)
|
|
_TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)}
|
|
|
|
|
|
def _type_ok(value, name):
|
|
if name == "integer":
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
if name == "number":
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
return isinstance(value, _TYPES[name])
|
|
|
|
|
|
def mini_validate(value, schema, root=None, path="$"):
|
|
"""Subset of draft-07: $ref (local), type, enum, required, properties, additionalProperties,
|
|
items, minItems, minProperties, minimum. Returns a list of error strings."""
|
|
root = root or schema
|
|
if "$ref" in schema:
|
|
node = root
|
|
for part in schema["$ref"].lstrip("#/").split("/"):
|
|
node = node[part]
|
|
return mini_validate(value, node, root, path)
|
|
errs = []
|
|
types = schema.get("type")
|
|
if types:
|
|
types = [types] if isinstance(types, str) else types
|
|
if not any(_type_ok(value, t) for t in types):
|
|
return ["%s: expected %s, got %s" % (path, "/".join(types), type(value).__name__)]
|
|
if "enum" in schema and value not in schema["enum"]:
|
|
errs.append("%s: %r not one of %s" % (path, value, schema["enum"]))
|
|
if "minimum" in schema and _type_ok(value, "number") and value < schema["minimum"]:
|
|
errs.append("%s: %r < minimum %s" % (path, value, schema["minimum"]))
|
|
if isinstance(value, dict):
|
|
for key in schema.get("required", []):
|
|
if key not in value:
|
|
errs.append("%s: missing required '%s'" % (path, key))
|
|
if len(value) < schema.get("minProperties", 0):
|
|
errs.append("%s: needs at least %d entries" % (path, schema["minProperties"]))
|
|
props = schema.get("properties", {})
|
|
extra = schema.get("additionalProperties", True)
|
|
for key, sub in value.items():
|
|
if key in props:
|
|
errs += mini_validate(sub, props[key], root, "%s.%s" % (path, key))
|
|
elif extra is False:
|
|
errs.append("%s: unknown key '%s'" % (path, key))
|
|
elif isinstance(extra, dict):
|
|
errs += mini_validate(sub, extra, root, "%s.%s" % (path, key))
|
|
if isinstance(value, list):
|
|
if len(value) < schema.get("minItems", 0):
|
|
errs.append("%s: needs at least %d items" % (path, schema["minItems"]))
|
|
if "items" in schema:
|
|
for i, item in enumerate(value):
|
|
errs += mini_validate(item, schema["items"], root, "%s[%d]" % (path, i))
|
|
return errs
|
|
|
|
|
|
def schema_errors(data, schema):
|
|
try:
|
|
import jsonschema
|
|
except ImportError:
|
|
return mini_validate(data, schema)
|
|
validator = jsonschema.Draft7Validator(schema)
|
|
return ["$%s: %s" % ("".join(".%s" % p if isinstance(p, str) else "[%d]" % p for p in e.absolute_path), e.message)
|
|
for e in validator.iter_errors(data)]
|
|
|
|
|
|
def semantic_errors(data):
|
|
"""Cross-references the schema cannot express."""
|
|
errs = []
|
|
terms, execs = data.get("terminals", {}), data.get("executors", {})
|
|
for tname, t in terms.items():
|
|
for ex in t.get("executors", []):
|
|
if ex not in execs:
|
|
errs.append("terminals.%s: unknown executor '%s'" % (tname, ex))
|
|
if t.get("transport") == "warpgate-ssh" and t.get("os") == "windows":
|
|
if t.get("launch") != "schtasks":
|
|
errs.append("terminals.%s: warpgate-ssh on windows needs launch=schtasks" % tname)
|
|
if t.get("target", tname) != "win10":
|
|
errs.append("terminals.%s: the Windows helper scripts only reach target 'win10' for now" % tname)
|
|
elif t.get("transport") == "warpgate-ssh":
|
|
if t.get("launch") != "nohup":
|
|
errs.append("terminals.%s: warpgate-ssh on %s needs launch=nohup" % (tname, t.get("os")))
|
|
for key in ("repo", "run_root"):
|
|
if key in t and not str(t[key]).startswith("/"):
|
|
errs.append("terminals.%s.%s: must be an absolute path on the remote (no ~)" % (tname, key))
|
|
if not data.get("vault", {}).get("config"):
|
|
errs.append("terminals.%s: remote POSIX terminals need vault.config (relay identity)" % tname)
|
|
if t.get("transport") == "local" and t.get("os") != "linux":
|
|
errs.append("terminals.%s: local transport supports only os=linux" % tname)
|
|
for ename, e in execs.items():
|
|
adapter = e.get("adapter", ename)
|
|
if adapter not in ("codex", "claude-code", "shell"):
|
|
errs.append("executors.%s: no adapter '%s' (set 'adapter')" % (ename, adapter))
|
|
cli = data.get("agentd", {}).get("cli_terminal")
|
|
if cli and cli not in terms:
|
|
errs.append("agentd.cli_terminal: unknown terminal '%s'" % cli)
|
|
for pname, p in data.get("projects", {}).items():
|
|
routing = p.get("routing", {})
|
|
for user, tname in routing.get("by_assignee", {}).items():
|
|
if tname not in terms:
|
|
errs.append("projects.%s.routing.by_assignee.%s: unknown terminal '%s'" % (pname, user, tname))
|
|
for tname, ex in routing.get("default_executor", {}).items():
|
|
if tname not in terms:
|
|
errs.append("projects.%s.routing.default_executor: unknown terminal '%s'" % (pname, tname))
|
|
elif ex not in terms[tname].get("executors", []):
|
|
errs.append("projects.%s.routing.default_executor.%s: '%s' not in that terminal's executors" % (pname, tname, ex))
|
|
for label, route in routing.get("by_label", {}).items():
|
|
if "executor" in route and route["executor"] not in execs:
|
|
errs.append("projects.%s.routing.by_label.%s: unknown executor '%s'" % (pname, label, route["executor"]))
|
|
backoff = p.get("retry", {}).get("backoff_minutes")
|
|
if backoff is not None and not backoff:
|
|
errs.append("projects.%s.retry.backoff_minutes: empty" % pname)
|
|
return errs
|
|
|
|
|
|
def validate(data, schema=None):
|
|
if schema is None:
|
|
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
errs = schema_errors(data, schema)
|
|
return errs or semantic_errors(data)
|
|
|
|
|
|
class Config:
|
|
def __init__(self, data, path=None):
|
|
self.data = data
|
|
self.path = path
|
|
self.plane = data["plane"]
|
|
self.terminals = data["terminals"]
|
|
self.executors = data["executors"]
|
|
self.projects = data["projects"]
|
|
self.agentd = data.get("agentd", {})
|
|
self.webhook = data.get("webhook", {})
|
|
self.notify = data.get("notify") or {"kind": "none"}
|
|
|
|
@property
|
|
def state_dir(self):
|
|
p = Path(os.path.expanduser(self.agentd.get("state_dir", "state")))
|
|
return p if p.is_absolute() else ROOT / p
|
|
|
|
def helpers_dir(self, terminal):
|
|
d = self.terminals[terminal].get("helpers_dir") or self.agentd.get("helpers_dir") or "tools/win10"
|
|
p = Path(os.path.expanduser(d))
|
|
return p if p.is_absolute() else ROOT / p
|
|
|
|
@property
|
|
def log_dir(self):
|
|
"""agentd.log lives here (default: state_dir)."""
|
|
d = self.agentd.get("log_dir")
|
|
if not d:
|
|
return self.state_dir
|
|
p = Path(os.path.expanduser(d))
|
|
return p if p.is_absolute() else ROOT / p
|
|
|
|
def token_env(self, terminal):
|
|
"""Environment variable that carries the terminal's Plane token (== the Vault key name)."""
|
|
return self.terminals[terminal]["token_key"]
|
|
|
|
def token(self, terminal):
|
|
return os.environ.get(self.token_env(terminal))
|
|
|
|
def secret_keys(self):
|
|
"""Every env var name that carries a secret (Plane tokens, the webhook secret): never handed to children."""
|
|
keys = {t["token_key"] for t in self.terminals.values()}
|
|
if self.webhook.get("secret_key"):
|
|
keys.add(self.webhook["secret_key"])
|
|
return keys
|
|
|
|
def scrubbed_env(self):
|
|
"""os.environ without the secrets above, for every child process agentd starts."""
|
|
drop = self.secret_keys()
|
|
return {k: v for k, v in os.environ.items() if k not in drop}
|
|
|
|
def secret_env_args(self):
|
|
"""`--secret-env` arguments for agent-access.py exec, one per distinct token key."""
|
|
keys = sorted({t["token_key"] for t in self.terminals.values()})
|
|
return ["--secret-env %s=%s" % (k, k) for k in keys]
|
|
|
|
def project(self, name):
|
|
if name not in self.projects:
|
|
raise ConfigError("unknown project %s (have %s)" % (name, ", ".join(self.projects)))
|
|
return self.projects[name]
|
|
|
|
|
|
def load(path=None):
|
|
if path is None:
|
|
env = os.environ.get("AGENTD_CONFIG")
|
|
candidates = [Path(env)] if env else list(DEFAULT_PATHS)
|
|
path = next((p for p in candidates if p.exists()), None)
|
|
if path is None:
|
|
raise ConfigError("no config found (tried %s); copy config/agentd.example.yaml" % ", ".join(map(str, candidates)))
|
|
path = Path(path)
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
errs = validate(data)
|
|
if errs:
|
|
raise ConfigError("%s is invalid:\n " % path + "\n ".join(errs))
|
|
return Config(data, path)
|