Merge branch 'main' into fix/session-state-per-provider-and-agent-route-files
This commit is contained in:
@@ -128,7 +128,7 @@ Codex also ships first-class local-runner flags — `codex --oss --local-provide
|
|||||||
|
|
||||||
### Per group / per session
|
### Per group / per session
|
||||||
|
|
||||||
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `codex` for groups or sessions that should use Codex. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
|
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||||
|
|
||||||
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
||||||
|
|
||||||
|
|||||||
210
.claude/skills/add-gcal-tool/SKILL.md
Normal file
210
.claude/skills/add-gcal-tool/SKILL.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
---
|
||||||
|
name: add-gcal-tool
|
||||||
|
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Google Calendar Tool (OneCLI-native)
|
||||||
|
|
||||||
|
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
|
||||||
|
|
||||||
|
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
|
||||||
|
|
||||||
|
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
|
||||||
|
|
||||||
|
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
|
||||||
|
|
||||||
|
## Phase 1: Pre-flight
|
||||||
|
|
||||||
|
### Verify OneCLI has Google Calendar connected
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onecli apps get --provider google-calendar
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
|
||||||
|
|
||||||
|
If not connected, tell the user:
|
||||||
|
|
||||||
|
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
|
||||||
|
|
||||||
|
### Verify stub credentials exist
|
||||||
|
|
||||||
|
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
If both exist with `onecli-managed`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
|
||||||
|
|
||||||
|
If absent, write them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.calendar-mcp
|
||||||
|
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
|
||||||
|
{
|
||||||
|
"installed": {
|
||||||
|
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||||
|
"client_secret": "onecli-managed",
|
||||||
|
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
cat > ~/.calendar-mcp/credentials.json <<'EOF'
|
||||||
|
{
|
||||||
|
"access_token": "onecli-managed",
|
||||||
|
"refresh_token": "onecli-managed",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expiry_date": 99999999999999,
|
||||||
|
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 600 ~/.calendar-mcp/*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify mount allowlist covers the path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
|
||||||
|
|
||||||
|
### Check agent secret-mode
|
||||||
|
|
||||||
|
For each target agent group, confirm OneCLI will inject the Google Calendar token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onecli agents list
|
||||||
|
```
|
||||||
|
|
||||||
|
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
|
||||||
|
|
||||||
|
## Phase 2: Apply Code Changes
|
||||||
|
|
||||||
|
### Check if already applied
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
|
||||||
|
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
|
||||||
|
echo "ALREADY APPLIED — skip to Phase 3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add MCP server to Dockerfile
|
||||||
|
|
||||||
|
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
ARG CALENDAR_MCP_VERSION=2.6.1
|
||||||
|
```
|
||||||
|
|
||||||
|
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||||
|
pnpm install -g \
|
||||||
|
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||||
|
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
|
||||||
|
"zod-to-json-schema@3.22.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||||
|
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add tools to allowlist
|
||||||
|
|
||||||
|
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
|
||||||
|
|
||||||
|
### Rebuild the container image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./container/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 3: Wire Per-Agent-Group
|
||||||
|
|
||||||
|
For each agent group, merge into `groups/<folder>/container.json`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"calendar": {
|
||||||
|
"command": "google-calendar-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
|
||||||
|
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalMounts": [
|
||||||
|
{
|
||||||
|
"hostPath": "/home/<user>/.calendar-mcp",
|
||||||
|
"containerPath": ".calendar-mcp",
|
||||||
|
"readonly": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||||
|
|
||||||
|
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
|
||||||
|
|
||||||
|
## Phase 4: Build and Restart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
systemctl --user restart nanoclaw # Linux
|
||||||
|
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
Kill any existing agent containers so they respawn with the new mcpServers config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: Verify
|
||||||
|
|
||||||
|
### Test from a wired agent
|
||||||
|
|
||||||
|
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
|
||||||
|
>
|
||||||
|
> First call takes 2–3s while the MCP server starts and OneCLI does the token exchange.
|
||||||
|
|
||||||
|
### Check logs if the tool isn't working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
|
||||||
|
```
|
||||||
|
|
||||||
|
Common signals:
|
||||||
|
- `command not found: google-calendar-mcp` → image not rebuilt.
|
||||||
|
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
|
||||||
|
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
|
||||||
|
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
|
||||||
|
|
||||||
|
## Removal
|
||||||
|
|
||||||
|
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
|
||||||
|
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
|
||||||
|
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
|
||||||
|
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||||
|
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
|
||||||
|
|
||||||
|
## Credits & references
|
||||||
|
|
||||||
|
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
|
||||||
|
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
|
||||||
|
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
|
||||||
229
.claude/skills/add-gmail-tool/SKILL.md
Normal file
229
.claude/skills/add-gmail-tool/SKILL.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
name: add-gmail-tool
|
||||||
|
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Gmail Tool (OneCLI-native)
|
||||||
|
|
||||||
|
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
|
||||||
|
|
||||||
|
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
|
||||||
|
|
||||||
|
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
|
||||||
|
|
||||||
|
## Phase 1: Pre-flight
|
||||||
|
|
||||||
|
### Verify OneCLI has Gmail connected
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onecli apps get --provider gmail
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
|
||||||
|
|
||||||
|
If not connected, tell the user:
|
||||||
|
|
||||||
|
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
|
||||||
|
|
||||||
|
### Verify stub credentials exist
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
If both exist and contain `"onecli-managed"`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
...skip to Phase 2.
|
||||||
|
|
||||||
|
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
|
||||||
|
|
||||||
|
If both files are absent, write them now:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.gmail-mcp
|
||||||
|
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
|
||||||
|
{
|
||||||
|
"installed": {
|
||||||
|
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||||
|
"client_secret": "onecli-managed",
|
||||||
|
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
cat > ~/.gmail-mcp/credentials.json <<'EOF'
|
||||||
|
{
|
||||||
|
"access_token": "onecli-managed",
|
||||||
|
"refresh_token": "onecli-managed",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expiry_date": 99999999999999,
|
||||||
|
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify mount allowlist covers the path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
|
||||||
|
|
||||||
|
### Check agent secret-mode
|
||||||
|
|
||||||
|
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onecli agents list
|
||||||
|
```
|
||||||
|
|
||||||
|
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
|
||||||
|
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2: Apply Code Changes
|
||||||
|
|
||||||
|
### Check if already applied
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
|
||||||
|
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
|
||||||
|
echo "ALREADY APPLIED — skip to Phase 3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add MCP server to Dockerfile
|
||||||
|
|
||||||
|
Edit `container/Dockerfile`. Find the pinned-version ARG block:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||||
|
ARG AGENT_BROWSER_VERSION=latest
|
||||||
|
ARG VERCEL_VERSION=latest
|
||||||
|
ARG BUN_VERSION=1.3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new line:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
ARG GMAIL_MCP_VERSION=1.1.11
|
||||||
|
```
|
||||||
|
|
||||||
|
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||||
|
pnpm install -g \
|
||||||
|
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||||
|
"zod-to-json-schema@3.22.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
|
||||||
|
|
||||||
|
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
|
||||||
|
|
||||||
|
### Add tools to allowlist
|
||||||
|
|
||||||
|
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
|
||||||
|
|
||||||
|
### Rebuild the container image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./container/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
|
||||||
|
|
||||||
|
## Phase 3: Wire Per-Agent-Group
|
||||||
|
|
||||||
|
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
|
||||||
|
|
||||||
|
Merge these into the group's `container.json`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gmail": {
|
||||||
|
"command": "gmail-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
|
||||||
|
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalMounts": [
|
||||||
|
{
|
||||||
|
"hostPath": "/home/<user>/.gmail-mcp",
|
||||||
|
"containerPath": ".gmail-mcp",
|
||||||
|
"readonly": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
|
||||||
|
|
||||||
|
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
|
||||||
|
|
||||||
|
## Phase 4: Build and Restart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
systemctl --user restart nanoclaw # Linux
|
||||||
|
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: Verify
|
||||||
|
|
||||||
|
### Test from the wired agent
|
||||||
|
|
||||||
|
Tell the user:
|
||||||
|
|
||||||
|
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
|
||||||
|
>
|
||||||
|
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
|
||||||
|
|
||||||
|
### Check logs if the tool isn't working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
|
||||||
|
# Per-container logs — session-scoped:
|
||||||
|
ls data/v2-sessions/*/stderr.log | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Common signals:
|
||||||
|
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
|
||||||
|
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
|
||||||
|
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
|
||||||
|
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
|
||||||
|
|
||||||
|
## Removal
|
||||||
|
|
||||||
|
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
|
||||||
|
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
|
||||||
|
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
|
||||||
|
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||||
|
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
|
||||||
|
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
|
||||||
|
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
|
||||||
|
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
|
||||||
|
|
||||||
|
## Credits & references
|
||||||
|
|
||||||
|
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
|
||||||
|
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
|
||||||
|
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
|
||||||
|
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||||
|
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
|
||||||
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
|||||||
|
|
||||||
### Per group / per session
|
### Per group / per session
|
||||||
|
|
||||||
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
|
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||||
|
|
||||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
||||||
|
|
||||||
|
|||||||
7
.github/workflows/label-pr.yml
vendored
7
.github/workflows/label-pr.yml
vendored
@@ -1,7 +1,12 @@
|
|||||||
name: Label PR
|
name: Label PR
|
||||||
|
|
||||||
|
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
|
||||||
|
# because `pull_request_target` executes in the context of the base branch.
|
||||||
|
# Keep it metadata-only — do NOT add actions/checkout or any step that
|
||||||
|
# executes PR-supplied content (install scripts, build commands, etc.).
|
||||||
|
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request_target:
|
||||||
types: [opened, edited]
|
types: [opened, edited]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.0.10",
|
"version": "2.0.11",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
|||||||
30
setup/lib/agent-ping.test.ts
Normal file
30
setup/lib/agent-ping.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { classifyPingResult } from './agent-ping.js';
|
||||||
|
|
||||||
|
describe('classifyPingResult', () => {
|
||||||
|
it('treats a normal text reply as ok', () => {
|
||||||
|
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Anthropic auth errors printed as a chat reply', () => {
|
||||||
|
expect(
|
||||||
|
classifyPingResult(
|
||||||
|
0,
|
||||||
|
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
|
||||||
|
),
|
||||||
|
).toBe('auth_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects auth errors on stderr too', () => {
|
||||||
|
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves socket errors', () => {
|
||||||
|
expect(classifyPingResult(2, '')).toBe('socket_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty output as no reply', () => {
|
||||||
|
expect(classifyPingResult(0, '')).toBe('no_reply');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,21 @@
|
|||||||
*/
|
*/
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
|
export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error';
|
||||||
|
|
||||||
|
export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult {
|
||||||
|
const output = `${stdout}\n${stderr}`;
|
||||||
|
if (
|
||||||
|
/Invalid bearer token/i.test(output) ||
|
||||||
|
/authentication[_ ]error/i.test(output) ||
|
||||||
|
/Failed to authenticate/i.test(output)
|
||||||
|
) {
|
||||||
|
return 'auth_error';
|
||||||
|
}
|
||||||
|
if (exitCode === 2) return 'socket_error';
|
||||||
|
if (exitCode === 0 && stdout.trim().length > 0) return 'ok';
|
||||||
|
return 'no_reply';
|
||||||
|
}
|
||||||
|
|
||||||
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
|||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
|||||||
child.stdout.on('data', (chunk: Buffer) => {
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
stdout += chunk.toString('utf-8');
|
stdout += chunk.toString('utf-8');
|
||||||
});
|
});
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString('utf-8');
|
||||||
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (code === 2) resolve('socket_error');
|
resolve(classifyPingResult(code, stdout, stderr));
|
||||||
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
|
|
||||||
else resolve('no_reply');
|
|
||||||
});
|
});
|
||||||
child.on('error', () => {
|
child.on('error', () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
|
|||||||
@@ -167,18 +167,16 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
newlyWired = true;
|
newlyWired = true;
|
||||||
const mgaId = generateId('mga');
|
const mgaId = generateId('mga');
|
||||||
const triggerRules = parsed.trigger
|
const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention';
|
||||||
? JSON.stringify({
|
const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null);
|
||||||
pattern: parsed.trigger,
|
|
||||||
requiresTrigger: parsed.requiresTrigger,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
createMessagingGroupAgent({
|
createMessagingGroupAgent({
|
||||||
id: mgaId,
|
id: mgaId,
|
||||||
messaging_group_id: messagingGroup.id,
|
messaging_group_id: messagingGroup.id,
|
||||||
agent_group_id: agentGroup.id,
|
agent_group_id: agentGroup.id,
|
||||||
trigger_rules: triggerRules,
|
engage_mode: engageMode,
|
||||||
response_scope: 'all',
|
engage_pattern: engagePattern,
|
||||||
|
sender_scope: 'all',
|
||||||
|
ignored_message_policy: 'drop',
|
||||||
session_mode: parsed.sessionMode,
|
session_mode: parsed.sessionMode,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
|||||||
55
setup/verify.test.ts
Normal file
55
setup/verify.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { determineVerifyStatus } from './verify.js';
|
||||||
|
|
||||||
|
const healthyBase = {
|
||||||
|
service: 'running' as const,
|
||||||
|
credentials: 'configured',
|
||||||
|
anyChannelConfigured: false,
|
||||||
|
registeredGroups: 1,
|
||||||
|
agentPing: 'ok' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('determineVerifyStatus', () => {
|
||||||
|
it('accepts a working CLI-only install', () => {
|
||||||
|
expect(determineVerifyStatus(healthyBase)).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a messaging-channel install when CLI ping is skipped', () => {
|
||||||
|
expect(
|
||||||
|
determineVerifyStatus({
|
||||||
|
...healthyBase,
|
||||||
|
anyChannelConfigured: true,
|
||||||
|
agentPing: 'skipped',
|
||||||
|
}),
|
||||||
|
).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when neither CLI nor messaging channels are usable', () => {
|
||||||
|
expect(
|
||||||
|
determineVerifyStatus({
|
||||||
|
...healthyBase,
|
||||||
|
agentPing: 'skipped',
|
||||||
|
}),
|
||||||
|
).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when the CLI agent does not respond', () => {
|
||||||
|
expect(
|
||||||
|
determineVerifyStatus({
|
||||||
|
...healthyBase,
|
||||||
|
anyChannelConfigured: true,
|
||||||
|
agentPing: 'no_reply',
|
||||||
|
}),
|
||||||
|
).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when no agent groups are registered', () => {
|
||||||
|
expect(
|
||||||
|
determineVerifyStatus({
|
||||||
|
...healthyBase,
|
||||||
|
registeredGroups: 0,
|
||||||
|
}),
|
||||||
|
).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { DATA_DIR } from '../src/config.js';
|
import { DATA_DIR } from '../src/config.js';
|
||||||
import { readEnvFile } from '../src/env.js';
|
import { readEnvFile } from '../src/env.js';
|
||||||
import { log } from '../src/log.js';
|
import { log } from '../src/log.js';
|
||||||
import { pingCliAgent } from './lib/agent-ping.js';
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||||
import {
|
import {
|
||||||
getPlatform,
|
getPlatform,
|
||||||
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
|
|
||||||
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
|
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
|
||||||
// everything upstream looks healthy, since a broken socket would just hang.
|
// everything upstream looks healthy, since a broken socket would just hang.
|
||||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
|
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
|
||||||
if (service === 'running' && registeredGroups > 0) {
|
if (service === 'running' && registeredGroups > 0) {
|
||||||
log.info('Pinging CLI agent');
|
log.info('Pinging CLI agent');
|
||||||
agentPing = await pingCliAgent();
|
agentPing = await pingCliAgent();
|
||||||
log.info('Agent ping result', { agentPing });
|
log.info('Agent ping result', { agentPing });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine overall status
|
// Determine overall status. A CLI-only install is valid when the local
|
||||||
const status =
|
// agent round-trip succeeds; messaging app credentials are optional.
|
||||||
service === 'running' &&
|
const status = determineVerifyStatus({
|
||||||
credentials !== 'missing' &&
|
service,
|
||||||
anyChannelConfigured &&
|
credentials,
|
||||||
registeredGroups > 0 &&
|
anyChannelConfigured,
|
||||||
(agentPing === 'ok' || agentPing === 'skipped')
|
registeredGroups,
|
||||||
? 'success'
|
agentPing,
|
||||||
: 'failed';
|
});
|
||||||
|
|
||||||
log.info('Verification complete', { status, channelAuth });
|
log.info('Verification complete', { status, channelAuth });
|
||||||
|
|
||||||
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
if (status === 'failed') process.exit(1);
|
if (status === 'failed') process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function determineVerifyStatus(input: {
|
||||||
|
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
|
||||||
|
credentials: string;
|
||||||
|
anyChannelConfigured: boolean;
|
||||||
|
registeredGroups: number;
|
||||||
|
agentPing: PingResult | 'skipped';
|
||||||
|
}): 'success' | 'failed' {
|
||||||
|
const cliAgentResponds = input.agentPing === 'ok';
|
||||||
|
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
|
||||||
|
|
||||||
|
return input.service === 'running' &&
|
||||||
|
input.credentials !== 'missing' &&
|
||||||
|
hasUsableChannel &&
|
||||||
|
input.registeredGroups > 0 &&
|
||||||
|
(cliAgentResponds || input.agentPing === 'skipped')
|
||||||
|
? 'success'
|
||||||
|
: 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a PID, resolve the script path the process is executing (i.e. the
|
* Given a PID, resolve the script path the process is executing (i.e. the
|
||||||
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||||
|
|||||||
@@ -125,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
let setupConfig: ChannelSetup;
|
let setupConfig: ChannelSetup;
|
||||||
let gatewayAbort: AbortController | null = null;
|
let gatewayAbort: AbortController | null = null;
|
||||||
|
|
||||||
async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise<InboundMessage> {
|
async function messageToInbound(
|
||||||
|
message: ChatMessage,
|
||||||
|
isMention: boolean,
|
||||||
|
isGroup?: boolean,
|
||||||
|
): Promise<InboundMessage> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const serialized = message.toJSON() as Record<string, any>;
|
const serialized = message.toJSON() as Record<string, any>;
|
||||||
|
|
||||||
@@ -216,7 +220,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
// wirings still fire on in-thread mentions.
|
// wirings still fire on in-thread mentions.
|
||||||
chat.onSubscribedMessage(async (thread, message) => {
|
chat.onSubscribedMessage(async (thread, message) => {
|
||||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true));
|
await setupConfig.onInbound(
|
||||||
|
channelId,
|
||||||
|
thread.id,
|
||||||
|
await messageToInbound(message, message.isMention === true, true),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// @mention in an unsubscribed thread — SDK-confirmed bot mention.
|
// @mention in an unsubscribed thread — SDK-confirmed bot mention.
|
||||||
|
|||||||
Reference in New Issue
Block a user