refactor: restructure add-gmail skill for new skill architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Fritzzzz
2026-02-24 22:29:34 +02:00
parent 1c31726c63
commit 41e2424856
14 changed files with 2714 additions and 611 deletions

View File

@@ -1,111 +1,142 @@
---
name: add-gmail
description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
---
# Add Gmail Integration # Add Gmail Integration
This skill adds Gmail capabilities to NanoClaw. It can be configured in two modes: This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox.
1. **Tool Mode** - Agent can read/send emails, but only when triggered from WhatsApp ## Phase 1: Pre-flight
2. **Channel Mode** - Emails can trigger the agent, schedule tasks, and receive email replies
## Initial Questions ### Check if already applied
Use `AskUserQuestion` to determine the configuration: Read `.nanoclaw/state.yaml`. If `gmail` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
AskUserQuestion: How do you want to use Gmail with NanoClaw? ### Ask the user
- **Tool Mode** - Agent can read/send emails when triggered from WhatsApp (simpler setup)
- **Channel Mode** - Emails can trigger the agent, schedule tasks, and receive email replies (requires polling)
Store their choice and proceed to the appropriate section. Use `AskUserQuestion`:
--- AskUserQuestion: Should incoming emails be able to trigger the agent?
## Prerequisites (Both Modes) - **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically
- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added.
### 1. Check Existing Gmail Setup ## Phase 2: Apply Code Changes
First, check if Gmail is already configured: ### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
```bash
npx tsx scripts/apply-skill.ts --init
```
### Path A: Tool-only (user chose "No")
Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, config flags, index.ts channel logic, routing tests, `googleapis` dependency).
#### 1. Mount Gmail credentials in container
Apply the changes described in `modify/src/container-runner.ts.intent.md` to `src/container-runner.ts`: import `os`, add a conditional read-write mount of `~/.gmail-mcp` to `/home/node/.gmail-mcp` in `buildVolumeMounts()` after the session mounts.
#### 2. Add Gmail MCP server to agent runner
Apply the changes described in `modify/container/agent-runner/src/index.ts.intent.md` to `container/agent-runner/src/index.ts`: add `gmail` MCP server (`npx -y @gongrzhe/server-gmail-autoauth-mcp`) and `'mcp__gmail__*'` to `allowedTools`.
#### 3. Record in state
Add `gmail` to `.nanoclaw/state.yaml` under `applied_skills` with `mode: tool-only`.
#### 4. Validate
```bash
npm run build
```
Build must be clean before proceeding. Skip to Phase 3.
### Path B: Channel mode (user chose "Yes")
Run the full skills engine to apply all code changes:
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-gmail
```
This deterministically:
- Adds `src/channels/gmail.ts` (GmailChannel class implementing Channel interface)
- Adds `src/channels/gmail.test.ts` (unit tests)
- Three-way merges Gmail support into `src/index.ts` (conditional GmailChannel creation)
- Three-way merges Gmail config into `src/config.ts` (`GMAIL_CHANNEL_ENABLED`)
- Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp)
- Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp)
- Three-way merges Gmail JID tests into `src/routing.test.ts`
- Installs the `googleapis` npm dependency
- Updates `.env.example` with `GMAIL_CHANNEL_ENABLED`
- Records the application in `.nanoclaw/state.yaml`
If the apply reports merge conflicts, read the intent files:
- `modify/src/index.ts.intent.md` — what changed and invariants for index.ts
- `modify/src/config.ts.intent.md` — what changed for config.ts
- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
- `modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner
#### Add email handling instructions
Append the following to `groups/main/CLAUDE.md` (before the formatting section):
```markdown
## Email Notifications
When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
```
#### Validate
```bash
npm test
npm run build
```
All tests must pass (including the new gmail tests) and build must be clean before proceeding.
## Phase 3: Setup
### Check existing Gmail credentials
```bash ```bash
ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found" ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found"
``` ```
If `credentials.json` exists, skip to "Verify Gmail Access" below. If `credentials.json` already exists, skip to "Configure environment" below.
### 2. Create Gmail Config Directory ### GCP Project Setup
```bash
mkdir -p ~/.gmail-mcp
```
### 3. GCP Project Setup
**USER ACTION REQUIRED**
Tell the user: Tell the user:
> I need you to set up Google Cloud OAuth credentials. I'll walk you through it: > I need you to set up Google Cloud OAuth credentials:
> >
> 1. Open https://console.cloud.google.com in your browser > 1. Open https://console.cloud.google.com — create a new project or select existing
> 2. Create a new project (or select existing) - click the project dropdown at the top > 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable**
> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID**
Wait for user confirmation, then continue: > - If prompted for consent screen: choose "External", fill in app name and email, save
> - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail")
> 3. Now enable the Gmail API: > 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json`
> - In the left sidebar, go to **APIs & Services → Library**
> - Search for "Gmail API"
> - Click on it, then click **Enable**
Wait for user confirmation, then continue:
> 4. Now create OAuth credentials:
> - Go to **APIs & Services → Credentials** (in the left sidebar)
> - Click **+ CREATE CREDENTIALS** at the top
> - Select **OAuth client ID**
> - If prompted for consent screen, choose "External", fill in app name (e.g., "NanoClaw"), your email, and save
> - For Application type, select **Desktop app**
> - Name it anything (e.g., "NanoClaw Gmail")
> - Click **Create**
Wait for user confirmation, then continue:
> 5. Download the credentials:
> - Click **DOWNLOAD JSON** on the popup (or find it in the credentials list and click the download icon)
> - Save it as `gcp-oauth.keys.json`
> >
> Where did you save the file? (Give me the full path, or just paste the file contents here) > Where did you save the file? (Give me the full path, or paste the file contents here)
If user provides a path, copy it: If user provides a path, copy it:
```bash ```bash
mkdir -p ~/.gmail-mcp
cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json
``` ```
If user pastes the JSON content, write it directly: If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`.
```bash ### OAuth Authorization
cat > ~/.gmail-mcp/gcp-oauth.keys.json << 'EOF'
{paste the JSON here}
EOF
```
Verify the file is valid JSON:
```bash
cat ~/.gmail-mcp/gcp-oauth.keys.json | head -5
```
### 4. OAuth Authorization
**USER ACTION REQUIRED**
Tell the user: Tell the user:
> I'm going to run the Gmail authorization. A browser window will open asking you to sign in to Google and grant access. > I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps.
>
> **Important:** If you see a warning that the app isn't verified, click "Advanced" then "Go to [app name] (unsafe)" - this is normal for personal OAuth apps.
Run the authorization: Run the authorization:
@@ -113,591 +144,116 @@ Run the authorization:
npx -y @gongrzhe/server-gmail-autoauth-mcp auth npx -y @gongrzhe/server-gmail-autoauth-mcp auth
``` ```
If that doesn't work (some versions don't have an auth subcommand), run it and let it prompt: If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`.
### Configure environment
**Channel mode only** — add to `.env`:
```
GMAIL_CHANNEL_ENABLED=true
```
Sync to container environment:
```bash ```bash
timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true mkdir -p data/env && cp .env data/env/env
``` ```
Tell user: ### Build and restart
> Complete the authorization in your browser. The window should close automatically when done. Let me know when you've authorized.
### 5. Verify Gmail Access Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server):
Check that credentials were saved:
```bash ```bash
if [ -f ~/.gmail-mcp/credentials.json ]; then rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
echo "Gmail authorization successful!"
ls -la ~/.gmail-mcp/
else
echo "ERROR: credentials.json not found - authorization may have failed"
fi
``` ```
Test the connection by listing labels (quick sanity check): Rebuild the container (agent-runner changed):
```bash
echo '{"method": "tools/list"}' | timeout 10 npx -y @gongrzhe/server-gmail-autoauth-mcp 2>/dev/null | head -20 || echo "MCP responded (check output above)"
```
If everything works, proceed to implementation.
---
## Tool Mode Implementation
For Tool Mode, integrate Gmail MCP into the agent runner. Execute these changes directly.
### Step 1: Add Gmail MCP to Agent Runner
Read `container/agent-runner/src/index.ts` and find the `mcpServers` config in the `query()` call.
Add `gmail` to the `mcpServers` object:
```typescript
gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] }
```
Find the `allowedTools` array and add Gmail tools:
```typescript
'mcp__gmail__*'
```
The result should look like:
```typescript
mcpServers: {
nanoclaw: ipcMcp,
gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] }
},
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'mcp__nanoclaw__*',
'mcp__gmail__*'
],
```
### Step 2: Mount Gmail Credentials in Container
Read `src/container-runner.ts` and find the `buildVolumeMounts` function.
Add this mount block (after the `.claude` mount is a good location):
```typescript
// Gmail credentials directory
const gmailDir = path.join(homeDir, '.gmail-mcp');
if (fs.existsSync(gmailDir)) {
mounts.push({
hostPath: gmailDir,
containerPath: '/home/node/.gmail-mcp',
readonly: false // MCP may need to refresh tokens
});
}
```
### Step 3: Update Group Memory
Append to `groups/CLAUDE.md` (the global memory file):
```markdown
## Email (Gmail)
You have access to Gmail via MCP tools:
- `mcp__gmail__search_emails` - Search emails with query
- `mcp__gmail__get_email` - Get full email content by ID
- `mcp__gmail__send_email` - Send an email
- `mcp__gmail__draft_email` - Create a draft
- `mcp__gmail__list_labels` - List available labels
Example: "Check my unread emails from today" or "Send an email to john@example.com about the meeting"
```
Also append the same section to `groups/main/CLAUDE.md`.
### Step 4: Rebuild and Restart
Run these commands:
```bash ```bash
cd container && ./build.sh cd container && ./build.sh
``` ```
Wait for container build to complete, then: Then compile and restart:
```bash
cd .. && npm run build
```
Wait for TypeScript compilation, then restart the service:
```bash ```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw # Linux: systemctl --user restart nanoclaw
``` ```
Check that it started: ## Phase 4: Verify
```bash ### Test tool access (both modes)
sleep 2 && launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
```
### Step 5: Test Gmail Integration
Tell the user: Tell the user:
> Gmail integration is set up! Test it by sending this message in your WhatsApp main channel: > Gmail is connected! Send this in your WhatsApp main channel:
> >
> `@Andy check my recent emails` > `@Andy check my recent emails` or `@Andy list my Gmail labels`
>
> Or:
>
> `@Andy list my Gmail labels`
Watch the logs for any errors: ### Test channel mode (Channel mode only)
Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`.
Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters.
### Check logs if needed
```bash ```bash
tail -f logs/nanoclaw.log tail -f logs/nanoclaw.log
``` ```
---
## Channel Mode Implementation
Channel Mode includes everything from Tool Mode, plus email polling and routing.
### Additional Questions for Channel Mode
Use `AskUserQuestion` to configure email triggering:
AskUserQuestion: How should the agent be triggered from email?
- **Specific Label** - Create a Gmail label (e.g., "NanoClaw"), emails with this label trigger the agent
- **Email Address Pattern** - Emails to a specific address pattern (e.g., andy+task@gmail.com) via plus-addressing
- **Subject Prefix** - Emails with a subject starting with a keyword (e.g., "[Andy]")
AskUserQuestion: How should email conversations be grouped?
- **Per Email Thread** - Each email thread gets its own conversation context
- **Per Sender** - All emails from the same sender share context
- **Single Context** - All emails share the main group context
Store their choices for implementation.
### Step 1: Complete Tool Mode First
Complete all Tool Mode steps above before continuing. Verify Gmail tools work by having the user test `@Andy check my recent emails`.
### Step 2: Add Email Polling Configuration
Read `src/types.ts` and add this interface:
```typescript
export interface EmailChannelConfig {
enabled: boolean;
triggerMode: 'label' | 'address' | 'subject';
triggerValue: string; // Label name, address pattern, or subject prefix
contextMode: 'thread' | 'sender' | 'single';
pollIntervalMs: number;
replyPrefix?: string; // Optional prefix for replies
}
```
Read `src/config.ts` and add this configuration (customize values based on user's earlier answers):
```typescript
export const EMAIL_CHANNEL: EmailChannelConfig = {
enabled: true,
triggerMode: 'label', // or 'address' or 'subject'
triggerValue: 'NanoClaw', // the label name, address pattern, or prefix
contextMode: 'thread',
pollIntervalMs: 60000, // Check every minute
replyPrefix: '[Andy] '
};
```
### Step 3: Add Email State Tracking
Read `src/db.ts` and add these functions for tracking processed emails:
```typescript
// Track processed emails to avoid duplicates
export function initEmailTable(): void {
db.exec(`
CREATE TABLE IF NOT EXISTS processed_emails (
message_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
sender TEXT NOT NULL,
subject TEXT,
processed_at TEXT NOT NULL,
response_sent INTEGER DEFAULT 0
)
`);
}
export function isEmailProcessed(messageId: string): boolean {
const row = db.prepare('SELECT 1 FROM processed_emails WHERE message_id = ?').get(messageId);
return !!row;
}
export function markEmailProcessed(messageId: string, threadId: string, sender: string, subject: string): void {
db.prepare(`
INSERT OR REPLACE INTO processed_emails (message_id, thread_id, sender, subject, processed_at)
VALUES (?, ?, ?, ?, ?)
`).run(messageId, threadId, sender, subject, new Date().toISOString());
}
export function markEmailResponded(messageId: string): void {
db.prepare('UPDATE processed_emails SET response_sent = 1 WHERE message_id = ?').run(messageId);
}
```
Also find the `initDatabase()` function in `src/db.ts` and add a call to `initEmailTable()`.
### Step 4: Create Email Channel Module
Create a new file `src/email-channel.ts` with this content:
```typescript
import { EMAIL_CHANNEL } from './config.js';
import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js';
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } }
});
interface EmailMessage {
id: string;
threadId: string;
from: string;
subject: string;
body: string;
date: string;
}
// Gmail MCP client functions (call via subprocess or import the MCP directly)
// These would invoke the Gmail MCP tools
export async function checkForNewEmails(): Promise<EmailMessage[]> {
// Build query based on trigger mode
let query: string;
switch (EMAIL_CHANNEL.triggerMode) {
case 'label':
query = `label:${EMAIL_CHANNEL.triggerValue} is:unread`;
break;
case 'address':
query = `to:${EMAIL_CHANNEL.triggerValue} is:unread`;
break;
case 'subject':
query = `subject:${EMAIL_CHANNEL.triggerValue} is:unread`;
break;
}
// This requires calling Gmail MCP's search_emails tool
// Implementation depends on how you want to invoke MCP from Node
// Option 1: Use @anthropic-ai/claude-agent-sdk with just gmail MCP
// Option 2: Run npx gmail MCP as subprocess and parse output
// Option 3: Import gmail-autoauth-mcp directly
// Placeholder - implement based on preference
return [];
}
export async function sendEmailReply(
threadId: string,
to: string,
subject: string,
body: string
): Promise<void> {
// Call Gmail MCP's send_email tool with in_reply_to for threading
// Prefix subject with replyPrefix if configured
const replySubject = subject.startsWith('Re:')
? subject
: `Re: ${subject}`;
const prefixedBody = EMAIL_CHANNEL.replyPrefix
? `${EMAIL_CHANNEL.replyPrefix}${body}`
: body;
// Implementation: invoke Gmail MCP send_email
}
export function getContextKey(email: EmailMessage): string {
switch (EMAIL_CHANNEL.contextMode) {
case 'thread':
return `email-thread-${email.threadId}`;
case 'sender':
return `email-sender-${email.from.toLowerCase()}`;
case 'single':
return 'email-main';
}
}
```
### Step 5: Add Email Polling to Main Loop
Read `src/index.ts` and add the email polling infrastructure. First, add these imports at the top:
```typescript
import { checkForNewEmails, sendEmailReply, getContextKey } from './email-channel.js';
import { EMAIL_CHANNEL } from './config.js';
import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js';
```
Then add the `startEmailLoop` function:
```typescript
async function startEmailLoop(): Promise<void> {
if (!EMAIL_CHANNEL.enabled) {
logger.info('Email channel disabled');
return;
}
logger.info(`Email channel running (trigger: ${EMAIL_CHANNEL.triggerMode}:${EMAIL_CHANNEL.triggerValue})`);
while (true) {
try {
const emails = await checkForNewEmails();
for (const email of emails) {
if (isEmailProcessed(email.id)) continue;
logger.info({ from: email.from, subject: email.subject }, 'Processing email');
markEmailProcessed(email.id, email.threadId, email.from, email.subject);
// Determine which group/context to use
const contextKey = getContextKey(email);
// Build prompt with email content
const prompt = `<email>
<from>${email.from}</from>
<subject>${email.subject}</subject>
<body>${email.body}</body>
</email>
Respond to this email. Your response will be sent as an email reply.`;
// Run agent with email context
// You'll need to create a registered group for email or use a special handler
const response = await runEmailAgent(contextKey, prompt, email);
if (response) {
await sendEmailReply(email.threadId, email.from, email.subject, response);
markEmailResponded(email.id);
logger.info({ to: email.from }, 'Email reply sent');
}
}
} catch (err) {
logger.error({ err }, 'Error in email loop');
}
await new Promise(resolve => setTimeout(resolve, EMAIL_CHANNEL.pollIntervalMs));
}
}
```
Then add `startEmailLoop()` in the `main()` function, after `startMessageLoop()`:
```typescript
// In main(), after startMessageLoop():
startEmailLoop();
```
### Step 6: Implement Email Agent Runner
Add this function to `src/index.ts` (or create a separate `src/email-agent.ts` if preferred):
```typescript
async function runEmailAgent(
contextKey: string,
prompt: string,
email: EmailMessage
): Promise<string | null> {
// Email uses either:
// 1. A dedicated "email" group folder
// 2. Or dynamic folders per thread/sender
const groupFolder = EMAIL_CHANNEL.contextMode === 'single'
? 'main' // Use main group context
: `email/${contextKey}`; // Isolated email context
// Ensure folder exists
const groupDir = path.join(GROUPS_DIR, groupFolder);
fs.mkdirSync(groupDir, { recursive: true });
// Create minimal registered group for email
const emailGroup: RegisteredGroup = {
name: contextKey,
folder: groupFolder,
trigger: '', // No trigger for email
added_at: new Date().toISOString()
};
// Use existing runContainerAgent
const output = await runContainerAgent(emailGroup, {
prompt,
sessionId: sessions[groupFolder],
groupFolder,
chatJid: `email:${email.from}`, // Use email: prefix for JID
isMain: false,
isScheduledTask: false
});
if (output.newSessionId) {
sessions[groupFolder] = output.newSessionId;
setSession(groupFolder, output.newSessionId);
}
return output.status === 'success' ? output.result : null;
}
```
### Step 7: Update IPC for Email Responses (Optional)
If you want the agent to be able to send emails proactively from within a session, read `container/agent-runner/src/ipc-mcp.ts` and add this tool:
```typescript
// Add to the MCP tools
{
name: 'send_email_reply',
description: 'Send an email reply in the current thread',
inputSchema: {
type: 'object',
properties: {
body: { type: 'string', description: 'Email body content' }
},
required: ['body']
}
}
```
Then add handling in `src/ipc.ts` in the `processTaskIpc` function or create a new IPC handler for email actions.
### Step 8: Create Email Group Memory
Create the email group directory and memory file:
```bash
mkdir -p groups/email
```
Write `groups/email/CLAUDE.md`:
```markdown
# Email Channel
You are responding to emails. Your responses will be sent as email replies.
## Guidelines
- Be professional and clear
- Keep responses concise but complete
- Use proper email formatting (greetings, sign-off)
- If the email requires action you can't take, explain what the user should do
## Context
Each email thread or sender (depending on configuration) has its own conversation history.
```
### Step 9: Rebuild and Test
Rebuild the container (required since agent-runner changed):
```bash
cd container && ./build.sh
```
Wait for build to complete, then compile TypeScript:
```bash
cd .. && npm run build
```
Restart the service:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Verify it started and check for email channel startup message:
```bash
sleep 3 && tail -20 logs/nanoclaw.log | grep -i email
```
Tell the user:
> Email channel is now active! Test it by sending an email that matches your trigger:
> - **Label mode:** Apply the "${triggerValue}" label to any email
> - **Address mode:** Send an email to ${triggerValue}
> - **Subject mode:** Send an email with subject starting with "${triggerValue}"
>
> The agent should process it within a minute and send a reply.
Monitor for the test:
```bash
tail -f logs/nanoclaw.log | grep -E "(email|Email)"
```
---
## Troubleshooting ## Troubleshooting
### Gmail MCP not responding ### Gmail connection not responding
Test directly:
```bash ```bash
# Test Gmail MCP directly
npx -y @gongrzhe/server-gmail-autoauth-mcp npx -y @gongrzhe/server-gmail-autoauth-mcp
``` ```
### OAuth token expired ### OAuth token expired
Re-authorize:
```bash ```bash
# Re-authorize
rm ~/.gmail-mcp/credentials.json rm ~/.gmail-mcp/credentials.json
npx -y @gongrzhe/server-gmail-autoauth-mcp npx -y @gongrzhe/server-gmail-autoauth-mcp
``` ```
### Emails not being detected
- Check the trigger configuration matches your test email
- Verify the label exists (for label mode)
- Check `processed_emails` table for already-processed emails
### Container can't access Gmail ### Container can't access Gmail
- Verify `~/.gmail-mcp` is mounted in container
- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount
- Check container logs: `cat groups/main/logs/container-*.log | tail -50` - Check container logs: `cat groups/main/logs/container-*.log | tail -50`
--- ### Emails not being detected (Channel Mode only)
## Removing Gmail Integration - Check `GMAIL_CHANNEL_ENABLED=true` in `.env` AND `data/env/env`
- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`)
- Check logs for Gmail polling errors
To remove Gmail entirely: ## Removal
1. Remove from `container/agent-runner/src/index.ts`: ### Tool-only mode
- Delete `gmail` from `mcpServers`
- Remove `mcp__gmail__*` from `allowedTools`
2. Remove from `src/container-runner.ts`: 1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
- Delete the `~/.gmail-mcp` mount block 2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
3. Remove `gmail` from `.nanoclaw/state.yaml`
4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
3. Remove from `src/index.ts` (Channel Mode only): ### Channel mode
- Delete `startEmailLoop()` call
- Delete email-related imports
4. Delete `src/email-channel.ts` (if created) 1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts`
2. Remove `GmailChannel` import and creation from `src/index.ts`
5. Remove Gmail sections from `groups/*/CLAUDE.md` 3. Remove Gmail config (`GMAIL_CHANNEL_ENABLED`) from `src/config.ts`
4. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
6. Rebuild: 5. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
```bash 6. Remove Gmail JID tests from `src/routing.test.ts`
cd container && ./build.sh && cd .. 7. Uninstall: `npm uninstall googleapis`
npm run build 8. Remove env var from `.env` and `data/env/env`
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS 9. Remove `gmail` from `.nanoclaw/state.yaml`
# Linux: systemctl --user restart nanoclaw 10. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
``` 11. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GmailChannel, GmailChannelOpts } from './gmail.js';
function makeOpts(overrides?: Partial<GmailChannelOpts>): GmailChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: () => ({}),
...overrides,
};
}
describe('GmailChannel', () => {
let channel: GmailChannel;
beforeEach(() => {
channel = new GmailChannel(makeOpts());
});
describe('ownsJid', () => {
it('returns true for gmail: prefixed JIDs', () => {
expect(channel.ownsJid('gmail:abc123')).toBe(true);
expect(channel.ownsJid('gmail:thread-id-456')).toBe(true);
});
it('returns false for non-gmail JIDs', () => {
expect(channel.ownsJid('12345@g.us')).toBe(false);
expect(channel.ownsJid('tg:123')).toBe(false);
expect(channel.ownsJid('dc:456')).toBe(false);
expect(channel.ownsJid('user@s.whatsapp.net')).toBe(false);
});
});
describe('name', () => {
it('is gmail', () => {
expect(channel.name).toBe('gmail');
});
});
describe('isConnected', () => {
it('returns false before connect', () => {
expect(channel.isConnected()).toBe(false);
});
});
describe('disconnect', () => {
it('sets connected to false', async () => {
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
});
describe('constructor options', () => {
it('accepts custom poll interval', () => {
const ch = new GmailChannel(makeOpts(), 30000);
expect(ch.name).toBe('gmail');
});
it('defaults to unread query when no filter configured', () => {
const ch = new GmailChannel(makeOpts());
const query = (ch as unknown as { buildQuery: () => string }).buildQuery();
expect(query).toBe('is:unread category:primary');
});
it('defaults with no options provided', () => {
const ch = new GmailChannel(makeOpts());
expect(ch.name).toBe('gmail');
});
});
});

View File

@@ -0,0 +1,326 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { google, gmail_v1 } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import { ASSISTANT_NAME, MAIN_GROUP_FOLDER } from '../config.js';
import { logger } from '../logger.js';
import {
Channel,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
} from '../types.js';
export interface GmailChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
interface ThreadMeta {
sender: string;
senderName: string;
subject: string;
messageId: string; // RFC 2822 Message-ID for In-Reply-To
}
export class GmailChannel implements Channel {
name = 'gmail';
private oauth2Client: OAuth2Client | null = null;
private gmail: gmail_v1.Gmail | null = null;
private opts: GmailChannelOpts;
private pollIntervalMs: number;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private processedIds = new Set<string>();
private threadMeta = new Map<string, ThreadMeta>();
private userEmail = '';
constructor(
opts: GmailChannelOpts,
pollIntervalMs = 60000,
) {
this.opts = opts;
this.pollIntervalMs = pollIntervalMs;
}
async connect(): Promise<void> {
const credDir = path.join(os.homedir(), '.gmail-mcp');
const keysPath = path.join(credDir, 'gcp-oauth.keys.json');
const tokensPath = path.join(credDir, 'credentials.json');
if (!fs.existsSync(keysPath) || !fs.existsSync(tokensPath)) {
throw new Error(
'Gmail credentials not found in ~/.gmail-mcp/. Run Gmail OAuth setup first.',
);
}
const keys = JSON.parse(fs.readFileSync(keysPath, 'utf-8'));
const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
const clientConfig = keys.installed || keys.web || keys;
const { client_id, client_secret, redirect_uris } = clientConfig;
this.oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris?.[0],
);
this.oauth2Client.setCredentials(tokens);
// Persist refreshed tokens
this.oauth2Client.on('tokens', (newTokens) => {
try {
const current = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
Object.assign(current, newTokens);
fs.writeFileSync(tokensPath, JSON.stringify(current, null, 2));
logger.debug('Gmail OAuth tokens refreshed');
} catch (err) {
logger.warn({ err }, 'Failed to persist refreshed Gmail tokens');
}
});
this.gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
// Verify connection
const profile = await this.gmail.users.getProfile({ userId: 'me' });
this.userEmail = profile.data.emailAddress || '';
logger.info(
{ email: this.userEmail },
'Gmail channel connected',
);
// Start polling
this.pollTimer = setInterval(
() => this.pollForMessages().catch((err) =>
logger.error({ err }, 'Gmail poll error'),
),
this.pollIntervalMs,
);
// Initial poll
await this.pollForMessages();
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.gmail) {
logger.warn('Gmail not initialized');
return;
}
const threadId = jid.replace(/^gmail:/, '');
const meta = this.threadMeta.get(threadId);
if (!meta) {
logger.warn({ jid }, 'No thread metadata for reply, cannot send');
return;
}
const subject = meta.subject.startsWith('Re:')
? meta.subject
: `Re: ${meta.subject}`;
const headers = [
`To: ${meta.sender}`,
`From: ${this.userEmail}`,
`Subject: ${subject}`,
`In-Reply-To: ${meta.messageId}`,
`References: ${meta.messageId}`,
'Content-Type: text/plain; charset=utf-8',
'',
text,
].join('\r\n');
const encodedMessage = Buffer.from(headers)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
try {
await this.gmail.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
threadId,
},
});
logger.info({ to: meta.sender, threadId }, 'Gmail reply sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send Gmail reply');
}
}
isConnected(): boolean {
return this.gmail !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith('gmail:');
}
async disconnect(): Promise<void> {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this.gmail = null;
this.oauth2Client = null;
logger.info('Gmail channel stopped');
}
// --- Private ---
private buildQuery(): string {
return 'is:unread category:primary';
}
private async pollForMessages(): Promise<void> {
if (!this.gmail) return;
try {
const query = this.buildQuery();
const res = await this.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: 10,
});
const messages = res.data.messages || [];
for (const stub of messages) {
if (!stub.id || this.processedIds.has(stub.id)) continue;
this.processedIds.add(stub.id);
await this.processMessage(stub.id);
}
// Cap processed ID set to prevent unbounded growth
if (this.processedIds.size > 5000) {
const ids = [...this.processedIds];
this.processedIds = new Set(ids.slice(ids.length - 2500));
}
} catch (err) {
logger.error({ err }, 'Gmail poll failed');
}
}
private async processMessage(messageId: string): Promise<void> {
if (!this.gmail) return;
const msg = await this.gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'full',
});
const headers = msg.data.payload?.headers || [];
const getHeader = (name: string) =>
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
const from = getHeader('From');
const subject = getHeader('Subject');
const rfc2822MessageId = getHeader('Message-ID');
const threadId = msg.data.threadId || messageId;
const timestamp = new Date(
parseInt(msg.data.internalDate || '0', 10),
).toISOString();
// Extract sender name and email
const senderMatch = from.match(/^(.+?)\s*<(.+?)>$/);
const senderName = senderMatch ? senderMatch[1].replace(/"/g, '') : from;
const senderEmail = senderMatch ? senderMatch[2] : from;
// Skip emails from self (our own replies)
if (senderEmail === this.userEmail) return;
// Extract body text
const body = this.extractTextBody(msg.data.payload);
if (!body) {
logger.debug({ messageId, subject }, 'Skipping email with no text body');
return;
}
const chatJid = `gmail:${threadId}`;
// Cache thread metadata for replies
this.threadMeta.set(threadId, {
sender: senderEmail,
senderName,
subject,
messageId: rfc2822MessageId,
});
// Store chat metadata for group discovery
this.opts.onChatMetadata(chatJid, timestamp, subject, 'gmail', false);
// Find the main group to deliver the email notification
const groups = this.opts.registeredGroups();
const mainEntry = Object.entries(groups).find(([, g]) => g.folder === MAIN_GROUP_FOLDER);
if (!mainEntry) {
logger.debug({ chatJid, subject }, 'No main group registered, skipping email');
return;
}
const mainJid = mainEntry[0];
const content = `[Email from ${senderName} <${senderEmail}>]\nSubject: ${subject}\n\n${body}`;
this.opts.onMessage(mainJid, {
id: messageId,
chat_jid: mainJid,
sender: senderEmail,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
// Mark as read
try {
await this.gmail.users.messages.modify({
userId: 'me',
id: messageId,
requestBody: { removeLabelIds: ['UNREAD'] },
});
} catch (err) {
logger.warn({ messageId, err }, 'Failed to mark email as read');
}
logger.info(
{ mainJid, from: senderName, subject },
'Gmail email delivered to main group',
);
}
private extractTextBody(
payload: gmail_v1.Schema$MessagePart | undefined,
): string {
if (!payload) return '';
// Direct text/plain body
if (payload.mimeType === 'text/plain' && payload.body?.data) {
return Buffer.from(payload.body.data, 'base64').toString('utf-8');
}
// Multipart: search parts recursively
if (payload.parts) {
// Prefer text/plain
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body?.data) {
return Buffer.from(part.body.data, 'base64').toString('utf-8');
}
}
// Recurse into nested multipart
for (const part of payload.parts) {
const text = this.extractTextBody(part);
if (text) return text;
}
}
return '';
}
}

View File

@@ -0,0 +1,21 @@
skill: gmail
version: 1.0.0
description: "Gmail integration via Google APIs"
core_version: 0.1.0
adds:
- src/channels/gmail.ts
- src/channels/gmail.test.ts
modifies:
- src/index.ts
- src/config.ts
- src/container-runner.ts
- container/agent-runner/src/index.ts
- src/routing.test.ts
structured:
npm_dependencies:
googleapis: "^144.0.0"
env_additions:
- GMAIL_CHANNEL_ENABLED
conflicts: []
depends: []
test: "npx vitest run src/channels/gmail.test.ts"

View File

@@ -0,0 +1,593 @@
/**
* NanoClaw Agent Runner
* Runs inside a container, receives config via stdin, outputs result to stdout
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF, like before)
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
* Files: {type:"message", text:"..."}.json — polled and consumed
* Sentinel: /workspace/ipc/input/_close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
* Multiple results may be emitted (one per agent teams result).
* Final marker after loop ends signals completion.
*/
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url';
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface SessionEntry {
sessionId: string;
fullPath: string;
summary: string;
firstPrompt: string;
}
interface SessionsIndex {
entries: SessionEntry[];
}
interface SDKUserMessage {
type: 'user';
message: { role: 'user'; content: string };
parent_tool_use_id: null;
session_id: string;
}
const IPC_INPUT_DIR = '/workspace/ipc/input';
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
/**
* Push-based async iterable for streaming user messages to the SDK.
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
*/
class MessageStream {
private queue: SDKUserMessage[] = [];
private waiting: (() => void) | null = null;
private done = false;
push(text: string): void {
this.queue.push({
type: 'user',
message: { role: 'user', content: text },
parent_tool_use_id: null,
session_id: '',
});
this.waiting?.();
}
end(): void {
this.done = true;
this.waiting?.();
}
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
while (true) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (this.done) return;
await new Promise<void>(r => { this.waiting = r; });
this.waiting = null;
}
}
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
}
function log(message: string): void {
console.error(`[agent-runner] ${message}`);
}
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
const projectDir = path.dirname(transcriptPath);
const indexPath = path.join(projectDir, 'sessions-index.json');
if (!fs.existsSync(indexPath)) {
log(`Sessions index not found at ${indexPath}`);
return null;
}
try {
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
const entry = index.entries.find(e => e.sessionId === sessionId);
if (entry?.summary) {
return entry.summary;
}
} catch (err) {
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
}
return null;
}
/**
* Archive the full transcript to conversations/ before compaction.
*/
function createPreCompactHook(assistantName?: string): HookCallback {
return async (input, _toolUseId, _context) => {
const preCompact = input as PreCompactHookInput;
const transcriptPath = preCompact.transcript_path;
const sessionId = preCompact.session_id;
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
log('No transcript found for archiving');
return {};
}
try {
const content = fs.readFileSync(transcriptPath, 'utf-8');
const messages = parseTranscript(content);
if (messages.length === 0) {
log('No messages to archive');
return {};
}
const summary = getSessionSummary(sessionId, transcriptPath);
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
const conversationsDir = '/workspace/group/conversations';
fs.mkdirSync(conversationsDir, { recursive: true });
const date = new Date().toISOString().split('T')[0];
const filename = `${date}-${name}.md`;
const filePath = path.join(conversationsDir, filename);
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
fs.writeFileSync(filePath, markdown);
log(`Archived conversation to ${filePath}`);
} catch (err) {
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
}
return {};
};
}
// Secrets to strip from Bash tool subprocess environments.
// These are needed by claude-code for API auth but should never
// be visible to commands Kit runs.
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
function createSanitizeBashHook(): HookCallback {
return async (input, _toolUseId, _context) => {
const preInput = input as PreToolUseHookInput;
const command = (preInput.tool_input as { command?: string })?.command;
if (!command) return {};
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
updatedInput: {
...(preInput.tool_input as Record<string, unknown>),
command: unsetPrefix + command,
},
},
};
};
}
function sanitizeFilename(summary: string): string {
return summary
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
}
function generateFallbackName(): string {
const time = new Date();
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
}
interface ParsedMessage {
role: 'user' | 'assistant';
content: string;
}
function parseTranscript(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = [];
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
const text = typeof entry.message.content === 'string'
? entry.message.content
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
if (text) messages.push({ role: 'user', content: text });
} else if (entry.type === 'assistant' && entry.message?.content) {
const textParts = entry.message.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text);
const text = textParts.join('');
if (text) messages.push({ role: 'assistant', content: text });
}
} catch {
}
}
return messages;
}
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
const now = new Date();
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
const lines: string[] = [];
lines.push(`# ${title || 'Conversation'}`);
lines.push('');
lines.push(`Archived: ${formatDateTime(now)}`);
lines.push('');
lines.push('---');
lines.push('');
for (const msg of messages) {
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
const content = msg.content.length > 2000
? msg.content.slice(0, 2000) + '...'
: msg.content;
lines.push(`**${sender}**: ${content}`);
lines.push('');
}
return lines.join('\n');
}
/**
* Check for _close sentinel.
*/
function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
return true;
}
return false;
}
/**
* Drain all pending IPC input messages.
* Returns messages found, or empty array.
*/
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs.readdirSync(IPC_INPUT_DIR)
.filter(f => f.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
messages.push(data.text);
}
} catch (err) {
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
/**
* Wait for a new IPC message or _close sentinel.
* Returns the messages as a single string, or null if _close.
*/
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (shouldClose()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
/**
* Run a single query and stream results via writeOutput.
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
* allowing agent teams subagents to run to completion.
* Also pipes IPC messages into the stream during the query.
*/
async function runQuery(
prompt: string,
sessionId: string | undefined,
mcpServerPath: string,
containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>,
resumeAt?: string,
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
const stream = new MessageStream();
stream.push(prompt);
// Poll IPC for follow-up messages and _close sentinel during the query
let ipcPolling = true;
let closedDuringQuery = false;
const pollIpcDuringQuery = () => {
if (!ipcPolling) return;
if (shouldClose()) {
log('Close sentinel detected during query, ending stream');
closedDuringQuery = true;
stream.end();
ipcPolling = false;
return;
}
const messages = drainIpcInput();
for (const text of messages) {
log(`Piping IPC message into active query (${text.length} chars)`);
stream.push(text);
}
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
};
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
let newSessionId: string | undefined;
let lastAssistantUuid: string | undefined;
let messageCount = 0;
let resultCount = 0;
// Load global CLAUDE.md as additional system context (shared across all groups)
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
let globalClaudeMd: string | undefined;
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
}
// Discover additional directories mounted at /workspace/extra/*
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
const extraDirs: string[] = [];
const extraBase = '/workspace/extra';
if (fs.existsSync(extraBase)) {
for (const entry of fs.readdirSync(extraBase)) {
const fullPath = path.join(extraBase, entry);
if (fs.statSync(fullPath).isDirectory()) {
extraDirs.push(fullPath);
}
}
}
if (extraDirs.length > 0) {
log(`Additional directories: ${extraDirs.join(', ')}`);
}
for await (const message of query({
prompt: stream,
options: {
cwd: '/workspace/group',
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
resumeSessionAt: resumeAt,
systemPrompt: globalClaudeMd
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
: undefined,
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__nanoclaw__*',
'mcp__gmail__*',
],
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
mcpServers: {
nanoclaw: {
command: 'node',
args: [mcpServerPath],
env: {
NANOCLAW_CHAT_JID: containerInput.chatJid,
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
},
},
gmail: {
command: 'npx',
args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
},
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
},
}
})) {
messageCount++;
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
log(`[msg #${messageCount}] type=${msgType}`);
if (message.type === 'assistant' && 'uuid' in message) {
lastAssistantUuid = (message as { uuid: string }).uuid;
}
if (message.type === 'system' && message.subtype === 'init') {
newSessionId = message.session_id;
log(`Session initialized: ${newSessionId}`);
}
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { task_id: string; status: string; summary: string };
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
}
if (message.type === 'result') {
resultCount++;
const textResult = 'result' in message ? (message as { result?: string }).result : null;
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
writeOutput({
status: 'success',
result: textResult || null,
newSessionId
});
}
}
ipcPolling = false;
log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
return { newSessionId, lastAssistantUuid, closedDuringQuery };
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
// Delete the temp file the entrypoint wrote — it contains secrets
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
});
process.exit(1);
}
// Build SDK env: merge secrets into process.env for the SDK only.
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
const sdkEnv: Record<string, string | undefined> = { ...process.env };
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
sdkEnv[key] = value;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
let sessionId = containerInput.sessionId;
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel from previous container runs
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
// Build initial prompt (drain any pending IPC messages too)
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
prompt += '\n' + pending.join('\n');
}
// Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined;
try {
while (true) {
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;
}
if (queryResult.lastAssistantUuid) {
resumeAt = queryResult.lastAssistantUuid;
}
// If _close was consumed during the query, exit immediately.
// Don't emit a session-update marker (it would reset the host's
// idle timer and cause a 30-min delay before the next _close).
if (queryResult.closedDuringQuery) {
log('Close sentinel consumed during query, exiting');
break;
}
// Emit session update so host can track it
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
log('Query ended, waiting for next IPC message...');
// Wait for the next message or _close sentinel
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting');
break;
}
log(`Got new message (${nextMessage.length} chars), starting new query`);
prompt = nextMessage;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Agent error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
newSessionId: sessionId,
error: errorMessage
});
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,32 @@
# Intent: container/agent-runner/src/index.ts modifications
## What changed
Added Gmail MCP server to the agent's available tools so it can read and send emails.
## Key sections
### mcpServers (inside runQuery → query() call)
- Added: `gmail` MCP server alongside the existing `nanoclaw` server:
```
gmail: {
command: 'npx',
args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
},
```
### allowedTools (inside runQuery → query() call)
- Added: `'mcp__gmail__*'` to allow all Gmail MCP tools
## Invariants
- The `nanoclaw` MCP server configuration is unchanged
- All existing allowed tools are preserved
- The query loop, IPC handling, MessageStream, and all other logic is untouched
- Hooks (PreCompact, sanitize Bash) are unchanged
- Output protocol (markers) is unchanged
## Must-keep
- The `nanoclaw` MCP server with its environment variables
- All existing allowedTools entries
- The hook system (PreCompact, PreToolUse sanitize)
- The IPC input/close sentinel handling
- The MessageStream class and query loop

View File

@@ -0,0 +1,74 @@
import os from 'os';
import path from 'path';
import { readEnvFile } from './env.js';
// Read config values from .env (falls back to process.env).
// Secrets are NOT read here — they stay on disk and are loaded only
// where needed (container-runner.ts) to avoid leaking to child processes.
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'GMAIL_CHANNEL_ENABLED',
]);
export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
// Absolute paths needed for container mounts
const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || os.homedir();
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
export const MOUNT_ALLOWLIST_PATH = path.join(
HOME_DIR,
'.config',
'nanoclaw',
'mount-allowlist.json',
);
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
export const MAIN_GROUP_FOLDER = 'main';
export const CONTAINER_IMAGE =
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
export const CONTAINER_TIMEOUT = parseInt(
process.env.CONTAINER_TIMEOUT || '1800000',
10,
);
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
10,
); // 10MB default
export const IPC_POLL_INTERVAL = 1000;
export const IDLE_TIMEOUT = parseInt(
process.env.IDLE_TIMEOUT || '1800000',
10,
); // 30min default — how long to keep container alive after last result
export const MAX_CONCURRENT_CONTAINERS = Math.max(
1,
parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
);
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const TRIGGER_PATTERN = new RegExp(
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
'i',
);
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default
export const TIMEZONE =
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Gmail configuration
export const GMAIL_CHANNEL_ENABLED =
(process.env.GMAIL_CHANNEL_ENABLED || envConfig.GMAIL_CHANNEL_ENABLED) === 'true';

View File

@@ -0,0 +1,20 @@
# Intent: src/config.ts modifications
## What changed
Added configuration exports for Gmail channel support.
## Key sections
- **readEnvFile call**: Must include `GMAIL_CHANNEL_ENABLED` in the keys array. NanoClaw does NOT load `.env` into `process.env` — all `.env` values must be explicitly requested via `readEnvFile()`.
- **GMAIL_CHANNEL_ENABLED**: Boolean feature flag — when `true`, the Gmail channel is connected and polls for inbound emails. When `false` (default), Gmail is available as a tool only (agent can read/send emails when asked from other channels).
## Invariants
- All existing config exports remain unchanged
- New Gmail keys are added to the `readEnvFile` call alongside existing keys
- New exports are appended at the end of the file
- No existing behavior is modified — Gmail config is additive and minimal
- Both `process.env` and `envConfig` are checked (same pattern as `ASSISTANT_NAME`)
## Must-keep
- All existing exports (`ASSISTANT_NAME`, `POLL_INTERVAL`, `TRIGGER_PATTERN`, etc.)
- The `readEnvFile` pattern — ALL config read from `.env` must go through this function
- The `escapeRegex` helper and `TRIGGER_PATTERN` construction

View File

@@ -0,0 +1,661 @@
/**
* Container Runner for NanoClaw
* Spawns agent execution in containers and handles IPC
*/
import { ChildProcess, exec, spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
TIMEZONE,
} from './config.js';
import { readEnvFile } from './env.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
// Sentinel markers for robust output parsing (must match agent-runner)
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
export interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
}
export interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface VolumeMount {
hostPath: string;
containerPath: string;
readonly: boolean;
}
function buildVolumeMounts(
group: RegisteredGroup,
isMain: boolean,
): VolumeMount[] {
const mounts: VolumeMount[] = [];
const projectRoot = process.cwd();
const homeDir = os.homedir();
const groupDir = resolveGroupFolderPath(group.folder);
if (isMain) {
// Main gets the project root read-only. Writable paths the agent needs
// (group folder, IPC, .claude/) are mounted separately below.
// Read-only prevents the agent from modifying host application code
// (src/, dist/, package.json, etc.) which would bypass the sandbox
// entirely on next restart.
mounts.push({
hostPath: projectRoot,
containerPath: '/workspace/project',
readonly: true,
});
// Main also gets its group folder as the working directory
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
} else {
// Other groups only get their own folder
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
// Global memory directory (read-only for non-main)
// Only directory mounts are supported, not file mounts
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({
hostPath: globalDir,
containerPath: '/workspace/global',
readonly: true,
});
}
}
// Per-group Claude sessions directory (isolated from other groups)
// Each group gets their own .claude/ to prevent cross-group session access
const groupSessionsDir = path.join(
DATA_DIR,
'sessions',
group.folder,
'.claude',
);
fs.mkdirSync(groupSessionsDir, { recursive: true });
const settingsFile = path.join(groupSessionsDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, JSON.stringify({
env: {
// Enable agent swarms (subagent orchestration)
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
// Load CLAUDE.md from additional mounted directories
// https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
// Enable Claude's memory feature (persists user preferences between sessions)
// https://code.claude.com/docs/en/memory#manage-auto-memory
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
}, null, 2) + '\n');
}
// Sync skills from container/skills/ into each group's .claude/skills/
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
const skillsDst = path.join(groupSessionsDir, 'skills');
if (fs.existsSync(skillsSrc)) {
for (const skillDir of fs.readdirSync(skillsSrc)) {
const srcDir = path.join(skillsSrc, skillDir);
if (!fs.statSync(srcDir).isDirectory()) continue;
const dstDir = path.join(skillsDst, skillDir);
fs.cpSync(srcDir, dstDir, { recursive: true });
}
}
mounts.push({
hostPath: groupSessionsDir,
containerPath: '/home/node/.claude',
readonly: false,
});
// Gmail credentials directory (for Gmail MCP inside the container)
const gmailDir = path.join(homeDir, '.gmail-mcp');
if (fs.existsSync(gmailDir)) {
mounts.push({
hostPath: gmailDir,
containerPath: '/home/node/.gmail-mcp',
readonly: false, // MCP may need to refresh OAuth tokens
});
}
// Per-group IPC namespace: each group gets its own IPC directory
// This prevents cross-group privilege escalation via IPC
const groupIpcDir = resolveGroupIpcPath(group.folder);
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
mounts.push({
hostPath: groupIpcDir,
containerPath: '/workspace/ipc',
readonly: false,
});
// Copy agent-runner source into a per-group writable location so agents
// can customize it (add tools, change behavior) without affecting other
// groups. Recompiled on container startup via entrypoint.sh.
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
mounts.push({
hostPath: groupAgentRunnerDir,
containerPath: '/app/src',
readonly: false,
});
// Additional mounts validated against external allowlist (tamper-proof from containers)
if (group.containerConfig?.additionalMounts) {
const validatedMounts = validateAdditionalMounts(
group.containerConfig.additionalMounts,
group.name,
isMain,
);
mounts.push(...validatedMounts);
}
return mounts;
}
/**
* Read allowed secrets from .env for passing to the container via stdin.
* Secrets are never written to disk or mounted as files.
*/
function readSecrets(): Record<string, string> {
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
}
function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
// Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`);
// Run as host user so bind-mounted files are accessible.
// Skip when running as root (uid 0), as the container's node user (uid 1000),
// or when getuid is unavailable (native Windows without WSL).
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
args.push('--user', `${hostUid}:${hostGid}`);
args.push('-e', 'HOME=/home/node');
}
for (const mount of mounts) {
if (mount.readonly) {
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
} else {
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
}
}
args.push(CONTAINER_IMAGE);
return args;
}
export async function runContainerAgent(
group: RegisteredGroup,
input: ContainerInput,
onProcess: (proc: ChildProcess, containerName: string) => void,
onOutput?: (output: ContainerOutput) => Promise<void>,
): Promise<ContainerOutput> {
const startTime = Date.now();
const groupDir = resolveGroupFolderPath(group.folder);
fs.mkdirSync(groupDir, { recursive: true });
const mounts = buildVolumeMounts(group, input.isMain);
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
const containerArgs = buildContainerArgs(mounts, containerName);
logger.debug(
{
group: group.name,
containerName,
mounts: mounts.map(
(m) =>
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
),
containerArgs: containerArgs.join(' '),
},
'Container mount configuration',
);
logger.info(
{
group: group.name,
containerName,
mountCount: mounts.length,
isMain: input.isMain,
},
'Spawning container agent',
);
const logsDir = path.join(groupDir, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
return new Promise((resolve) => {
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
});
onProcess(container, containerName);
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
// Pass secrets via stdin (never written to disk or mounted as files)
input.secrets = readSecrets();
container.stdin.write(JSON.stringify(input));
container.stdin.end();
// Remove secrets from input so they don't appear in logs
delete input.secrets;
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
let parseBuffer = '';
let newSessionId: string | undefined;
let outputChain = Promise.resolve();
container.stdout.on('data', (data) => {
const chunk = data.toString();
// Always accumulate for logging
if (!stdoutTruncated) {
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
if (chunk.length > remaining) {
stdout += chunk.slice(0, remaining);
stdoutTruncated = true;
logger.warn(
{ group: group.name, size: stdout.length },
'Container stdout truncated due to size limit',
);
} else {
stdout += chunk;
}
}
// Stream-parse for output markers
if (onOutput) {
parseBuffer += chunk;
let startIdx: number;
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
if (endIdx === -1) break; // Incomplete pair, wait for more data
const jsonStr = parseBuffer
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
try {
const parsed: ContainerOutput = JSON.parse(jsonStr);
if (parsed.newSessionId) {
newSessionId = parsed.newSessionId;
}
hadStreamingOutput = true;
// Activity detected — reset the hard timeout
resetTimeout();
// Call onOutput for all markers (including null results)
// so idle timers start even for "silent" query completions.
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(
{ group: group.name, error: err },
'Failed to parse streamed output chunk',
);
}
}
}
});
container.stderr.on('data', (data) => {
const chunk = data.toString();
const lines = chunk.trim().split('\n');
for (const line of lines) {
if (line) logger.debug({ container: group.folder }, line);
}
// Don't reset timeout on stderr — SDK writes debug logs continuously.
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
if (stderrTruncated) return;
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
if (chunk.length > remaining) {
stderr += chunk.slice(0, remaining);
stderrTruncated = true;
logger.warn(
{ group: group.name, size: stderr.length },
'Container stderr truncated due to size limit',
);
} else {
stderr += chunk;
}
});
let timedOut = false;
let hadStreamingOutput = false;
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
// graceful _close sentinel has time to trigger before the hard kill fires.
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
const killOnTimeout = () => {
timedOut = true;
logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully');
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
if (err) {
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
container.kill('SIGKILL');
}
});
};
let timeout = setTimeout(killOnTimeout, timeoutMs);
// Reset the timeout whenever there's activity (streaming output)
const resetTimeout = () => {
clearTimeout(timeout);
timeout = setTimeout(killOnTimeout, timeoutMs);
};
container.on('close', (code) => {
clearTimeout(timeout);
const duration = Date.now() - startTime;
if (timedOut) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
fs.writeFileSync(timeoutLog, [
`=== Container Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`Container: ${containerName}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Had Streaming Output: ${hadStreamingOutput}`,
].join('\n'));
// Timeout after output = idle cleanup, not failure.
// The agent already sent its response; this is just the
// container being reaped after the idle period expired.
if (hadStreamingOutput) {
logger.info(
{ group: group.name, containerName, duration, code },
'Container timed out after output (idle cleanup)',
);
outputChain.then(() => {
resolve({
status: 'success',
result: null,
newSessionId,
});
});
return;
}
logger.error(
{ group: group.name, containerName, duration, code },
'Container timed out with no output',
);
resolve({
status: 'error',
result: null,
error: `Container timed out after ${configTimeout}ms`,
});
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(logsDir, `container-${timestamp}.log`);
const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
const logLines = [
`=== Container Run Log ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`IsMain: ${input.isMain}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Stdout Truncated: ${stdoutTruncated}`,
`Stderr Truncated: ${stderrTruncated}`,
``,
];
const isError = code !== 0;
if (isVerbose || isError) {
logLines.push(
`=== Input ===`,
JSON.stringify(input, null, 2),
``,
`=== Container Args ===`,
containerArgs.join(' '),
``,
`=== Mounts ===`,
mounts
.map(
(m) =>
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
)
.join('\n'),
``,
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
stderr,
``,
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
stdout,
);
} else {
logLines.push(
`=== Input Summary ===`,
`Prompt length: ${input.prompt.length} chars`,
`Session ID: ${input.sessionId || 'new'}`,
``,
`=== Mounts ===`,
mounts
.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
.join('\n'),
``,
);
}
fs.writeFileSync(logFile, logLines.join('\n'));
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
if (code !== 0) {
logger.error(
{
group: group.name,
code,
duration,
stderr,
stdout,
logFile,
},
'Container exited with error',
);
resolve({
status: 'error',
result: null,
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
});
return;
}
// Streaming mode: wait for output chain to settle, return completion marker
if (onOutput) {
outputChain.then(() => {
logger.info(
{ group: group.name, duration, newSessionId },
'Container completed (streaming mode)',
);
resolve({
status: 'success',
result: null,
newSessionId,
});
});
return;
}
// Legacy mode: parse the last output marker pair from accumulated stdout
try {
// Extract JSON between sentinel markers for robust parsing
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
let jsonLine: string;
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
jsonLine = stdout
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
} else {
// Fallback: last non-empty line (backwards compatibility)
const lines = stdout.trim().split('\n');
jsonLine = lines[lines.length - 1];
}
const output: ContainerOutput = JSON.parse(jsonLine);
logger.info(
{
group: group.name,
duration,
status: output.status,
hasResult: !!output.result,
},
'Container completed',
);
resolve(output);
} catch (err) {
logger.error(
{
group: group.name,
stdout,
stderr,
error: err,
},
'Failed to parse container output',
);
resolve({
status: 'error',
result: null,
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
});
}
});
container.on('error', (err) => {
clearTimeout(timeout);
logger.error({ group: group.name, containerName, error: err }, 'Container spawn error');
resolve({
status: 'error',
result: null,
error: `Container spawn error: ${err.message}`,
});
});
});
}
export function writeTasksSnapshot(
groupFolder: string,
isMain: boolean,
tasks: Array<{
id: string;
groupFolder: string;
prompt: string;
schedule_type: string;
schedule_value: string;
status: string;
next_run: string | null;
}>,
): void {
// Write filtered tasks to the group's IPC directory
const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true });
// Main sees all tasks, others only see their own
const filteredTasks = isMain
? tasks
: tasks.filter((t) => t.groupFolder === groupFolder);
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
}
export interface AvailableGroup {
jid: string;
name: string;
lastActivity: string;
isRegistered: boolean;
}
/**
* Write available groups snapshot for the container to read.
* Only main group can see all available groups (for activation).
* Non-main groups only see their own registration status.
*/
export function writeGroupsSnapshot(
groupFolder: string,
isMain: boolean,
groups: AvailableGroup[],
registeredJids: Set<string>,
): void {
const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true });
// Main sees all groups; others see nothing (they can't activate groups)
const visibleGroups = isMain ? groups : [];
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
fs.writeFileSync(
groupsFile,
JSON.stringify(
{
groups: visibleGroups,
lastSync: new Date().toISOString(),
},
null,
2,
),
);
}

View File

@@ -0,0 +1,37 @@
# Intent: src/container-runner.ts modifications
## What changed
Added a volume mount for Gmail OAuth credentials (`~/.gmail-mcp/`) so the Gmail MCP server inside the container can authenticate with Google.
## Key sections
### buildVolumeMounts()
- Added: Gmail credentials mount after the `.claude` sessions mount:
```
const gmailDir = path.join(homeDir, '.gmail-mcp');
if (fs.existsSync(gmailDir)) {
mounts.push({
hostPath: gmailDir,
containerPath: '/home/node/.gmail-mcp',
readonly: false, // MCP may need to refresh OAuth tokens
});
}
```
- Uses `os.homedir()` to resolve the home directory
- Mount is read-write because the Gmail MCP server needs to refresh OAuth tokens
- Mount is conditional — only added if `~/.gmail-mcp/` exists on the host
### Imports
- Added: `os` import for `os.homedir()`
## Invariants
- All existing mounts are unchanged
- Mount ordering is preserved (Gmail added after session mounts, before additional mounts)
- The `buildContainerArgs`, `runContainerAgent`, and all other functions are untouched
- Additional mount validation via `validateAdditionalMounts` is unchanged
## Must-keep
- All existing volume mounts (project root, group dir, global, sessions, IPC, agent-runner, additional)
- The mount security model (allowlist validation for additional mounts)
- The `readSecrets` function and stdin-based secret passing
- Container lifecycle (spawn, timeout, output parsing)

View File

@@ -0,0 +1,506 @@
import fs from 'fs';
import path from 'path';
import {
ASSISTANT_NAME,
GMAIL_CHANNEL_ENABLED,
IDLE_TIMEOUT,
MAIN_GROUP_FOLDER,
POLL_INTERVAL,
TRIGGER_PATTERN,
} from './config.js';
import { GmailChannel } from './channels/gmail.js';
import { WhatsAppChannel } from './channels/whatsapp.js';
import {
ContainerOutput,
runContainerAgent,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './container-runner.js';
import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
import {
getAllChats,
getAllRegisteredGroups,
getAllSessions,
getAllTasks,
getMessagesSince,
getNewMessages,
getRouterState,
initDatabase,
setRegisteredGroup,
setRouterState,
setSession,
storeChatMetadata,
storeMessage,
} from './db.js';
import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath } from './group-folder.js';
import { startIpcWatcher } from './ipc.js';
import { findChannel, formatMessages, formatOutbound } from './router.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
// Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
let messageLoopRunning = false;
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
const queue = new GroupQueue();
function loadState(): void {
lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp');
try {
lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
} catch {
logger.warn('Corrupted last_agent_timestamp in DB, resetting');
lastAgentTimestamp = {};
}
sessions = getAllSessions();
registeredGroups = getAllRegisteredGroups();
logger.info(
{ groupCount: Object.keys(registeredGroups).length },
'State loaded',
);
}
function saveState(): void {
setRouterState('last_timestamp', lastTimestamp);
setRouterState(
'last_agent_timestamp',
JSON.stringify(lastAgentTimestamp),
);
}
function registerGroup(jid: string, group: RegisteredGroup): void {
let groupDir: string;
try {
groupDir = resolveGroupFolderPath(group.folder);
} catch (err) {
logger.warn(
{ jid, folder: group.folder, err },
'Rejecting group registration with invalid folder',
);
return;
}
registeredGroups[jid] = group;
setRegisteredGroup(jid, group);
// Create group folder
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
logger.info(
{ jid, name: group.name, folder: group.folder },
'Group registered',
);
}
/**
* Get available groups list for the agent.
* Returns groups ordered by most recent activity.
*/
export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
return chats
.filter((c) => c.jid !== '__group_sync__' && c.is_group)
.map((c) => ({
jid: c.jid,
name: c.name,
lastActivity: c.last_message_time,
isRegistered: registeredJids.has(c.jid),
}));
}
/** @internal - exported for testing */
export function _setRegisteredGroups(groups: Record<string, RegisteredGroup>): void {
registeredGroups = groups;
}
/**
* Process all pending messages for a group.
* Called by the GroupQueue when it's this group's turn.
*/
async function processGroupMessages(chatJid: string): Promise<boolean> {
const group = registeredGroups[chatJid];
if (!group) return true;
const channel = findChannel(channels, chatJid);
if (!channel) {
console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
return true;
}
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
if (missedMessages.length === 0) return true;
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const hasTrigger = missedMessages.some((m) =>
TRIGGER_PATTERN.test(m.content.trim()),
);
if (!hasTrigger) return true;
}
const prompt = formatMessages(missedMessages);
// Advance cursor so the piping path in startMessageLoop won't re-fetch
// these messages. Save the old cursor so we can roll back on error.
const previousCursor = lastAgentTimestamp[chatJid] || '';
lastAgentTimestamp[chatJid] =
missedMessages[missedMessages.length - 1].timestamp;
saveState();
logger.info(
{ group: group.name, messageCount: missedMessages.length },
'Processing messages',
);
// Track idle timer for closing stdin when agent is idle
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const resetIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
queue.closeStdin(chatJid);
}, IDLE_TIMEOUT);
};
await channel.setTyping?.(chatJid, true);
let hadError = false;
let outputSentToUser = false;
const output = await runAgent(group, prompt, chatJid, async (result) => {
// Streaming output callback — called for each agent result
if (result.result) {
const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
// Strip <internal>...</internal> blocks — agent uses these for internal reasoning
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
if (text) {
await channel.sendMessage(chatJid, text);
outputSentToUser = true;
}
// Only reset idle timer on actual results, not session-update markers (result: null)
resetIdleTimer();
}
if (result.status === 'success') {
queue.notifyIdle(chatJid);
}
if (result.status === 'error') {
hadError = true;
}
});
await channel.setTyping?.(chatJid, false);
if (idleTimer) clearTimeout(idleTimer);
if (output === 'error' || hadError) {
// If we already sent output to the user, don't roll back the cursor —
// the user got their response and re-processing would send duplicates.
if (outputSentToUser) {
logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
return true;
}
// Roll back cursor so retries can re-process these messages
lastAgentTimestamp[chatJid] = previousCursor;
saveState();
logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
return false;
}
return true;
}
async function runAgent(
group: RegisteredGroup,
prompt: string,
chatJid: string,
onOutput?: (output: ContainerOutput) => Promise<void>,
): Promise<'success' | 'error'> {
const isMain = group.folder === MAIN_GROUP_FOLDER;
const sessionId = sessions[group.folder];
// Update tasks snapshot for container to read (filtered by group)
const tasks = getAllTasks();
writeTasksSnapshot(
group.folder,
isMain,
tasks.map((t) => ({
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
next_run: t.next_run,
})),
);
// Update available groups snapshot (main group only can see all groups)
const availableGroups = getAvailableGroups();
writeGroupsSnapshot(
group.folder,
isMain,
availableGroups,
new Set(Object.keys(registeredGroups)),
);
// Wrap onOutput to track session ID from streamed results
const wrappedOnOutput = onOutput
? async (output: ContainerOutput) => {
if (output.newSessionId) {
sessions[group.folder] = output.newSessionId;
setSession(group.folder, output.newSessionId);
}
await onOutput(output);
}
: undefined;
try {
const output = await runContainerAgent(
group,
{
prompt,
sessionId,
groupFolder: group.folder,
chatJid,
isMain,
assistantName: ASSISTANT_NAME,
},
(proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
wrappedOnOutput,
);
if (output.newSessionId) {
sessions[group.folder] = output.newSessionId;
setSession(group.folder, output.newSessionId);
}
if (output.status === 'error') {
logger.error(
{ group: group.name, error: output.error },
'Container agent error',
);
return 'error';
}
return 'success';
} catch (err) {
logger.error({ group: group.name, err }, 'Agent error');
return 'error';
}
}
async function startMessageLoop(): Promise<void> {
if (messageLoopRunning) {
logger.debug('Message loop already running, skipping duplicate start');
return;
}
messageLoopRunning = true;
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
while (true) {
try {
const jids = Object.keys(registeredGroups);
const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
if (messages.length > 0) {
logger.info({ count: messages.length }, 'New messages');
// Advance the "seen" cursor for all messages immediately
lastTimestamp = newTimestamp;
saveState();
// Deduplicate by group
const messagesByGroup = new Map<string, NewMessage[]>();
for (const msg of messages) {
const existing = messagesByGroup.get(msg.chat_jid);
if (existing) {
existing.push(msg);
} else {
messagesByGroup.set(msg.chat_jid, [msg]);
}
}
for (const [chatJid, groupMessages] of messagesByGroup) {
const group = registeredGroups[chatJid];
if (!group) continue;
const channel = findChannel(channels, chatJid);
if (!channel) {
console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
continue;
}
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
// For non-main groups, only act on trigger messages.
// Non-trigger messages accumulate in DB and get pulled as
// context when a trigger eventually arrives.
if (needsTrigger) {
const hasTrigger = groupMessages.some((m) =>
TRIGGER_PATTERN.test(m.content.trim()),
);
if (!hasTrigger) continue;
}
// Pull all messages since lastAgentTimestamp so non-trigger
// context that accumulated between triggers is included.
const allPending = getMessagesSince(
chatJid,
lastAgentTimestamp[chatJid] || '',
ASSISTANT_NAME,
);
const messagesToSend =
allPending.length > 0 ? allPending : groupMessages;
const formatted = formatMessages(messagesToSend);
if (queue.sendMessage(chatJid, formatted)) {
logger.debug(
{ chatJid, count: messagesToSend.length },
'Piped messages to active container',
);
lastAgentTimestamp[chatJid] =
messagesToSend[messagesToSend.length - 1].timestamp;
saveState();
// Show typing indicator while the container processes the piped message
channel.setTyping?.(chatJid, true)?.catch((err) =>
logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
);
} else {
// No active container — enqueue for a new one
queue.enqueueMessageCheck(chatJid);
}
}
}
} catch (err) {
logger.error({ err }, 'Error in message loop');
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
}
/**
* Startup recovery: check for unprocessed messages in registered groups.
* Handles crash between advancing lastTimestamp and processing messages.
*/
function recoverPendingMessages(): void {
for (const [chatJid, group] of Object.entries(registeredGroups)) {
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
if (pending.length > 0) {
logger.info(
{ group: group.name, pendingCount: pending.length },
'Recovery: found unprocessed messages',
);
queue.enqueueMessageCheck(chatJid);
}
}
}
function ensureContainerSystemRunning(): void {
ensureContainerRuntimeRunning();
cleanupOrphans();
}
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
registeredGroups: () => registeredGroups,
};
// Create and connect channels
whatsapp = new WhatsAppChannel(channelOpts);
channels.push(whatsapp);
await whatsapp.connect();
if (GMAIL_CHANNEL_ENABLED) {
const gmail = new GmailChannel(channelOpts);
channels.push(gmail);
await gmail.connect();
}
// Start subsystems (independently of connection handler)
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage: async (jid, rawText) => {
const channel = findChannel(channels, jid);
if (!channel) {
console.log(`Warning: no channel owns JID ${jid}, cannot send message`);
return;
}
const text = formatOutbound(rawText);
if (text) await channel.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop().catch((err) => {
logger.fatal({ err }, 'Message loop crashed unexpectedly');
process.exit(1);
});
}
// Guard: only run when executed directly, not when imported by tests
const isDirectRun =
process.argv[1] &&
new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
if (isDirectRun) {
main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1);
});
}

View File

@@ -0,0 +1,43 @@
# Intent: src/index.ts modifications
## What changed
Added Gmail as a channel option alongside WhatsApp (and any other channels).
## Key sections
### Imports (top of file)
- Added: `GmailChannel` from `./channels/gmail.js`
- Added: `GMAIL_CHANNEL_ENABLED` from `./config.js`
### main()
- Added: conditional Gmail channel creation after WhatsApp:
```
if (GMAIL_CHANNEL_ENABLED) {
const gmail = new GmailChannel(channelOpts);
channels.push(gmail);
await gmail.connect();
}
```
- Gmail uses the same `channelOpts` callbacks as other channels
- Incoming emails are delivered to the main group (agent decides how to respond, user can configure)
## Invariants
- All existing message processing logic (triggers, cursors, idle timers) is preserved
- The `runAgent` function is completely unchanged
- State management (loadState/saveState) is unchanged
- Recovery logic is unchanged
- Container runtime check is unchanged
- WhatsApp and any other channel creation is untouched
- Shutdown iterates `channels` array (Gmail is included automatically)
## Must-keep
- The `escapeXml` and `formatMessages` re-exports
- The `_setRegisteredGroups` test helper
- The `isDirectRun` guard at bottom
- All error handling and cursor rollback logic in processGroupMessages
- The outgoing queue flush and reconnection logic

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
import { getAvailableGroups, _setRegisteredGroups } from './index.js';
beforeEach(() => {
_initTestDatabase();
_setRegisteredGroups({});
});
// --- JID ownership patterns ---
describe('JID ownership patterns', () => {
// These test the patterns that will become ownsJid() on the Channel interface
it('WhatsApp group JID: ends with @g.us', () => {
const jid = '12345678@g.us';
expect(jid.endsWith('@g.us')).toBe(true);
});
it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
const jid = '12345678@s.whatsapp.net';
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
});
it('Gmail JID: starts with gmail:', () => {
const jid = 'gmail:abc123def';
expect(jid.startsWith('gmail:')).toBe(true);
});
it('Gmail thread JID: starts with gmail: followed by thread ID', () => {
const jid = 'gmail:18d3f4a5b6c7d8e9';
expect(jid.startsWith('gmail:')).toBe(true);
});
});
// --- getAvailableGroups ---
describe('getAvailableGroups', () => {
it('returns only groups, excludes DMs', () => {
storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(2);
expect(groups.map((g) => g.jid)).toContain('group1@g.us');
expect(groups.map((g) => g.jid)).toContain('group2@g.us');
expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
});
it('excludes __group_sync__ sentinel', () => {
storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('marks registered groups correctly', () => {
storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
_setRegisteredGroups({
'reg@g.us': {
name: 'Registered',
folder: 'registered',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
});
const groups = getAvailableGroups();
const reg = groups.find((g) => g.jid === 'reg@g.us');
const unreg = groups.find((g) => g.jid === 'unreg@g.us');
expect(reg?.isRegistered).toBe(true);
expect(unreg?.isRegistered).toBe(false);
});
it('returns groups ordered by most recent activity', () => {
storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups[0].jid).toBe('new@g.us');
expect(groups[1].jid).toBe('mid@g.us');
expect(groups[2].jid).toBe('old@g.us');
});
it('excludes non-group chats regardless of JID format', () => {
// Unknown JID format stored without is_group should not appear
storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
// Explicitly non-group with unusual JID
storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
// A real group for contrast
storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('returns empty array when no chats exist', () => {
const groups = getAvailableGroups();
expect(groups).toHaveLength(0);
});
it('excludes Gmail threads from group list (Gmail threads are not groups)', () => {
storeChatMetadata('gmail:abc123', '2024-01-01T00:00:01.000Z', 'Email thread', 'gmail', false);
storeChatMetadata('group@g.us', '2024-01-01T00:00:02.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
const root = process.cwd();
const read = (f: string) => fs.readFileSync(path.join(root, f), 'utf-8');
function getGmailMode(): 'tool-only' | 'channel' {
const p = path.join(root, '.nanoclaw/state.yaml');
if (!fs.existsSync(p)) return 'channel';
return read('.nanoclaw/state.yaml').includes('mode: tool-only') ? 'tool-only' : 'channel';
}
const mode = getGmailMode();
const channelOnly = mode === 'tool-only';
describe('add-gmail skill', () => {
it('container-runner mounts ~/.gmail-mcp', () => {
expect(read('src/container-runner.ts')).toContain('.gmail-mcp');
});
it('agent-runner has gmail MCP server', () => {
const content = read('container/agent-runner/src/index.ts');
expect(content).toContain('mcp__gmail__*');
expect(content).toContain('@gongrzhe/server-gmail-autoauth-mcp');
});
it.skipIf(channelOnly)('gmail channel file exists', () => {
expect(fs.existsSync(path.join(root, 'src/channels/gmail.ts'))).toBe(true);
});
it.skipIf(channelOnly)('config exports GMAIL_CHANNEL_ENABLED', () => {
expect(read('src/config.ts')).toContain('GMAIL_CHANNEL_ENABLED');
});
it.skipIf(channelOnly)('index.ts wires up GmailChannel', () => {
expect(read('src/index.ts')).toContain('GmailChannel');
});
it.skipIf(channelOnly)('googleapis dependency installed', () => {
const pkg = JSON.parse(read('package.json'));
expect(pkg.dependencies?.googleapis || pkg.devDependencies?.googleapis).toBeDefined();
});
});