{ config, pkgs, ... }: let stateDir = "/var/lib/finance-agent"; # Lukas copies his own repo here (its own AGENTS.md/agent.md defines the # role -- see finance-agent/README.md). Also Codex's one --add-dir/ # workspace-write target, so the role itself can maintain its data (CSV, # downloaded invoice scans, ...) directly, unlike the read-only agent@ on # blunix. roleDir = "/home/drone/finance-agent"; runtimeXmppConfig = "${stateDir}/sendxmpp.conf"; listenerScript = ./finance-agent/listener.py; workerScript = ./finance-agent/worker.py; mailToolScript = ./finance-agent/mailtool.py; # Strictly read-only IMAP: search INBOX for messages with attachments, # fetch one attachment to disk. Never marks-as-read (opens the mailbox # via IMAP EXAMINE, not SELECT), never deletes/moves/sends. Same account # as home/tnix/localModules/aerc.nix -- reuses mailPW, doesn't duplicate # it. Wrapped as a $PATH binary so Codex just calls `finance-mail ...` # the same way it already calls `curl` for upload downloads, rather than # needing to know the underlying script's store path. mailToolBin = pkgs.writeShellApplication { name = "finance-mail"; runtimeInputs = [ pkgs.python3 ]; text = '' python3 ${mailToolScript} "$@" ''; }; codexHome = "${stateDir}/codex-home"; agentHome = "${stateDir}/home"; owner = "lukas@${config.domain}"; xmppConfig = pkgs.writeText "finance-sendxmpp.conf" '' username: finance jserver: bot.${config.domain} eval_password: ${pkgs.coreutils}/bin/cat ${config.age.secrets.financeXmpp.path} ''; # Same pattern as hosts/blunix/localServices/agent-codex.nix's # prepareCodexHome, but sourced from pinix's own drone home -- `codex # login` has to be run once, interactively, as drone on pinix before this # can find anything to copy. prepareCodexHome = pkgs.writeShellScript "prepare-finance-agent-home" '' set -eu ${pkgs.coreutils}/bin/install -d -m 700 ${codexHome} ${agentHome} if [ ! -s ${codexHome}/auth.json ]; then ${pkgs.coreutils}/bin/install \ -m 600 \ /home/drone/.codex/auth.json \ ${codexHome}/auth.json fi ''; commonEnvironment = { AGENT_ALLOWED_SENDERS = owner; AGENT_CODEX_BIN = "${pkgs.codex}/bin/codex"; AGENT_CODEX_HOME = codexHome; # Smaller/cheaper than the account's frontier default -- this bot only # reads invoices and answers simple financial questions, doesn't need # top-tier reasoning, and burns through the ChatGPT usage cap fast # otherwise (hit the weekly limit 2026-08-05). AGENT_CODEX_MODEL = "gpt-5.6-luna"; AGENT_DEBOUNCE_SECONDS = "3"; AGENT_FAILED_DIR = "${stateDir}/failed"; AGENT_HOME = agentHome; AGENT_MAX_INPUT_CHARS = "20000"; AGENT_MAX_RESPONSE_CHARS = "12000"; AGENT_PROCESSING_DIR = "${stateDir}/processing"; AGENT_QUEUE_DIR = "${stateDir}/inbox"; AGENT_ROLE_DIR = roleDir; AGENT_WRITABLE_DIRS = roleDir; AGENT_SENDER_STATE_DIR = "${stateDir}/senders"; AGENT_SESSION_DIR = "${stateDir}/sessions"; AGENT_SESSION_IDLE_SECONDS = "1800"; AGENT_SENDXMPP_BIN = "${pkgs.go-sendxmpp}/bin/go-sendxmpp"; AGENT_XMPP_CONFIG = runtimeXmppConfig; XDG_CACHE_HOME = "${stateDir}/xdg-cache"; XDG_DATA_HOME = "${stateDir}/xdg-data"; # Same Zoho mailbox as home/tnix/localModules/aerc.nix/msmtp.nix. # Read-only IMAP only (see mailtool.py) -- no SMTP/sending here. FINANCE_MAIL_HOST = "imappro.zoho.eu"; FINANCE_MAIL_USER = "mail@lukasrosier.com"; FINANCE_MAIL_PASSWORD_FILE = config.age.secrets.mailPW.path; }; in { # Empty until Lukas copies his repo in; owned by drone from the start so # the copy (scp/rsync/git clone as drone) just works without a manual # chown first. systemd.tmpfiles.rules = [ "d ${roleDir} 0700 drone users - -" ]; systemd.services.finance-xmpp-listener = { description = "Queue allowlisted XMPP messages for the finance agent"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; environment = commonEnvironment; serviceConfig = { User = "drone"; Group = "users"; StateDirectory = "finance-agent"; StateDirectoryMode = "0700"; UMask = "0077"; ExecStartPre = "${pkgs.coreutils}/bin/install -m 600 ${xmppConfig} ${runtimeXmppConfig}"; ExecStart = pkgs.writeShellScript "finance-xmpp-listener" '' set -o pipefail ${pkgs.go-sendxmpp}/bin/go-sendxmpp \ --file ${runtimeXmppConfig} \ --listen \ | ${pkgs.python3}/bin/python3 ${listenerScript} ''; Restart = "always"; RestartSec = "10s"; NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = "read-only"; }; }; # Workspace-write over roleDir (unlike blunix's read-only agent@) -- # this role's whole point is maintaining its own data (CSV, invoice # scans) there, not just talking. No git/commit workflow: it's not a # repo Codex needs to push anywhere, just files it owns. systemd.services.finance-agent-worker = { description = "Process queued XMPP messages with the finance agent"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; environment = commonEnvironment; path = [ pkgs.curl mailToolBin ]; serviceConfig = { Type = "oneshot"; User = "drone"; Group = "users"; WorkingDirectory = roleDir; StateDirectory = "finance-agent"; StateDirectoryMode = "0700"; UMask = "0077"; ExecStartPre = prepareCodexHome; ExecStart = "${pkgs.python3}/bin/python3 ${workerScript}"; TimeoutStartSec = "20min"; NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = "read-only"; ReadWritePaths = [ roleDir ]; }; }; systemd.paths.finance-agent-worker = { description = "Watch the finance agent's XMPP inbox"; wantedBy = [ "multi-user.target" ]; pathConfig = { PathExistsGlob = "${stateDir}/inbox/*.json"; Unit = "finance-agent-worker.service"; }; }; }