From d0c608c75114fb6ada970be3bc3f7212ad5bc47a Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 23 Apr 2026 19:44:47 -0400 Subject: [PATCH 01/19] fix(setup): register step uses engage_mode columns dropped by migration 010 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 010-engage-modes (replace trigger_rules + response_scope with engage_mode/engage_pattern/sender_scope/ignored_message_policy) updated the schema and the production code paths, but missed setup/register.ts. The step still constructed a payload with the dropped columns. On any fresh v2 install, attempting to register a channel via: pnpm exec tsx setup/index.ts --step register -- --platform-id ... fails with: `Missing named parameter "engage_mode"`. This affects every flow that calls the register step — the /add- skills, /manage-channels, and the setup auto driver. Map old → new: - trigger_rules.pattern (string) → engage_mode='pattern', engage_pattern= - requiresTrigger=false (no pattern) → engage_mode='pattern', engage_pattern='.' (the "always" sentinel from migration 010) - requiresTrigger=true (no pattern) → engage_mode='mention' - response_scope='all' → sender_scope='all', ignored_message_policy='drop' (conservative default matching the migration backfill rule) Tested by registering three Telegram channels (one DM, two groups) on a fresh v2 install — all succeeded. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/register.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index a308add..ff194fc 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -167,18 +167,16 @@ export async function run(args: string[]): Promise { if (!existing) { newlyWired = true; const mgaId = generateId('mga'); - const triggerRules = parsed.trigger - ? JSON.stringify({ - pattern: parsed.trigger, - requiresTrigger: parsed.requiresTrigger, - }) - : null; + const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention'; + const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null); createMessagingGroupAgent({ id: mgaId, messaging_group_id: messagingGroup.id, agent_group_id: agentGroup.id, - trigger_rules: triggerRules, - response_scope: 'all', + engage_mode: engageMode, + engage_pattern: engagePattern, + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: parsed.sessionMode, priority: 0, created_at: new Date().toISOString(), From 9e33274e2a81121fbf65531108f8239bb4e1e465 Mon Sep 17 00:00:00 2001 From: grtwrn Date: Thu, 23 Apr 2026 20:43:02 -0400 Subject: [PATCH 02/19] skill(add-gmail-tool): OneCLI-native Gmail MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /add-gmail-tool — a Utility skill that installs Gmail as an MCP tool in NanoClaw v2 using OneCLI for credential injection. No raw OAuth tokens ever reach the container; the gateway swaps the "onecli-managed" stub bearer for the real token at request time. Scope (3 files): - container/Dockerfile: pnpm global-install of @gongrzhe/server-gmail-autoauth-mcp@1.1.11, pinned behind GMAIL_MCP_VERSION. Also pins zod-to-json-schema@3.22.5 to avoid an ERR_PACKAGE_PATH_NOT_EXPORTED crash: the MCP server's loose zod range resolves zod@3.24.x while zod-to-json-schema@3.25.x imports the zod/v3 subpath that only exists in zod>=3.25. - container/agent-runner/src/providers/claude.ts: adds 'mcp__gmail__*' to TOOL_ALLOWLIST so the agent can invoke the server's tools. - .claude/skills/add-gmail-tool/SKILL.md: pre-flight checks (OneCLI Gmail app connected, stubs present, mount allowlist covers ~/.gmail-mcp, agent secret-mode), per-group wiring in container.json (mount + mcpServers), verification steps, troubleshooting, removal instructions. Credits to gongrzhe for the MCP server and the add-atomic-chat-tool / add-vercel skill patterns. Addresses #1500 (proxy Gmail OAuth through credential proxy) on the Gmail side. Overlaps in intent with #1810 but stays surgical — no bundled unrelated changes. Tested end-to-end on Linux/Docker: CLI and WhatsApp self-chat agents can list labels, search/read/send mail via OneCLI-injected tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-gmail-tool/SKILL.md | 229 ++++++++++++++++++ container/Dockerfile | 6 + .../agent-runner/src/providers/claude.ts | 1 + 3 files changed, 236 insertions(+) create mode 100644 .claude/skills/add-gmail-tool/SKILL.md diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md new file mode 100644 index 0000000..095c285 --- /dev/null +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -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__`): `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/`). 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 --secret-ids +``` + +## 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//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//.gmail-mcp", + "containerPath": ".gmail-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` 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 `` 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 `) 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. diff --git a/container/Dockerfile b/container/Dockerfile index 4b4cf22..8c296ea 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -23,6 +23,7 @@ ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest ARG BUN_VERSION=1.3.12 +ARG GMAIL_MCP_VERSION=1.1.11 # ---- System dependencies ----------------------------------------------------- # tini: correct PID 1 / signal forwarding so outbound.db writes finalize on @@ -104,6 +105,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +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" + # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..0ba0919 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,6 +55,7 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', + 'mcp__gmail__*', ]; interface SDKUserMessage { From 81ef193e692dcf76a2fcd72a3995dc7edd017aef Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 13:38:46 +1000 Subject: [PATCH 03/19] refactor(session-state): key continuations per provider to survive provider switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, every provider stored its opaque continuation id under the single outbound.db key `sdk_session_id`. Flipping a session's agent_provider (e.g. Codex → Claude) meant the new provider read the old provider's id at wake, handed it to its own SDK, and got a "No conversation found" error that cost the user one sacrificed message before the stale-session recovery path cleared the id. This reshapes session_state so continuations are keyed `continuation:` instead. Consequences: - Per-provider continuations coexist. Flipping Claude → Codex → Claude resumes the Claude thread exactly where it left off, with the intervening Codex thread also still on file. - No provider ever reads another provider's id. Switching costs no sacrificed message and emits no transient error. - Legacy installs are migrated forward on first startup: migrateLegacyContinuation() adopts any pre-existing `sdk_session_id` row into the current provider's slot (best guess — it was whichever provider ran last), then deletes the legacy row unconditionally so it can't poison a future provider's read. runPollLoop now takes providerName alongside the provider instance, and threads it through processQuery to setContinuation on init. Tests: 9 new tests covering set/get isolation across providers, clear-specificity, legacy-adoption, legacy-always-deleted, prefer-existing-slot-over-legacy, and idempotency of a second migration call. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-runner/src/db/session-state.test.ts | 100 ++++++++++++++++++ .../agent-runner/src/db/session-state.ts | 62 ++++++++--- container/agent-runner/src/index.ts | 1 + .../agent-runner/src/integration.test.ts | 1 + container/agent-runner/src/poll-loop.ts | 28 +++-- 5 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 container/agent-runner/src/db/session-state.test.ts diff --git a/container/agent-runner/src/db/session-state.test.ts b/container/agent-runner/src/db/session-state.test.ts new file mode 100644 index 0000000..b5aa269 --- /dev/null +++ b/container/agent-runner/src/db/session-state.test.ts @@ -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'); + }); +}); diff --git a/container/agent-runner/src/db/session-state.ts b/container/agent-runner/src/db/session-state.ts index a199ae1..9e12309 100644 --- a/container/agent-runner/src/db/session-state.ts +++ b/container/agent-runner/src/db/session-state.ts @@ -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)); } diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 236be4c..90c690f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -95,6 +95,7 @@ async function main(): Promise { await runPollLoop({ provider, + providerName, cwd: CWD, systemContext: { instructions }, }); diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 4a8b091..3447c38 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna return Promise.race([ runPollLoop({ provider, + providerName: 'mock', cwd: '/tmp', }), new Promise((_, reject) => { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index d93bdd3..bd48db2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -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 { // 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 { 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 { 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 { 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 { 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 From 672e228876aa84ecb5c1a7142ab3b991c5506cee Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 15:15:33 +1000 Subject: [PATCH 04/19] fix(agent-route): forward file attachments between agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: `send_file(to='parent')` from a sub-agent wrote the bytes to the sub-agent's own session outbox, but agent-to-agent routing copied only the content JSON — the target's inbound message referenced `files: ['x.png']` but the bytes lived in a session directory the target couldn't mount. Parent agents orchestrating sub-agents (e.g. Design Team delegating illustration work to an Illustrator sub-agent on Codex) received file-reference messages with nothing to forward. Fix: on route, if the source's content has `files`, copy each referenced file from `/outbox//` to `/inbox//`, and emit `attachments` (the existing formatter convention — see formatter.ts:223) with `localPath` relative to `/workspace/`. The target formatter already renders these as `[file: — saved to /workspace/inbox//]`, so the target agent sees the path and can call `send_file(path=…, to=…)` to forward onward. Convention matches what session-manager.ts:256 already does for base64-encoded channel-inbound attachments — same inbox layout, same content shape. Nothing on the formatter/agent side needed to change. ## Scope - `forwardAttachedFiles(source, target)` — pure-ish helper that copies files and returns the attachments array. - `forwardFileAttachments(msg, …)` — wraps the helper for the route path: parses content, copies files if present, merges into any existing `attachments`, re-serialises. - `routeAgentMessage` — uses the rewritten content when writing the target's inbound row. - Log line now includes `forwardedFileCount` for observability. Missing source files are skipped with a warning rather than killing the route — a bad filename in a batch shouldn't drop the accompanying text. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/agent-to-agent/agent-route.ts | 144 +++++++++++++++++++++- 1 file changed, 138 insertions(+), 6 deletions(-) diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 760356c..faec2b9 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -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//` 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//` 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,75 @@ * `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; +} + +/** + * 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 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) { + 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 +110,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; + 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[]) : []; + 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; + } +} From fd03b893336397728a09fa850473d001432e4063 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 15:44:19 +1000 Subject: [PATCH 05/19] fix(agent-route): reject unsafe attachment filenames to prevent path traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filenames in forwardAttachedFiles arrived from the source agent's messages_out content and were used directly in path.join on both source outbox read and target inbox write. A value like `../evil.sh` could escape `inbox//` on the target session (and similarly the source outbox on read), breaking session isolation — an adversarial or hallucinating sub-agent could overwrite files in a sibling session. Adds isSafeAttachmentName(name) — exported so it's unit-testable — which rejects empty, `.`, `..`, anything containing `/`, `\`, or NUL, and anything path.basename would strip. Guard runs before any I/O. Unsafe names are dropped with a warning log, same pattern as missing-source-file handling; a bad filename in one attachment doesn't kill the whole route's text delivery. Addresses Codex Review P1 on qwibitai/nanoclaw#1967. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-to-agent/agent-route.test.ts | 46 +++++++++++++++++++ src/modules/agent-to-agent/agent-route.ts | 33 +++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/modules/agent-to-agent/agent-route.test.ts diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts new file mode 100644 index 0000000..4d48f6f --- /dev/null +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -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); + }); +}); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index faec2b9..812cb8e 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -36,6 +36,26 @@ export interface ForwardedAttachment { 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 @@ -43,9 +63,9 @@ export interface ForwardedAttachment { * as relative to `/workspace/`, matching how channel-inbound attachments * are surfaced today. * - * Missing source files are skipped with a warning rather than failing - * the whole route — a bad filename reference shouldn't kill the - * accompanying text. + * 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[] }, @@ -67,6 +87,13 @@ export function forwardAttachedFiles( 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', { From f41c1620091185a65239e2169c1d402a731a916d Mon Sep 17 00:00:00 2001 From: Pankaj Garg Date: Fri, 24 Apr 2026 08:42:10 +0200 Subject: [PATCH 06/19] detect setup auth ping failures --- setup/lib/agent-ping.test.ts | 30 ++++++++++++++++++++++++++++++ setup/lib/agent-ping.ts | 24 ++++++++++++++++++++---- setup/verify.ts | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 setup/lib/agent-ping.test.ts diff --git a/setup/lib/agent-ping.test.ts b/setup/lib/agent-ping.test.ts new file mode 100644 index 0000000..5f2be2c --- /dev/null +++ b/setup/lib/agent-ping.test.ts @@ -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'); + }); +}); diff --git a/setup/lib/agent-ping.ts b/setup/lib/agent-ping.ts index 8c5127f..49c5fe2 100644 --- a/setup/lib/agent-ping.ts +++ b/setup/lib/agent-ping.ts @@ -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 { return new Promise((resolve) => { @@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise { 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 { 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; diff --git a/setup/verify.ts b/setup/verify.ts index 281b243..873af66 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -220,7 +220,7 @@ export async function run(_args: string[]): Promise { // 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(); From 1de5a0356bd5fddd6f36eb8470316883e297a238 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:44:35 +0200 Subject: [PATCH 07/19] fix(setup): accept CLI-only verify success --- setup/verify.ts | 69 ++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/setup/verify.ts b/setup/verify.ts index 281b243..4bfd3d0 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -14,14 +14,9 @@ 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, - getServiceManager, - hasSystemd, - isRoot, -} from './platform.js'; +import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { @@ -38,11 +33,7 @@ export async function run(_args: string[]): Promise { // a sibling checkout (common for developers with multiple clones), this // repo's `data/cli.sock` won't exist — AGENT_PING would return a // misleading `socket_error`. Surface the mismatch directly instead. - let service: - | 'not_found' - | 'stopped' - | 'running' - | 'running_other_checkout' = 'not_found'; + let service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout' = 'not_found'; let runningFromPath: string | null = null; const mgr = getServiceManager(); @@ -74,10 +65,7 @@ export async function run(_args: string[]): Promise { execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { - const pidStr = execSync( - `${prefix} show ${systemdUnit} -p MainPID --value`, - { encoding: 'utf-8' }, - ).trim(); + const pidStr = execSync(`${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }).trim(); const pid = Number(pidStr); if (Number.isInteger(pid) && pid > 0) { runningFromPath = resolveBinaryScript(pid); @@ -115,11 +103,7 @@ export async function run(_args: string[]): Promise { } } - if ( - service === 'running' && - runningFromPath && - !isPathInside(runningFromPath, projectRoot) - ) { + if (service === 'running' && runningFromPath && !isPathInside(runningFromPath, projectRoot)) { service = 'running_other_checkout'; } @@ -210,11 +194,7 @@ export async function run(_args: string[]): Promise { // 6. Check mount allowlist let mountAllowlist = 'missing'; - if ( - fs.existsSync( - path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), - ) - ) { + if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) { mountAllowlist = 'configured'; } @@ -227,15 +207,15 @@ export async function run(_args: string[]): Promise { 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 +235,25 @@ export async function run(_args: string[]): Promise { 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 From 4fc2c4275cc41be6abf2d2d7ad51e7911dad4b08 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:44:58 +0200 Subject: [PATCH 08/19] test(setup): cover CLI-only verify status --- setup/verify.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 setup/verify.test.ts diff --git a/setup/verify.test.ts b/setup/verify.test.ts new file mode 100644 index 0000000..1e09acd --- /dev/null +++ b/setup/verify.test.ts @@ -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'); + }); +}); From 9fd694c763d086253717567d1f624e68abc803c7 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:49:04 +0200 Subject: [PATCH 09/19] chore(setup): minimize verify diff --- setup/verify.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/setup/verify.ts b/setup/verify.ts index 4bfd3d0..dbd37e5 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -16,7 +16,12 @@ import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; -import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js'; +import { + getPlatform, + getServiceManager, + hasSystemd, + isRoot, +} from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { @@ -33,7 +38,11 @@ export async function run(_args: string[]): Promise { // a sibling checkout (common for developers with multiple clones), this // repo's `data/cli.sock` won't exist — AGENT_PING would return a // misleading `socket_error`. Surface the mismatch directly instead. - let service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout' = 'not_found'; + let service: + | 'not_found' + | 'stopped' + | 'running' + | 'running_other_checkout' = 'not_found'; let runningFromPath: string | null = null; const mgr = getServiceManager(); @@ -65,7 +74,10 @@ export async function run(_args: string[]): Promise { execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { - const pidStr = execSync(`${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }).trim(); + const pidStr = execSync( + `${prefix} show ${systemdUnit} -p MainPID --value`, + { encoding: 'utf-8' }, + ).trim(); const pid = Number(pidStr); if (Number.isInteger(pid) && pid > 0) { runningFromPath = resolveBinaryScript(pid); @@ -103,7 +115,11 @@ export async function run(_args: string[]): Promise { } } - if (service === 'running' && runningFromPath && !isPathInside(runningFromPath, projectRoot)) { + if ( + service === 'running' && + runningFromPath && + !isPathInside(runningFromPath, projectRoot) + ) { service = 'running_other_checkout'; } @@ -194,7 +210,11 @@ export async function run(_args: string[]): Promise { // 6. Check mount allowlist let mountAllowlist = 'missing'; - if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) { + if ( + fs.existsSync( + path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), + ) + ) { mountAllowlist = 'configured'; } From 3d6837c411133227a4de7a5ae4b347c275d5fbcd Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 12:12:05 +0200 Subject: [PATCH 10/19] chore(format): apply prettier to chat-sdk-bridge.ts Two long-line violations introduced in d121cd1 (isGroup plumbing) exceed the printWidth limit. CI format:check fails on every PR opened against main until this is fixed; the fix is isolated here so no behavior change is mixed in. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index c8cf3cc..18ab2cb 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -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 { + async function messageToInbound( + message: ChatMessage, + isMention: boolean, + isGroup?: boolean, + ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -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. From 2b51a4e7076d154b389499afb1df011cbe1e8123 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 12:50:25 +0200 Subject: [PATCH 11/19] fix(workflows): label PRs from forks that follow the contributing template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fork PR, GITHUB_TOKEN is demoted to read-only regardless of the workflow's permissions: block, so issues.addLabels() returns 403. The label workflow silently works for PRs that skip the template (no checkboxes ticked → no API call) and fails for PRs that actually follow it — a hostile incentive against contributors who do the right thing. pull_request_target runs in the context of the base branch with full declared permissions, which is the documented fix for this case. Safe here because the workflow is metadata-only: it reads context.payload.pull_request.body and calls addLabels. No checkout, no PR-supplied code executes. A SECURITY comment is added above the trigger to keep it that way. Refs: - https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target - https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/label-pr.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index bec9d3e..ebfe3f3 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -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: From 5cbfccec05ef4fd078a8a0188e2d67c485d76c6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 12:25:45 +0000 Subject: [PATCH 12/19] chore: bump version to 2.0.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 20afddb..5454aa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.10", + "version": "2.0.11", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From f37e7753589b44dffe0aaf3f5d10e56f6cc091b3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 16:30:14 +0300 Subject: [PATCH 13/19] Revert src changes; skill applies them at install time Phase 2 of the SKILL.md already contains the Dockerfile + TOOL_ALLOWLIST edit instructions with an "ALREADY APPLIED" short-circuit. Keeping those edits out of trunk means users who never run /add-gmail-tool don't carry the Gmail MCP package in their image. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/Dockerfile | 6 ------ container/agent-runner/src/providers/claude.ts | 1 - 2 files changed, 7 deletions(-) diff --git a/container/Dockerfile b/container/Dockerfile index 8c296ea..4b4cf22 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -23,7 +23,6 @@ ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest ARG BUN_VERSION=1.3.12 -ARG GMAIL_MCP_VERSION=1.1.11 # ---- System dependencies ----------------------------------------------------- # tini: correct PID 1 / signal forwarding so outbound.db writes finalize on @@ -105,11 +104,6 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -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" - # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 0ba0919..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,7 +55,6 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', - 'mcp__gmail__*', ]; interface SDKUserMessage { From 52f8661f0cb172c953b6c361dc963c68d4d8c417 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 24 Apr 2026 13:35:49 +0000 Subject: [PATCH 14/19] docs(providers): note that container.json provider is what the runner reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream precedence fix (5845a5a) made agent_groups.agent_provider and sessions.agent_provider authoritative for host-side provider contribution (per-session mount, env passthrough), but those DB values don't propagate into the group's container.json — and the in-container runner reads `provider` from container.json, not from the DB. That caused a confusing failure mode: flipping the DB column to 'codex', rebuilding, and restarting still spawned a Claude runner because container.json had no provider field. The old skill wording ("container receives AGENT_PROVIDER from the resolved value") overstated the integration. Update add-codex and add-opencode "Per group / per session" sections to say: set `"provider": ""` in the group's container.json — that's the source the runner reads. Keep the DB columns documented for the host-side contribution they actually drive, and spell out the session → group → container.json → 'claude' fallback so the precedence is still discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 2 +- .claude/skills/add-opencode/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index 3411bae..14b3072 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -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//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. diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md index 08a558f..555f0fe 100644 --- a/.claude/skills/add-opencode/SKILL.md +++ b/.claude/skills/add-opencode/SKILL.md @@ -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//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. From 6d35c8512997e5639af0d1f1ac1d95313226caa4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 16:49:40 +0300 Subject: [PATCH 15/19] skill(add-gcal-tool): OneCLI-native Google Calendar MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /add-gcal-tool — a sibling of /add-gmail-tool that installs @cocal/google-calendar-mcp with the same OneCLI stub-file pattern. Skill applies the Dockerfile + TOOL_ALLOWLIST changes at install time; trunk stays clean so users who never run the skill don't carry the calendar MCP in their image. Dropped the Phase 5 dry-run section since it hardcoded a per-install image tag slug and duplicated Phase 4's live agent test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-gcal-tool/SKILL.md | 210 ++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .claude/skills/add-gcal-tool/SKILL.md diff --git a/.claude/skills/add-gcal-tool/SKILL.md b/.claude/skills/add-gcal-tool/SKILL.md new file mode 100644 index 0000000..5751933 --- /dev/null +++ b/.claude/skills/add-gcal-tool/SKILL.md @@ -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__`, 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//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//.calendar-mcp", + "containerPath": ".calendar-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/`). + +**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. From fc375ca72b28ce2582e9ea5a0de492d1cef04a5a Mon Sep 17 00:00:00 2001 From: grtwrn Date: Thu, 23 Apr 2026 21:04:15 -0400 Subject: [PATCH 16/19] fix(register): wire channels with correct engage fields, skip prefix for native IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup/register.ts had two bugs that prevented new channels from being registered via `/manage-channels`: 1. createMessagingGroupAgent was called with the legacy field names `trigger_rules` and `response_scope`. The SQL INSERT expects `engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy` (migration 010). Every register call failed with `RangeError: Missing named parameter "engage_mode"` after the agent and messaging group were partially created — leaving an orphaned pair. Now mirrors scripts/init-first-agent.ts:wireIfMissing: - Groups (is_group=1) default to engage_mode='mention' (bot only responds when addressed). - DMs (is_group=0) default to engage_mode='pattern' with '.' (respond to every message). - An explicit --trigger overrides the pattern regex. 2. The "normalize platform_id" block unconditionally prefixed ":" even for native IDs like WhatsApp JIDs ("120363408974444974@g.us"), iMessage emails ("user@example.com"), or Signal phones ("+15551234567") / Signal groups ("group:abc"). But the router (src/router.ts:158) looks up messaging_groups by the raw event.platformId from the adapter, which for these native adapters never has a prefix. So the prefixed row was never matched — the message was silently dropped with no "Message routed" log. Extracted scripts/init-first-agent.ts:namespacedPlatformId into src/platform-id.ts so both setup paths use the same heuristic (skip the prefix for IDs containing '@', starting with '+', or starting with 'group:'). Prevents future drift between the two paths. Tested by: re-running `setup/index.ts --step register` for a WhatsApp group JID, confirming the row is created with correct engage fields and matching platform_id, then sending a test message and observing "Message routed" with the right agent group. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/init-first-agent.ts | 27 +-------------------------- setup/register.ts | 22 +++++++++++++--------- src/platform-id.ts | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 src/platform-id.ts diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index fc61b9c..61a17d6 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -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:" for group chats. WhatsApp sends JIDs containing - * '@' (@s.whatsapp.net, @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)}`; } diff --git a/setup/register.ts b/setup/register.ts index ff194fc..7bd5ae3 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -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 { 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,8 +166,13 @@ export async function run(args: string[]): Promise { if (!existing) { newlyWired = true; const mgaId = generateId('mga'); - const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention'; - const engagePattern = parsed.trigger ? parsed.trigger : (!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, @@ -177,7 +181,7 @@ export async function run(args: string[]): Promise { engage_pattern: engagePattern, sender_scope: 'all', ignored_message_policy: 'drop', - session_mode: parsed.sessionMode, + session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared', priority: 0, created_at: new Date().toISOString(), }); diff --git a/src/platform-id.ts b/src/platform-id.ts new file mode 100644 index 0000000..1c49325 --- /dev/null +++ b/src/platform-id.ts @@ -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:' 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}`; +} From 226fc9379595b97eb1746200bffc9ed396ca0ade Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:13:32 +0000 Subject: [PATCH 17/19] =?UTF-8?q?docs:=20update=20token=20count=20to=20132?= =?UTF-8?q?k=20tokens=20=C2=B7=2066%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index fd8a436..0dfb9a2 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 130k tokens, 65% of context window + + 132k tokens, 66% of context window @@ -15,8 +15,8 @@ tokens - - 130k + + 132k From 15a6950b5b74f65afe3f86c85882323204369d8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:13:34 +0000 Subject: [PATCH 18/19] chore: bump version to 2.0.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5454aa4..c3a3d85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.11", + "version": "2.0.12", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 8d8522202a0604d187f9da132c59f386e3c489a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:20:58 +0000 Subject: [PATCH 19/19] chore: bump version to 2.0.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c3a3d85..6029e0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.12", + "version": "2.0.13", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0",