Merge remote-tracking branch 'upstream/main'
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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
7
.github/workflows/label-pr.yml
vendored
7
.github/workflows/label-pr.yml
vendored
@@ -1,7 +1,12 @@
|
||||
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:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
|
||||
100
container/agent-runner/src/db/session-state.test.ts
Normal file
100
container/agent-runner/src/db/session-state.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getOutboundDb, initTestSessionDb } from './connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
getContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './session-state.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
function seedLegacy(value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('sdk_session_id', value, new Date().toISOString());
|
||||
}
|
||||
|
||||
describe('session-state — per-provider continuations', () => {
|
||||
test('set/get round-trip, case-insensitive provider key', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('Claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
|
||||
});
|
||||
|
||||
test('providers are isolated — switching reads the right slot', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
setContinuation('codex', 'codex-thread-xyz');
|
||||
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('codex')).toBe('codex-thread-xyz');
|
||||
});
|
||||
|
||||
test('clearContinuation only affects the specified provider', () => {
|
||||
setContinuation('claude', 'keep-me');
|
||||
setContinuation('codex', 'drop-me');
|
||||
|
||||
clearContinuation('codex');
|
||||
|
||||
expect(getContinuation('claude')).toBe('keep-me');
|
||||
expect(getContinuation('codex')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown provider returns undefined', () => {
|
||||
expect(getContinuation('never-used')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-state — legacy migration', () => {
|
||||
test('adopts legacy value into current provider when current is empty', () => {
|
||||
seedLegacy('old-session-id');
|
||||
|
||||
const adopted = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(adopted).toBe('old-session-id');
|
||||
expect(getContinuation('claude')).toBe('old-session-id');
|
||||
});
|
||||
|
||||
test('always deletes legacy row regardless of migration outcome', () => {
|
||||
seedLegacy('old-session-id');
|
||||
setContinuation('claude', 'existing');
|
||||
|
||||
migrateLegacyContinuation('claude');
|
||||
|
||||
// After migration the legacy key must be gone, whether or not it was adopted.
|
||||
// A subsequent migration for a different provider must not see it.
|
||||
const resultAfterSecondCall = migrateLegacyContinuation('codex');
|
||||
expect(resultAfterSecondCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('prefers existing current-provider slot over legacy', () => {
|
||||
seedLegacy('legacy-value');
|
||||
setContinuation('claude', 'claude-value');
|
||||
|
||||
const result = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(result).toBe('claude-value');
|
||||
expect(getContinuation('claude')).toBe('claude-value');
|
||||
});
|
||||
|
||||
test('no legacy row — returns current provider value (possibly undefined)', () => {
|
||||
expect(migrateLegacyContinuation('claude')).toBeUndefined();
|
||||
|
||||
setContinuation('codex', 'codex-value');
|
||||
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
|
||||
});
|
||||
|
||||
test('migration is idempotent on a second call (legacy already gone)', () => {
|
||||
seedLegacy('once');
|
||||
|
||||
const first = migrateLegacyContinuation('claude');
|
||||
expect(first).toBe('once');
|
||||
|
||||
const second = migrateLegacyContinuation('claude');
|
||||
expect(second).toBe('once');
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,20 @@
|
||||
* Persistent key/value state for the container. Lives in outbound.db
|
||||
* (container-owned, already scoped per channel/thread).
|
||||
*
|
||||
* Primary use: remember the SDK session ID so the agent's conversation
|
||||
* resumes across container restarts. Cleared by /clear.
|
||||
* Primary use: remember each provider's opaque continuation id so the
|
||||
* agent's conversation resumes across container restarts. Keyed per
|
||||
* provider because continuations are provider-private — a Claude
|
||||
* conversation id means nothing to Codex and vice versa. Switching
|
||||
* providers is therefore lossless: each provider's last thread stays
|
||||
* on file and resumes cleanly if the user flips back.
|
||||
*/
|
||||
import { getOutboundDb } from './connection.js';
|
||||
|
||||
const SDK_SESSION_KEY = 'sdk_session_id';
|
||||
const LEGACY_KEY = 'sdk_session_id';
|
||||
|
||||
function continuationKey(providerName: string): string {
|
||||
return `continuation:${providerName.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function getValue(key: string): string | undefined {
|
||||
const row = getOutboundDb()
|
||||
@@ -18,9 +26,7 @@ function getValue(key: string): string | undefined {
|
||||
|
||||
function setValue(key: string, value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare(
|
||||
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
|
||||
)
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run(key, value, new Date().toISOString());
|
||||
}
|
||||
|
||||
@@ -28,14 +34,46 @@ function deleteValue(key: string): void {
|
||||
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
|
||||
}
|
||||
|
||||
export function getStoredSessionId(): string | undefined {
|
||||
return getValue(SDK_SESSION_KEY);
|
||||
/**
|
||||
* One-time migration of the pre-per-provider continuation row.
|
||||
*
|
||||
* Before this was keyed per provider, continuations lived under the
|
||||
* single key `sdk_session_id`. On container start, if that legacy row
|
||||
* exists and the current provider has no continuation of its own, adopt
|
||||
* the legacy value into the current provider's slot (best-guess — the
|
||||
* legacy row was written by whatever provider ran last). The legacy row
|
||||
* is always deleted so future provider flips never re-read a stale id
|
||||
* through the wrong lens.
|
||||
*
|
||||
* Returns the continuation the caller should use at startup (either the
|
||||
* current provider's existing value, the adopted legacy value, or
|
||||
* undefined).
|
||||
*/
|
||||
export function migrateLegacyContinuation(providerName: string): string | undefined {
|
||||
const legacy = getValue(LEGACY_KEY);
|
||||
const currentKey = continuationKey(providerName);
|
||||
const current = getValue(currentKey);
|
||||
|
||||
if (legacy === undefined) return current;
|
||||
|
||||
// Always drop the legacy row so no future provider reads it.
|
||||
deleteValue(LEGACY_KEY);
|
||||
|
||||
// Prefer the current provider's own slot if one already exists.
|
||||
if (current !== undefined) return current;
|
||||
|
||||
setValue(currentKey, legacy);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function setStoredSessionId(sessionId: string): void {
|
||||
setValue(SDK_SESSION_KEY, sessionId);
|
||||
export function getContinuation(providerName: string): string | undefined {
|
||||
return getValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
export function clearStoredSessionId(): void {
|
||||
deleteValue(SDK_SESSION_KEY);
|
||||
export function setContinuation(providerName: string, id: string): void {
|
||||
setValue(continuationKey(providerName), id);
|
||||
}
|
||||
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ async function main(): Promise<void> {
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
||||
return Promise.race([
|
||||
runPollLoop({
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
@@ -19,6 +23,12 @@ function generateId(): string {
|
||||
|
||||
export interface PollLoopConfig {
|
||||
provider: AgentProvider;
|
||||
/**
|
||||
* Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
|
||||
* the stored continuation per-provider so flipping providers doesn't
|
||||
* resurrect a stale id from a different backend.
|
||||
*/
|
||||
providerName: string;
|
||||
cwd: string;
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
@@ -39,8 +49,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Resume the agent's prior session from a previous container run if one
|
||||
// was persisted. The continuation is opaque to the poll-loop — the
|
||||
// provider decides how to use it (Claude resumes a .jsonl transcript,
|
||||
// other providers may reload a thread ID, etc.).
|
||||
let continuation: string | undefined = getStoredSessionId();
|
||||
// other providers may reload a thread ID, etc.). Keyed per-provider so
|
||||
// a Codex thread id never gets handed to Claude or vice versa.
|
||||
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
|
||||
|
||||
if (continuation) {
|
||||
log(`Resuming agent session ${continuation}`);
|
||||
@@ -94,7 +105,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
clearContinuation(config.providerName);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
@@ -160,10 +171,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds);
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setStoredSessionId(continuation);
|
||||
setContinuation(config.providerName, continuation);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -175,7 +186,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if (continuation && config.provider.isSessionInvalid(err)) {
|
||||
log(`Stale session detected (${continuation}) — clearing for next retry`);
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
clearContinuation(config.providerName);
|
||||
}
|
||||
|
||||
// Write error response so the user knows something went wrong
|
||||
@@ -238,6 +249,7 @@ async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
@@ -288,7 +300,7 @@ async function processQuery(
|
||||
// container died between `init` and `result`, the SDK session was
|
||||
// effectively orphaned and the next message started a blank
|
||||
// Claude session with no prior context.
|
||||
setStoredSessionId(event.continuation);
|
||||
setContinuation(providerName, event.continuation);
|
||||
} else if (event.type === 'result') {
|
||||
// A result — with or without text — means the turn is done. Mark
|
||||
// the initial batch completed now so the host sweep doesn't see
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.13",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="130k tokens, 65% of context window">
|
||||
<title>130k tokens, 65% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="132k tokens, 66% of context window">
|
||||
<title>132k tokens, 66% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">130k</text>
|
||||
<text x="71" y="14">130k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">132k</text>
|
||||
<text x="71" y="14">132k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -48,6 +48,7 @@ import { addMember } from '../src/modules/permissions/db/agent-group-members.js'
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
type Role = 'owner' | 'admin' | 'member';
|
||||
@@ -137,32 +138,6 @@ function namespacedUserId(channel: string, raw: string): string {
|
||||
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a platform ID needs a channel-type prefix.
|
||||
*
|
||||
* Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their
|
||||
* platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan".
|
||||
* The router stores `channel_type` and `platform_id` in separate columns, but
|
||||
* Chat SDK adapters send the prefixed form as the platform_id, so this script
|
||||
* must match that format.
|
||||
*
|
||||
* Native adapters (Signal, WhatsApp) use their own ID formats and send them
|
||||
* as-is — no channel prefix. Signal sends raw phone numbers (+15551234567)
|
||||
* for DMs and "group:<id>" for group chats. WhatsApp sends JIDs containing
|
||||
* '@' (<phone>@s.whatsapp.net, <groupId>@g.us). Prefixing these would cause
|
||||
* a mismatch between what the adapter sends and what the DB stores, breaking
|
||||
* message routing.
|
||||
*/
|
||||
function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
// Native WhatsApp JIDs contain '@' — no prefix needed.
|
||||
if (raw.includes('@')) return raw;
|
||||
// Native Signal IDs: phone numbers (+...) and group IDs (group:...).
|
||||
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
||||
// Chat SDK adapters — add the channel prefix.
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 2) resolve('socket_error');
|
||||
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
|
||||
else resolve('no_reply');
|
||||
resolve(classifyPingResult(code, stdout, stderr));
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
@@ -112,12 +113,10 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// Chat SDK adapters prefix platform IDs with the channel type
|
||||
// (e.g. "telegram:123", "discord:guild:channel"). Normalize here so
|
||||
// the stored ID always matches what the adapter sends at runtime.
|
||||
if (!parsed.platformId.startsWith(`${parsed.channel}:`)) {
|
||||
parsed.platformId = `${parsed.channel}:${parsed.platformId}`;
|
||||
}
|
||||
// Normalize platform_id to the same shape the adapter will emit at runtime,
|
||||
// so the router's (channel_type, platform_id) lookup matches what we store.
|
||||
// Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't.
|
||||
parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId);
|
||||
|
||||
log.info('Registering channel', parsed);
|
||||
|
||||
@@ -167,19 +166,22 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (!existing) {
|
||||
newlyWired = true;
|
||||
const mgaId = generateId('mga');
|
||||
const triggerRules = parsed.trigger
|
||||
? JSON.stringify({
|
||||
pattern: parsed.trigger,
|
||||
requiresTrigger: parsed.requiresTrigger,
|
||||
})
|
||||
: null;
|
||||
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
|
||||
// create rows with the same shape. Groups default to 'mention' (bot only
|
||||
// responds when addressed); DMs default to 'pattern'/'.' (respond to
|
||||
// every message). An explicit --trigger overrides the pattern regex.
|
||||
const isGroup = messagingGroup.is_group === 1;
|
||||
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
|
||||
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: messagingGroup.id,
|
||||
agent_group_id: agentGroup.id,
|
||||
trigger_rules: triggerRules,
|
||||
response_scope: 'all',
|
||||
session_mode: parsed.sessionMode,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
priority: 0,
|
||||
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 { readEnvFile } from '../src/env.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 {
|
||||
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
|
||||
// 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) {
|
||||
log.info('Pinging CLI agent');
|
||||
agentPing = await pingCliAgent();
|
||||
log.info('Agent ping result', { agentPing });
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
const status =
|
||||
service === 'running' &&
|
||||
credentials !== 'missing' &&
|
||||
anyChannelConfigured &&
|
||||
registeredGroups > 0 &&
|
||||
(agentPing === 'ok' || agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
// Determine overall status. A CLI-only install is valid when the local
|
||||
// agent round-trip succeeds; messaging app credentials are optional.
|
||||
const status = determineVerifyStatus({
|
||||
service,
|
||||
credentials,
|
||||
anyChannelConfigured,
|
||||
registeredGroups,
|
||||
agentPing,
|
||||
});
|
||||
|
||||
log.info('Verification complete', { status, channelAuth });
|
||||
|
||||
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
|
||||
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
|
||||
* 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 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
|
||||
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.
|
||||
chat.onSubscribedMessage(async (thread, message) => {
|
||||
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.
|
||||
|
||||
46
src/modules/agent-to-agent/agent-route.test.ts
Normal file
46
src/modules/agent-to-agent/agent-route.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSafeAttachmentName } from './agent-route.js';
|
||||
|
||||
/**
|
||||
* `forwardAttachedFiles` has a filesystem side that's awkward to unit-test
|
||||
* without mocking DATA_DIR. The guarantee worth pinning is that the
|
||||
* filename validator rejects everything that could escape the inbox dir —
|
||||
* `forwardAttachedFiles` runs this guard before any I/O, so traversal is
|
||||
* impossible as long as this matrix holds.
|
||||
*/
|
||||
describe('isSafeAttachmentName', () => {
|
||||
it('accepts plain filenames', () => {
|
||||
expect(isSafeAttachmentName('baby-duck.png')).toBe(true);
|
||||
expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true);
|
||||
expect(isSafeAttachmentName('report.v2.docx')).toBe(true);
|
||||
expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..`
|
||||
});
|
||||
|
||||
it('rejects empty / sentinel values', () => {
|
||||
expect(isSafeAttachmentName('')).toBe(false);
|
||||
expect(isSafeAttachmentName('.')).toBe(false);
|
||||
expect(isSafeAttachmentName('..')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects path separators', () => {
|
||||
expect(isSafeAttachmentName('../evil.png')).toBe(false);
|
||||
expect(isSafeAttachmentName('/etc/passwd')).toBe(false);
|
||||
expect(isSafeAttachmentName('nested/file.txt')).toBe(false);
|
||||
expect(isSafeAttachmentName('windows\\path.exe')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects NUL bytes', () => {
|
||||
expect(isSafeAttachmentName('clean\0.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects anything path.basename would strip', () => {
|
||||
expect(isSafeAttachmentName('a/b')).toBe(false);
|
||||
expect(isSafeAttachmentName('./thing')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string input', () => {
|
||||
expect(isSafeAttachmentName(null as unknown as string)).toBe(false);
|
||||
expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,13 @@
|
||||
*
|
||||
* Outbound messages with `channel_type === 'agent'` target another agent
|
||||
* group rather than a channel. Permission is enforced via `agent_destinations` —
|
||||
* the source agent must have a row for the target. Content is copied verbatim;
|
||||
* the target's formatter looks up the source agent in its own local map to
|
||||
* display a name.
|
||||
* the source agent must have a row for the target. Content is copied into the
|
||||
* target's inbound DB; if the source message had `files` (from `send_file`),
|
||||
* the actual bytes are copied from the source's outbox into the target's
|
||||
* `inbox/<a2a-msg-id>/` directory and surfaced to the target agent as
|
||||
* `attachments` (existing formatter convention — see formatter.ts:230).
|
||||
* The target agent can then forward the file onward via its own `send_file`
|
||||
* call using the absolute `/workspace/inbox/<a2a-msg-id>/<filename>` path.
|
||||
*
|
||||
* Self-messages are always allowed (used for system notes injected back into
|
||||
* an agent's own session, e.g. post-approval follow-up prompts).
|
||||
@@ -14,14 +18,102 @@
|
||||
* `channel_type === 'agent'` check. When the module is absent the check in
|
||||
* core throws with a "module not installed" message so retry → mark failed.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { resolveSession, writeSessionMessage } from '../../session-manager.js';
|
||||
import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
|
||||
export interface ForwardedAttachment {
|
||||
name: string;
|
||||
filename: string;
|
||||
type: 'file';
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `name` safe to use as the last segment of a path inside the target
|
||||
* agent's inbox directory? Filenames arrive in messages_out content from
|
||||
* the source agent — under a multi-agent setup with heterogenous providers
|
||||
* (or a compromised / hallucinating sub-agent) they can't be trusted.
|
||||
*
|
||||
* Rejects:
|
||||
* - empty string
|
||||
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
|
||||
* - anything containing a path separator (`/` or `\`) or NUL
|
||||
* - any value where `path.basename(name) !== name`, catching OS-specific
|
||||
* separators and covering drives/prefixes on Windows runtimes
|
||||
*/
|
||||
export function isSafeAttachmentName(name: string): boolean {
|
||||
if (typeof name !== 'string' || name.length === 0) return false;
|
||||
if (name === '.' || name === '..') return false;
|
||||
if (/[\\/\0]/.test(name)) return false;
|
||||
return path.basename(name) === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
* `{name, type, localPath}` convention — target agent reads `localPath`
|
||||
* as relative to `/workspace/`, matching how channel-inbound attachments
|
||||
* are surfaced today.
|
||||
*
|
||||
* Missing source files and unsafe (path-traversal) filenames are skipped
|
||||
* with a warning rather than failing the whole route — a bad filename
|
||||
* reference shouldn't kill the accompanying text.
|
||||
*/
|
||||
export function forwardAttachedFiles(
|
||||
source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] },
|
||||
target: { agentGroupId: string; sessionId: string; messageId: string },
|
||||
): ForwardedAttachment[] {
|
||||
if (source.filenames.length === 0) return [];
|
||||
|
||||
const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
log.warn('agent-route: source outbox dir missing, no files forwarded', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
||||
|
||||
const attachments: ForwardedAttachment[] = [];
|
||||
for (const filename of source.filenames) {
|
||||
if (!isSafeAttachmentName(filename)) {
|
||||
log.warn('agent-route: rejecting unsafe attachment filename (path traversal attempt?)', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const src = path.join(sourceDir, filename);
|
||||
if (!fs.existsSync(src)) {
|
||||
log.warn('agent-route: referenced file missing in source outbox, skipped', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dst = path.join(targetInboxDir, filename);
|
||||
fs.copyFileSync(src, dst);
|
||||
attachments.push({
|
||||
name: filename,
|
||||
filename,
|
||||
type: 'file',
|
||||
localPath: `inbox/${target.messageId}/${filename}`,
|
||||
});
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export interface RoutableAgentMessage {
|
||||
id: string;
|
||||
platform_id: string | null;
|
||||
@@ -45,20 +137,87 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
}
|
||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// If the source message references files (via `send_file`), forward the
|
||||
// bytes from the source's outbox into the target's inbox so the target
|
||||
// agent can actually see and re-send them. Without this, agent-to-agent
|
||||
// file attachments look like they arrive but the target has no way to
|
||||
// read the bytes — they live in a session dir it doesn't mount.
|
||||
const forwardedContent = forwardFileAttachments(msg, a2aMsgId, session, targetAgentGroupId, targetSession.id);
|
||||
|
||||
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: a2aMsgId,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: msg.content,
|
||||
content: forwardedContent,
|
||||
});
|
||||
log.info('Agent message routed', {
|
||||
from: session.agent_group_id,
|
||||
to: targetAgentGroupId,
|
||||
targetSession: targetSession.id,
|
||||
a2aMsgId,
|
||||
forwardedFileCount: countForwardedFiles(forwardedContent),
|
||||
});
|
||||
const fresh = getSession(targetSession.id);
|
||||
if (fresh) await wakeContainer(fresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse source content, copy any referenced `files` from source outbox to
|
||||
* target inbox, and return a JSON string with an `attachments` array added
|
||||
* (formatter.ts:223 already knows how to render this shape).
|
||||
*
|
||||
* If the source content isn't JSON or has no files, returns the original
|
||||
* content string unchanged — this is safe to call on every route.
|
||||
*/
|
||||
function forwardFileAttachments(
|
||||
msg: RoutableAgentMessage,
|
||||
a2aMsgId: string,
|
||||
sourceSession: Session,
|
||||
targetAgentGroupId: string,
|
||||
targetSessionId: string,
|
||||
): string {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(msg.content);
|
||||
} catch {
|
||||
return msg.content;
|
||||
}
|
||||
const files = parsed.files as unknown;
|
||||
if (!Array.isArray(files) || files.length === 0) return msg.content;
|
||||
const filenames = files.filter((f): f is string => typeof f === 'string');
|
||||
if (filenames.length === 0) return msg.content;
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{
|
||||
agentGroupId: sourceSession.agent_group_id,
|
||||
sessionId: sourceSession.id,
|
||||
messageId: msg.id,
|
||||
filenames,
|
||||
},
|
||||
{
|
||||
agentGroupId: targetAgentGroupId,
|
||||
sessionId: targetSessionId,
|
||||
messageId: a2aMsgId,
|
||||
},
|
||||
);
|
||||
|
||||
// Merge into any existing `attachments` (unlikely in a2a context but safe).
|
||||
const existing = Array.isArray(parsed.attachments) ? (parsed.attachments as Record<string, unknown>[]) : [];
|
||||
parsed.attachments = [...existing, ...attachments];
|
||||
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
function countForwardedFiles(contentStr: string): number {
|
||||
try {
|
||||
const parsed = JSON.parse(contentStr);
|
||||
return Array.isArray(parsed.attachments) ? parsed.attachments.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/platform-id.ts
Normal file
23
src/platform-id.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Determine whether a platform ID needs a channel-type prefix.
|
||||
*
|
||||
* Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their
|
||||
* platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan".
|
||||
* The router stores channel_type and platform_id in separate columns, but
|
||||
* Chat SDK adapters send the prefixed form as the platform_id — so any code
|
||||
* that writes messaging_groups rows must produce the same shape the adapter
|
||||
* will later emit as event.platformId, or router lookups miss and messages
|
||||
* get silently dropped.
|
||||
*
|
||||
* Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and
|
||||
* send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails
|
||||
* containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs
|
||||
* and 'group:<id>' for group chats. Prefixing any of these would cause a
|
||||
* mismatch with what the adapter later emits.
|
||||
*/
|
||||
export function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
if (raw.includes('@')) return raw;
|
||||
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
Reference in New Issue
Block a user