"""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 base64 import json import os import struct from pathlib import Path import yaml from . import ROOT SCHEMA_PATH = ROOT / "config" / "schema.json" DEFAULT_SSH_KEY = "AGENTD_SSH_KEY" # env var (== Vault key name) carrying the direct-ssh private key 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 host_key_error(value): """None if `value` is a usable pinned host key ('ssh-ed25519 ' whose blob names the same type).""" parts = str(value).split() if len(parts) < 2 or parts[0] != "ssh-ed25519": return "must be 'ssh-ed25519 ' (the terminal's /etc/ssh/ssh_host_ed25519_key.pub, verified out of band)" try: blob = base64.b64decode(parts[1], validate=True) n = struct.unpack(">I", blob[:4])[0] ok = blob[4:4 + n] == b"ssh-ed25519" and len(blob) == 4 + n + 4 + 32 except (ValueError, struct.error): ok = False return None if ok else "the base64 part is not an ssh-ed25519 public key (copy/paste damage?)" def ssh_dir_of(data): d = (data.get("agentd") or {}).get("ssh_dir") if d: p = Path(os.path.expanduser(d)) return p if p.is_absolute() else ROOT / p s = Path(os.path.expanduser((data.get("agentd") or {}).get("state_dir", "state"))) return (s if s.is_absolute() else ROOT / s) / "ssh" def remote_errors(data, tname, t, kind): """Rules every remote transport (warpgate-ssh, direct-ssh, and the fallback channel) shares.""" errs = [] if t.get("os") == "windows": if t.get("launch") != "schtasks": errs.append("terminals.%s: %s on windows needs launch=schtasks" % (tname, kind)) if kind == "warpgate-ssh" and t.get("target", tname) != "win10": errs.append("terminals.%s: the Windows helper scripts only reach target 'win10' for now" % tname) else: if t.get("launch") != "nohup": errs.append("terminals.%s: %s on %s needs launch=nohup" % (tname, kind, 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 kind == "warpgate-ssh" and not data.get("vault", {}).get("config"): errs.append("terminals.%s: remote POSIX terminals need vault.config (relay identity)" % tname) return errs 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)) kind = t.get("transport") if kind == "warpgate-ssh": errs += remote_errors(data, tname, t, kind) elif kind == "direct-ssh": errs += remote_errors(data, tname, t, kind) s = t.get("ssh") if not s: errs.append("terminals.%s: direct-ssh needs ssh: {host, user, port, host_key}" % tname) else: if not s.get("host") or str(s["host"]).startswith("-") or not s.get("user"): errs.append("terminals.%s.ssh: host and user must be non-empty (host not starting with '-')" % tname) if s.get("port", 22) > 65535: errs.append("terminals.%s.ssh.port: %r > 65535" % (tname, s["port"])) why = host_key_error(s.get("host_key", "")) if why: errs.append("terminals.%s.ssh.host_key: %s" % (tname, why)) if t.get("fallback") == "warpgate-ssh": if "target" not in t: errs.append("terminals.%s: fallback warpgate-ssh needs the Warpgate 'target'" % tname) elif t.get("os") == "windows" and t["target"] != "win10": errs.append("terminals.%s: the Windows helper scripts (fallback) only reach target 'win10' for now" % tname) if t.get("os") != "windows" and not data.get("vault", {}).get("config"): errs.append("terminals.%s: fallback warpgate-ssh needs vault.config (relay identity)" % tname) elif t.get("fallback"): errs.append("terminals.%s: 'fallback' only applies to transport direct-ssh" % tname) if kind == "local" and t.get("os") != "linux": errs.append("terminals.%s: local transport supports only os=linux" % tname) ctl = ssh_dir_of(data) # "/-<40-char %C>", plus ".<16 chars>" while ssh sets the socket up if any(t.get("transport") == "direct-ssh" for t in terms.values()) and len(str(ctl)) + 1 + 9 + 40 + 17 > 104: errs.append("agentd.ssh_dir: %s is too long for the ControlMaster socket path (UNIX limit 104)" % ctl) 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 @property def ssh_dir(self): """direct-ssh ControlMaster sockets + generated known_hosts (default /ssh).""" return ssh_dir_of(self.data) @property def ssh_key_env(self): """Env var (== Vault key name) holding the direct-ssh private key.""" return self.agentd.get("ssh_key") or DEFAULT_SSH_KEY def uses_direct_ssh(self): return any(t["transport"] == "direct-ssh" for t in self.terminals.values()) 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. The direct-ssh key variable is always dropped, even with no direct-ssh terminal (a rollback may leave the cron script injecting it).""" drop = self.secret_keys() | {self.ssh_key_env} 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, plus the direct-ssh key.""" keys = sorted({t["token_key"] for t in self.terminals.values()}) if self.uses_direct_ssh(): keys.append(self.ssh_key_env) 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)