architecture: Hermes-Proton multi-layer integration design
Complete ARCHITECTURE.md covering: 1. Komodo plugin spec: shared SRP-6a auth, token lifecycle, encrypted store 2. Hermes skill specs: Mail (Bridge), Pass (pass-cli), Drive (rclone), VPN (vpn-cli) 3. MCP tool server: Python stdio MCP server for non-Hermes agents 4. Auth flow: single Proton login shared across all skills 5. File layout: monorepo with plugin/skills/mcp-server/tests/ 6. Environment/credential management: full env table, encrypted storage format References: go-proton-api, pass-cli, rclone protondrive, proton-vpn-cli, hydroxide
This commit is contained in:
parent
44e75a77fa
commit
8fdf219337
1 changed files with 848 additions and 0 deletions
848
ARCHITECTURE.md
Normal file
848
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,848 @@
|
||||||
|
# Hermes-Proton Architecture
|
||||||
|
|
||||||
|
> **Multi-layer integration design** — Hermes Agent runtime + Proton product suite
|
||||||
|
> (Mail, Pass, Drive, VPN, Calendar)
|
||||||
|
>
|
||||||
|
> Target: Hermes Agent 0.x (komodo plugin system, Hermes skills, MCP tools)
|
||||||
|
> References: README.md (research report), [go-proton-api](https://github.com/ProtonMail/go-proton-api),
|
||||||
|
> [pass-cli](https://protonpass.github.io/pass-cli/), [rclone protondrive](https://rclone.org/protondrive/),
|
||||||
|
> [proton-vpn-cli](https://github.com/ProtonVPN/linux-cli)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Project Monorepo Layout](#1-project-monorepo-layout)
|
||||||
|
2. [Auth Architecture — Komodo Plugin](#2-auth-architecture--komodo-plugin)
|
||||||
|
3. [Hermes Skill: Proton Mail](#3-hermes-skill-proton-mail)
|
||||||
|
4. [Hermes Skill: Proton Pass](#4-hermes-skill-proton-pass)
|
||||||
|
5. [Hermes Skill: Proton Drive](#5-hermes-skill-proton-drive)
|
||||||
|
6. [Hermes Skill: Proton VPN](#6-hermes-skill-proton-vpn)
|
||||||
|
7. [MCP Tool Server (Optional)](#7-mcp-tool-server-optional)
|
||||||
|
8. [Environment & Credential Management](#8-environment--credential-management)
|
||||||
|
9. [Auth Flow Detail](#9-auth-flow-detail)
|
||||||
|
10. [Integration Layer Matrix](#10-integration-layer-matrix)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project Monorepo Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
hermes-proton/
|
||||||
|
├── README.md # Research report (already written)
|
||||||
|
├── ARCHITECTURE.md # This file
|
||||||
|
├── plugin/ # Komodo plugin — shared auth layer
|
||||||
|
│ ├── plugin.yaml # Manifest
|
||||||
|
│ ├── __init__.py # Registration: tools + hooks
|
||||||
|
│ ├── schemas.py # Auth tool schemas
|
||||||
|
│ ├── tools.py # Auth tool handlers
|
||||||
|
│ ├── auth.py # SRP-6a client, session state machine
|
||||||
|
│ ├── store.py # Encrypted credential store
|
||||||
|
│ ├── token.py # Token lifecycle (refresh, re-auth)
|
||||||
|
│ └── config.py # Plugin-scoped config
|
||||||
|
│
|
||||||
|
├── skills/
|
||||||
|
│ ├── proton-mail/ # Hermes skill (IMAP/SMTP via Bridge)
|
||||||
|
│ │ └── SKILL.md
|
||||||
|
│ ├── proton-pass/ # Hermes skill (pass-cli subprocess)
|
||||||
|
│ │ └── SKILL.md
|
||||||
|
│ ├── proton-drive/ # Hermes skill (rclone subprocess)
|
||||||
|
│ │ └── SKILL.md
|
||||||
|
│ └── proton-vpn/ # Hermes skill (vpn-cli subprocess)
|
||||||
|
│ └── SKILL.md
|
||||||
|
│
|
||||||
|
├── mcp-server/ # Optional MCP server
|
||||||
|
│ ├── package.json # Node.js / Python?
|
||||||
|
│ └── src/
|
||||||
|
│ ├── index.ts # MCP server entry
|
||||||
|
│ ├── proton-mail.ts # Mail MCP tools
|
||||||
|
│ ├── proton-pass.ts # Pass MCP tools
|
||||||
|
│ ├── proton-drive.ts # Drive MCP tools
|
||||||
|
│ └── proton-vpn.ts # VPN MCP tools
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ ├── install-plugin.sh # one-command plugin install
|
||||||
|
│ └── install-skills.sh # one-command skill install
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_auth.py # Auth flow tests
|
||||||
|
│ ├── test_mail.py # Mail skill tests
|
||||||
|
│ ├── test_pass.py # Pass skill tests
|
||||||
|
│ ├── test_drive.py # Drive skill tests
|
||||||
|
│ └── test_vpn.py # VPN skill tests
|
||||||
|
│
|
||||||
|
├── .env.example # Stub env file (no secrets)
|
||||||
|
├── .gitignore
|
||||||
|
├── LICENSE
|
||||||
|
└── hermes-proton.code-workspace # Optional VS Code workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Rationale
|
||||||
|
|
||||||
|
| Directory | Purpose | Why separate? |
|
||||||
|
|-----------|---------|---------------|
|
||||||
|
| `plugin/` | Auth layer — SRP-6a session, encrypted store | Single plugin owns shared auth; skills never touch Proton API directly |
|
||||||
|
| `skills/` | Product-specific Hermes skills | Each skill maps to one Proton product; clean isolation |
|
||||||
|
| `mcp-server/` | MCP compatibility layer | Optional; only needed for non-Hermes MCP clients |
|
||||||
|
| `scripts/` | Install/setup helpers | One-command deployment for Hermes users |
|
||||||
|
| `tests/` | Integration & unit tests | Auth tests mock the Proton API; skill tests mock subprocess |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Auth Architecture — Komodo Plugin
|
||||||
|
|
||||||
|
### 2.1 Role of the Plugin
|
||||||
|
|
||||||
|
The `hermes-proton` komodo plugin is the **single authority for Proton authentication**. No skill talks directly to the Proton REST API. The plugin:
|
||||||
|
|
||||||
|
- Handles the SRP-6a handshake once at login
|
||||||
|
- Encrypts and stores session tokens (UID, AccessToken, RefreshToken)
|
||||||
|
- Manages token refresh lifecycle (server-delivered + proactive refresh)
|
||||||
|
- Provides auth context to skills via plugin-internal API
|
||||||
|
- Detects session expiry and triggers re-authentication
|
||||||
|
|
||||||
|
### 2.2 Plugin Manifest (`plugin/plugin.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: hermes-proton
|
||||||
|
version: 1.0.0
|
||||||
|
description: Shared authentication layer for Proton services — SRP-6a session management, encrypted token storage, token refresh
|
||||||
|
provides_tools:
|
||||||
|
- proton_auth_login
|
||||||
|
- proton_auth_status
|
||||||
|
- proton_auth_logout
|
||||||
|
provides_hooks:
|
||||||
|
- post_tool_call
|
||||||
|
requires_env:
|
||||||
|
- PROTON_USERNAME
|
||||||
|
- PROTON_PASSWORD # Only for initial login; cached after
|
||||||
|
- PROTON_CREDENTIAL_STORE # Optional: path to encrypted store
|
||||||
|
- PROTON_API_URL # Optional: default https://api.protonmail.ch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Tool Schemas (`plugin/schemas.py`)
|
||||||
|
|
||||||
|
**`proton_auth_login`**
|
||||||
|
```python
|
||||||
|
PROTON_AUTH_LOGIN = {
|
||||||
|
"name": "proton_auth_login",
|
||||||
|
"description": (
|
||||||
|
"Authenticate with Proton using SRP-6a. Uses PROTON_USERNAME from env. "
|
||||||
|
"Password is prompted interactively or read from encrypted store. "
|
||||||
|
"Returns session status. Subsequent calls use cached tokens."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Proton password (optional if stored)"
|
||||||
|
},
|
||||||
|
"force_reauth": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Force re-authentication even if tokens exist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`proton_auth_status`**
|
||||||
|
```python
|
||||||
|
PROTON_AUTH_STATUS = {
|
||||||
|
"name": "proton_auth_status",
|
||||||
|
"description": (
|
||||||
|
"Check Proton session status. Returns whether authenticated, "
|
||||||
|
"token expiry, and which products are available."
|
||||||
|
),
|
||||||
|
"parameters": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`proton_auth_logout`**
|
||||||
|
```python
|
||||||
|
PROTON_AUTH_LOGOUT = {
|
||||||
|
"name": "proton_auth_logout",
|
||||||
|
"description": (
|
||||||
|
"Log out of Proton. Clears cached tokens and encrypted store. "
|
||||||
|
"Revokes refresh token with Proton API."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"revoke_all": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Revoke all sessions (remote too)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Token Lifecycle (`plugin/token.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
State machine:
|
||||||
|
┌──────────┐ first-login ┌────────────┐
|
||||||
|
│ NO_AUTH ├─────────────────►│ AUTHED │
|
||||||
|
└──────────┘ └─────┬──────┘
|
||||||
|
▲ │
|
||||||
|
│ refresh fails │ token expires
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ REFRESHING │◄────┘
|
||||||
|
│ └────────┬─────────┘
|
||||||
|
│ │ refresh succeeds
|
||||||
|
│ ▼
|
||||||
|
│ ┌────────────┐
|
||||||
|
└────┤ AUTHED │ (new tokens)
|
||||||
|
└────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Refresh mechanism:**
|
||||||
|
1. Plugin hooks `post_tool_call` on every Proton skill call
|
||||||
|
2. Before dispatching a tool, checks `AccessToken` expiry (JWT-like)
|
||||||
|
3. If within 5 minutes of expiry, proactively refreshes via `POST /api/core/v4/auth/refresh`
|
||||||
|
4. On 401 from any call, triggers immediate refresh and retries
|
||||||
|
5. On 422 (invalid grant), transitions to `NO_AUTH`, blocks all skills, surfaces re-auth prompt
|
||||||
|
|
||||||
|
**Storage:**
|
||||||
|
- Encrypted at rest via `gopenpgp` or a symmetric AES-GCM key derived from a local master password
|
||||||
|
- Stored at `$XDG_DATA_HOME/hermes-proton/session.enc` by default
|
||||||
|
- Plaintext fields: `UID`, `AccessToken`, `RefreshToken`, `ServerProof`, `KeySalt`, `PasswordSalt`, token expiry timestamps
|
||||||
|
|
||||||
|
### 2.5 Credential Store (`plugin/store.py`)
|
||||||
|
|
||||||
|
The credential store wraps an encrypted JSON file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ProtonCredentialStore:
|
||||||
|
path: Path # e.g. ~/.local/share/hermes-proton/session.enc
|
||||||
|
master_key: bytes # Derived from PROTON_PASSWORD env or keyring
|
||||||
|
|
||||||
|
def save(self, session: ProtonSession) -> None
|
||||||
|
def load(self) -> ProtonSession | None
|
||||||
|
def clear(self) -> None
|
||||||
|
def has_valid_session(self) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Encryption scheme:**
|
||||||
|
```
|
||||||
|
master_key = argon2id(
|
||||||
|
password=PROTON_PASSWORD or keyring_password,
|
||||||
|
salt=random_salt,
|
||||||
|
time_cost=3, mem_cost=65536, parallelism=4
|
||||||
|
)
|
||||||
|
nonce = random(12)
|
||||||
|
ciphertext = aes256_gcm_encrypt(plaintext=json(session), key=master_key, nonce=nonce)
|
||||||
|
store = base64(nonce + ciphertext + tag)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 Plugin API for Skills
|
||||||
|
|
||||||
|
The plugin exposes an in-process API for skills (not exposed as tools):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Plugin internal API (consumed by skills via plugin context)
|
||||||
|
class ProtonAuthAPI:
|
||||||
|
def get_session(self) -> ProtonSession | None
|
||||||
|
def get_authenticated_client(self) -> proton.Client | None
|
||||||
|
def is_authenticated(self) -> bool
|
||||||
|
def require_auth(self) -> ProtonSession # raises if not authenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills call `require_auth()` at the top of every handler. If the plugin returns a session, the skill proceeds; if not, the skill returns an error prompting the user to run `proton_auth_login` first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Hermes Skill: Proton Mail
|
||||||
|
|
||||||
|
### 3.1 Strategy
|
||||||
|
|
||||||
|
**Path:** Proton Bridge → local IMAP/SMTP
|
||||||
|
|
||||||
|
Proton Bridge is the recommended integration vehicle for Mail. It runs as a local daemon exposing:
|
||||||
|
- IMAP on `127.0.0.1:1143` (read)
|
||||||
|
- SMTP on `127.0.0.1:1025` (send)
|
||||||
|
- gRPC API on `127.0.0.1:1042` (management)
|
||||||
|
- OpenPGP crypto is handled transparently by the Bridge — no key management in the skill
|
||||||
|
|
||||||
|
### 3.2 Skill Metadata
|
||||||
|
|
||||||
|
- **Name:** `proton-mail`
|
||||||
|
- **Category:** `email`
|
||||||
|
- **Dependencies:** Proton Bridge (headless, `--cli` mode), Python `imaplib` / `smtplib` (stdlib), or `node-imap` / `nodemailer` if the Bridge gRPC interface is preferred
|
||||||
|
- **Auth:** Relies on plugin for session; Bridge has its own local auth (TLS cert)
|
||||||
|
|
||||||
|
### 3.3 Tool Definitions
|
||||||
|
|
||||||
|
| Tool | Description | Implementation |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| `proton_mail_list` | List inbox messages (folder, limit, page) | IMAP SEARCH / FETCH |
|
||||||
|
| `proton_mail_read` | Read full message by ID | IMAP FETCH (RFC822) |
|
||||||
|
| `proton_mail_search` | Search messages by subject/from/body/date | IMAP SEARCH (CHARSET UTF-8) |
|
||||||
|
| `proton_mail_send` | Send email (to, subject, body, attachments) | SMTP via localhost:1025 |
|
||||||
|
| `proton_mail_reply` | Reply to message by ID (thread support) | SMTP + In-Reply-To/References |
|
||||||
|
| `proton_mail_draft` | Save draft | IMAP APPEND to Drafts |
|
||||||
|
| `proton_mail_folders` | List mailbox folders | IMAP LIST |
|
||||||
|
| `proton_mail_move` | Move message between folders | IMAP MOVE / COPY + STORE \Deleted |
|
||||||
|
|
||||||
|
### 3.4 Implementation Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
def proton_mail_read(args: dict, **kwargs) -> str:
|
||||||
|
# 1. Verify Bridge is running (systemctl check or process check)
|
||||||
|
# 2. Connect to IMAP localhost:1143 (TLS)
|
||||||
|
# 3. FETCH the message by UID
|
||||||
|
# 4. Parse MIME (body text/HTML, attachments)
|
||||||
|
# 5. Return structured JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Bridge Management
|
||||||
|
|
||||||
|
The skill should also expose a management tool confirming Bridge is running:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PROTON_MAIL_BRIDGE_STATUS = {
|
||||||
|
"name": "proton_mail_bridge_status",
|
||||||
|
"description": "Check Proton Bridge status — running, authenticated, connected to Proton servers."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Fallback: hydroxide
|
||||||
|
|
||||||
|
If the official Bridge is unavailable (e.g. macOS or headless server without GUI), the skill should support **hydroxide** (`emersion/hydroxide`) as an alternative:
|
||||||
|
- Implements SRP-6a natively (no GUI required)
|
||||||
|
- Exposes same IMAP/SMTP surface
|
||||||
|
- Configurable via `PROTON_MAIL_BRIDGE_TYPE=hydroxide` env var
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Hermes Skill: Proton Pass
|
||||||
|
|
||||||
|
### 4.1 Strategy
|
||||||
|
|
||||||
|
**Path:** Official `pass-cli` → subprocess with `--json` output
|
||||||
|
|
||||||
|
`pass-cli` v2.1+ supports `--json` output on all commands, making it trivially machine-readable. No auth management needed — pass-cli handles its own token lifecycle via its own offline-first encrypted vault.
|
||||||
|
|
||||||
|
### 4.2 Skill Metadata
|
||||||
|
|
||||||
|
- **Name:** `proton-pass`
|
||||||
|
- **Category:** `productivity`
|
||||||
|
- **Dependencies:** `proton-pass-cli` (Rust binary, installed separately)
|
||||||
|
- **Auth:** Independent — pass-cli manages its own session/session-key via `proton-pass auth login`
|
||||||
|
|
||||||
|
### 4.3 Tool Definitions
|
||||||
|
|
||||||
|
| Tool | Description | CLI command |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| `proton_pass_list` | List vaults and items | `proton-pass item list --json` |
|
||||||
|
| `proton_pass_get` | Get secret by item ID | `proton-pass item get <id> --json` |
|
||||||
|
| `proton_pass_search` | Search items by name/URL | `proton-pass item search <query> --json` |
|
||||||
|
| `proton_pass_create` | Create new password item | `proton-pass item create --json` |
|
||||||
|
| `proton_pass_edit` | Update existing item | `proton-pass item edit <id> --json` |
|
||||||
|
| `proton_pass_delete` | Delete item | `proton-pass item delete <id>` |
|
||||||
|
| `proton_pass_totp` | Get TOTP code for item | `proton-pass totp <id>` |
|
||||||
|
| `proton_pass_inject` | Inject secret as env var (SSH agent support) | Reads + sets env |
|
||||||
|
| `proton_pass_vaults` | List vaults | `proton-pass vault list --json` |
|
||||||
|
|
||||||
|
### 4.4 Implementation Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
def proton_pass_get(args: dict, **kwargs) -> str:
|
||||||
|
item_id = args["item_id"]
|
||||||
|
result = subprocess.run(
|
||||||
|
["proton-pass", "item", "get", item_id, "--json"],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return json.dumps({"error": result.stderr.strip()})
|
||||||
|
return result.stdout # already JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Security Considerations
|
||||||
|
|
||||||
|
- Passwords returned by pass-cli enter the agent's context. This is expected (the agent needs them to use them), but users should be warned.
|
||||||
|
- `proton_pass_inject` sets temporary env vars for subprocesses and scrubs them after — prevents secret leakage across turns.
|
||||||
|
- SSH agent support via `pass-cli`'s built-in SSH key injection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hermes Skill: Proton Drive
|
||||||
|
|
||||||
|
### 5.1 Strategy
|
||||||
|
|
||||||
|
**Primary Path:** rclone protondrive backend → subprocess
|
||||||
|
**Alternative Path:** Drive SDK (TypeScript, preview)
|
||||||
|
|
||||||
|
rclone's protondrive backend is the most battle-tested option. It handles Proton's non-standard API, encryption/decryption, and chunked uploads transparently. The skill shells out to `rclone` with protondrive remote configured.
|
||||||
|
|
||||||
|
### 5.2 Skill Metadata
|
||||||
|
|
||||||
|
- **Name:** `proton-drive`
|
||||||
|
- **Category:** `productivity`
|
||||||
|
- **Dependencies:** `rclone` with protondrive backend configured (`rclone config create protondrive ...`)
|
||||||
|
- **Auth:** Relies on rclone's token storage (rclone handles its own refresh)
|
||||||
|
|
||||||
|
### 5.3 Tool Definitions
|
||||||
|
|
||||||
|
| Tool | Description | CLI command |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| `proton_drive_list` | List files/folders in path | `rclone ls protondrive:path/` or `rclone lsf --json` |
|
||||||
|
| `proton_drive_read` | Read file content (text/small binary) | `rclone cat protondrive:path/file` |
|
||||||
|
| `proton_drive_download` | Download file to local path | `rclone copy protondrive:path/file local/` |
|
||||||
|
| `proton_drive_upload` | Upload local file to Drive | `rclone copy local/file protondrive:path/` |
|
||||||
|
| `proton_drive_search` | Search files by name | `rclone lsf -R --files-only protondrive: \| grep -i <query>` |
|
||||||
|
| `proton_drive_mkdir` | Create folder | `rclone mkdir protondrive:path/` |
|
||||||
|
| `proton_drive_delete` | Delete file | `rclone delete protondrive:path/file` |
|
||||||
|
| `proton_drive_stat` | Get file/bucket metadata | `rclone lsl protondrive:path/file` |
|
||||||
|
| `proton_drive_sync` | Two-way sync (with confirmation) | `rclone sync ...` |
|
||||||
|
|
||||||
|
### 5.4 Implementation Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
def proton_drive_list(args: dict, **kwargs) -> str:
|
||||||
|
path = args.get("path", "/")
|
||||||
|
result = subprocess.run(
|
||||||
|
["rclone", "lsf", "--json", f"protondrive:{path}"],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return json.dumps({"error": result.stderr.strip()})
|
||||||
|
items = [json.loads(line) for line in result.stdout.strip().split("\n") if line]
|
||||||
|
return json.dumps({"items": items})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Large File Considerations
|
||||||
|
|
||||||
|
- For files over 10MB, `proton_drive_read` should stream to stdout rather than loading into memory
|
||||||
|
- `proton_drive_download` targets a temp path and returns the local path
|
||||||
|
- rclone handles chunking and retry automatically
|
||||||
|
|
||||||
|
### 5.6 Drive SDK Alternative
|
||||||
|
|
||||||
|
If rclone proves unreliable for specific operations (notably incremental sync or finer-grained metadata), fall back to the TypeScript Drive SDK:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Concept: Drive SDK wrapper via node subprocess
|
||||||
|
# Only if rclone protondrive is insufficient
|
||||||
|
PROTON_DRIVE_USE_SDK = os.getenv("PROTON_DRIVE_USE_SDK", "false")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hermes Skill: Proton VPN
|
||||||
|
|
||||||
|
### 6.1 Strategy
|
||||||
|
|
||||||
|
**Path:** Official `proton-vpn-cli` → subprocess
|
||||||
|
|
||||||
|
`protonvpn-cli` (Python) is the official Linux CLI. It manages its own auth (Proton API credentials stored in `~/.cache/protonvpn-cli/`). The skill wraps connect/disconnect/status as tools.
|
||||||
|
|
||||||
|
### 6.2 Skill Metadata
|
||||||
|
|
||||||
|
- **Name:** `proton-vpn`
|
||||||
|
- **Category:** `infra`
|
||||||
|
- **Dependencies:** `protonvpn-cli` (PyPI: `pip install proton-vpn-gtk-app` or the CLI-only variant)
|
||||||
|
- **Auth:** Independent — `protonvpn-cli init` stores credentials
|
||||||
|
|
||||||
|
### 6.3 Tool Definitions
|
||||||
|
|
||||||
|
| Tool | Description | CLI command |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| `proton_vpn_connect` | Connect to fastest/selected server | `protonvpn-cli connect <country>` or `--fastest` |
|
||||||
|
| `proton_vpn_disconnect` | Disconnect VPN | `protonvpn-cli disconnect` |
|
||||||
|
| `proton_vpn_status` | Get connection status | `protonvpn-cli status --json` |
|
||||||
|
| `proton_vpn_servers` | List available servers/countries | `protonvpn-cli list --json` |
|
||||||
|
| `proton_vpn_connect_to` | Connect to specific server | `protonvpn-cli connect <server>` |
|
||||||
|
| `proton_vpn_killswitch` | Enable/disable kill switch | `protonvpn-cli killswitch <on/off>` |
|
||||||
|
| `proton_vpn_config` | Show current config | `protonvpn-cli config --json` |
|
||||||
|
|
||||||
|
### 6.4 Implementation Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
def proton_vpn_connect(args: dict, **kwargs) -> str:
|
||||||
|
server = args.get("server", "--fastest")
|
||||||
|
result = subprocess.run(
|
||||||
|
["protonvpn-cli", "connect", server],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return json.dumps({"error": result.stderr.strip()})
|
||||||
|
return json.dumps({"status": "connected", "server": server})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Privilege Management
|
||||||
|
|
||||||
|
`protonvpn-cli` typically requires root or a `protonvpn` group membership for WireGuard interface creation. The skill should check at init:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _check_vpn_privileges():
|
||||||
|
user = os.getenv("USER")
|
||||||
|
groups = subprocess.run(["groups"], capture_output=True, text=True).stdout
|
||||||
|
if "protonvpn" not in groups and os.geteuid() != 0:
|
||||||
|
return json.dumps({"error": "User not in protonvpn group or not root. Run: sudo usermod -aG protonvpn $USER"})
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. MCP Tool Server (Optional)
|
||||||
|
|
||||||
|
### 7.1 Purpose
|
||||||
|
|
||||||
|
An MCP (Model Context Protocol) server exposes Proton services to **any MCP-compatible agent**, not just Hermes. This includes Claude Code, Cursor, VS Code ACP, and custom MCP clients.
|
||||||
|
|
||||||
|
### 7.2 Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ MCP Client (any agent) │
|
||||||
|
│ Claude Code / Cursor / etc. │
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│ MCP protocol (stdio or HTTP)
|
||||||
|
┌──────────┴───────────────────┐
|
||||||
|
│ MCP Server: hermes-proton │
|
||||||
|
│ (Node.js or Python) │
|
||||||
|
│ │
|
||||||
|
│ proton-mail-read │
|
||||||
|
│ proton-drive-sync │
|
||||||
|
│ proton-vpn-connect │
|
||||||
|
│ proton-pass-get │
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────┴───────────────────┐
|
||||||
|
│ Backend (same as skills) │
|
||||||
|
│ Bridge / pass-cli / rclone │
|
||||||
|
│ / vpn-cli / plugin auth │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Tool Exports
|
||||||
|
|
||||||
|
The MCP server exposes a curated subset of skills as MCP tools:
|
||||||
|
|
||||||
|
| MCP Tool | Maps To | Notes |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| `proton_mail_list` | Mail skill → IMAP | Inbox list — most useful for agents |
|
||||||
|
| `proton_mail_read` | Mail skill → IMAP | Full message content |
|
||||||
|
| `proton_mail_send` | Mail skill → SMTP | Send via Bridge |
|
||||||
|
| `proton_mail_search` | Mail skill → IMAP | Search mailbox |
|
||||||
|
| `proton_pass_get` | Pass skill → pass-cli | Get secret (**caution: context exposure**) |
|
||||||
|
| `proton_pass_list` | Pass skill → pass-cli | List without secret values |
|
||||||
|
| `proton_drive_read` | Drive skill → rclone | Read file content |
|
||||||
|
| `proton_drive_list` | Drive skill → rclone | List directory |
|
||||||
|
| `proton_drive_search` | Drive skill → rclone | Find files |
|
||||||
|
| `proton_vpn_connect` | VPN skill → vpn-cli | Connect VPN |
|
||||||
|
| `proton_vpn_disconnect` | VPN skill → vpn-cli | Disconnect |
|
||||||
|
| `proton_vpn_status` | VPN skill → vpn-cli | Status check |
|
||||||
|
|
||||||
|
### 7.4 Implementation Options
|
||||||
|
|
||||||
|
**Option A: Node.js (TypeScript)**
|
||||||
|
- Uses `@modelcontextprotocol/sdk` for MCP server
|
||||||
|
- Wraps Bridge IMAP/SMTP via `node-imap` and `nodemailer`
|
||||||
|
- Shells out to pass-cli and rclone via `child_process`
|
||||||
|
- Pros: MCP SDK is first-class in Node.js ecosystem
|
||||||
|
- Cons: Another runtime dependency
|
||||||
|
|
||||||
|
**Option B: Python**
|
||||||
|
- Uses `mcp` PyPI package (`pip install mcp`)
|
||||||
|
- Reuses Python implementations from Hermes skills directly
|
||||||
|
- Pros: No runtime switch; skills and MCP share code
|
||||||
|
- Cons: Python MCP ecosystem less mature than Node.js
|
||||||
|
|
||||||
|
**Recommendation: Python first.** The skills are written in Python. The MCP server should reuse the same code paths. If Python MCP proves problematic, fall back to Node.js.
|
||||||
|
|
||||||
|
### 7.5 MCP Server Design (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# mcp-server/src/server.py
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import Tool, TextContent
|
||||||
|
|
||||||
|
class ProtonMCP(Server):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("hermes-proton")
|
||||||
|
self.auth = ProtonAuthAPI()
|
||||||
|
self.mail = MailSkill(auth=self.auth)
|
||||||
|
self.drive = DriveSkill(auth=self.auth)
|
||||||
|
self.pass_ = PassSkill(auth=self.auth)
|
||||||
|
self.vpn = VPNService()
|
||||||
|
|
||||||
|
app = ProtonMCP()
|
||||||
|
|
||||||
|
@app.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
return [
|
||||||
|
Tool(name="proton_mail_list", ...),
|
||||||
|
Tool(name="proton_mail_read", ...),
|
||||||
|
# ... etc
|
||||||
|
]
|
||||||
|
|
||||||
|
@app.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict):
|
||||||
|
if name == "proton_mail_list":
|
||||||
|
result = app.mail.list_mail(**arguments)
|
||||||
|
return [TextContent(content=json.dumps(result))]
|
||||||
|
# ... etc
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import anyio
|
||||||
|
anyio.run(stdio_server, app)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 MCP Config for Hermes
|
||||||
|
|
||||||
|
When the MCP server is installed, Hermes can connect via its built-in MCP client:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.hermes/config.yaml
|
||||||
|
mcp_servers:
|
||||||
|
proton:
|
||||||
|
command: python
|
||||||
|
args: ["/path/to/hermes-proton/mcp-server/src/server.py"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then all MCP tools appear alongside Hermes tools automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Environment & Credential Management
|
||||||
|
|
||||||
|
### 8.1 Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Purpose | Default |
|
||||||
|
|----------|----------|---------|---------|
|
||||||
|
| `PROTON_USERNAME` | **Yes** | Proton account email/username | — |
|
||||||
|
| `PROTON_PASSWORD` | Optional | Password (prompted if absent) | — |
|
||||||
|
| `PROTON_CREDENTIAL_STORE` | No | Path to encrypted session store | `~/.local/share/hermes-proton/session.enc` |
|
||||||
|
| `PROTON_API_URL` | No | Proton API base URL | `https://api.protonmail.ch` |
|
||||||
|
| `PROTON_MAIL_BRIDGE_TYPE` | No | `bridge` (official) or `hydroxide` | `bridge` |
|
||||||
|
| `PROTON_DRIVE_USE_SDK` | No | Use Drive SDK instead of rclone | `false` |
|
||||||
|
| `PROTON_BRIDGE_PASSFILE` | No | Bridge password file path | Auto-generated |
|
||||||
|
| `PROTON_BRIDGE_PORT_IMAP` | No | Bridge IMAP port | `1143` |
|
||||||
|
| `PROTON_BRIDGE_PORT_SMTP` | No | Bridge SMTP port | `1025` |
|
||||||
|
| `PROTON_VPN_CLI_PATH` | No | Path to protonvpn-cli binary | Auto-detected |
|
||||||
|
| `PROTON_PASS_CLI_PATH` | No | Path to pass-cli binary | Auto-detected |
|
||||||
|
| `PROTON_RCLONE_REMOTE` | No | rclone remote name for protondrive | `protondrive` |
|
||||||
|
|
||||||
|
### 8.2 Credential Store Design
|
||||||
|
|
||||||
|
```
|
||||||
|
Storage chain:
|
||||||
|
1. PROTON_USERNAME (from env) ← minimal env config
|
||||||
|
2. Plugin prompts for password ← first login
|
||||||
|
3. Plugin encrypts tokens via AES-GCM
|
||||||
|
4. Writes to ~/.local/share/hermes-proton/session.enc
|
||||||
|
5. Subsequent sessions auto-load encrypted tokens
|
||||||
|
6. Re-auth only needed on: expiry, password change, revocation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Encrypted Storage Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"created_at": "2026-06-08T...",
|
||||||
|
"username": "user@proton.me",
|
||||||
|
"encrypted_data": "base64...", // AES-256-GCM encrypted payload
|
||||||
|
"iv": "base64...",
|
||||||
|
"salt": "base64...",
|
||||||
|
"auth_tag": "base64..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The inner plaintext (before encryption):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uid": "proton-uid",
|
||||||
|
"access_token": "...",
|
||||||
|
"refresh_token": "...",
|
||||||
|
"server_proof": "...",
|
||||||
|
"key_salt": "...",
|
||||||
|
"password_salt": "...",
|
||||||
|
"expires_at": 1780936666,
|
||||||
|
"product_access": ["mail", "pass", "drive", "vpn"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Multi-Account Support (Future)
|
||||||
|
|
||||||
|
The credential store supports multiple accounts via a key rotation scheme:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"active_account": "user@proton.me",
|
||||||
|
"accounts": {
|
||||||
|
"user@proton.me": { "encrypted_data": "...", ... },
|
||||||
|
"other@pm.me": { "encrypted_data": "...", ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Skill tools accept an optional `account` parameter to switch contexts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Auth Flow Detail
|
||||||
|
|
||||||
|
### 9.1 Full Login Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent Plugin Proton API
|
||||||
|
│ │ │
|
||||||
|
│ proton_auth_login() │ │
|
||||||
|
│───────────────────────►│ │
|
||||||
|
│ │ GET /api/core/v4/auth/info │
|
||||||
|
│ │──────────────────────────────►│
|
||||||
|
│ │◄──────────────────────────────│
|
||||||
|
│ │ {Modulus, ModulusID, ServerEphemeral,
|
||||||
|
│ │ Salt, Version} │
|
||||||
|
│ │ │
|
||||||
|
│ │ SRP client proof: │
|
||||||
|
│ │ clientProof = SRP( │
|
||||||
|
│ │ password, salt, modulus │
|
||||||
|
│ │ ) │
|
||||||
|
│ │ │
|
||||||
|
│ │ POST /api/core/v4/auth │
|
||||||
|
│ │ {username, clientEphemeral, │
|
||||||
|
│ │ clientProof, SRPSession} │
|
||||||
|
│ │──────────────────────────────►│
|
||||||
|
│ │◄──────────────────────────────│
|
||||||
|
│ │ {UID, AccessToken, │
|
||||||
|
│ │ RefreshToken, ServerProof} │
|
||||||
|
│ │ │
|
||||||
|
│ │ Encrypt tokens, store on disk│
|
||||||
|
│ │ Set plugin state = AUTHED │
|
||||||
|
│ │ │
|
||||||
|
│ {status: "authenticated", products: [...], expiry} │
|
||||||
|
│◄───────────────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Token Refresh Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
Skill call Plugin Proton API
|
||||||
|
│ │ │
|
||||||
|
│ skill handler calls require_auth│ │
|
||||||
|
│─────────────────────────────────►│ │
|
||||||
|
│ │ Check token expiry │
|
||||||
|
│ │ ├─ fresh → return │
|
||||||
|
│ │ └─ stale → refresh │
|
||||||
|
│ │ │
|
||||||
|
│ │ POST /auth/refresh │
|
||||||
|
│ │──────────────────────►│
|
||||||
|
│ │◄──────────────────────│
|
||||||
|
│ │ {AccessToken, │
|
||||||
|
│ │ RefreshToken, expiry}│
|
||||||
|
│ │ │
|
||||||
|
│ │ Re-encrypt, store │
|
||||||
|
│ │ │
|
||||||
|
│ session (refreshed) │ │
|
||||||
|
│◄─────────────────────────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Session-Expired Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
Skill call → require_auth()
|
||||||
|
→ plugin checks tokens → valid
|
||||||
|
→ API call returns 401
|
||||||
|
→ plugin attempts refresh → 422 (invalid grant)
|
||||||
|
→ plugin transitions to NO_AUTH
|
||||||
|
→ returns error: "Session expired. Run proton_auth_login to re-authenticate."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 Auth Lifecycle Hooks
|
||||||
|
|
||||||
|
The plugin registers a `post_tool_call` hook to detect 401s on any skill's Proton API calls:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
|
||||||
|
if result and "401" in result or "token_expired" in result:
|
||||||
|
auth_state = _get_plugin_auth_state()
|
||||||
|
if auth_state == "AUTHED":
|
||||||
|
_schedule_background_refresh()
|
||||||
|
elif result and "422" in result or "invalid_grant" in result:
|
||||||
|
_set_auth_state("NO_AUTH")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Integration Layer Matrix
|
||||||
|
|
||||||
|
### 10.1 How Each Product Connects
|
||||||
|
|
||||||
|
| Product | Primary Path | Auth Owner | Protocol | Maturity | Risks |
|
||||||
|
|---------|-------------|------------|----------|----------|-------|
|
||||||
|
| **Mail** | Bridge (IMAP/SMTP) | Bridge app password | TCP + TLS | **Proven** — OpenClaw uses this | Bridge requires display (GUI) for initial login; `--cli` mode may be flaky |
|
||||||
|
| **Mail (fallback)** | hydroxide (IMAP/SMTP) | hydroxide SRP | TCP + TLS | **Stable** — 2.1k stars | No official support; slower development |
|
||||||
|
| **Pass** | pass-cli (Rust) | pass-cli self | stdio | **Mature** — v2.1.x | Secrets in agent context; SSH agent integration needs care |
|
||||||
|
| **Drive** | rclone protondrive | rclone OAuth-like token | subprocess | **Beta** — most-used 3rd party | rclone sync semantics may not match Drive UX |
|
||||||
|
| **Drive (alt)** | Drive SDK (TS) | go-proton-api session | HTTP | **Preview** — breaking crypto changes | Unstable API; TypeScript dependency |
|
||||||
|
| **VPN** | protonvpn-cli | protonvpn self | subprocess | **Mature** | Requires `protonvpn` group / sudo |
|
||||||
|
| **Calendar** | go-proton-api (direct) | Plugin session | HTTP | **Exploratory** | Calendar endpoints exist but underdocumented |
|
||||||
|
| **Wallet** | None | N/A | N/A | **None** | No public API |
|
||||||
|
|
||||||
|
### 10.2 Auth Responsibility Summary
|
||||||
|
|
||||||
|
| Component | Auth Type | Owns Tokens? | Can Re-auth? |
|
||||||
|
|-----------|-----------|--------------|--------------|
|
||||||
|
| **Plugin** | SRP-6a | Yes — master session tokens | Yes — full SRP login flow |
|
||||||
|
| **Bridge** | Bridge app password | Yes — separate Bridge cred | No — must be re-authed via Proton login |
|
||||||
|
| **pass-cli** | pass-cli session key | Yes — encrypted local vault | Yes — `proton-pass auth login` |
|
||||||
|
| **rclone** | protondrive token | Yes — rclone config file | Yes — `rclone config reconnect` |
|
||||||
|
| **vpn-cli** | Proton API credentials | Yes — `~/.cache/protonvpn-cli/` | Yes — `protonvpn-cli init` |
|
||||||
|
|
||||||
|
### 10.3 Which Extension Mechanism When
|
||||||
|
|
||||||
|
| Scenario | Use |
|
||||||
|
|----------|-----|
|
||||||
|
| Agent needs to read/send mail | `proton-mail` Hermes skill |
|
||||||
|
| Agent needs to retrieve secrets | `proton-pass` Hermes skill |
|
||||||
|
| Agent needs to access stored files | `proton-drive` Hermes skill |
|
||||||
|
| Agent needs VPN connect/disconnect | `proton-vpn` Hermes skill |
|
||||||
|
| Multiple skills need auth | `hermes-proton` komodo plugin |
|
||||||
|
| Non-Hermes MCP agent needs Proton | `mcp-server` with Python stdio |
|
||||||
|
| VS Code / Cursor agent needs Proton | MCP server (same as above) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Risk Register
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation | Owner |
|
||||||
|
|------|--------|------------|-------|
|
||||||
|
| Bridge requires display for initial login | High — headless servers can't auth Bridge | Use `protonmail-bridge --cli` with passfile; fallback to hydroxide | Phase 2 |
|
||||||
|
| Proton API changes (breaking) | High — all products break | Wrap each API call in version negotiation; log API version at login | Ongoing |
|
||||||
|
| pass-cli secrets in agent context | Medium — leakage through prompt logging | Warn user; never log raw secret output; offer `inject` mode | Phase 3 |
|
||||||
|
| rclone protondrive backend removed | Medium — Drive breaks | Abstract rclone behind an interface; implement SDK fallback | Phase 4 |
|
||||||
|
| Token refresh race condition | Medium — two skills refresh simultaneously | Plugin uses a `asyncio.Lock` or `threading.Lock` around refresh | Phase 1 |
|
||||||
|
| VPN requires sudo | Low — user friction | Document `protonvpn` group setup in skill; check at tool dispatch | Phase 5 |
|
||||||
|
| MCP server version mismatch | Low — tools drift from skills | MCP server imports skills directly; always in lockstep | Phase 6 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: Implementation Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Foundation) ───► Project scaffold + ARCHITECTURE.md
|
||||||
|
Phase 2 (Mail) ───► Bridge install → proton-mail skill → IMAP/SMTP tools
|
||||||
|
Phase 3 (Pass) ───► pass-cli install → proton-pass skill → vault tools
|
||||||
|
Phase 4 (Drive) ───► rclone config → proton-drive skill → file tools
|
||||||
|
Phase 5 (VPN) ───► vpn-cli install → proton-vpn skill → network tools
|
||||||
|
Phase 6 (Auth Plugin) ───► Komodo plugin → SRP-6a login → encrypted store → shared session
|
||||||
|
Phase 7 (MCP Server) ───► Python MCP server → expose tools to non-Hermes agents
|
||||||
|
Phase 8 (Calendar) ───► go-proton-api Calendar endpoints (exploratory)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth plugin is Phase 6 (after all skills exist independently) because early phases can prototype skill-level auth independently. Plugin consolidation happens once patterns are proven.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-06-08*
|
||||||
|
*Author: Colonel Hannibal — A-Team Architecture*
|
||||||
Loading…
Add table
Add a link
Reference in a new issue