Compare commits
35 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0e1f6de6 | ||
| 7c999d2aba | |||
| 846894c1a7 | |||
| 7c016e8c5e | |||
| 90dbed4d37 | |||
| 078aa17285 | |||
|
|
200674778b | ||
| 267b83bd0c | |||
| 92b63fe83d | |||
| 64671fc8b2 | |||
| 9ac814fda4 | |||
| 39b2bfbcf9 | |||
| ea3a37559a | |||
| 4e2bfbee02 | |||
| 3a61387215 | |||
| b9b729e439 | |||
| f8eb0d3449 | |||
| e6c372bffc | |||
| d23d79b6c1 | |||
| 9a52b5dce1 | |||
| 4a7248cfa9 | |||
| 955c96650a | |||
| cd0740c75f | |||
| 6bf9dd1866 | |||
| 6e85969cf4 | |||
| 1e11181c02 | |||
| 893600ffd5 | |||
| 0135b093a5 | |||
| 5552e63974 | |||
| fd08615950 | |||
| 85da274772 | |||
| 1a44a2ea35 | |||
| 9675490cd3 | |||
| e5f2244ad8 | |||
| e99a1c109a |
45 changed files with 3951 additions and 261 deletions
|
|
@ -6,12 +6,13 @@ name: Release
|
|||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
releases: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -19,38 +20,55 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build changelog
|
||||
- name: Extract changelog and update CHANGELOG.md
|
||||
id: changelog
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
previous_stable_tag=""
|
||||
today=$(date +%Y-%m-%d)
|
||||
|
||||
if previous_stable_tag="$(
|
||||
git describe --tags --abbrev=0 \
|
||||
--exclude '*-rc*' \
|
||||
--exclude '*-beta*' \
|
||||
--exclude '*-alpha*' \
|
||||
"${current_tag}^" 2>/dev/null
|
||||
)"; then
|
||||
range="${previous_stable_tag}..${current_tag}"
|
||||
{
|
||||
printf '## Changelog\n\n'
|
||||
printf 'Changes since `%s`.\n\n' "${previous_stable_tag}"
|
||||
git log --reverse --pretty=format:'- %h %s' "${range}"
|
||||
printf '\n'
|
||||
} >CHANGELOG.md
|
||||
else
|
||||
{
|
||||
printf '## Changelog\n\n'
|
||||
printf 'Initial release.\n\n'
|
||||
git log --reverse --pretty=format:'- %h %s' "${current_tag}"
|
||||
printf '\n'
|
||||
} >CHANGELOG.md
|
||||
# Extract content of [Unreleased] section (non-empty lines)
|
||||
release_notes=$(awk '/^## \[Unreleased\]/{found=1; next} found && /^## \[/{exit} found{print}' CHANGELOG.md | sed '/^[[:space:]]*$/d')
|
||||
|
||||
if [ -z "${release_notes}" ]; then
|
||||
release_notes="Voir les commits pour le détail des changements."
|
||||
fi
|
||||
|
||||
printf '%s\n' "${release_notes}" > release_notes.md
|
||||
|
||||
# For stable releases: rename [Unreleased] and insert a new empty section
|
||||
case "${current_tag}" in
|
||||
*-rc*|*-beta*|*-alpha*)
|
||||
echo "Pre-release tag — CHANGELOG.md non modifié"
|
||||
;;
|
||||
*)
|
||||
# Rename [Unreleased] → version header
|
||||
sed -i "s/^## \[Unreleased\]$/## [${current_tag}] — ${today}/" CHANGELOG.md
|
||||
|
||||
# Insert new empty [Unreleased] section after "# Changelog"
|
||||
sed -i "s/^# Changelog$/# Changelog\n\n## [Unreleased]/" CHANGELOG.md
|
||||
|
||||
# Insert reference link before the first existing [vX...] link
|
||||
awk -v tag="${current_tag}" -v url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${current_tag}" '
|
||||
!inserted && /^\[v/ { print "[" tag "]: " url; inserted=1 }
|
||||
{ print }
|
||||
' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md
|
||||
|
||||
git config user.name "CI"
|
||||
git config user.email "ci@forge.lclr.dev"
|
||||
scheme="${GITHUB_SERVER_URL%%://*}"
|
||||
host="${GITHUB_SERVER_URL#*://}"
|
||||
git remote set-url origin "${scheme}://x-token:${GITEA_TOKEN}@${host}/${GITHUB_REPOSITORY}.git"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore(changelog): release ${current_tag}"
|
||||
git push origin HEAD:main
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Create or update release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
|
@ -69,11 +87,32 @@ jobs:
|
|||
;;
|
||||
esac
|
||||
|
||||
# Build commit log since previous stable tag
|
||||
previous_stable_tag=""
|
||||
if previous_stable_tag="$(
|
||||
git describe --tags --abbrev=0 \
|
||||
--exclude '*-rc*' \
|
||||
--exclude '*-beta*' \
|
||||
--exclude '*-alpha*' \
|
||||
"${current_tag}^" 2>/dev/null
|
||||
)"; then
|
||||
range="${previous_stable_tag}..${current_tag}"
|
||||
else
|
||||
range="${current_tag}"
|
||||
fi
|
||||
|
||||
{
|
||||
cat release_notes.md
|
||||
printf '\n\n## Commits\n\n'
|
||||
git log --reverse --pretty=format:'- %h %s' "${range}"
|
||||
printf '\n'
|
||||
} > release_body.md
|
||||
|
||||
json_escape() {
|
||||
sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g'
|
||||
}
|
||||
|
||||
body="$(json_escape < CHANGELOG.md)"
|
||||
body="$(json_escape < release_body.md)"
|
||||
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
|
||||
"${current_tag}" \
|
||||
"${current_tag}" \
|
||||
|
|
@ -60,3 +60,11 @@ Avant d'ouvrir ou de mettre à jour une PR :
|
|||
## Commits
|
||||
|
||||
Conserver des messages de commit au format conventional commits, conformément aux règles globales.
|
||||
|
||||
## Changelog
|
||||
|
||||
Après chaque développement fonctionnel, mettre à jour la section `## [Unreleased]` du `CHANGELOG.md` avec une description claire des changements apportés.
|
||||
|
||||
Ne pas logger les changements purement liés à la CI ou au changelog lui-même.
|
||||
|
||||
La CI se charge de versioner automatiquement la section `[Unreleased]` lors du push d'un tag stable.
|
||||
|
|
|
|||
240
CHANGELOG.md
Normal file
240
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.13.0] — 2026-05-13
|
||||
|
||||
### Corrections
|
||||
|
||||
- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage.
|
||||
|
||||
## [v1.12.0] — 2026-05-13
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler`** : handler de login Bitwarden prêt à l'emploi avec confirmation, évitant de réimplémenter le même code dans chaque MCP.
|
||||
- **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`.
|
||||
- **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile.
|
||||
|
||||
### Changements cassants
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`.
|
||||
|
||||
## [v1.11.0] — 2026-05-12
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — masquage automatique des commandes non configurées** : les commandes dont aucun hook n'est défini dans le manifeste sont désormais automatiquement masquées de l'aide CLI, ce qui rend la sortie `--help` plus propre et adaptée à chaque projet.
|
||||
- **Bootstrap — option `DisabledCommands`** : il est maintenant possible de désactiver explicitement des commandes via l'option `DisabledCommands`, indépendamment de la configuration des hooks.
|
||||
|
||||
---
|
||||
|
||||
## [v1.10.0] — 2026-05-11
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler`** : un handler de login générique est désormais disponible dans le package bootstrap, utilisable par défaut pour les projets qui n'ont pas besoin d'un flux de connexion personnalisé.
|
||||
|
||||
### Corrections
|
||||
|
||||
- Renommage du dossier `.forgejo` corrigé suite à une erreur de casse.
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.0] — 2026-05-05
|
||||
|
||||
### Changements internes
|
||||
|
||||
- **Migration vers Forgejo** : les workflows CI ont été migrés de GitHub Actions vers Forgejo.
|
||||
- **Mise à jour du module path** : le chemin du module Go a été mis à jour pour pointer vers la forge interne.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.2] — 2026-05-02
|
||||
|
||||
### Performances
|
||||
|
||||
- Suppression d'un appel de sonde Bitwarden inutile lors de la génération de la description runtime, ce qui réduit les appels CLI superflus au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.1] — 2026-05-02
|
||||
|
||||
### Performances
|
||||
|
||||
- La vérification de disponibilité de Bitwarden est désormais chargée de manière paresseuse (lazy), évitant une initialisation coûteuse si le secret store n'est pas utilisé.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.0] — 2026-05-02
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Cache Bitwarden chiffré** : les lectures de secrets Bitwarden sont maintenant mises en cache localement sous forme chiffrée, ce qui réduit considérablement le nombre d'appels au CLI `bw` durant une session.
|
||||
- **Configuration du cache via `mcp.toml`** : les options de cache (durée, activation) sont configurables directement dans le manifeste du projet.
|
||||
- **Helpers générés** : les helpers de code générés exposent les contrôles de cache Bitwarden, permettant aux projets scaffoldés de bénéficier automatiquement du cache.
|
||||
|
||||
---
|
||||
|
||||
## [v1.7.0] — 2026-05-02
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Génération de code depuis le manifeste** : le framework peut désormais générer automatiquement du code Go à partir du `mcp.toml`, incluant les helpers de champs de configuration et le code de glue pour les helpers de manifeste. Cela réduit le boilerplate dans les projets utilisant le framework.
|
||||
|
||||
---
|
||||
|
||||
## [v1.6.0] — 2026-04-20
|
||||
|
||||
### Corrections
|
||||
|
||||
- L'invite de connexion Bitwarden s'affiche désormais en rouge lorsque la session est absente, rendant l'état d'erreur plus visible pour l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## [v1.5.1] — 2026-04-16
|
||||
|
||||
### Améliorations
|
||||
|
||||
- Le script d'installation généré par le scaffold récupère désormais les binaires depuis la dernière release disponible, plutôt qu'une version fixée en dur.
|
||||
|
||||
---
|
||||
|
||||
## [v1.5.0] — 2026-04-16
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Fallback runtime embarqué pour les apps scaffoldées** : si le binaire runtime n'est pas trouvé dans l'environnement, les applications générées par le scaffold peuvent désormais utiliser un runtime de fallback embarqué directement dans le manifeste.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.2] — 2026-04-15
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Vérification Ed25519 des artefacts de release** : les artefacts téléchargés lors des mises à jour automatiques sont désormais vérifiés par signature Ed25519, garantissant leur intégrité.
|
||||
|
||||
### Corrections
|
||||
|
||||
- Le mécanisme de mise à jour (`self-update`) rejette maintenant les artefacts HTML (erreurs de redirection ou pages d'erreur) pour éviter d'installer un binaire corrompu.
|
||||
- Durcissement du runtime scaffold et de la sécurité du processus de mise à jour.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Le README a été réorganisé et la documentation détaillée déplacée dans des fichiers séparés.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.1] — 2026-04-15
|
||||
|
||||
### Améliorations
|
||||
|
||||
- L'assistant d'installation généré par le scaffold est aligné avec le dernier flux TUI, garantissant la cohérence entre le code généré et le comportement attendu.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.0] — 2026-04-15
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Assistant d'installation TUI pour Claude et Codex** : le scaffold injecte désormais un wizard interactif (TUI) dans le script `install.sh` des projets générés, guidant l'utilisateur lors de la première installation avec des étapes de configuration pour Claude et Codex.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.2] — 2026-04-15
|
||||
|
||||
### Corrections
|
||||
|
||||
- Revert des fonctionnalités `build` unifiée et matrice CI introduites en cours de cycle, jugées non stables pour cette release.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.1] — 2026-04-14
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **CLI — vérification doctor sur les champs de profil** : un helper réutilisable permet de valider les champs de configuration résolus depuis plusieurs sources (env, fichier, défaut) lors du diagnostic `doctor`.
|
||||
- **CLI — lookup multi-sources** : ajout d'un helper de résolution de valeur avec traçabilité de la source (d'où vient la valeur résolue).
|
||||
- **Secretstore — helper de manifeste runtime** : ajout d'un helper facilitant l'ouverture du backend secret store depuis le manifeste runtime.
|
||||
- **Bootstrap — expansion d'alias de commandes** : les commandes bootstrap peuvent maintenant définir des alias qui sont développés automatiquement.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.0] — 2026-04-14
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Commande `scaffold init`** : nouvelle commande CLI pour initialiser un projet MCP depuis zéro via le scaffold.
|
||||
- **Générateur de scaffold MCP** : ajout d'un générateur de projet binaire MCP complet, produisant la structure de fichiers, le manifeste `mcp.toml`, et le code de démarrage.
|
||||
|
||||
---
|
||||
|
||||
## [v1.2.1] — 2026-04-14
|
||||
|
||||
### Améliorations
|
||||
|
||||
- Les commandes `config show` et `config test` générées par le bootstrap suivent désormais une structure standardisée cohérente entre les projets.
|
||||
|
||||
### Corrections
|
||||
|
||||
- La CI construit le changelog depuis le dernier tag de release stable (et non depuis un tag RC).
|
||||
|
||||
---
|
||||
|
||||
## [v1.2.0] — 2026-04-14
|
||||
|
||||
### Documentation
|
||||
|
||||
- Mise en place de la convention de nommage des branches d'amélioration dans les instructions agents du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.0] — 2026-04-13
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Migrations de configuration versionnées** : le framework gère désormais les migrations de configuration entre versions, permettant aux projets d'évoluer leur schéma de config sans casser les installations existantes.
|
||||
- **Secrets structurés et politiques de backend** : support des secrets structurés (objets, non plus uniquement des chaînes) et des politiques de sélection de backend secret store par champ.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Ajout des instructions de workflow du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## [v1.0.0] — 2026-04-13
|
||||
|
||||
Première release stable du framework.
|
||||
|
||||
### Fonctionnalités initiales
|
||||
|
||||
- **Framework MCP réutilisable** : socle commun pour construire des serveurs MCP en Go, avec gestion du cycle de vie, configuration, et intégration des outils.
|
||||
- **Loader de manifeste TOML** : chargement de la configuration projet depuis un fichier `mcp.toml`.
|
||||
- **Package de mise à jour** : mécanisme de self-update découplé, pilotable par des drivers de forge (GitLab, Forgejo, etc.) avec validation de checksum.
|
||||
- **Bootstrap CLI optionnel** : package permettant de bootstrapper rapidement une CLI pour un projet MCP, avec commandes `config`, `login`, `doctor`, et `update` préconfigurées.
|
||||
- **Workflow de release CI** : pipeline de release automatisée avec génération de changelog et publication des artefacts.
|
||||
|
||||
---
|
||||
|
||||
[v1.13.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.13.0
|
||||
[v1.12.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.12.0
|
||||
[v1.11.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.11.0
|
||||
[v1.10.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.10.0
|
||||
[v1.9.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.9.0
|
||||
[v1.8.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.2
|
||||
[v1.8.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.1
|
||||
[v1.8.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.0
|
||||
[v1.7.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.7.0
|
||||
[v1.6.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.6.0
|
||||
[v1.5.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.1
|
||||
[v1.5.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.0
|
||||
[v1.4.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.2
|
||||
[v1.4.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.1
|
||||
[v1.4.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.0
|
||||
[v1.3.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.2
|
||||
[v1.3.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.1
|
||||
[v1.3.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.0
|
||||
[v1.2.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.1
|
||||
[v1.2.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.0
|
||||
[v1.1.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.1.0
|
||||
[v1.0.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.0.0
|
||||
1
CLAUDE.md
Symbolic link
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
|
|
@ -9,13 +9,13 @@ manifeste `mcp.toml`, diagnostic et auto-update.
|
|||
Dans un projet Go :
|
||||
|
||||
```bash
|
||||
go get gitea.lclr.dev/AI/mcp-framework
|
||||
go get forge.lclr.dev/AI/mcp-framework
|
||||
```
|
||||
|
||||
Pour utiliser le CLI :
|
||||
|
||||
```bash
|
||||
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||
```
|
||||
|
||||
## Créer un projet MCP
|
||||
|
|
|
|||
|
|
@ -50,17 +50,18 @@ type Hooks struct {
|
|||
}
|
||||
|
||||
type Options struct {
|
||||
BinaryName string
|
||||
Description string
|
||||
Version string
|
||||
Aliases map[string][]string
|
||||
AliasDescriptions map[string]string
|
||||
EnableDoctorAlias bool
|
||||
Args []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Hooks Hooks
|
||||
BinaryName string
|
||||
Description string
|
||||
Version string
|
||||
Aliases map[string][]string
|
||||
AliasDescriptions map[string]string
|
||||
EnableDoctorAlias bool
|
||||
DisabledCommands []string
|
||||
Args []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Hooks Hooks
|
||||
}
|
||||
|
||||
type Invocation struct {
|
||||
|
|
@ -144,6 +145,10 @@ func Run(ctx context.Context, opts Options) error {
|
|||
return printHelp(normalized, "")
|
||||
}
|
||||
|
||||
if isCommandDisabled(command, normalized.DisabledCommands) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
||||
}
|
||||
|
||||
if command == CommandConfig {
|
||||
return runConfigCommand(ctx, normalized, commandArgs)
|
||||
}
|
||||
|
|
@ -154,14 +159,16 @@ func Run(ctx context.Context, opts Options) error {
|
|||
}
|
||||
|
||||
if handler == nil {
|
||||
if command == CommandVersion {
|
||||
switch command {
|
||||
case CommandVersion:
|
||||
if strings.TrimSpace(normalized.Version) == "" {
|
||||
return ErrVersionRequired
|
||||
}
|
||||
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
||||
}
|
||||
|
||||
return handler(ctx, Invocation{
|
||||
|
|
@ -188,6 +195,11 @@ func normalize(opts Options) Options {
|
|||
}
|
||||
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
|
||||
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias)
|
||||
for _, cmd := range autoDisabledCommands(opts) {
|
||||
if !isCommandDisabled(cmd, opts.DisabledCommands) {
|
||||
opts.DisabledCommands = append(opts.DisabledCommands, cmd)
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
|
|
@ -444,6 +456,10 @@ func printHelp(opts Options, command string, args ...[]string) error {
|
|||
return printGlobalHelp(opts)
|
||||
}
|
||||
|
||||
if isCommandDisabled(command, opts.DisabledCommands) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
||||
}
|
||||
|
||||
if command == CommandConfig {
|
||||
return printConfigHelp(opts, commandArgs)
|
||||
}
|
||||
|
|
@ -523,6 +539,9 @@ func printGlobalHelp(opts Options) error {
|
|||
}
|
||||
|
||||
for _, def := range commands {
|
||||
if isCommandDisabled(def.Name, opts.DisabledCommands) {
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -559,6 +578,39 @@ func printGlobalHelp(opts Options) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func autoDisabledCommands(opts Options) []string {
|
||||
h := opts.Hooks
|
||||
var disabled []string
|
||||
if h.Setup == nil {
|
||||
disabled = append(disabled, CommandSetup)
|
||||
}
|
||||
if h.Login == nil {
|
||||
disabled = append(disabled, CommandLogin)
|
||||
}
|
||||
if h.MCP == nil {
|
||||
disabled = append(disabled, CommandMCP)
|
||||
}
|
||||
if h.Config == nil && h.ConfigShow == nil && h.ConfigTest == nil && h.ConfigDelete == nil {
|
||||
disabled = append(disabled, CommandConfig)
|
||||
}
|
||||
if h.Update == nil {
|
||||
disabled = append(disabled, CommandUpdate)
|
||||
}
|
||||
if h.Version == nil && strings.TrimSpace(opts.Version) == "" {
|
||||
disabled = append(disabled, CommandVersion)
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
|
||||
func isCommandDisabled(command string, disabled []string) bool {
|
||||
for _, d := range disabled {
|
||||
if d == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func aliasDescription(descriptions map[string]string, name, target string) string {
|
||||
description := strings.TrimSpace(descriptions[name])
|
||||
if description == "" {
|
||||
|
|
|
|||
|
|
@ -91,11 +91,13 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if !errors.Is(err, ErrSubcommandRequired) {
|
||||
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
|
||||
|
|
@ -148,7 +150,7 @@ func TestRunVersionHookOverridesDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
|
||||
func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
|
|
@ -158,8 +160,8 @@ func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
|
|||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if !errors.Is(err, ErrVersionRequired) {
|
||||
t.Fatalf("Run error = %v, want ErrVersionRequired", err)
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,12 +169,15 @@ func TestRunPrintsGlobalHelp(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Binaire MCP de test.",
|
||||
Version: "v1.2.3",
|
||||
Args: []string{"help"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -197,12 +202,15 @@ func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Binaire MCP de test.",
|
||||
Version: "v1.2.3",
|
||||
Args: []string{},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -227,11 +235,13 @@ func TestRunPrintsCommandHelp(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "update"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Update: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -250,11 +260,13 @@ func TestRunPrintsLoginCommandHelp(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "login"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{Login: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -273,11 +285,13 @@ func TestRunPrintsConfigHelp(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "config"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -299,11 +313,13 @@ func TestRunPrintsConfigSubcommandHelp(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"help", "config", "show"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -349,11 +365,13 @@ func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "show"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigTest: noop},
|
||||
})
|
||||
if !errors.Is(err, ErrCommandNotConfigured) {
|
||||
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
|
||||
|
|
@ -364,11 +382,13 @@ func TestRunConfigReturnsUnknownSubcommand(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "sync"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigShow: noop},
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownSubcommand) {
|
||||
t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err)
|
||||
|
|
@ -412,6 +432,7 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Aliases: map[string][]string{
|
||||
|
|
@ -420,6 +441,7 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
|
|||
Args: []string{"help", "doctor"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigTest: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -435,6 +457,7 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
|
|||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Aliases: map[string][]string{
|
||||
|
|
@ -443,6 +466,7 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
|
|||
Args: []string{"doctor", "--help"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Hooks: Hooks{ConfigTest: noop},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
|
|
@ -607,3 +631,58 @@ func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) {
|
|||
t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledCommandReturnsUnknown(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
DisabledCommands: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledCommandHiddenFromHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := printGlobalHelp(Options{
|
||||
BinaryName: "my-mcp",
|
||||
DisabledCommands: []string{"login", "setup"},
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("printGlobalHelp error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "login") {
|
||||
t.Fatalf("help should not contain disabled command %q, got:\n%s", "login", out)
|
||||
}
|
||||
if strings.Contains(out, "setup") {
|
||||
t.Fatalf("help should not contain disabled command %q, got:\n%s", "setup", out)
|
||||
}
|
||||
if !strings.Contains(out, "mcp") {
|
||||
t.Fatalf("help should contain enabled command %q, got:\n%s", "mcp", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledCommandHelpReturnsUnknown(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := printHelp(Options{
|
||||
BinaryName: "my-mcp",
|
||||
DisabledCommands: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
}, "login")
|
||||
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
bootstrap/configtest.go
Normal file
56
bootstrap/configtest.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
// StandardConfigTestOptions configure le handler de config test standard.
|
||||
// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application.
|
||||
type StandardConfigTestOptions struct {
|
||||
// ConfigCheck vérifie que le fichier de configuration est lisible.
|
||||
// Construire avec cli.NewConfigCheck(store).
|
||||
ConfigCheck fwcli.DoctorCheck
|
||||
|
||||
// OpenStore ouvre le secret store pour vérifier sa disponibilité.
|
||||
// Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus.
|
||||
OpenStore func() (secretstore.Store, error)
|
||||
|
||||
// ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.).
|
||||
ConnectivityCheck fwcli.DoctorCheck
|
||||
|
||||
// ExtraChecks contient des vérifications supplémentaires spécifiques à l'application.
|
||||
ExtraChecks []fwcli.DoctorCheck
|
||||
}
|
||||
|
||||
// StandardConfigTestHandler retourne un Handler pour la commande config test.
|
||||
// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie),
|
||||
// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest
|
||||
// est un artefact de build, pas une contrainte runtime.
|
||||
func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler {
|
||||
return func(ctx context.Context, inv Invocation) error {
|
||||
doctorOpts := fwcli.DoctorOptions{
|
||||
ConfigCheck: opts.ConfigCheck,
|
||||
ConnectivityCheck: opts.ConnectivityCheck,
|
||||
ExtraChecks: opts.ExtraChecks,
|
||||
}
|
||||
|
||||
if opts.OpenStore != nil {
|
||||
doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore)
|
||||
}
|
||||
|
||||
report := fwcli.RunDoctor(ctx, doctorOpts)
|
||||
|
||||
if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if report.HasFailures() {
|
||||
return fmt.Errorf("config checks failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
155
bootstrap/configtest_test.go
Normal file
155
bootstrap/configtest_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func TestStandardConfigTestHandlerRendersChecks(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ConfigCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"}
|
||||
},
|
||||
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"}
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "[OK] config") {
|
||||
t.Fatalf("stdout = %q, want [OK] config", out)
|
||||
}
|
||||
if !strings.Contains(out, "[OK] connectivity") {
|
||||
t.Fatalf("stdout = %q, want [OK] connectivity", out)
|
||||
}
|
||||
if strings.Contains(out, "manifest") {
|
||||
t.Fatalf("stdout should not contain manifest check, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "secret-store") {
|
||||
t.Fatalf("stdout = %q, want secret-store check", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"}
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err == nil {
|
||||
t.Fatal("handler should return error when checks fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "config checks failed") {
|
||||
t.Fatalf("err = %q, want 'config checks failed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[FAIL] connectivity") {
|
||||
t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ExtraChecks: []fwcli.DoctorCheck{
|
||||
func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(stdout.String(), "manifest") {
|
||||
t.Fatalf("manifest check should not appear, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
openCalled := false
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "test"},
|
||||
Stdout: &stdout,
|
||||
Hooks: Hooks{
|
||||
ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
openCalled = true
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if !openCalled {
|
||||
t.Fatal("OpenStore should have been called")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "secret-store") {
|
||||
t.Fatalf("stdout = %q, want secret-store in output", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
storeErr := errors.New("bitwarden unavailable")
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
return nil, storeErr
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err == nil {
|
||||
t.Fatal("handler should return error when store fails")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[FAIL] secret-store") {
|
||||
t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String())
|
||||
}
|
||||
}
|
||||
34
bootstrap/login.go
Normal file
34
bootstrap/login.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
var loginBitwarden = secretstore.LoginBitwarden
|
||||
|
||||
// BitwardenLoginHandler retourne un Handler pour la commande login des MCPs
|
||||
// qui utilisent le backend Bitwarden. Il authentifie et déverrouille le vault,
|
||||
// persiste BW_SESSION, et confirme le résultat.
|
||||
//
|
||||
// N'utiliser que si le MCP déclare secret_store.backend_policy = "bitwarden-cli"
|
||||
// dans son manifest. Pour les backends env-only ou keyring, ne pas définir de
|
||||
// hook Login : la commande sera automatiquement masquée.
|
||||
func BitwardenLoginHandler(binaryName string) Handler {
|
||||
name := strings.TrimSpace(binaryName)
|
||||
return func(_ context.Context, inv Invocation) error {
|
||||
if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{
|
||||
ServiceName: name,
|
||||
Stdin: inv.Stdin,
|
||||
Stdout: inv.Stdout,
|
||||
Stderr: inv.Stderr,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
103
bootstrap/login_test.go
Normal file
103
bootstrap/login_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) {
|
||||
t.Helper()
|
||||
previous := loginBitwarden
|
||||
loginBitwarden = fn
|
||||
t.Cleanup(func() { loginBitwarden = previous })
|
||||
}
|
||||
|
||||
func TestBitwardenLoginHandlerPrintsConfirmation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) {
|
||||
if opts.ServiceName != "my-mcp" {
|
||||
t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp")
|
||||
}
|
||||
return "session-token", nil
|
||||
})
|
||||
|
||||
handler := BitwardenLoginHandler("my-mcp")
|
||||
err := handler(context.Background(), Invocation{
|
||||
Command: CommandLogin,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"my-mcp"`) {
|
||||
t.Fatalf("stdout = %q, want mention of binary name", out)
|
||||
}
|
||||
if !strings.Contains(out, "persistée") {
|
||||
t.Fatalf("stdout = %q, want confirmation message", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenLoginHandlerPropagatesError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
loginErr := errors.New("vault locked")
|
||||
|
||||
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||
return "", loginErr
|
||||
})
|
||||
|
||||
handler := BitwardenLoginHandler("my-mcp")
|
||||
err := handler(context.Background(), Invocation{
|
||||
Command: CommandLogin,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if !errors.Is(err, loginErr) {
|
||||
t.Fatalf("err = %v, want %v", err, loginErr)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("stdout should be empty on error, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesBitwardenLoginHandlerWhenHookSet(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||
return "tok", nil
|
||||
})
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
Hooks: Hooks{
|
||||
Login: BitwardenLoginHandler("my-mcp"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "persistée") {
|
||||
t.Fatalf("stdout = %q, want confirmation", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoginAutoHiddenWithoutHook(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"forge.lclr.dev/AI/mcp-framework/config"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type DoctorStatus string
|
||||
|
|
@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
|||
}
|
||||
if options.ManifestCheck != nil {
|
||||
checks = append(checks, options.ManifestCheck)
|
||||
} else {
|
||||
} else if strings.TrimSpace(options.ManifestDir) != "" {
|
||||
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||||
}
|
||||
if options.ConnectivityCheck != nil {
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"forge.lclr.dev/AI/mcp-framework/config"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type doctorProfile struct {
|
||||
|
|
@ -198,6 +198,24 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) {
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
ConnectivityCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"}
|
||||
},
|
||||
// ManifestDir intentionally empty, ManifestCheck intentionally nil.
|
||||
})
|
||||
|
||||
for _, r := range report.Results {
|
||||
if r.Name == "manifest" {
|
||||
t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r)
|
||||
}
|
||||
}
|
||||
if len(report.Results) != 1 {
|
||||
t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
|
||||
prev := checkBitwardenReady
|
||||
t.Cleanup(func() {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"errors"
|
||||
"os"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type KeyLookupFunc func(key string) (string, bool, error)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type testSecretStore struct {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type SetupSecretWriteOptions struct {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
generatepkg "gitea.lclr.dev/AI/mcp-framework/generate"
|
||||
scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold"
|
||||
generatepkg "forge.lclr.dev/AI/mcp-framework/generate"
|
||||
scaffoldpkg "forge.lclr.dev/AI/mcp-framework/scaffold"
|
||||
)
|
||||
|
||||
const toolName = "mcp-framework"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,27 @@
|
|||
# Bootstrap CLI
|
||||
|
||||
Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique.
|
||||
Le package `bootstrap` fournit un point d'entrée CLI uniforme pour les binaires
|
||||
MCP. Il gère le parsing des arguments, l'aide, les alias et le routage vers les
|
||||
hooks fournis par l'application.
|
||||
|
||||
Exemple minimal :
|
||||
## Commandes disponibles
|
||||
|
||||
| Commande | Description |
|
||||
|---|---|
|
||||
| `setup` | Initialiser ou mettre à jour la configuration locale |
|
||||
| `login` | Authentifier et déverrouiller Bitwarden pour persister `BW_SESSION` |
|
||||
| `mcp` | Démarrer le serveur MCP |
|
||||
| `config show` | Afficher la configuration résolue et la provenance des valeurs |
|
||||
| `config test` | Vérifier la configuration et la connectivité |
|
||||
| `config delete` | Supprimer un profil local |
|
||||
| `update` | Auto-update du binaire |
|
||||
| `version` | Afficher la version |
|
||||
|
||||
Les commandes sans hook correspondant sont automatiquement masquées de l'aide et
|
||||
retournent une erreur `ErrUnknownCommand`. Exception : `version` affiche
|
||||
`Options.Version` si fourni, sans hook.
|
||||
|
||||
## Utilisation
|
||||
|
||||
```go
|
||||
func main() {
|
||||
|
|
@ -10,26 +29,22 @@ func main() {
|
|||
BinaryName: "my-mcp",
|
||||
Description: "Client MCP",
|
||||
Version: version,
|
||||
EnableDoctorAlias: true, // expose `doctor` comme alias de `config test`
|
||||
AliasDescriptions: map[string]string{
|
||||
"doctor": "Diagnostiquer la configuration locale.",
|
||||
},
|
||||
EnableDoctorAlias: true,
|
||||
Hooks: bootstrap.Hooks{
|
||||
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runSetup(ctx, inv.Args)
|
||||
},
|
||||
Login: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runLogin(ctx, inv.Args)
|
||||
},
|
||||
Login: bootstrap.BitwardenLoginHandler("my-mcp"),
|
||||
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runMCP(ctx, inv.Args)
|
||||
},
|
||||
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runConfigShow(ctx, inv.Args)
|
||||
},
|
||||
ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runConfigTest(ctx, inv.Args)
|
||||
},
|
||||
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
|
||||
OpenStore: openStore,
|
||||
ConnectivityCheck: connectivityCheck,
|
||||
}),
|
||||
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
return runUpdate(ctx, inv.Args)
|
||||
},
|
||||
|
|
@ -41,11 +56,97 @@ func main() {
|
|||
}
|
||||
```
|
||||
|
||||
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`.
|
||||
## Handlers fournis
|
||||
|
||||
Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`).
|
||||
La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif.
|
||||
La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`).
|
||||
Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale.
|
||||
Le flag global `--debug` est supporté et active le debug des appels Bitwarden
|
||||
### `BitwardenLoginHandler`
|
||||
|
||||
```go
|
||||
bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler
|
||||
```
|
||||
|
||||
Handler prêt à l'emploi pour la commande `login` des MCPs qui utilisent le
|
||||
backend Bitwarden CLI. Il lance le flux interactif `bw unlock --raw`, persiste
|
||||
`BW_SESSION` dans un fichier `0600` sous le répertoire de config utilisateur, et
|
||||
confirme le résultat.
|
||||
|
||||
À n'utiliser que si le MCP déclare `secret_store.backend_policy = "bitwarden-cli"`
|
||||
dans son manifest. Pour les autres backends (`env-only`, `keyring-any`), ne pas
|
||||
définir de hook `Login` : la commande est automatiquement masquée.
|
||||
|
||||
```go
|
||||
Hooks: bootstrap.Hooks{
|
||||
Login: bootstrap.BitwardenLoginHandler(mcpgen.BinaryName),
|
||||
}
|
||||
```
|
||||
|
||||
### `StandardConfigTestHandler`
|
||||
|
||||
```go
|
||||
bootstrap.StandardConfigTestHandler(opts bootstrap.StandardConfigTestOptions) bootstrap.Handler
|
||||
```
|
||||
|
||||
Handler pour `config test` qui exécute un ensemble de checks standards et affiche
|
||||
un rapport formaté. Aucun champ n'est obligatoire.
|
||||
|
||||
```go
|
||||
type StandardConfigTestOptions struct {
|
||||
ConfigCheck cli.DoctorCheck // cli.NewConfigCheck(store)
|
||||
OpenStore func() (secretstore.Store, error) // check disponibilité secret store
|
||||
ConnectivityCheck cli.DoctorCheck // check applicatif (HTTP, IMAP…)
|
||||
ExtraChecks []cli.DoctorCheck
|
||||
}
|
||||
```
|
||||
|
||||
Checks inclus automatiquement selon les champs fournis :
|
||||
|
||||
| Champ | Check résultant |
|
||||
|---|---|
|
||||
| `ConfigCheck` | Fichier de configuration lisible |
|
||||
| `OpenStore` | Secret store disponible |
|
||||
| `ConnectivityCheck` | Connectivité applicative |
|
||||
| `ExtraChecks` | Checks supplémentaires |
|
||||
|
||||
Le `ManifestCheck` n'est pas inclus : le manifest est un artefact de build, pas
|
||||
une contrainte runtime.
|
||||
|
||||
```go
|
||||
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
|
||||
ConfigCheck: cli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig]("my-mcp")),
|
||||
OpenStore: openSecretStore,
|
||||
ConnectivityCheck: func(ctx context.Context) cli.DoctorResult {
|
||||
if err := pingBackend(ctx); err != nil {
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusFail,
|
||||
Summary: "backend inaccessible",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusOK,
|
||||
Summary: "backend accessible",
|
||||
}
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
Pour un config test applicatif spécifique (appels API, messages ✓/✗), implémenter
|
||||
un hook `ConfigTest` custom.
|
||||
|
||||
## Options
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| `BinaryName` | Nom du binaire, utilisé dans l'aide et les messages |
|
||||
| `Description` | Description affichée dans l'aide globale |
|
||||
| `Version` | Version affichée par `version` sans hook |
|
||||
| `Args` | Arguments CLI (défaut : `os.Args[1:]`) |
|
||||
| `Stdin/Stdout/Stderr` | I/O (défaut : `os.Stdin/Stdout/Stderr`) |
|
||||
| `Aliases` | Alias de commandes |
|
||||
| `AliasDescriptions` | Descriptions des alias dans l'aide |
|
||||
| `EnableDoctorAlias` | Active `doctor` comme alias de `config test` |
|
||||
| `DisabledCommands` | Commandes à masquer explicitement |
|
||||
|
||||
Le flag global `--debug` active le debug des appels Bitwarden
|
||||
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).
|
||||
|
|
|
|||
|
|
@ -126,8 +126,11 @@ if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil {
|
|||
|
||||
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
|
||||
|
||||
Le package fournit aussi un socle réutilisable pour une commande `doctor`.
|
||||
L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks :
|
||||
Le package fournit un socle réutilisable pour une commande `doctor`.
|
||||
Pour les cas standards (config, secret store, connectivité), préférer
|
||||
`bootstrap.StandardConfigTestHandler` qui câble `RunDoctor` sans boilerplate.
|
||||
|
||||
Pour un contrôle fin ou un config test impératif, utiliser `RunDoctor` directement :
|
||||
|
||||
```go
|
||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||
|
|
@ -137,32 +140,19 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
|||
ServiceName: "my-mcp",
|
||||
})
|
||||
}),
|
||||
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
BitwardenOptions: cli.BitwardenDoctorOptions{
|
||||
LookupEnv: os.LookupEnv,
|
||||
},
|
||||
RequiredSecrets: []cli.DoctorSecret{
|
||||
{Name: "api-token", Label: "API token"},
|
||||
},
|
||||
SecretStoreFactory: func() (secretstore.Store, error) {
|
||||
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
},
|
||||
ManifestDir: ".",
|
||||
ConnectivityCheck: func(context.Context) cli.DoctorResult {
|
||||
if err := pingBackend(); err != nil {
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusFail,
|
||||
Summary: "backend is unreachable",
|
||||
Summary: "backend inaccessible",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusOK,
|
||||
Summary: "backend is reachable",
|
||||
Summary: "backend accessible",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -176,6 +166,10 @@ if report.HasFailures() {
|
|||
}
|
||||
```
|
||||
|
||||
`ManifestDir` est optionnel. Quand il est fourni, `RunDoctor` inclut un
|
||||
`ManifestCheck` qui vérifie la présence et la validité de `mcp.toml` dans ce
|
||||
répertoire. Ne l'inclure que si ce check est pertinent pour l'application.
|
||||
|
||||
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
|
||||
Pour le désactiver explicitement :
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
## Installation
|
||||
|
||||
```bash
|
||||
go get gitea.lclr.dev/AI/mcp-framework
|
||||
go get forge.lclr.dev/AI/mcp-framework
|
||||
```
|
||||
|
||||
## CLI de scaffold
|
||||
|
|
@ -11,7 +11,7 @@ go get gitea.lclr.dev/AI/mcp-framework
|
|||
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
|
||||
|
||||
```bash
|
||||
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||
mcp-framework scaffold init \
|
||||
--target ./my-mcp \
|
||||
--module example.com/my-mcp \
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"]
|
|||
|
||||
[secret_store]
|
||||
backend_policy = "auto"
|
||||
# Optionnel : mettre false pour désactiver le cache Bitwarden.
|
||||
bitwarden_cache = true
|
||||
|
||||
[profiles]
|
||||
default = "prod"
|
||||
|
|
@ -80,6 +82,7 @@ Champs supportés :
|
|||
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
||||
- `[environment].known` : variables d'environnement connues du projet.
|
||||
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`).
|
||||
- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire et disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver.
|
||||
- `[profiles].default` : profil recommandé par défaut.
|
||||
- `[profiles].known` : profils connus du projet.
|
||||
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import (
|
|||
"os"
|
||||
|
||||
"example.com/my-mcp/mcpgen"
|
||||
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||
"gitea.lclr.dev/AI/mcp-framework/update"
|
||||
"forge.lclr.dev/AI/mcp-framework/config"
|
||||
"forge.lclr.dev/AI/mcp-framework/update"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Exemple :
|
|||
```go
|
||||
result, err := scaffold.Generate(scaffold.Options{
|
||||
TargetDir: "./my-mcp",
|
||||
ModulePath: "gitea.lclr.dev/AI/my-mcp",
|
||||
ModulePath: "forge.lclr.dev/AI/my-mcp",
|
||||
BinaryName: "my-mcp",
|
||||
Description: "Client MCP interne",
|
||||
DefaultProfile: "prod",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,34 @@ store, err := secretstore.Open(secretstore.Options{
|
|||
})
|
||||
```
|
||||
|
||||
## Cache Bitwarden
|
||||
|
||||
Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut.
|
||||
Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache
|
||||
disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et
|
||||
AES-GCM.
|
||||
|
||||
TTL par défaut : 10 minutes.
|
||||
|
||||
Pour désactiver le cache dans `mcp.toml` :
|
||||
|
||||
```toml
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
bitwarden_cache = false
|
||||
```
|
||||
|
||||
Pour le désactiver sans modifier le manifeste :
|
||||
|
||||
```bash
|
||||
MCP_FRAMEWORK_BITWARDEN_CACHE=0
|
||||
```
|
||||
|
||||
Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les
|
||||
secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes
|
||||
deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut
|
||||
lire l'environnement ou la mémoire du process pendant l'exécution.
|
||||
|
||||
Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) :
|
||||
|
||||
```go
|
||||
|
|
@ -159,7 +187,8 @@ effective := secretstore.EffectiveBackendPolicy(store)
|
|||
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any...
|
||||
```
|
||||
|
||||
Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) :
|
||||
Pour obtenir en un seul appel une description runtime légère (source manifeste,
|
||||
policy déclarée/effective, backend affiché) :
|
||||
|
||||
```go
|
||||
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
|
||||
|
|
@ -174,7 +203,8 @@ fmt.Println(secretstore.FormatBackendStatus(desc))
|
|||
// declared=... effective=... display=... ready=... source=...
|
||||
```
|
||||
|
||||
Pour un préflight réutilisable dans `setup`, `config show` et `config test` :
|
||||
`DescribeRuntime` ne contacte pas Bitwarden par défaut. Pour vérifier réellement
|
||||
la disponibilité du backend, utiliser le préflight :
|
||||
|
||||
```go
|
||||
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{
|
||||
|
|
|
|||
1356
docs/superpowers/plans/2026-05-02-bitwarden-cache.md
Normal file
1356
docs/superpowers/plans/2026-05-02-bitwarden-cache.md
Normal file
File diff suppressed because it is too large
Load diff
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal file
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Bitwarden Cache Design
|
||||
|
||||
Date: 2026-05-02
|
||||
|
||||
## Context
|
||||
|
||||
The `secretstore` package supports a `bitwarden-cli` backend. Each secret read can call the Bitwarden CLI several times:
|
||||
|
||||
- `bw list items --search <service>/<secret>`
|
||||
- `bw get item <id>` for each candidate item
|
||||
|
||||
These calls can take several seconds when commands such as MCP `setup`, `doctor`, or generated config resolution read multiple secrets or are run repeatedly.
|
||||
|
||||
The framework already requires an unlocked Bitwarden session for this backend and restores a persisted `BW_SESSION` into the process environment when available. The cache must use that runtime session as the trust root without embedding a static key in the binary or repository.
|
||||
|
||||
## Goals
|
||||
|
||||
- Avoid repeated Bitwarden CLI calls for short-lived and long-running framework processes.
|
||||
- Keep the feature portable across Windows, macOS, Linux, and WSL.
|
||||
- Enable the encrypted disk cache by default when `BW_SESSION` is available.
|
||||
- Allow projects and operators to disable the cache.
|
||||
- Never make cached secrets decryptable with only the installed binary, cache files, and repository content.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Protect against an attacker who can read the process memory or environment while the process is running.
|
||||
- Add an OS keyring dependency for the first implementation.
|
||||
- Cache non-Bitwarden secret backends.
|
||||
- Persist decrypted secrets on disk.
|
||||
|
||||
## Configuration
|
||||
|
||||
`manifest.SecretStore` gains:
|
||||
|
||||
```toml
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
bitwarden_cache = true
|
||||
```
|
||||
|
||||
`bitwarden_cache` defaults to `true` when omitted.
|
||||
|
||||
The generated helper options continue to flow through `OpenFromManifest` and `DescribeRuntime`. The runtime option is represented in `secretstore.Options` and `secretstore.OpenFromManifestOptions` so generated packages can still override it programmatically if needed.
|
||||
|
||||
An environment variable can force-disable the cache without editing `mcp.toml`:
|
||||
|
||||
```text
|
||||
MCP_FRAMEWORK_BITWARDEN_CACHE=0
|
||||
```
|
||||
|
||||
Accepted false values are `0`, `false`, `no`, `off`, and `disabled`, case-insensitive. Any other value leaves the manifest/default behavior in place.
|
||||
|
||||
## Architecture
|
||||
|
||||
The cache is internal to the `bitwarden-cli` backend.
|
||||
|
||||
`bitwardenStore.GetSecret(name)` resolves secrets in this order:
|
||||
|
||||
1. In-memory cache.
|
||||
2. Encrypted disk cache, only if a cache key can be derived from the effective `BW_SESSION`.
|
||||
3. Bitwarden CLI lookup.
|
||||
|
||||
After a successful CLI lookup, the secret is written to the memory cache and, when available, the encrypted disk cache.
|
||||
|
||||
`SetSecret` and `DeleteSecret` update Bitwarden first. After success, they invalidate the cache entry for the affected secret. `SetSecret` may repopulate the memory and disk cache with the new value after the Bitwarden write succeeds, but it must not expose a value that failed to persist to Bitwarden.
|
||||
|
||||
## Cache Contents
|
||||
|
||||
Memory entries store:
|
||||
|
||||
- secret value
|
||||
- expiration timestamp
|
||||
|
||||
Disk entries store encrypted JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"service_name": "graylog-mcp",
|
||||
"secret_name": "profile/prod/api-token",
|
||||
"scoped_name": "graylog-mcp/profile/prod/api-token",
|
||||
"created_at": "2026-05-02T10:00:00Z",
|
||||
"expires_at": "2026-05-02T10:10:00Z",
|
||||
"value": "secret"
|
||||
}
|
||||
```
|
||||
|
||||
The plaintext exists only before encryption and after decryption in memory.
|
||||
|
||||
## Key Derivation
|
||||
|
||||
Disk cache is enabled only when the effective `BW_SESSION` is non-empty. The effective session is the value available after `EnsureBitwardenSessionEnv`, so it can come from either the process environment or the framework-restored session file.
|
||||
|
||||
The cache derives dedicated keys with HKDF-SHA256:
|
||||
|
||||
```text
|
||||
master_key = HKDF-SHA256(
|
||||
input key material: BW_SESSION,
|
||||
salt: "mcp-framework bitwarden cache salt v1",
|
||||
info: "mcp-framework bitwarden cache v1"
|
||||
)
|
||||
```
|
||||
|
||||
Separate subkeys are derived from `master_key`:
|
||||
|
||||
- encryption key: `info = "mcp-framework bitwarden cache encryption v1"`
|
||||
- entry ID key: `info = "mcp-framework bitwarden cache entry id v1"`
|
||||
|
||||
`BW_SESSION` is never used directly as an AES key.
|
||||
|
||||
## Disk Encryption
|
||||
|
||||
Disk entries use AES-256-GCM from the Go standard library.
|
||||
|
||||
Each write generates a fresh random nonce with `crypto/rand`. The file format is JSON with metadata needed to decrypt:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"algorithm": "AES-256-GCM",
|
||||
"nonce": "<base64>",
|
||||
"ciphertext": "<base64>"
|
||||
}
|
||||
```
|
||||
|
||||
Authenticated additional data includes stable, non-secret cache context:
|
||||
|
||||
```text
|
||||
mcp-framework bitwarden cache v1
|
||||
service=<serviceName>
|
||||
secret=<secretName>
|
||||
scoped=<serviceName>/<secretName>
|
||||
```
|
||||
|
||||
If decryption fails, the entry is treated as a cache miss. The framework may remove the unusable file as best effort.
|
||||
|
||||
## Entry Identity
|
||||
|
||||
Disk file names do not expose secret names or Bitwarden item refs. The file name is:
|
||||
|
||||
```text
|
||||
hex(HMAC-SHA256(entry_id_key, cache_context)) + ".json"
|
||||
```
|
||||
|
||||
The cache context includes:
|
||||
|
||||
- cache format version
|
||||
- service name
|
||||
- raw secret name
|
||||
- scoped Bitwarden item name
|
||||
- backend scope marker
|
||||
|
||||
Because the HMAC key is derived from `BW_SESSION`, changing or losing `BW_SESSION` makes existing file names undiscoverable and existing entries undecryptable.
|
||||
|
||||
## TTL and Invalidation
|
||||
|
||||
Default TTL: 10 minutes.
|
||||
|
||||
TTL applies to both memory and disk entries. Expired entries are treated as misses and may be deleted best-effort.
|
||||
|
||||
The first implementation uses a constant default TTL. It keeps room for a future `bitwarden_cache_ttl` option, but does not add that option now to keep scope tight.
|
||||
|
||||
Invalidation rules:
|
||||
|
||||
- `SetSecret` invalidates the affected entry after the Bitwarden write succeeds.
|
||||
- `DeleteSecret` invalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent.
|
||||
- `BW_SESSION` change implicitly invalidates disk cache through key derivation.
|
||||
- `bitwarden_cache = false` disables both memory and disk cache for the backend instance.
|
||||
- `MCP_FRAMEWORK_BITWARDEN_CACHE=0` disables both memory and disk cache.
|
||||
|
||||
## Storage Location and Permissions
|
||||
|
||||
Disk cache path:
|
||||
|
||||
```text
|
||||
os.UserCacheDir()/serviceName/bitwarden-cache
|
||||
```
|
||||
|
||||
If `os.UserCacheDir` fails, disk cache is disabled and secret reads continue through memory cache and Bitwarden CLI.
|
||||
|
||||
The cache directory is created with `0700` and cache files with `0600` where the platform supports Unix-style permissions. Permission setting errors disable disk cache for that operation rather than failing secret resolution, because Bitwarden remains the source of truth.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Cache failures must not make a healthy Bitwarden backend unusable.
|
||||
|
||||
- Memory cache errors are not expected.
|
||||
- Disk cache read/decrypt/parse errors are treated as misses.
|
||||
- Disk cache write errors are ignored after optional debug logging.
|
||||
- Bitwarden CLI errors keep their current behavior and typed error classification.
|
||||
- Malformed or expired cache entries are never returned.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests should cover:
|
||||
|
||||
- repeated `GetSecret` hits memory and avoids a second CLI item read
|
||||
- reopened store can read from encrypted disk cache without calling `bw get item`
|
||||
- disk cache file does not contain the secret value or clear secret name
|
||||
- cache is disabled by `bitwarden_cache = false`
|
||||
- cache is disabled by `MCP_FRAMEWORK_BITWARDEN_CACHE=0`
|
||||
- expired entries are missed and refreshed from Bitwarden
|
||||
- `SetSecret` invalidates or refreshes stale cache data
|
||||
- `DeleteSecret` removes cached data
|
||||
- missing `BW_SESSION` disables disk cache
|
||||
- changed `BW_SESSION` cannot decrypt a previous disk entry
|
||||
- manifest parsing preserves the default enabled behavior when the field is omitted
|
||||
|
||||
## Documentation
|
||||
|
||||
Update:
|
||||
|
||||
- `docs/manifest.md` for `[secret_store].bitwarden_cache`
|
||||
- `docs/secrets.md` for cache behavior, TTL, disable controls, and threat model
|
||||
- generated/scaffolded `mcp.toml` examples only if the option should be visible by default
|
||||
|
||||
The preferred scaffold output should omit `bitwarden_cache` because the default is enabled. Documentation should show it as the explicit disable knob.
|
||||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date")
|
||||
|
|
@ -211,7 +211,7 @@ func renderManifestLoader(packageName, manifestContent string) (string, error) {
|
|||
|
||||
package %s
|
||||
|
||||
import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const embeddedManifest = %s
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ func renderMetadata(packageName string, manifestFile manifest.File) (string, err
|
|||
|
||||
package %s
|
||||
|
||||
import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const BinaryName = %s
|
||||
const DefaultDescription = %s
|
||||
|
|
@ -275,7 +275,7 @@ import (
|
|||
"io"
|
||||
"strings"
|
||||
|
||||
fwupdate "gitea.lclr.dev/AI/mcp-framework/update"
|
||||
fwupdate "forge.lclr.dev/AI/mcp-framework/update"
|
||||
)
|
||||
|
||||
func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {
|
||||
|
|
@ -338,18 +338,19 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type SecretStoreOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
Shell string
|
||||
ExecutableResolver fwsecretstore.ExecutableResolver
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
Shell string
|
||||
ExecutableResolver fwsecretstore.ExecutableResolver
|
||||
}
|
||||
|
||||
func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {
|
||||
|
|
@ -372,6 +373,7 @@ func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromMa
|
|||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: options.DisableBitwardenCache,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: LoadManifest,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
|
|
@ -386,6 +388,7 @@ func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.Descri
|
|||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: options.DisableBitwardenCache,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: LoadManifest,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
|
|
@ -480,7 +483,7 @@ import (
|
|||
"flag"
|
||||
"strings"
|
||||
|
||||
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
)
|
||||
|
||||
type ConfigFlags struct {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ description = "Demo MCP"
|
|||
for _, snippet := range []string{
|
||||
"// Code generated by mcp-framework generate. DO NOT EDIT.",
|
||||
"package mcpgen",
|
||||
"import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"",
|
||||
"import fwmanifest \"forge.lclr.dev/AI/mcp-framework/manifest\"",
|
||||
"const embeddedManifest = ",
|
||||
"func LoadManifest(startDir string) (fwmanifest.File, string, error) {",
|
||||
"return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)",
|
||||
|
|
@ -322,6 +322,33 @@ sources = ["flag", "env", "secret"]
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) {
|
||||
projectDir := newProject(t, `
|
||||
binary_name = "demo-mcp"
|
||||
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
`)
|
||||
|
||||
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile generated secretstore: %v", err)
|
||||
}
|
||||
text := string(content)
|
||||
for _, snippet := range []string{
|
||||
"DisableBitwardenCache bool",
|
||||
"DisableBitwardenCache: options.DisableBitwardenCache,",
|
||||
} {
|
||||
if !strings.Contains(text, snippet) {
|
||||
t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newProject(t *testing.T, manifest string) string {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -359,7 +386,7 @@ func writeModule(t *testing.T, projectDir string) {
|
|||
t.Fatalf("Abs repo root: %v", err)
|
||||
}
|
||||
|
||||
goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n"
|
||||
goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tforge.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace forge.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n"
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile go.mod: %v", err)
|
||||
}
|
||||
|
|
@ -379,10 +406,10 @@ import (
|
|||
"io"
|
||||
"testing"
|
||||
|
||||
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"example.com/generated-demo/mcpgen"
|
||||
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module gitea.lclr.dev/AI/mcp-framework
|
||||
module forge.lclr.dev/AI/mcp-framework
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/update"
|
||||
"forge.lclr.dev/AI/mcp-framework/update"
|
||||
)
|
||||
|
||||
const DefaultFile = "mcp.toml"
|
||||
|
|
@ -49,7 +49,8 @@ type Environment struct {
|
|||
}
|
||||
|
||||
type SecretStore struct {
|
||||
BackendPolicy string `toml:"backend_policy"`
|
||||
BackendPolicy string `toml:"backend_policy"`
|
||||
BitwardenCache *bool `toml:"bitwarden_cache"`
|
||||
}
|
||||
|
||||
type Profiles struct {
|
||||
|
|
|
|||
|
|
@ -235,6 +235,54 @@ description = " Client MCP interne "
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, DefaultFile)
|
||||
|
||||
const content = `
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
bitwarden_cache = false
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
file, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if file.SecretStore.BitwardenCache == nil {
|
||||
t.Fatal("bitwarden cache option is nil, want explicit false pointer")
|
||||
}
|
||||
if *file.SecretStore.BitwardenCache {
|
||||
t.Fatal("bitwarden cache option = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, DefaultFile)
|
||||
|
||||
const content = `
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
file, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if file.SecretStore.BitwardenCache != nil {
|
||||
t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesConfigFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, DefaultFile)
|
||||
|
|
|
|||
|
|
@ -1539,12 +1539,12 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/bootstrap"
|
||||
"gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"gitea.lclr.dev/AI/mcp-framework/update"
|
||||
"forge.lclr.dev/AI/mcp-framework/bootstrap"
|
||||
"forge.lclr.dev/AI/mcp-framework/cli"
|
||||
"forge.lclr.dev/AI/mcp-framework/config"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
"forge.lclr.dev/AI/mcp-framework/update"
|
||||
)
|
||||
|
||||
var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ type bitwardenStore struct {
|
|||
command string
|
||||
serviceName string
|
||||
debug bool
|
||||
lookupEnv func(string) (string, bool)
|
||||
shell string
|
||||
cache *bitwardenCache
|
||||
}
|
||||
|
||||
type bitwardenListItem struct {
|
||||
|
|
@ -68,12 +71,6 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
|
|||
}
|
||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||
|
||||
store := &bitwardenStore{
|
||||
command: command,
|
||||
serviceName: serviceName,
|
||||
debug: debugEnabled,
|
||||
}
|
||||
|
||||
if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{
|
||||
ServiceName: serviceName,
|
||||
}); err != nil {
|
||||
|
|
@ -85,41 +82,60 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
|
|||
)
|
||||
}
|
||||
|
||||
if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return nil, fmt.Errorf(
|
||||
"secret backend policy %q requires bitwarden CLI command %q in PATH: %w",
|
||||
policy,
|
||||
command,
|
||||
ErrBackendUnavailable,
|
||||
)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"secret backend policy %q cannot verify bitwarden CLI command %q: %w",
|
||||
policy,
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
if err := EnsureBitwardenReady(Options{
|
||||
BitwardenCommand: command,
|
||||
BitwardenDebug: debugEnabled,
|
||||
LookupEnv: options.LookupEnv,
|
||||
Shell: options.Shell,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"secret backend policy %q cannot use bitwarden CLI command %q right now: %w",
|
||||
policy,
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
session, _ := os.LookupEnv(bitwardenSessionEnvName)
|
||||
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
|
||||
store := &bitwardenStore{
|
||||
command: command,
|
||||
serviceName: serviceName,
|
||||
debug: debugEnabled,
|
||||
lookupEnv: options.LookupEnv,
|
||||
shell: options.Shell,
|
||||
cache: newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: serviceName,
|
||||
Session: session,
|
||||
TTL: defaultBitwardenCacheTTL,
|
||||
CacheDir: resolveBitwardenCacheDir(serviceName),
|
||||
Enabled: cacheEnabled,
|
||||
}),
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func verifyBitwardenCLIReady(options Options) error {
|
||||
command := strings.TrimSpace(options.BitwardenCommand)
|
||||
if command == "" {
|
||||
command = defaultBitwardenCommand
|
||||
}
|
||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||
|
||||
if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return fmt.Errorf(
|
||||
"requires bitwarden CLI command %q in PATH: %w",
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"cannot verify bitwarden CLI command %q: %w",
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
if err := EnsureBitwardenReady(options); err != nil {
|
||||
return fmt.Errorf(
|
||||
"cannot use bitwarden CLI command %q right now: %w",
|
||||
command,
|
||||
errors.Join(ErrBackendUnavailable, err),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureBitwardenReady(options Options) error {
|
||||
command := strings.TrimSpace(options.BitwardenCommand)
|
||||
if command == "" {
|
||||
|
|
@ -242,6 +258,10 @@ func detectShellFlavor(shellHint string) string {
|
|||
|
||||
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
||||
secretName := s.scopedName(name)
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
|
||||
}
|
||||
|
||||
item, payload, err := s.findItem(secretName, name)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
|
|
@ -265,6 +285,10 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
|||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
s.cache.store(name, secretName, secret)
|
||||
}
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
|
|
@ -287,11 +311,25 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
s.cache.store(name, secretName, secret)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
||||
secretName := s.scopedName(name)
|
||||
if s.cache != nil {
|
||||
if secret, ok := s.cache.load(name, secretName); ok {
|
||||
return secret, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err)
|
||||
}
|
||||
|
||||
_, payload, err := s.findItem(secretName, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -302,13 +340,23 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
|||
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.store(name, secretName, secret)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) DeleteSecret(name string) error {
|
||||
secretName := s.scopedName(name)
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err)
|
||||
}
|
||||
|
||||
item, _, err := s.findItem(secretName, name)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
|
|
@ -325,6 +373,9 @@ func (s *bitwardenStore) DeleteSecret(name string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
s.cache.invalidate(name, secretName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -332,6 +383,24 @@ func (s *bitwardenStore) scopedName(name string) string {
|
|||
return fmt.Sprintf("%s/%s", s.serviceName, name)
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) ensureReady() error {
|
||||
s.refreshSessionEnv()
|
||||
return verifyBitwardenCLIReady(Options{
|
||||
BitwardenCommand: s.command,
|
||||
BitwardenDebug: s.debug,
|
||||
LookupEnv: s.lookupEnv,
|
||||
Shell: s.shell,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *bitwardenStore) refreshSessionEnv() {
|
||||
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil || strings.TrimSpace(session) == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
|
||||
}
|
||||
|
||||
type bitwardenResolvedItem struct {
|
||||
item bitwardenListItem
|
||||
payload map[string]any
|
||||
|
|
|
|||
378
secretstore/bitwarden_cache.go
Normal file
378
secretstore/bitwarden_cache.go
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE"
|
||||
defaultBitwardenCacheTTL = 10 * time.Minute
|
||||
bitwardenCacheFormatVersion = 1
|
||||
bitwardenCacheAlgorithm = "AES-256-GCM"
|
||||
bitwardenCacheDirName = "bitwarden-cache"
|
||||
bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1"
|
||||
bitwardenCacheInfo = "mcp-framework bitwarden cache v1"
|
||||
bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1"
|
||||
bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1"
|
||||
bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1"
|
||||
)
|
||||
|
||||
var bitwardenUserCacheDir = os.UserCacheDir
|
||||
|
||||
type bitwardenCacheOptions struct {
|
||||
ServiceName string
|
||||
Session string
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
CacheDir string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type bitwardenCache struct {
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
serviceName string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
cacheDir string
|
||||
encryptionKey []byte
|
||||
entryIDKey []byte
|
||||
memory map[string]bitwardenCacheMemoryEntry
|
||||
}
|
||||
|
||||
type bitwardenCacheMemoryEntry struct {
|
||||
value string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type bitwardenCachePlaintext struct {
|
||||
Version int `json:"version"`
|
||||
ServiceName string `json:"service_name"`
|
||||
SecretName string `json:"secret_name"`
|
||||
ScopedName string `json:"scoped_name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type bitwardenCacheEnvelope struct {
|
||||
Version int `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache {
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
ttl := options.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultBitwardenCacheTTL
|
||||
}
|
||||
|
||||
cache := &bitwardenCache{
|
||||
enabled: options.Enabled,
|
||||
serviceName: strings.TrimSpace(options.ServiceName),
|
||||
ttl: ttl,
|
||||
now: now,
|
||||
cacheDir: strings.TrimSpace(options.CacheDir),
|
||||
memory: map[string]bitwardenCacheMemoryEntry{},
|
||||
}
|
||||
if !cache.enabled {
|
||||
return cache
|
||||
}
|
||||
|
||||
session := strings.TrimSpace(options.Session)
|
||||
if session == "" {
|
||||
return cache
|
||||
}
|
||||
masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32)
|
||||
if err != nil {
|
||||
return cache
|
||||
}
|
||||
cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32)
|
||||
if err != nil {
|
||||
cache.encryptionKey = nil
|
||||
return cache
|
||||
}
|
||||
cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32)
|
||||
if err != nil {
|
||||
cache.encryptionKey = nil
|
||||
cache.entryIDKey = nil
|
||||
return cache
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) {
|
||||
if value, ok := c.loadMemory(secretName, scopedName); ok {
|
||||
return value, true
|
||||
}
|
||||
return c.loadDisk(secretName, scopedName)
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) store(secretName, scopedName, value string) {
|
||||
if c == nil || !c.enabled {
|
||||
return
|
||||
}
|
||||
c.storeMemory(secretName, scopedName, value)
|
||||
c.storeDisk(secretName, scopedName, value)
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) invalidate(secretName, scopedName string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
key := c.memoryKey(secretName, scopedName)
|
||||
c.mu.Lock()
|
||||
delete(c.memory, key)
|
||||
c.mu.Unlock()
|
||||
if path, ok := c.entryPath(secretName, scopedName); ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) {
|
||||
if c == nil || !c.enabled {
|
||||
return "", false
|
||||
}
|
||||
key := c.memoryKey(secretName, scopedName)
|
||||
now := c.now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.memory[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if !entry.expiresAt.After(now) {
|
||||
delete(c.memory, key)
|
||||
return "", false
|
||||
}
|
||||
return entry.value, true
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) {
|
||||
key := c.memoryKey(secretName, scopedName)
|
||||
c.mu.Lock()
|
||||
c.memory[key] = bitwardenCacheMemoryEntry{
|
||||
value: value,
|
||||
expiresAt: c.now().Add(c.ttl),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) {
|
||||
path, ok := c.entryPath(secretName, scopedName)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var envelope bitwardenCacheEnvelope
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", false
|
||||
}
|
||||
plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", false
|
||||
}
|
||||
if plaintext.Version != bitwardenCacheFormatVersion ||
|
||||
plaintext.ServiceName != c.serviceName ||
|
||||
plaintext.SecretName != strings.TrimSpace(secretName) ||
|
||||
plaintext.ScopedName != strings.TrimSpace(scopedName) ||
|
||||
!plaintext.ExpiresAt.After(c.now()) {
|
||||
_ = os.Remove(path)
|
||||
return "", false
|
||||
}
|
||||
c.storeMemory(secretName, scopedName, plaintext.Value)
|
||||
return plaintext.Value, true
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) {
|
||||
if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 {
|
||||
return
|
||||
}
|
||||
path, ok := c.entryPath(secretName, scopedName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Chmod(filepath.Dir(path), 0o700)
|
||||
|
||||
now := c.now()
|
||||
plaintext := bitwardenCachePlaintext{
|
||||
Version: bitwardenCacheFormatVersion,
|
||||
ServiceName: c.serviceName,
|
||||
SecretName: strings.TrimSpace(secretName),
|
||||
ScopedName: strings.TrimSpace(scopedName),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(c.ttl),
|
||||
Value: value,
|
||||
}
|
||||
envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
_ = tmp.Chmod(0o600)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Chmod(path, 0o600)
|
||||
cleanup = false
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) {
|
||||
raw, err := json.Marshal(plaintext)
|
||||
if err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
block, err := aes.NewCipher(c.encryptionKey)
|
||||
if err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return bitwardenCacheEnvelope{}, err
|
||||
}
|
||||
ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName))
|
||||
return bitwardenCacheEnvelope{
|
||||
Version: bitwardenCacheFormatVersion,
|
||||
Algorithm: bitwardenCacheAlgorithm,
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) {
|
||||
if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm {
|
||||
return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope")
|
||||
}
|
||||
nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
block, err := aes.NewCipher(c.encryptionKey)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName))
|
||||
if err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
var plaintext bitwardenCachePlaintext
|
||||
if err := json.Unmarshal(raw, &plaintext); err != nil {
|
||||
return bitwardenCachePlaintext{}, err
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) {
|
||||
if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 {
|
||||
return "", false
|
||||
}
|
||||
mac := hmac.New(sha256.New, c.entryIDKey)
|
||||
_, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName)))
|
||||
return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) memoryKey(secretName, scopedName string) string {
|
||||
return c.cacheContext(secretName, scopedName)
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte {
|
||||
return []byte(fmt.Sprintf(
|
||||
"mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s",
|
||||
c.serviceName,
|
||||
strings.TrimSpace(secretName),
|
||||
strings.TrimSpace(scopedName),
|
||||
))
|
||||
}
|
||||
|
||||
func (c *bitwardenCache) cacheContext(secretName, scopedName string) string {
|
||||
return fmt.Sprintf(
|
||||
"version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s",
|
||||
bitwardenCacheFormatVersion,
|
||||
c.serviceName,
|
||||
strings.TrimSpace(secretName),
|
||||
strings.TrimSpace(scopedName),
|
||||
bitwardenCacheContextScope,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveBitwardenCacheDir(serviceName string) string {
|
||||
cacheRoot, err := bitwardenUserCacheDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName)
|
||||
}
|
||||
|
||||
func bitwardenCacheDisabledByEnv() bool {
|
||||
raw, ok := os.LookupEnv(bitwardenCacheEnvName)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "0", "false", "no", "off", "disabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
141
secretstore/bitwarden_cache_test.go
Normal file
141
secretstore/bitwarden_cache_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBitwardenCacheMemoryHit(t *testing.T) {
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) },
|
||||
CacheDir: t.TempDir(),
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
got, ok := cache.loadMemory("api-token", "email-mcp/api-token")
|
||||
if !ok {
|
||||
t.Fatal("memory cache miss, want hit")
|
||||
}
|
||||
if got != "secret-v1" {
|
||||
t.Fatalf("memory cache value = %q, want secret-v1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
|
||||
reopened := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now.Add(time.Minute) },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
got, ok := reopened.loadDisk("api-token", "email-mcp/api-token")
|
||||
if !ok {
|
||||
t.Fatal("disk cache miss, want hit")
|
||||
}
|
||||
if got != "secret-v1" {
|
||||
t.Fatalf("disk cache value = %q, want secret-v1", got)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir cache dir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("cache file count = %d, want 1", len(entries))
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile cache file: %v", err)
|
||||
}
|
||||
if bytes.Contains(data, []byte("secret-v1")) {
|
||||
t.Fatalf("cache file contains plaintext secret: %s", data)
|
||||
}
|
||||
if strings.Contains(entries[0].Name(), "api-token") {
|
||||
t.Fatalf("cache file name exposes secret name: %s", entries[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenCacheRejectsChangedSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
|
||||
changed := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v2",
|
||||
TTL: 10 * time.Minute,
|
||||
Now: func() time.Time { return now.Add(time.Minute) },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok {
|
||||
t.Fatalf("disk cache hit with changed session = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenCacheExpiresEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: time.Minute,
|
||||
Now: func() time.Time { return now },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-v1")
|
||||
|
||||
expired := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: "session-v1",
|
||||
TTL: time.Minute,
|
||||
Now: func() time.Time { return now.Add(2 * time.Minute) },
|
||||
CacheDir: dir,
|
||||
Enabled: true,
|
||||
})
|
||||
if got, ok := expired.load("api-token", "email-mcp/api-token"); ok {
|
||||
t.Fatalf("expired cache hit = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
func withBitwardenUserCacheDir(t *testing.T, resolver func() (string, error)) {
|
||||
t.Helper()
|
||||
|
||||
previous := bitwardenUserCacheDir
|
||||
bitwardenUserCacheDir = resolver
|
||||
t.Cleanup(func() {
|
||||
bitwardenUserCacheDir = previous
|
||||
})
|
||||
}
|
||||
|
|
@ -9,7 +9,10 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
const bitwardenSessionFileName = "bw-session"
|
||||
const (
|
||||
bitwardenSessionFileName = "bw-session"
|
||||
bitwardenSharedSessionName = "mcp-framework"
|
||||
)
|
||||
|
||||
var bitwardenUserConfigDir = os.UserConfigDir
|
||||
|
||||
|
|
@ -108,10 +111,14 @@ func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
|
|||
|
||||
session, err := LoadBitwardenSession(options)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return false, err
|
||||
}
|
||||
// Service-specific file not found; try the shared file written by any MCP login.
|
||||
session, err = LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
|
||||
|
|
@ -156,7 +163,20 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
|
|||
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
|
||||
return "", fmt.Errorf("login to bitwarden CLI: %w", err)
|
||||
}
|
||||
case "locked", "unlocked":
|
||||
case "unlocked":
|
||||
// Vault is already unlocked. Reuse an existing session to avoid calling
|
||||
// bw unlock again, which would generate a new token and invalidate the
|
||||
// tokens held by other running MCP processes.
|
||||
if existing := loadAnyBitwardenSession(); existing != "" {
|
||||
if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil {
|
||||
return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err)
|
||||
}
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, existing); err != nil {
|
||||
return "", fmt.Errorf("persist bitwarden session: %w", err)
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
case "locked":
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
|
||||
}
|
||||
|
|
@ -175,13 +195,25 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
|
|||
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
|
||||
}
|
||||
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil {
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil {
|
||||
return "", fmt.Errorf("persist bitwarden session: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// loadAnyBitwardenSession looks for an existing valid session in: the process
|
||||
// environment, then the shared file written by any MCP login.
|
||||
func loadAnyBitwardenSession() string {
|
||||
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
|
||||
return strings.TrimSpace(session)
|
||||
}
|
||||
if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil {
|
||||
return session
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
|
||||
serviceName := strings.TrimSpace(options.ServiceName)
|
||||
if serviceName == "" {
|
||||
|
|
|
|||
|
|
@ -60,12 +60,12 @@ func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) {
|
|||
t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
|
||||
}
|
||||
|
||||
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
||||
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBitwardenSession returned error: %v", err)
|
||||
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
|
||||
}
|
||||
if persisted != "persisted-session" {
|
||||
t.Fatalf("persisted session = %q, want persisted-session", persisted)
|
||||
t.Fatalf("shared persisted session = %q, want persisted-session", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,6 +207,112 @@ func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoginBitwardenPersistsToSharedFileAfterUnlock(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"locked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
|
||||
return []byte("shared-session\n"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected interactive args: %v", args)
|
||||
})
|
||||
|
||||
if _, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
}); err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
|
||||
shared, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
|
||||
}
|
||||
if shared != "shared-session" {
|
||||
t.Fatalf("shared session = %q, want shared-session", shared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithEnvSession(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "existing-session")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"unlocked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("bw unlock must not be called when vault is already unlocked with a session: %v", args)
|
||||
})
|
||||
|
||||
session, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "email-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
if session != "existing-session" {
|
||||
t.Fatalf("session = %q, want existing-session", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithSharedSession(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
|
||||
// graylog-mcp logged in earlier and wrote to the shared file
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "graylog-session"); err != nil {
|
||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
||||
}
|
||||
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"unlocked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("bw unlock must not be called when shared session exists: %v", args)
|
||||
})
|
||||
|
||||
session, err := LoginBitwarden(BitwardenLoginOptions{
|
||||
ServiceName: "email-mcp",
|
||||
BitwardenCommand: "bw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginBitwarden returned error: %v", err)
|
||||
}
|
||||
if session != "graylog-session" {
|
||||
t.Fatalf("session = %q, want graylog-session (from shared file)", session)
|
||||
}
|
||||
|
||||
// The shared session must also be persisted to the service-specific file
|
||||
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBitwardenSession returned error: %v", err)
|
||||
}
|
||||
if persisted != "graylog-session" {
|
||||
t.Fatalf("email-mcp persisted session = %q, want graylog-session", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func withBitwardenInteractiveRunner(
|
||||
t *testing.T,
|
||||
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||
|
|
@ -34,23 +34,29 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
|||
if _, ok := store.(*bitwardenStore); !ok {
|
||||
t.Fatalf("store type = %T, want *bitwardenStore", store)
|
||||
}
|
||||
if !fakeCLI.versionChecked {
|
||||
t.Fatal("expected bitwarden CLI version check")
|
||||
if fakeCLI.versionChecked {
|
||||
t.Fatal("Open should not check bitwarden CLI version before a cache miss or write")
|
||||
}
|
||||
if !fakeCLI.statusChecked {
|
||||
t.Fatal("expected bitwarden CLI status check")
|
||||
if fakeCLI.statusChecked {
|
||||
t.Fatal("Open should not check bitwarden CLI status before a cache miss or write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
|
||||
func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
|
||||
})
|
||||
|
||||
_, err := Open(Options{
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.GetSecret("api-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
|
@ -59,26 +65,31 @@ func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) {
|
||||
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
_, err := Open(Options{
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.GetSecret("api-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrBWLocked) {
|
||||
t.Fatalf("error = %v, want ErrBWLocked", err)
|
||||
}
|
||||
if !errors.Is(err, ErrBackendUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) {
|
||||
|
|
@ -370,7 +381,203 @@ func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
value, err := store.GetSecret("api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
||||
}
|
||||
if value != "secret-v1" {
|
||||
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
|
||||
}
|
||||
}
|
||||
if fakeCLI.getItemCalls != 1 {
|
||||
t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
cacheRoot := t.TempDir()
|
||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
||||
return cacheRoot, nil
|
||||
})
|
||||
|
||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
||||
ServiceName: "email-mcp",
|
||||
Session: os.Getenv("BW_SESSION"),
|
||||
TTL: defaultBitwardenCacheTTL,
|
||||
CacheDir: resolveBitwardenCacheDir("email-mcp"),
|
||||
Enabled: true,
|
||||
})
|
||||
cache.store("api-token", "email-mcp/api-token", "secret-from-cache")
|
||||
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args)
|
||||
})
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "secret-from-cache" {
|
||||
t.Fatalf("GetSecret = %q, want secret-from-cache", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
if fakeCLI.statusChecked {
|
||||
t.Fatal("Open should not check bitwarden status")
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "secret-v1" {
|
||||
t.Fatalf("GetSecret = %q, want secret-v1", value)
|
||||
}
|
||||
if !fakeCLI.statusChecked {
|
||||
t.Fatal("cache miss should check bitwarden status before CLI lookup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0")
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := store.GetSecret("api-token"); err != nil {
|
||||
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if fakeCLI.getItemCalls != 2 {
|
||||
t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStoreSetSecretRefreshesCache(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
||||
t.Fatalf("SetSecret v1 returned error: %v", err)
|
||||
}
|
||||
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v1" {
|
||||
t.Fatalf("GetSecret after v1 = %q, %v; want secret-v1, nil", got, err)
|
||||
}
|
||||
if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil {
|
||||
t.Fatalf("SetSecret v2 returned error: %v", err)
|
||||
}
|
||||
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v2" {
|
||||
t.Fatalf("GetSecret after v2 = %q, %v; want secret-v2, nil", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
||||
t.Fatalf("SetSecret returned error: %v", err)
|
||||
}
|
||||
if _, err := store.GetSecret("api-token"); err != nil {
|
||||
t.Fatalf("GetSecret before delete returned error: %v", err)
|
||||
}
|
||||
if err := store.DeleteSecret("api-token"); err != nil {
|
||||
t.Fatalf("DeleteSecret returned error: %v", err)
|
||||
}
|
||||
_, err = store.GetSecret("api-token")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
|
||||
|
|
@ -404,20 +611,33 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) {
|
|||
|
||||
func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1")
|
||||
|
||||
var logs bytes.Buffer
|
||||
withBitwardenDebugOutput(t, &logs)
|
||||
|
||||
_, err := Open(Options{
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
if _, err := store.GetSecret("api-token"); err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
|
||||
text := logs.String()
|
||||
if !strings.Contains(text, "bw --version") {
|
||||
|
|
@ -514,6 +734,84 @@ func stripANSIControlSequences(value string) string {
|
|||
return strings.ReplaceAll(noANSI, "\r", "")
|
||||
}
|
||||
|
||||
func TestBitwardenStorePicksUpSessionRotatedByAnotherProcess(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "old-session")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "new-session"); err != nil {
|
||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.GetSecret("api-token"); err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if got := os.Getenv("BW_SESSION"); got != "new-session" {
|
||||
t.Fatalf("BW_SESSION = %q, want new-session after session rotation", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStorePicksUpSessionFromFileWhenEnvIsEmpty(t *testing.T) {
|
||||
t.Setenv("BW_SESSION", "")
|
||||
configDir := t.TempDir()
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return configDir, nil
|
||||
})
|
||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "file-session"); err != nil {
|
||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.GetSecret("api-token"); err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if got := os.Getenv("BW_SESSION"); got != "file-session" {
|
||||
t.Fatalf("BW_SESSION = %q, want file-session loaded from file after login by another process", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
|
|
@ -549,7 +847,14 @@ func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
|||
|
||||
func withBitwardenSession(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("BW_SESSION", "test-session")
|
||||
sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
|
||||
t.Setenv("BW_SESSION", "test-session-"+sessionName)
|
||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func withBitwardenRunner(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
type ManifestLoader func(startDir string) (manifest.File, string, error)
|
||||
|
|
@ -15,15 +15,16 @@ type ManifestLoader func(startDir string) (manifest.File, string, error)
|
|||
type ExecutableResolver func() (string, error)
|
||||
|
||||
type OpenFromManifestOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
}
|
||||
|
||||
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
||||
|
|
@ -33,14 +34,15 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
|||
}
|
||||
|
||||
return Open(Options{
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: manifestPolicy.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
Shell: strings.TrimSpace(options.Shell),
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: manifestPolicy.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache),
|
||||
Shell: strings.TrimSpace(options.Shell),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -53,8 +55,9 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic
|
|||
}
|
||||
|
||||
type manifestPolicyResolution struct {
|
||||
Policy BackendPolicy
|
||||
Source string
|
||||
Policy BackendPolicy
|
||||
Source string
|
||||
BitwardenCache bool
|
||||
}
|
||||
|
||||
func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) {
|
||||
|
|
@ -82,17 +85,24 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol
|
|||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return manifestPolicyResolution{
|
||||
Policy: BackendAuto,
|
||||
Source: "",
|
||||
Policy: BackendAuto,
|
||||
Source: "",
|
||||
BitwardenCache: true,
|
||||
}, nil
|
||||
}
|
||||
return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
|
||||
}
|
||||
|
||||
bitwardenCache := true
|
||||
if file.SecretStore.BitwardenCache != nil {
|
||||
bitwardenCache = *file.SecretStore.BitwardenCache
|
||||
}
|
||||
|
||||
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
|
||||
return manifestPolicyResolution{
|
||||
Policy: BackendAuto,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
Policy: BackendAuto,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
BitwardenCache: bitwardenCache,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +116,12 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol
|
|||
}
|
||||
|
||||
return manifestPolicyResolution{
|
||||
Policy: policy,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
Policy: policy,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
BitwardenCache: bitwardenCache,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool {
|
||||
return runtimeDisabled || !manifestEnabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
"github.com/99designs/keyring"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {
|
||||
|
|
@ -111,6 +111,78 @@ func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) {
|
||||
cacheDisabled := false
|
||||
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{
|
||||
BackendPolicy: string(BackendBitwardenCLI),
|
||||
BitwardenCache: &cacheDisabled,
|
||||
},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveManifestPolicy returned error: %v", err)
|
||||
}
|
||||
if resolution.BitwardenCache {
|
||||
t.Fatal("resolution BitwardenCache = true, want false")
|
||||
}
|
||||
if resolution.Policy != BackendBitwardenCLI {
|
||||
t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
||||
ID: "item-1",
|
||||
Name: "email-mcp/api-token",
|
||||
Secret: "secret-v1",
|
||||
MarkerService: "email-mcp",
|
||||
MarkerSecretName: "api-token",
|
||||
}
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
cacheDisabled := false
|
||||
store, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{
|
||||
BackendPolicy: string(BackendBitwardenCLI),
|
||||
BitwardenCache: &cacheDisabled,
|
||||
},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFromManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
value, err := store.GetSecret("api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
||||
}
|
||||
if value != "secret-v1" {
|
||||
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
|
||||
}
|
||||
}
|
||||
if fakeCLI.getItemCalls != 2 {
|
||||
t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) {
|
||||
execErr := errors.New("boom")
|
||||
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
|
|
|
|||
|
|
@ -9,15 +9,17 @@ import (
|
|||
const DefaultManifestSource = "default:auto (manifest not found)"
|
||||
|
||||
type DescribeRuntimeOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
CheckReady bool
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
}
|
||||
|
||||
type RuntimeDescription struct {
|
||||
|
|
@ -47,14 +49,15 @@ type PreflightReport struct {
|
|||
|
||||
func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) {
|
||||
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
||||
ServiceName: options.ServiceName,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: options.ManifestLoader,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
ServiceName: options.ServiceName,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
DisableBitwardenCache: options.DisableBitwardenCache,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: options.ManifestLoader,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
})
|
||||
if err != nil {
|
||||
return RuntimeDescription{}, err
|
||||
|
|
@ -68,14 +71,15 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
|
|||
}
|
||||
|
||||
store, openErr := Open(Options{
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: resolution.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
Shell: options.Shell,
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: resolution.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, resolution.BitwardenCache),
|
||||
Shell: options.Shell,
|
||||
})
|
||||
if openErr != nil {
|
||||
desc.Ready = false
|
||||
|
|
@ -88,11 +92,23 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
|
|||
desc.EffectivePolicy = effective
|
||||
desc.DisplayName = BackendDisplayName(effective)
|
||||
}
|
||||
if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI {
|
||||
if err := verifyBitwardenCLIReady(Options{
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
LookupEnv: options.LookupEnv,
|
||||
Shell: options.Shell,
|
||||
}); err != nil {
|
||||
desc.Ready = false
|
||||
desc.ReadyError = err
|
||||
}
|
||||
}
|
||||
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) {
|
||||
options.CheckReady = true
|
||||
desc, err := DescribeRuntime(options)
|
||||
if err != nil {
|
||||
return PreflightReport{}, err
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
|
||||
|
|
@ -46,16 +46,9 @@ func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) {
|
||||
func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) {
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case len(args) == 1 && args[0] == "--version":
|
||||
return []byte("2026.1.0\n"), nil
|
||||
case len(args) == 1 && args[0] == "status":
|
||||
return []byte(`{"status":"locked"}`), nil
|
||||
default:
|
||||
return nil, errors.New("unexpected bitwarden invocation")
|
||||
}
|
||||
return nil, errors.New("unexpected bitwarden invocation")
|
||||
})
|
||||
|
||||
desc, err := DescribeRuntime(DescribeRuntimeOptions{
|
||||
|
|
@ -80,14 +73,11 @@ func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) {
|
|||
if desc.EffectivePolicy != BackendBitwardenCLI {
|
||||
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
|
||||
}
|
||||
if desc.Ready {
|
||||
t.Fatalf("Ready = %v, want false", desc.Ready)
|
||||
if !desc.Ready {
|
||||
t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready)
|
||||
}
|
||||
if !errors.Is(desc.ReadyError, ErrBWLocked) {
|
||||
t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError)
|
||||
}
|
||||
if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") {
|
||||
t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError)
|
||||
if desc.ReadyError != nil {
|
||||
t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,15 @@ const (
|
|||
)
|
||||
|
||||
type Options struct {
|
||||
ServiceName string
|
||||
BackendPolicy BackendPolicy
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
Shell string
|
||||
ServiceName string
|
||||
BackendPolicy BackendPolicy
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
Shell string
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
|
|
|
|||
Loading…
Reference in a new issue