feat(flake): lock updates and protonmail-mcp flake

This commit is contained in:
Hermes Agent
2026-09-24 21:31:02 -04:00
parent 355b8c443e
commit f55067d0b7
13 changed files with 1143 additions and 0 deletions
+8
View File
@@ -24,3 +24,11 @@ creation_rules:
key_groups:
- age:
- *tower
- path_regex: secrets/protonmail\.yml$
key_groups:
- age:
- *tower
- path_regex: secrets/zitadel\.yml$
key_groups:
- age:
- *tower
Generated
+52
View File
@@ -183,6 +183,23 @@
"type": "github"
}
},
"firefox-devtools-mcp": {
"flake": false,
"locked": {
"lastModified": 1790073791,
"narHash": "sha256-Ch9dk7E3Nn1lt8dgrEo43P36oB73KfRwyHlZgDOyA8s=",
"owner": "mozilla",
"repo": "firefox-devtools-mcp",
"rev": "bdb377af9ce3a3596e8c196786014ecee50fe178",
"type": "github"
},
"original": {
"owner": "mozilla",
"repo": "firefox-devtools-mcp",
"rev": "bdb377af9ce3a3596e8c196786014ecee50fe178",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
@@ -1112,6 +1129,39 @@
"type": "github"
}
},
"proton-mail-bridge-client": {
"flake": false,
"locked": {
"lastModified": 1790247463,
"narHash": "sha256-AoIuoIb8us2DfyjgfoU0/AouILBnSIVNB1GGtw+AYH0=",
"owner": "googlarz",
"repo": "proton-mail-bridge-client",
"rev": "811be5de9dda5debf4d41ceae22b69c9fed8a42f",
"type": "github"
},
"original": {
"owner": "googlarz",
"repo": "proton-mail-bridge-client",
"type": "github"
}
},
"protonmail-mcp": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"proton-mail-bridge-client": "proton-mail-bridge-client"
},
"locked": {
"path": "./protonmail-mcp",
"type": "path"
},
"original": {
"path": "./protonmail-mcp",
"type": "path"
},
"parent": []
},
"pyproject-build-systems": {
"inputs": {
"nixpkgs": [
@@ -1446,6 +1496,7 @@
"clawdbot": "clawdbot",
"discord-bot": "discord-bot",
"disko": "disko",
"firefox-devtools-mcp": "firefox-devtools-mcp",
"hermes-agent": "hermes-agent",
"home-manager": "home-manager_4",
"jovian": "jovian",
@@ -1460,6 +1511,7 @@
"nur": "nur",
"option-pricer": "option-pricer",
"portfolio-optimizer": "portfolio-optimizer",
"protonmail-mcp": "protonmail-mcp",
"pyproject-nix": "pyproject-nix_8",
"sops": "sops",
"sopswarden": "sopswarden",
+11
View File
@@ -117,6 +117,16 @@
hermes-agent.url = "github:NousResearch/hermes-agent";
# hermes-agent.inputs.nixpkgs.follows = "github:NousResearch/hermes-agent";
protonmail-mcp.url = "path:./protonmail-mcp";
protonmail-mcp.inputs.nixpkgs.follows = "nixpkgs";
firefox-devtools-mcp = {
# Officiel Mozilla, MCP browser automation via WebDriver BiDi. Pin sur le
# commit de la release v0.10.4 (2026-09-22) ; flake=false, source brute.
url = "github:mozilla/firefox-devtools-mcp/bdb377af9ce3a3596e8c196786014ecee50fe178";
flake = false;
};
jovian.url = "github:Jovian-Experiments/Jovian-NixOS";
# jovian.inputs.nixpkgs.follows = "nixpkgs";
@@ -148,6 +158,7 @@
./profiles/common
./profiles/tower/configuration.nix
./services
inputs.protonmail-mcp.nixosModules.default
];
};
+142
View File
@@ -0,0 +1,142 @@
{
lib,
config,
pkgs,
...
}:
# Client Eternal Terminal : ouvre (ou crée) une session zellij sur l'hôte distant
# et l'enregistre côté serveur avec asciinema (script `et-session`, cf.
# services/eternal-terminal/default.nix — module côté serveur).
#
# et lit ~/.ssh/config : les alias `ssh` existants (modules/home/ssh) servent donc
# aussi de cibles à `et <alias>`.
let
cfg = config.my.et;
hostModule =
{ name, ... }:
{
options = {
remoteCommand = lib.mkOption {
type = lib.types.str;
default = cfg.remoteCommand;
description = ''
Commande lancée sur l'hôte distant à la connexion. Chemin absolu de
préférence : le PATH des shells non interactifs distants n'est pas garanti.
'';
};
session = lib.mkOption {
type = lib.types.str;
default = cfg.defaultSession;
description = "Nom de la session zellij à attacher (créée si absente).";
};
user = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Utilisateur distant (`et -u`) ; null = celui de ~/.ssh/config.";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
description = "Port de l'etserver distant (`et -p`), défaut ET = 2022 ; null = défaut.";
};
functionName = lib.mkOption {
type = lib.types.str;
default = "et${name}";
description = "Nom de la fonction zsh générée.";
};
};
};
in
{
options.my.et = {
enable = lib.mkEnableOption "connexions Eternal Terminal avec zellij et enregistrement automatiques";
defaultSession = lib.mkOption {
type = lib.types.str;
default = "main";
description = "Session zellij par défaut (surchargée par ET_SESSION).";
};
remoteCommand = lib.mkOption {
type = lib.types.str;
default = "/run/current-system/sw/bin/et-session";
description = ''
Commande de session distante. Le défaut est le chemin absolu du script
installé par services/eternal-terminal (chemin stable sur NixOS).
'';
};
wrapEt = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Redéfinir `et` en fonction zsh : `et <hôte>` attache zellij enregistré,
tout appel avec option (`et -v 3 hôte`, `et -t 10080:80 hôte`) passe
directement au binaire.
'';
};
hosts = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule hostModule);
default = { };
example = {
tower = {
port = 2022;
};
towerlocal = { };
};
description = ''
Alias à exposer : chaque entrée `name` génère une fonction zsh `etname`.
`name` doit être un hôte/alias présent dans ~/.ssh/config.
'';
};
};
config = lib.mkIf cfg.enable {
home.packages = [ pkgs.eternal-terminal ];
programs.zsh.initContent = lib.mkAfter (
let
mkFunction =
name: h:
let
flags = lib.escapeShellArgs (
lib.optionals (h.user != null) [ "-u" h.user ]
++ lib.optionals (h.port != null) [ "-p" (toString h.port) ]
++ [ name ]
);
in
''
${h.functionName}() {
command et ${flags} -c ${lib.escapeShellArg "${h.remoteCommand} ${h.session}"}
}
'';
in
''
# --- my.et : Eternal Terminal + zellij + enregistrement asciinema ---
# Attache (ou crée) une session zellij distante enregistrée : ets <hôte> [session]
ets() {
local host="${"$"}{1:?usage: ets <hôte> [session]}"
shift
local session="${"$"}{1:-${"$"}{ET_SESSION:-${cfg.defaultSession}}}"
command et "$host" -c "${"$"}{ET_REMOTE_COMMAND:-${cfg.remoteCommand}} $session"
}
${lib.concatStringsSep "\n" (lib.mapAttrsToList mkFunction cfg.hosts)}
''
+ lib.optionalString cfg.wrapEt ''
# et <hôte> (sans option) = zellij enregistré ; le reste passe au binaire.
et() {
if (( $# >= 1 )) && [[ "$1" != -* ]]; then
command et "$@" -c "${"$"}{ET_REMOTE_COMMAND:-${cfg.remoteCommand}} ${"$"}{ET_SESSION:-${cfg.defaultSession}}"
else
command et "$@"
fi
}
''
);
};
}
+29
View File
@@ -0,0 +1,29 @@
# modules/home/wayvnc/default.nix
# Démarre wayvnc automatiquement avec la session niri (via graphical-session.target,
# le même point d'accroche que ironbar : niri.service upstream fait
# BindsTo=graphical-session.target + Before=graphical-session.target, donc démarrer
# niri.service active aussi graphical-session.target, ce qui déclenche ce service).
#
# Sécurité : aucune option = wayvnc écoute uniquement sur 127.0.0.1:5900 (pas
# d'exposition LAN, pas d'auth nécessaire). Accès distant :
# ssh -L 5900:localhost:5900 marc@tower
# puis un client VNC sur vnc://localhost:5900 (macOS : `open vnc://localhost:5900`)
{pkgs, ...}: {
home.packages = [pkgs.wayvnc];
systemd.user.services.wayvnc = {
Unit = {
Description = "wayvnc VNC server (accès distant à la session niri)";
After = ["graphical-session.target"];
PartOf = ["graphical-session.target"];
};
Service = {
ExecStart = "${pkgs.wayvnc}/bin/wayvnc";
Restart = "on-failure";
RestartSec = "5s";
};
Install = {
WantedBy = ["graphical-session.target"];
};
};
}
+44
View File
@@ -0,0 +1,44 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1790218706,
"narHash": "sha256-6e4Na3z008XpdVyOXgfasXIn1aN+z8AXFG+jXdQSyuI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c508844df6c28fa6dabc1b6af70f3ccbd65c5201",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"proton-mail-bridge-client": {
"flake": false,
"locked": {
"lastModified": 1790247463,
"narHash": "sha256-AoIuoIb8us2DfyjgfoU0/AouILBnSIVNB1GGtw+AYH0=",
"owner": "googlarz",
"repo": "proton-mail-bridge-client",
"rev": "811be5de9dda5debf4d41ceae22b69c9fed8a42f",
"type": "github"
},
"original": {
"owner": "googlarz",
"repo": "proton-mail-bridge-client",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"proton-mail-bridge-client": "proton-mail-bridge-client"
}
}
},
"root": "root",
"version": 7
}
+275
View File
@@ -0,0 +1,275 @@
# Proton Mail MCP pour NixOS
#
# - packages.<system>.default : proton-mail-bridge-client (serveur MCP + CLI)
# https://github.com/googlarz/proton-mail-bridge-client
# - nixosModules.default : Proton Mail Bridge headless (service système)
# + entrée MCP prête à brancher dans Hermes
#
# Flake local (sous-dossier de ce repo), branché en path: depuis le flake.nix
# racine (pas de repo GitHub séparé pour l'instant) :
# inputs.protonmail-mcp.url = "path:./protonmail-mcp";
# inputs.protonmail-mcp.inputs.nixpkgs.follows = "nixpkgs";
# modules = [ inputs.protonmail-mcp.nixosModules.default ];
# (déplaçable vers "github:MarcPartensky/<repo>" si ça sort un jour dans son
# propre repo : même wiring, juste changer l'url ci-dessus.)
#
# Sur l'hôte Hermes (Bridge et le MCP doivent tourner sur la même machine) :
# sops.secrets."protonmail/bridge-password".owner = "hermes";
# services.protonmail-mcp = {
# enable = true;
# username = "marc.partensky@proton.me";
# passwordFile = config.sops.secrets."protonmail/bridge-password".path;
# };
# services.hermes-agent.mcpServers.protonmail = config.services.protonmail-mcp.mcpServer;
#
# Premier login Bridge (une seule fois) : sudo protonmail-bridge-login
# puis dans le CLI : login, info (copier le mot de passe Bridge dans sops), exit
{
description = "Proton Mail MCP (proton-mail-bridge-client) + Proton Mail Bridge headless";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
proton-mail-bridge-client = {
url = "github:googlarz/proton-mail-bridge-client";
flake = false;
};
};
outputs =
{
self,
nixpkgs,
proton-mail-bridge-client,
}:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
mkPackage =
pkgs:
pkgs.callPackage (
{
lib,
stdenv,
buildNpmPackage,
importNpmLock,
}:
let
src = proton-mail-bridge-client;
package = lib.importJSON "${src}/package.json";
lock = lib.importJSON "${src}/package-lock.json";
# TypeScript 7 = un binaire natif par plateforme (~10 Mo chacun, 20 plateformes) :
# on ne garde que celui de la machine qui build
tsPrefix = "node_modules/@typescript/typescript-";
tsPlatform =
{
x86_64-linux = "linux-x64";
aarch64-linux = "linux-arm64";
aarch64-darwin = "darwin-arm64";
}
.${stdenv.hostPlatform.system};
in
buildNpmPackage {
pname = package.name;
inherit (package) version;
inherit src;
# Deps lues depuis package-lock.json : aucun hash à maintenir,
# `nix flake update` suffit pour monter de version
npmDeps = importNpmLock {
inherit package;
packageLock = lock // {
packages = lib.filterAttrs (
name: _: !(lib.hasPrefix tsPrefix name) || name == tsPrefix + tsPlatform
) lock.packages;
};
};
npmConfigHook = importNpmLock.npmConfigHook;
# better-sqlite3 compilé depuis les sources (pas de réseau dans le sandbox)
env.npm_config_build_from_source = "true";
# le script "prepare" relancerait tsc pendant `npm pack`
npmPackFlags = [ "--ignore-scripts" ];
meta = {
description = "MCP server and CLI for Proton Mail through Proton Mail Bridge";
homepage = "https://github.com/googlarz/proton-mail-bridge-client";
license = lib.licenses.mit;
mainProgram = "proton-mail-bridge-mcp";
platforms = systems;
};
}
) { };
in
{
packages = forAllSystems (pkgs: rec {
proton-mail-bridge-client = mkPackage pkgs;
default = proton-mail-bridge-client;
});
overlays.default = final: _prev: {
proton-mail-bridge-client = mkPackage final;
};
nixosModules.default =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.protonmail-mcp;
user = "protonmail-bridge";
home = "/var/lib/protonmail-bridge";
# Premier login : coupe le service et ouvre le CLI Bridge sous l'utilisateur dédié
loginHelper = pkgs.writeShellApplication {
name = "protonmail-bridge-login";
runtimeInputs = [
pkgs.coreutils
config.systemd.package
];
text = ''
if [ "$(id -u)" -ne 0 ]; then
echo "À lancer avec sudo" >&2
exit 1
fi
systemctl stop protonmail-bridge.service
trap 'systemctl start protonmail-bridge.service' EXIT
echo "CLI Bridge : login, puis info (mot de passe à mettre dans sops), puis exit"
systemd-run --pty --wait --collect --quiet \
--uid=${user} --gid=${user} \
--setenv=HOME=${home} \
--setenv=PATH=${
lib.makeBinPath [
pkgs.pass
pkgs.gnupg
]
} \
${lib.getExe cfg.bridgePackage} --cli
'';
};
in
{
options.services.protonmail-mcp = {
enable = lib.mkEnableOption "Proton Mail Bridge headless et le serveur MCP proton-mail-bridge-client";
package = lib.mkOption {
type = lib.types.package;
default = mkPackage pkgs;
defaultText = lib.literalMD "`proton-mail-bridge-client` construit par ce flake";
description = "Paquet du serveur MCP.";
};
bridgePackage = lib.mkPackageOption pkgs "protonmail-bridge" { };
username = lib.mkOption {
type = lib.types.str;
example = "you@proton.me";
description = "Adresse Proton (identifiant IMAP/SMTP de Bridge).";
};
passwordFile = lib.mkOption {
type = lib.types.str;
example = "/run/secrets/protonmail/bridge-password";
description = "Fichier contenant le mot de passe généré par Bridge (commande info du CLI), pas celui du compte Proton. Doit être lisible par le client MCP.";
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = {
PROTONMAIL_ALLOW_SEND = "true";
PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF = "true";
};
description = "Variables PROTONMAIL_* supplémentaires, prioritaires sur les garde-fous par défaut.";
};
mcpServer = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
readOnly = true;
defaultText = lib.literalMD "`command` + `env` dérivés des options ci-dessus";
description = "Entrée MCP stdio prête à l'emploi, pour services.hermes-agent.mcpServers.";
default = {
command = lib.getExe cfg.package;
env = {
PROTONMAIL_USERNAME = cfg.username;
PROTONMAIL_PASSWORD_FILE = cfg.passwordFile;
# Garde-fous (l'agent lit des mails écrits par n'importe qui) :
# lecture + brouillons, ni envoi ni suppression, 25 outils au lieu de 96
PROTONMAIL_TOOL_TIER = "core";
PROTONMAIL_ALLOW_SEND = "false";
PROTONMAIL_ALLOWED_ACTIONS = "mark_read,mark_unread,star,unstar,archive";
}
// cfg.environment;
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !config.services.protonmail-bridge.enable;
message = "services.protonmail-mcp lance déjà Proton Bridge : désactive services.protonmail-bridge.";
}
];
users.users.${user} = {
isSystemUser = true;
group = user;
inherit home;
};
users.groups.${user} = { };
systemd.services.protonmail-bridge = {
description = "Proton Mail Bridge (headless)";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = [
pkgs.pass
pkgs.gnupg
];
# Trousseau "pass" exigé par Bridge : clé GPG sans phrase de passe,
# protégée par les droits 0700 du dossier d'état
preStart = ''
if ! gpg --list-secret-keys ${user} >/dev/null 2>&1; then
gpg --batch --pinentry-mode loopback --passphrase "" \
--quick-gen-key ${user} default default never
fi
[ -f "$HOME/.password-store/.gpg-id" ] || pass init ${user}
'';
serviceConfig = {
User = user;
Group = user;
StateDirectory = "protonmail-bridge";
StateDirectoryMode = "0700";
ExecStart = "${lib.getExe cfg.bridgePackage} --noninteractive";
Restart = "always";
RestartSec = 10;
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
LockPersonality = true;
};
};
environment.systemPackages = [
cfg.package
loginHelper
];
};
};
};
}
+16
View File
@@ -0,0 +1,16 @@
bridge_password: ENC[AES256_GCM,data:wQcsTjgeco5adaFu4onNcWkXIEO71MtSRRZsY1sOv4c0hWL31KHLT+hXqNMl,iv:0I4DJ8Ce8NVxBqyOUQuWXoXtNTAvuRTWWXcmyBEH59I=,tag:fyH/4siYdzG/mZcVluUZXw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOeWxBWTlMS2Ywb1pNT0Zu
Zy8vdW5TdUQzUWV3MUJTSW5tTERVLzJlV1VnClUvYTVkRXNSMXZaeTZ4amYvdFRh
RVg5UE1RTDFlaTJCYWNkNTlFOG1DNjQKLS0tIGFid1Y3QWU5T1BDQzFyUWMraUpD
R1dzTUxjQnRUbzVuZnJ6K01sMVRNdmcKGcjvpi7LYGmIhP1OEjrMqI5ho09Wgtrt
z38WGqa5DA3oWTqFURXnSpX4241u3dcFtZSwQ1+HGUm9mUx6ZP5ZSQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm
lastmodified: "2026-09-24T23:58:23Z"
mac: ENC[AES256_GCM,data:22GAQLwjmwEDlydzdSnJl6BcCjereUyKixbZ9mNTtAEj6TuhUE3xtPmx3MFykEEbPo2Im3JrKzoOiFq724rkdrJ7KxowPgQ1oG+aGwDbonyALnzKDQIj8yGPgUhdBpUVA211DXKL6xoA9/xr6PY08PqKq0iuAUqigpF32kZLEAU=,iv:AMz/92xIQuNgKsp0AKp766JRiWRns5wO+QYoXOnZcfs=,tag:3S1ZPtJDXeG1vBrbolkCsA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+16
View File
@@ -0,0 +1,16 @@
zitadel_mcp_pat: ENC[AES256_GCM,data:5TtuBAkGCNu7KKZX5OXSoQNd2dcEP/2/WVOkdKE8oZUwDrun0Q==,iv:9I5H03JHfpXtelZSOZczIHNrav6W1Sg5ZE15zpYO7uk=,tag:C2rrNKdRKjgMw6Bepjg3yA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTT0NyQVliOFVMeDFwbUxB
c1NHZ3czb3Y2ODR4UkVIWkdMSXRDOUdMcDJNClNTSEZ0RzloVW1pRnIxai9IT1JH
M3hyenJ0TW9IZGRwTnFXYnFJWmtyemsKLS0tIE9XR08zUlN1NEx2UHJqTlM1eDR6
eU92RmJIaDBna1N2cE4wNDNMOGYyUjgKTCiFiRQ+FHgWIiq3NFo0mIroLAWysUDr
xZowsxaYr8kPSwyBJfwCDamAmofYrJr3/u3Lqz2KgtB2hKytZUn0eA==
-----END AGE ENCRYPTED FILE-----
recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm
lastmodified: "2026-09-25T01:06:13Z"
mac: ENC[AES256_GCM,data:kErglUT+GMaSVjIQw2Q1Wlz47MkX6qmFi459mMmmb41/vsIzmOAh3PUJhOuu3dOblIN0QmR0BxXmz/pSCJYSKU6AjUyJD4v2d3AVH1E2mlAeTDC/hu2NlM1VLUPpCwAPOQt0mOkCdcQlUqWo0mNIzDjvuqXXB5efXjYUibadWHk=,iv:MBpgWtW0gMEr2tgyKx9M90AibaleYAIvHo0219JAmLU=,tag:YWt1AS6QmA6uPe7QncfJ6Q==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+3
View File
@@ -30,8 +30,11 @@
./eternal-terminal
./nextcloud-mcp
./amazon-mcp
./protonmail-mcp
./firefox-mcp
# ./gotify
./zitadel
./zitadel-mcp
./roku
# ./kanidm
# ./syncserver
+136
View File
@@ -0,0 +1,136 @@
# services/firefox-mcp/default.nix
# Serveur MCP Firefox DevTools (mozilla/firefox-devtools-mcp) : officiel Mozilla,
# automatise un LibreWolf réel via WebDriver BiDi (geckodriver + Selenium) —
# navigation, snapshots d'accessibilité, clics/saisie, réseau, téléchargements,
# screenshots, exécution JS. PAS un navigateur patché comme Playwright.
# Actif (releases régulières, dernière v0.10.4 le 2026-09-22), dual
# MIT/Apache-2.0, documenté dans firefox-source-docs.mozilla.org.
#
# Transport stdio (hermes spawn le binaire en subprocess), comme amazon-mcp.
# Tools préfixés mcp_firefox_* (navigate_page, take_snapshot, click_by_uid,
# fill_by_uid, list_network_requests, screenshot_page, list_console_messages...).
# Preset par défaut "basic" (inclut déjà evaluate_script). Pour plus de outils :
# ajouter "--tool-preset" "developer" (debug/réseau/profiler) ou "mozilla"
# (contexte privilégié) aux args du wrapper ci-dessous.
#
# Navigateur : LibreWolf (pkgs.librewolf-unwrapped), même moteur Gecko.
# Geckodriver n'accepte que le VRAI binaire applicatif : son contrôle de
# « Firefox executable » se fait via l'application.ini situé DANS le dossier du
# binaire (vérifié : ${pkgs.librewolf-unwrapped}/lib/librewolf/librewolf passe,
# Name=LibreWolf dans application.ini ne le dérange pas), alors que le shim
# bin/librewolf (qui n'a pas d'application.ini à côté) est refusé avec
# « binary is not a Firefox executable » — pareil pour pkgs.librewolf/bin/librewolf,
# un script bash nix.
#
# Profil : dédié et persistant sous /var/lib/hermes/firefox-mcp/profile (JAMAIS
# le profil personnel de marc — recommandation sécurité upstream, README
# "Security"). Le serveur crée lui-même le sous-dossier
# firefox_devtools_mcp_profile/ au premier lancement (src/firefox/profile.ts),
# pas besoin de le pré-créer : juste le parent, via tmpfiles ci-dessous.
#
# Headless par défaut : le service hermes n'a pas de session graphique attachée
# (ProtectSystem=strict, pas de DISPLAY/WAYLAND_DISPLAY). Pour du debug visuel,
# lancer le wrapper à la main sans --headless depuis une session graphique de
# marc (wayvnc) — même principe que amazon-mcp-login mais pas câblé en script
# dédié ici faute de besoin (pas de login/2FA à faire pour naviguer).
#
# geckodriver : le paquet npm "geckodriver" (dépendance de firefox-devtools-mcp)
# télécharge un binaire non-nix au premier usage UNIQUEMENT s'il ne trouve aucun
# geckodriver sur le PATH (voir GECKODRIVER_AUTO_INSTALL dans son README — pas
# activé ici, donc pas de tentative réseau au build). On met pkgs.geckodriver
# (vrai binaire nix) en tête de PATH pour que findGeckodriverInPath()
# (src/firefox/core.ts) le trouve en premier et court-circuite tout téléchargement.
{
pkgs,
lib,
inputs,
...
}: let
stateDir = "/var/lib/hermes/firefox-mcp";
browser = pkgs.librewolf-unwrapped;
nodejs = pkgs.nodejs_22; # LTS ; le projet exige seulement node >=20.19.0
firefox-devtools-mcp = pkgs.buildNpmPackage {
pname = "firefox-devtools-mcp";
version = "0.10.4";
src = inputs.firefox-devtools-mcp;
inherit nodejs;
npmDepsHash = "sha256-+pJwxFmgnFWUCvt5wWWFXvyO5oUiTamgaEkQdk0lZoo=";
meta = {
description = "Official Mozilla MCP server for Firefox DevTools automation (WebDriver BiDi)";
homepage = "https://github.com/mozilla/firefox-devtools-mcp";
license = [lib.licenses.mit lib.licenses.asl20];
mainProgram = "firefox-devtools-mcp";
};
};
# PIÈGE vérifié en vrai : geckodriver lit la version de Firefox depuis
# `application.ini` à côté du binaire (mozversion), sinon en scannant le
# binaire ; sinon il refuse la session avec
# « <chemin> is not a Firefox executable » (src/capabilities.rs, version()).
# Ni ${pkgs.librewolf}/bin/librewolf (script bash de wrapFirefox) ni
# librewolf-unwrapped/bin/librewolf (shim ELF de 16 Ko, pas d'application.ini
# dans bin/) ne passent : le VRAI binaire d'application est
# lib/librewolf/librewolf, dont le dossier contient bien application.ini.
wrapper = pkgs.writeShellScript "firefox-mcp-hermes" ''
export PATH="${pkgs.geckodriver}/bin:$PATH"
exec ${lib.getExe firefox-devtools-mcp} \
--firefox-path "${browser}/lib/librewolf/librewolf" \
--profile-path "${stateDir}/profile" \
--headless \
"$@"
'';
# Profil réellement utilisé par le serveur : <profile-path>/firefox_devtools_mcp_profile
# (resolveProfilePath(), src/firefox/profile.ts — il ajoute toujours ce sous-dossier
# pour ne jamais tomber sur un vrai profil perso). C'est CE dossier qu'il faut
# ouvrir pour le login manuel, sinon les cookies ne sont pas ceux que le
# serveur headless relit ensuite.
profileDir = "${stateDir}/profile/firefox_devtools_mcp_profile";
# Login manuel : le serveur tourne en headless, donc l'humain doit ouvrir le
# même profil dans un navigateur VISIBLE une fois, se connecter (Proton,
# webmail sans Bridge), puis fermer. À lancer depuis la session graphique de
# marc (niri), sans sudo — le profil est partagé par ACL (voir tmpfiles).
loginHelper = pkgs.writeShellApplication {
name = "firefox-mcp-login";
runtimeInputs = [pkgs.librewolf pkgs.procps pkgs.gnugrep];
text = ''
url="''${1:-https://mail.proton.me/}"
# Firefox ne crée PAS le dossier de profil : sans lui il meurt sur
# « Could not find profile folder. » (vérifié). Le serveur le crée au
# premier lancement, ce mkdir couvre le cas où on se connecte avant.
mkdir -p "${profileDir}"
# Firefox refuse deux instances sur un même profil : on prévient avant.
if pgrep -f -- "--profile ${profileDir}" >/dev/null; then
echo "Un navigateur MCP tourne déjà sur ce profil (session hermes active)." >&2
echo "Arrête-la d'abord : le profil ne peut pas être ouvert deux fois." >&2
exit 1
fi
echo "Connecte-toi à $url dans la fenêtre qui s'ouvre, puis ferme-la."
exec librewolf --no-remote --profile "${profileDir}" "$url"
'';
};
in {
systemd.tmpfiles.rules = [
"d ${stateDir} 0700 hermes hermes -"
# Profil partagé : hermes (serveur MCP headless) <-> marc (login manuel).
# "a" ne rejoue qu'au BOOT ; après le premier switch :
# sudo systemd-tmpfiles --create
"a /var/lib/hermes - - - - u:marc:--x,m::rwx"
"d ${stateDir}/profile 0750 hermes hermes -"
"a ${stateDir} - - - - u:marc:rwx,d:u:marc:rwx,d:u:hermes:rwx,m::rwx,d:m::rwx"
"a ${stateDir}/profile - - - - u:marc:rwx,d:u:marc:rwx,d:u:hermes:rwx,m::rwx,d:m::rwx"
"d ${profileDir} 0750 hermes hermes -"
"a ${profileDir} - - - - u:marc:rwx,d:u:marc:rwx,d:u:hermes:rwx,m::rwx,d:m::rwx"
];
environment.systemPackages = [loginHelper];
services.hermes-agent.mcpServers.firefox = {
command = "${wrapper}";
timeout = 180; # certaines pages/SPA peuvent dépasser le défaut de 120s
};
}
+40
View File
@@ -0,0 +1,40 @@
# services/protonmail-mcp/default.nix
# Proton Mail Bridge headless (service système) + serveur MCP
# proton-mail-bridge-client, câblés via le flake local ../../protonmail-mcp
# (nixosModules.default = inputs.protonmail-mcp.nixosModules.default, importé
# directement dans flake.nix pour tower).
#
# Secret sops : secrets/protonmail.yml, fichier SÉPARÉ de tower.yml — hermes
# n'a ni le binaire sops ni la clé age privée de tower (lecture seule pour
# root), donc pas moyen de rééditer tower.yml en place. Un fichier neuf ne
# demande que les clés PUBLIQUES déjà présentes dans .sops.yaml -> chiffrable
# par hermes via `nix run nixpkgs#sops -- --encrypt --in-place`.
# Contient pour l'instant un mot de passe PLACEHOLDER (Bridge non logué) :
# le service démarre mais le MCP n'authentifie pas encore.
#
# PREMIER LOGIN (une fois, humain — nécessite le vrai mot de passe Proton de
# marc.partensky@proton.me, potentiellement 2FA) :
# sudo protonmail-bridge-login
# Dans le CLI Bridge : login, puis info (copier le mot de passe Bridge généré),
# puis exit.
# Ensuite : remplacer le placeholder dans secrets/protonmail.yml par ce mot de
# passe (re-chiffrement complet du fichier, toujours sans clé privée), puis
# décommenter la ligne services.hermes-agent.mcpServers.protonmail ci-dessous
# et relancer `just nixos`.
{config, ...}: {
sops.secrets."protonmail/bridge-password" = {
sopsFile = ../../secrets/protonmail.yml;
key = "bridge_password";
owner = "hermes";
};
services.protonmail-mcp = {
enable = true;
username = "marc.partensky@proton.me";
passwordFile = config.sops.secrets."protonmail/bridge-password".path;
};
# À décommenter seulement après le premier login Bridge (mot de passe réel
# dans secrets/protonmail.yml, pas le placeholder) :
# services.hermes-agent.mcpServers.protonmail = config.services.protonmail-mcp.mcpServer;
}
+371
View File
@@ -0,0 +1,371 @@
# services/zitadel-mcp/default.nix
# Serveur MCP Zitadel pour hermes : création/gestion des comptes (humains) de
# ses potes via l'API Zitadel, transport stdio (hermes spawn le subprocess).
#
# Auth : Personal Access Token d'un service user Zitadel. Le token vit dans un
# fichier sops (secrets/zitadel.yml), jamais dans le nix store ; le serveur le
# lit à chaque appel.
#
# Créer le PAT dans la console (https://auth.marcpartensky.com) :
# Users -> Service Users -> New (Access Token Type = Bearer)
# puis Organisation ZITADEL -> Managers -> ajouter le service user
# -> rôle ORG_OWNER (ou org user manager pour un scope plus étroit)
# puis Service User -> Personal Access Tokens -> New, copier le token.
#
# Debug : tester le serveur à la main en tant que hermes
# { printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; sleep 5; } \
# | ZITADEL_PAT_FILE=... /nix/store/.../bin/zitadel-mcp
# (garder stdin ouvert : un EOF tue la requête en vol)
{pkgs, config, ...}: let
zitadel-mcp =
pkgs.writers.writePython3Bin "zitadel-mcp" {
libraries = [pkgs.python3Packages.mcp];
flakeIgnore = ["E501" "W503" "W504"];
} ''
import json
import os
import urllib.error
import urllib.parse
import urllib.request
try:
from mcp.server import MCPServer # mcp >= 2
except ImportError:
from mcp.server.fastmcp import FastMCP as MCPServer # mcp 1.x
BASE = os.environ.get("ZITADEL_URL", "https://auth.marcpartensky.com").rstrip("/")
ORG_ID = os.environ.get("ZITADEL_ORG_ID", "") # optionnel : x-zitadel-orgid
STATES = {
0: "unspecified",
1: "active",
2: "inactive",
3: "deleted",
4: "locked",
5: "suspend",
6: "initial (invité, pas encore activé)",
}
mcp = MCPServer("zitadel")
def _pat():
path = os.environ.get("ZITADEL_PAT_FILE")
if path and os.path.exists(path):
with open(path) as fh:
return fh.read().strip()
return os.environ.get("ZITADEL_PAT", "").strip()
def api(method, path, body=None):
"""Appel API Zitadel. Lève RuntimeError avec le corps d'erreur brut."""
token = _pat()
if not token:
raise RuntimeError(
"Aucun PAT Zitadel : renseigner ZITADEL_PAT_FILE (secret sops) "
"ou ZITADEL_PAT."
)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", "application/json")
if ORG_ID:
req.add_header("x-zitadel-orgid", ORG_ID)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw.strip() else {})
except urllib.error.HTTPError as err:
raw = err.read().decode()
try:
detail = json.dumps(json.loads(raw), ensure_ascii=False)
except Exception:
detail = raw
raise RuntimeError(f"HTTP {err.code} sur {method} {path} : {detail[:900]}") from None
def _view(user):
"""Réduit un UserView v1 aux champs utiles."""
state = user.get("state")
return {
"id": user.get("id"),
"login": user.get("preferredLoginName") or user.get("username"),
"username": user.get("username"),
"email": user.get("email"),
"email_verified": user.get("isEmailVerified"),
"nom": user.get("displayName"),
"prenom": user.get("firstName"),
"nom_famille": user.get("lastName"),
"etat": STATES.get(state, state) if isinstance(state, int) else state,
"type": user.get("type"),
"derniere_connexion": user.get("lastLogin"),
"creation": (user.get("details") or {}).get("creationDate"),
}
def _search_users(**kwargs):
body = {"offset": 0, "limit": kwargs.pop("limit", 50)}
queries = kwargs.pop("queries", None)
if queries:
body["queries"] = queries
_, resp = api("POST", "/management/v1/users/_search", body)
return resp
@mcp.tool()
def status() -> dict:
"""État de l'instance Zitadel et de l'authentification du serveur.
Renvoie l'org courante et le nombre de comptes humains. Sert de test
de vie du PAT (renvoie l'erreur HTTP brute si le token est invalide
ou sans droits).
"""
_, org = api("GET", "/management/v1/orgs/me")
teams = _search_users(limit=1)
return {
"instance": BASE,
"org": {
"id": (org.get("org") or {}).get("id"),
"nom": (org.get("org") or {}).get("name"),
"domaine": (org.get("org") or {}).get("primaryDomain"),
},
"comptes_humains": (teams.get("details") or {}).get("totalResult"),
}
@mcp.tool()
def list_users(filtre: str = "", limit: int = 50) -> list:
"""Liste les comptes (humains et techniques) de l'org.
filtre : sous-chaîne cherchée dans le login ou l'email (casse
ignorée). Vide = tous.
"""
users = (_search_users(limit=max(limit, 1)).get("result") or [])
views = [_view(u) for u in users]
if filtre:
needle = filtre.lower()
views = [
v
for v in views
if any(
needle in (v[champ] or "").lower()
for champ in ("login", "email", "nom")
)
]
return views
@mcp.tool()
def find_user(email_ou_login: str) -> dict:
"""Trouve un compte par email ou login (égalité, casse ignorée)."""
for query in (
{"emailQuery": {"emailAddress": email_ou_login, "method": "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE"}},
{"userNameQuery": {"userName": email_ou_login, "method": "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE"}},
):
resp = _search_users(limit=5, queries=[query])
found = resp.get("result") or []
if found:
return _view(found[0])
return {"trouve": False, "recherche": email_ou_login}
@mcp.tool()
def get_user(user_id: str) -> dict:
"""Détail complet d'un compte par son user_id (id technique Zitadel)."""
_, resp = api("GET", f"/management/v1/users/{urllib.parse.quote(user_id)}")
return _view(resp.get("user") or {})
@mcp.tool()
def create_friend(
email: str,
prenom: str,
nom: str,
username: str = "",
envoyer_invitation: bool = True,
mot_de_passe: str = "",
langue: str = "fr",
) -> dict:
"""Crée un compte humain pour un pote et lui envoie l'invitation.
Par défaut : email marqué vérifié (évite un second mail de
vérification) puis envoi du mail d'invitation Zitadel ; le pote
choisit lui-même son mot de passe/passkey, l'agent ne le voit jamais.
Décocher envoyer_invitation n'a de sens que si mot_de_passe est
fourni (compte utilisable tout de suite, mot de passe à transmettre
hors ligne).
"""
if not envoyer_invitation and not mot_de_passe:
return {
"erreur": "sans invitation il faut fournir mot_de_passe, sinon "
"le compte reste en état initial et inutilisable"
}
body = {
"username": username or email,
"profile": {"givenName": prenom, "familyName": nom, "preferredLanguage": langue},
"email": {"email": email, "isVerified": True},
}
if mot_de_passe:
body["password"] = {"password": mot_de_passe, "changeRequired": False}
_, created = api("POST", "/v2/users/human", body)
user_id = created.get("userId")
out = {"id": user_id, "email": email, "invitation": "non demandée"}
if envoyer_invitation:
_, invite = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/invite_code",
{"sendCode": {}},
)
out["invitation"] = "mail envoyé"
return out
@mcp.tool()
def invite_user(user_id: str) -> dict:
"""(Re)envoie le mail d'invitation à un compte existant, y compris un
compte resté en état initial jamais activé. Écrase l'ancien code."""
_, resp = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/invite_code",
{"sendCode": {}},
)
return {"ok": True, "details": resp.get("details")}
@mcp.tool()
def set_password(user_id: str, mot_de_passe: str, changement_obligatoire: bool = False) -> dict:
"""Fixe le mot de passe d'un compte (l'agent le voit passer : à
réserver aux cas où l'invitation par mail ne convient pas)."""
_, resp = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/password",
{
"newPassword": {
"password": mot_de_passe,
"changeRequired": changement_obligatoire,
},
"verification": {},
},
)
return {"ok": True, "details": resp.get("details")}
@mcp.tool()
def reset_password(user_id: str) -> dict:
"""Envoie un mail de réinitialisation de mot de passe (le pote choisit
lui-même le nouveau)."""
_, resp = api(
"POST",
f"/v2/users/{urllib.parse.quote(user_id)}/password/reset",
{"sendLink": {}},
)
return {"ok": True, "details": resp.get("details")}
@mcp.tool()
def set_active(user_id: str, actif: bool) -> dict:
"""Active ou désactive un compte. Désactivé = ne peut plus se
connecter, données conservées. Réversible."""
action = "reactivate" if actif else "deactivate"
_, resp = api("POST", f"/v2/users/{urllib.parse.quote(user_id)}/{action}")
return {"ok": True, "etat": "actif" if actif else "désactivé", "details": resp.get("details")}
@mcp.tool()
def delete_user(user_id: str) -> dict:
"""Supprime un compte (irréversible). Préférer set_active(False)."""
api("DELETE", f"/v2/users/{urllib.parse.quote(user_id)}")
return {"ok": True, "supprime": user_id}
@mcp.tool()
def list_projects() -> list:
"""Projets Zitadel de l'org (les applications à rôle, ex. Nextcloud,
Portainer) : à croiser avec list_project_roles."""
_, resp = api("POST", "/management/v1/projects/_search", {"offset": 0, "limit": 50})
return [
{"id": p.get("id"), "nom": p.get("name"), "etat": p.get("state")}
for p in (resp.get("result") or [])
]
@mcp.tool()
def list_project_roles(project_id: str) -> list:
"""Rôles disponibles dans un projet (à passer à grant_roles)."""
_, resp = api(
"POST",
f"/management/v1/projects/{urllib.parse.quote(project_id)}/roles/_search",
{"offset": 0, "limit": 100},
)
return [
{
"cle": r.get("key"),
"nom": r.get("displayName") or (r.get("details") or {}).get("displayName"),
"groupe": r.get("group"),
}
for r in (resp.get("result") or [])
]
@mcp.tool()
def grant_roles(user_id: str, project_id: str, roles: list[str]) -> dict:
"""Donne des rôles projet à un compte sur un projet (accès applicatif)."""
_, resp = api(
"POST",
f"/management/v1/users/{urllib.parse.quote(user_id)}/grants",
{"projectId": project_id, "roleKeys": roles},
)
return {"ok": True, "grant_id": resp.get("userGrantId"), "roles": roles}
@mcp.tool()
def list_user_grants(user_id: str) -> list:
"""Rôles projet déjà accordés à un compte."""
_, resp = api("GET", f"/management/v1/users/{urllib.parse.quote(user_id)}/grants")
out = []
for g in resp.get("userGrantList") or resp.get("result") or []:
out.append(
{
"grant_id": g.get("id"),
"projet": g.get("projectName") or g.get("projectId"),
"roles": g.get("roleKeys"),
"etat": g.get("state"),
}
)
return out
@mcp.tool()
def revoke_grant(user_id: str, grant_id: str) -> dict:
"""Retire un rôle projet (voir list_user_grants pour le grant_id)."""
api(
"DELETE",
f"/management/v1/users/{urllib.parse.quote(user_id)}/grants/{urllib.parse.quote(grant_id)}",
)
return {"ok": True, "retire": grant_id}
if __name__ == "__main__":
mcp.run()
'';
in {
# Le PAT vit dans secrets/zitadel.yml (fichier sops distinct : tower.yml n'est
# pas modifiable par hermes, sa clé MAC exigerait la clé age privée).
sops.secrets."zitadel_mcp_pat" = {
sopsFile = ../../secrets/zitadel.yml;
owner = "hermes";
group = "hermes";
mode = "0400";
restartUnits = ["hermes-agent.service"];
};
services.hermes-agent.mcpServers.zitadel = {
command = "${zitadel-mcp}/bin/zitadel-mcp";
env = {
ZITADEL_URL = "https://auth.marcpartensky.com";
ZITADEL_PAT_FILE = config.sops.secrets."zitadel_mcp_pat".path;
};
};
}