{ config, pkgs, ... }: # Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix, # see its git history / b5fa599 / 713d91d for the terra->jupiter->mars # lineage). mars is dedicated to this one service, on-site, with no big # data array of its own — unlike jupiter it has nothing under /mnt/data, so # state lives on the local OS disk and the shared dropbox rides jupiter's # samba share as a CIFS client instead of being served locally. # # Runs the OFFICIAL published image (docker.io/nousresearch/hermes-agent — # real and actively maintained, contrary to what the checked-out repo's own # README/docker-compose.yml suggested; verified directly on Docker Hub) as a # plain podman container. It never sets HERMES_MANAGED or writes .managed, so # Hermes fully self-manages config.yaml, profiles, memories and skills at # runtime — no redeploy needed except to bump the pinned digest below. # # Security posture: # - Reachable paths: its own local state dir, the small shared "dropbox" # (via the jupiter samba mount) for darman to hand files to Hermes, and # `git`/`tea`, logged in as the `luna` gitea account (PR-tier only — # see services/dev/gitea.nix). No working copy of this repo is # provisioned for her: an earlier version cloned one into # ${hermesHome}/workspace/homelab, dropped again because nothing ever # told her at runtime where it was (she self-manages config/profiles/ # memories, so a host-side path in this file never reached her) — she # searched /opt/data/homelab and /workspace, found neither, and # concluded she had no repo at all. She can clone one herself if she # wants; the credentials below are what actually grants the access. # Nothing else on jupiter's array or the host is reachable if a # command goes wrong or gets injected via Telegram/tool output. # - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT # TELEGRAM_ALLOWED_USERS. # - Runs as a rootful podman container (services/containers.nix) with its # OWN numeric uid/gid — not darman, who is in the "hermes" group for # host-level debugging only (`hermes ...` alias below, needs sudo since # the container itself runs under root's podman, not darman's rootless # one). # - git/tea access is direct CLI, not a narrow wrapper: darman explicitly # chose this over a purpose-built MCP server (tried first, scrapped — # see git history) in favor of simplicity. The backstop is entirely # server-side: gitea's branch protection on `master` (only darman can # push/merge/approve there) is what actually keeps a bad or injected # command from reaching the base branch, not anything client-side here. # # Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on # jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth # provider registered, and 0.0.0.0 (not loopback) is required so neptun's # Caddy can reach it over tailscale0 — reachability itself stays LAN-closed # (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a # trustedInterface, services/vpn/tailscale.nix). Public route: neptun's # hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this # over the tailnet. mars runs no Caddy of its own (single-purpose box), so # there is no LAN vhost — reach the dashboard directly via mars's tailnet # name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging. # # Uses upstream's generic self-hosted OIDC plugin, same Authentik # application as before (slug `hermes`) — the client ID/secret didn't need # to change since the public redirect URI (hermes.mgaction.town) didn't. # # Data migration: this starts with a FRESH state dir. jupiter's instance was # itself reset to fresh on 2026-08-21 (see its old hermes-agent.nix), so # there was nothing irreplaceable to carry forward; if that turns out to be # wrong, jupiter's old data is backed up at # /mnt/data/AppData/hermes.bak-2026-08-21 and can be rsynced into # ${hermesHome} below before the first switch on mars. let stateDir = "/var/lib/hermes"; hermesHome = "${stateDir}/.hermes"; # Shared drop-in folder: darman can put files here from any host. Lives on # jupiter's array (reachable at /mnt/jupiter, the samba mount below) rather # than locally, so it's the same physical location it always was — only # the container reading it moved. Mounted under /opt/data so it falls # inside Hermes's own sealed write-safe root (HERMES_WRITE_SAFE_ROOT= # /opt/data) rather than a path its own tooling would treat as untrusted. dropboxDir = "/mnt/jupiter/AppData/hermes-dropbox"; # Pinned by digest (captured 2026-08-21 via `podman image inspect # docker.io/nousresearch/hermes-agent:latest --format '{{.Digest}}'` on # jupiter) rather than floating `:latest`, so a redeploy is reproducible — # bumping Hermes is an explicit edit here, not silent drift on next pull. hermesImage = "docker.io/nousresearch/hermes-agent@sha256:5342e518734a08f6c66b89b4262434813c28a77abbc59c230c8f1637df71a259"; # Kept identical to jupiter's instance purely so nothing else needs to # change if state ever gets migrated over. hermesUid = "986"; hermesGid = "983"; # luna's gitea identity (account + PR-tier repo access provisioned in # services/dev/gitea.nix). Only the server is pinned here — any checkout # is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data. giteaHost = "git.mgaction.town"; # luna's webhook filters, mounted READ-ONLY below. They live in the nix store # rather than being written into hermesHome because hermesHome IS # HERMES_WRITE_SAFE_ROOT: a filter dropped there is a loop guard sitting # inside the writable root of the agent it constrains, and she could edit # it back out. Deleting it would fail closed (Hermes treats a missing # script as "ignore"), but rewriting it to always-allow would silently # restore the reply loop. Read-only from the store makes that impossible # and keeps the guard versioned in git — same reasoning as the git/tea # binaries mounted below. prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" ( builtins.readFile ./gitea-pr-comment-filter.py ); prReviewFilter = pkgs.writeText "gitea-pr-review-filter.py" ( builtins.readFile ./gitea-pr-review-filter.py ); # The route prompts. These are NOT mounted into the container: the route # config below embeds them as strings, and jq reads them from these store # paths host-side with --rawfile. Keeping them in files rather than inline # nix strings is still what makes that work — they are ~60 lines of markdown # full of apostrophes and {placeholders} that would otherwise have to # survive nix string escaping on the way into a shell command. --rawfile # crosses all of that untouched, and they stay diffable in git. prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" ( builtins.readFile ./gitea-pr-comment-prompt.md ); prReviewPrompt = pkgs.writeText "gitea-pr-review-prompt.md" ( builtins.readFile ./gitea-pr-review-prompt.md ); # Wire event names (X-GitHub-Event) each route accepts — NOT the # subscription names the gitea hooks in services/dev/gitea.nix use. The two # namespaces collide; see the long comment on the route unit below. prCommentEvents = [ "issue_comment" ]; prReviewEvents = [ "pull_request_comment" "pull_request_rejected" ]; # Toolsets granted to both routes' agent runs. # # Hermes defaults webhook runs to a deliberately narrow set (web_search, # web_extract, vision_analyze, clarify) because a webhook payload is # third-party content. That default cannot clone, edit or push, so neither # prompt was executable under it: the run would be woken, read the comment, # and have no way to act on it. # # This list REPLACES the platform default for these routes rather than # merging with it, so anything the default provided has to be re-listed — # "web" is here for that reason, not because the prompts ask for research. # # Upstream's stated boundary is that `hermes webhook subscribe` has no # --toolsets flag, so "an agent creating its own subscription at runtime # cannot self-grant terminal". That boundary does NOT hold here and must not # be relied on: webhook_subscriptions.json lives under /opt/data, which is # HERMES_WRITE_SAFE_ROOT, so luna can edit her own grant — she already did # once, which is why this moved into nix. What this buys is that the grant # is deliberate, reviewable and re-asserted on every restart, not that it is # unforgeable. The real backstop stays server-side: gitea's branch # protection on master. routeToolsets = [ "terminal" "file" "web" ]; # hermesHome as the CONTAINER sees it (the bind mount below). Anything # written host-side that gets READ back inside the container must use this # prefix, not hermesHome — see the credential.helper below, which was # broken exactly that way from 3c1f3e5 until 2026-08-23. containerHome = "/opt/data"; in { # Browsing convenience (ssh access to the bind-mounted local state) — does # NOT touch the container, which keeps using HERMES_UID/GID above # regardless of what's declared here. users.groups.hermes.gid = 983; users.users.darman.extraGroups = [ "hermes" ]; # `hermes ` on mars == `sudo podman exec -it hermes-agent hermes `. # sudo is required: virtualisation.oci-containers runs rootful (system) # podman, a separate namespace from darman's own rootless `podman`/`docker` # — darman's "hermes"/"docker" group membership only grants filesystem # access to the bind-mounted state dir, not to root's container socket. programs.zsh.shellAliases.hermes = "sudo podman exec -it hermes-agent hermes"; systemd.tmpfiles.rules = [ "d ${stateDir} 0750 root hermes -" ]; # podman requires the bind-mount source to already exist (no auto-create), # and the dropbox lives on the CIFS mount below — mkdir there works fine # over cifs, no server-side (jupiter) config needed. # # Also provisions luna's git/tea access: writes a git credential-store file # and runs `tea logins add` INTO hermesHome (i.e. paths that appear at # /opt/data/... once the container is up). Both run on the HOST as root, # before the container starts, and both therefore have to chown what they # write themselves — see the chown at the end of the script. Do NOT assume # the image's cont-init fixes ownership under hermesHome: it does not # recurse into what this oneshot drops there, even though it runs after it. # # It deliberately does NOT clone the repo for her any more (see the # header). The stale ${hermesHome}/workspace/homelab left behind by the # version that did is not cleaned up here either — it just stops being # managed, and stops being updated. Remove it by hand if you want it gone. # # Delete-then-add for the tea login (not a "does it exist" check): tea can # leave a login entry behind even when `add` reports failure (e.g. a token # missing a scope errors out AFTER the entry is written — observed # directly against the real instance during the first version of this # setup). Delete-then-add is idempotent either way and picks up a rotated # token for free. systemd.services.hermes-agent-prepare-dirs = { description = "Create Hermes state dirs + luna's git/tea access before the container starts"; before = [ "podman-hermes-agent.service" ]; wantedBy = [ "podman-hermes-agent.service" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; path = [ pkgs.git pkgs.tea ]; serviceConfig.Type = "oneshot"; script = '' mkdir -p ${hermesHome} mkdir -p ${dropboxDir} # Parent for the read-only filters bind-mounted at # /opt/data/scripts/gitea-pr-*-filter.py. /opt/data is itself a bind # mount of hermesHome, so this directory has to exist HOST-side before # podman can mount a file inside it. mkdir -p ${hermesHome}/scripts export HOME=${hermesHome} export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig export XDG_CONFIG_HOME=${hermesHome}/.config token_file=${config.sops.secrets.gitea_luna_token.path} # Never embed the token in a remote URL (it would land in that # clone's .git/config in plaintext) — the credential helper reads it # from this file instead. install -m 0600 /dev/null ${hermesHome}/.git-credentials printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \ > ${hermesHome}/.git-credentials # containerHome, NOT hermesHome: git reads this .gitconfig from INSIDE # the container, where the host path does not exist. Nothing host-side # consumes these credentials any more (the clone that used to is gone), # so the container's view is the only one that has to be right. git config --global credential.helper "store --file=${containerHome}/.git-credentials" git config --global user.name "luna" git config --global user.email "luna@${giteaHost}" tea logins delete luna 2>/dev/null || true GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \ --name luna --url "https://${giteaHost}" --no-version-check # Hand everything written above to the container's uid/gid. This does # NOT happen by itself: the image's cont-init only chowns hermesHome's # top level and its own state, so root-owned 0600 files dropped here by # this oneshot (.git-credentials, and tea's config.yml — tea writes it # 0600 too) are simply unreadable to uid ${hermesUid}. Symptom is not an # error but an absence: git reports no credential helper and tea reports # no login, i.e. "they're missing". Confirmed on the real instance # 2026-08-23 — cont-init ran AFTER these files were written and left # them root-owned regardless. # # `if`, not `[ -d x ] && chown`: this script runs under `set -e`, where # a false test as the left side of an && list takes the whole list's # non-zero status and aborts the unit. chown ${hermesUid}:${hermesGid} \ ${hermesHome}/.gitconfig \ ${hermesHome}/.git-credentials # Same cont-init caveat as the files above: the directory is created # here as root, and Hermes reads its scripts as uid ${hermesUid}. The # mounted filters themselves are world-readable 0444 from the store, so # only the directory needs handing over. chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts if [ -d ${hermesHome}/.config ]; then chown ${hermesUid}:${hermesGid} ${hermesHome}/.config fi if [ -d ${hermesHome}/.config/tea ]; then chown -R ${hermesUid}:${hermesGid} ${hermesHome}/.config/tea fi ''; }; virtualisation.oci-containers.containers.hermes-agent = { image = hermesImage; autoStart = true; # Host networking: Hermes only long-polls Telegram outbound, no inbound # ports to publish (same reasoning as clonarr on jupiter). extraOptions = [ "--network=host" ]; # Upstream's own documented single-mount pattern (docker/docker-compose.yml): # ~/.hermes:/opt/data. volumes = [ "${hermesHome}:/opt/data" "${dropboxDir}:/opt/data/dropbox" # luna's Obsidian vault, kept in sync with CouchDB on jupiter by # livesync-bridge.nix. Under /opt/data so it lands inside # HERMES_WRITE_SAFE_ROOT and she can write notes, not just read them — # same reasoning as the dropbox above. The bridge runs as this very # uid/gid, so no ownership fixup is needed on either side. "/var/lib/livesync-bridge/vault:/opt/data/vault" # git/tea for luna: the image doesn't ship `tea` (and shouldn't be # trusted to have a known-good `git` either), so both come from this # host's Nix store instead — mounted read-only at fixed PATH-visible # locations. /nix/store itself has to come along too since both # binaries are dynamically linked against paths inside it; the store # is read-only content-addressed build output, not a source of # secrets, so mounting the whole thing read-only costs nothing beyond # the two specific binaries actually being reachable. # Read-only: see prCommentFilter above. Hermes resolves route scripts # under ~/.hermes/scripts, which is /opt/data/scripts in here. The route # prompts are NOT mounted — they are embedded in the route config the # unit below writes, so nothing inside the container reads them. "${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro" "${prReviewFilter}:/opt/data/scripts/gitea-pr-review-filter.py:ro" "/nix/store:/nix/store:ro" "${pkgs.git}/bin/git:/usr/local/bin/git:ro" "${pkgs.tea}/bin/tea:/usr/local/bin/tea:ro" ]; environment = { HERMES_UID = hermesUid; HERMES_GID = hermesGid; TZ = "Europe/Berlin"; # Point git/tea at the config the prepare-dirs oneshot wrote into # hermesHome (visible here as /opt/data/...) — the credential-store # helper, the luna gitea login, and (implicitly, via HOME not being # overridden) darman's Hermes state stays wherever it already was. GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig"; XDG_CONFIG_HOME = "/opt/data/.config"; # HERMES_TIMEZONE is the highest-priority source hermes_time.py checks # (ahead of config.yaml's `timezone` key) — the container has no host # /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in # 9403122 on jupiter; carried forward here). HERMES_TIMEZONE = "Europe/Berlin"; # Dashboard + Authentik OIDC gate — see the file-level comment above. HERMES_DASHBOARD = "1"; HERMES_DASHBOARD_HOST = "0.0.0.0"; # must be tailscale0-reachable, not just loopback HERMES_DASHBOARD_OIDC_ISSUER = "https://auth.mgaction.town/application/o/hermes/"; HERMES_DASHBOARD_OIDC_CLIENT_ID = "4BqdJu3htnMtSZnyEu5zHnsSOvlEbw3Ie3mYVlh6"; # uvicorn's proxy_headers=True (web_server.py) only trusts # X-Forwarded-Proto from forwarded_allow_ips, which defaults to # 127.0.0.1 — neptun's Caddy reaches this over the tailnet (a real # routed IP), so without this the dashboard sees the raw scheme (http) # and builds an http:// redirect_uri that Authentik rejects against its # registered https:// one. Safe to trust any peer here: 9119 is already # scoped to loopback + tailscale0 only (no LAN firewall rule), so # nothing untrusted can reach this process to begin with. FORWARDED_ALLOW_IPS = "*"; }; environmentFiles = [ config.sops.templates."hermes-agent.env".path ]; cmd = [ "gateway" "run" ]; }; systemd.services.podman-hermes-agent = { after = [ "hermes-agent-prepare-dirs.service" "systemd-tmpfiles-setup.service" ]; requires = [ "hermes-agent-prepare-dirs.service" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; }; # The two Gitea webhook routes, written as config rather than created with # `hermes webhook subscribe`. # # Gitea posts straight at Hermes (jupiter's gitea-hermes-webhook-provision # registers one hook per route at http://mars.orbit.sol:8644/webhooks/) # — there is no relay in between. Gitea's addDefaultHeaders sends # X-Hub-Signature-256 in GitHub's exact format AND X-GitHub-Event, # unconditionally, for every webhook type, which is precisely what Hermes # validates and reads the event name from. # # WHY NOT `hermes webhook subscribe`: it has no --toolsets flag, and without # a toolset override a webhook run gets Hermes's constrained default # (web_search, web_extract, vision_analyze, clarify) — no shell, no file # access, so neither prompt below can actually be carried out. Upstream's # documented answer is to write the `toolsets` key into # webhook_subscriptions.json by hand. Doing that by hand does not survive # this unit, which re-provisions on every start, so the whole route # definition moves here instead and the CLI is not used at all. See # routeToolsets above for what that costs. # # This writes the file HOST-side. hermesHome is bind-mounted at /opt/data, # so the container sees the same inode, and the webhook adapter hot-reloads # the file (mtime-gated) on the next delivery — no container restart, and no # `podman exec` quoting chain between nix and the prompt text. # # Events are WIRE names (X-GitHub-Event). Gitea spells the same events three # different ways and two of the spellings collide — from # HookEventType.Event() in modules/webhook/type.go, and updateHookEvents in # routers/api/v1/utils/hook.go for the api column: # # HookEventType wire name (here) api name (gitea.nix) # --------------------------- ---------------------- -------------------- # issue_comment issue_comment issue_comment # pull_request_comment issue_comment pull_request_comment # pull_request_review_comment pull_request_comment pull_request_review # pull_request_review_rejected pull_request_rejected pull_request_review # pull_request_review_approved pull_request_approved pull_request_review # # Hermes matches these against X-GitHub-Event, i.e. the WIRE name. So # "pull_request_comment" HERE means a review and "issue_comment" HERE means # a comment — the exact inversion of how they read. X-GitHub-Event-Type # carries the HookEventType, but Hermes does not look at it. This file and # services/dev/gitea.nix therefore name the same event differently on # purpose; neither is a typo. # # The api column is not a third alias but a coarser set: HasEvent # (models/webhook/webhook.go) collapses all three review types onto # pull_request_review, so the gitea hook cannot subscribe them separately. # Approvals arrive here as a result and are dropped by NOT being in # prReviewEvents — Hermes answers {"status": "ignored"} on the event match, # before the filter script and before any LLM call. Widening to approvals is # a mars-side change only: add "pull_request_approved" to prReviewEvents and # "pull_request_review_approved" to the filter's ALLOWED_REVIEW_TYPES. # # issue_comment on the wire covers comments on plain issues too; the hook # does not subscribe those, and the comment filter's is_pull check drops # them anyway if the hook is ever widened. # # deliver is "log", not a chat target: both prompts tell her to answer in # the pull request, so the PR comment IS the delivery. # # `script` is the selection that MUST NOT be retunable at runtime. # gitea-pr-comment-filter.py drops luna's own comments before any LLM call, # which is what stops the reply loop: the prompt tells her to answer on the # PR, and her answer is itself a pull_request_comment. Both filters are # bind-mounted read-only from the store above so the agent cannot edit her # own guard out. Hermes resolves the name relative to ~/.hermes/scripts, # hence the bare filename. # # What read-only does NOT buy: it protects the sources, and this unit # re-asserts prompt, filter, events and toolsets from them on every start, # so a restart restores the intended config. The live file is inside the # agent's own write-safe root, so a self-modification sticks until this unit # next runs. # # Routes this unit does not name are left alone (the merge below is # per-key), so retiring an old one stays a deliberate one-off: # sudo podman exec hermes-agent hermes webhook remove systemd.services.hermes-agent-webhook-routes = { description = "Write Hermes's Gitea webhook route config"; wantedBy = [ "multi-user.target" ]; # after, but not requires: this only writes a file that hermesHome must # already exist for. A container that fails to come up should not also # leave the routes unconfigured — the file is hot-reloaded whenever the # gateway does start. after = [ "hermes-agent-prepare-dirs.service" "podman-hermes-agent.service" ]; requires = [ "hermes-agent-prepare-dirs.service" ]; path = [ pkgs.jq ]; environment.SECRET_FILE = config.sops.secrets.gitea_hermes_webhook_secret.path; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' set -euo pipefail conf=${hermesHome}/webhook_subscriptions.json tmp="$conf.new" trap 'rm -f "$tmp"' EXIT # --slurpfile below cannot read a file that does not exist. Creating it # empty is safe: this only ever happens before the first run, when there # are no routes to lose. If it exists but is not valid JSON, slurpfile # fails the unit loudly and leaves it untouched, which is the right # direction — better a failed unit than silently discarded routes. [ -e "$conf" ] || printf '%s\n' '{}' > "$conf" # The secret reaches jq via --rawfile, never argv: /proc//cmdline # is world-readable, so `--arg secret "$(cat ...)"` would publish it to # every user on the box for the lifetime of the process. Same reason the # prompts come in by path rather than by value. # # sops stores this one without a trailing newline (see secrets.nix), but # rtrimstr is kept anyway: a stray newline would silently change the key # the HMAC is computed with and fail every delivery afterwards. # # The emptiness guards are load-bearing. Without them a truncated secret # file or an unreadable prompt yields "", and the route is written with # an empty secret — which fails EVERY signature check while the unit # still reports success. jq -n \ --slurpfile existing "$conf" \ --rawfile rawSecret "$SECRET_FILE" \ --rawfile commentPrompt ${prCommentPrompt} \ --rawfile reviewPrompt ${prReviewPrompt} \ --argjson commentEvents '${builtins.toJSON prCommentEvents}' \ --argjson reviewEvents '${builtins.toJSON prReviewEvents}' \ --argjson toolsets '${builtins.toJSON routeToolsets}' \ ' def nonempty($what): if length == 0 then error("\($what) is empty") else . end; ($rawSecret | rtrimstr("\n") | nonempty("gitea_hermes_webhook_secret")) as $secret | def route($desc; $events; $prompt; $script): { description: $desc, events: $events, secret: $secret, prompt: ($prompt | nonempty("\($script) prompt")), skills: [], script: $script, deliver: "log", toolsets: $toolsets }; # created_at is cosmetic (hermes webhook list prints it) and is the # one key carried over from whatever is already there, so it keeps # reading as when the route first appeared rather than as the last # deploy. Everything else is replaced outright: a leftover key from # an earlier definition — or from a hand edit — would otherwise # survive here forever. def upsert($name; $r): .[$name] = ($r + { created_at: (.[$name].created_at // (now | todate)) }); ($existing[0] // {}) | if type != "object" then error("webhook_subscriptions.json is not a JSON object") else . end | upsert("gitea-pr-comments"; route("Gitea PR comments -> L.U.N.A."; $commentEvents; $commentPrompt; "gitea-pr-comment-filter.py")) | upsert("gitea-pr-reviews"; route("Gitea PR reviews -> L.U.N.A."; $reviewEvents; $reviewPrompt; "gitea-pr-review-filter.py")) ' > "$tmp" # 0600 because the file holds the HMAC secret in cleartext, and owned by # the container's uid because Hermes rewrites it itself whenever anything # calls `hermes webhook subscribe`. mv is an atomic rename within the # same directory, so a delivery landing mid-write never reads a half # written config. chmod 0600 "$tmp" chown ${hermesUid}:${hermesGid} "$tmp" mv -f "$tmp" "$conf" ''; }; }