v2: make v2 the main entry point, move v1 to src/v1/

- Move all v1 files (index, router, container-runner, db, ipc, types,
  logger, channels/registry, and all utilities) to src/v1/ as a
  fully self-contained archive with no shared dependencies
- Rename v2 files to remove -v2 suffix (index-v2.ts → index.ts, etc.)
- Update all imports across v2 source, tests, and setup files
- Migrate shared utilities (config, env, container-runtime, mount-security,
  timezone, group-folder) from pino logger to v2 log module
- Migrate setup/ files from logger to log with argument order swap
- Container agent-runner: move v1 entry to v1/, rename v2 to index.ts
- Update setup skill to offer all 13 v2 channels
- Install all Chat SDK adapter packages
- dist/index.js now runs v2; dist/v1/index.js runs v1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-09 11:40:36 +03:00
parent 12af451069
commit 9486d56b01
96 changed files with 7904 additions and 3040 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"56e89c33-b844-4e6a-8df3-2210b2fb4a4d","pid":47993,"acquiredAt":1775696579277}
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds Google Chat support to NanoClaw v2 using the Chat SDK bridge.
## Phase 1: Pre-flight
Check if `src/channels/gchat-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/gchat.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @chat-adapter/gchat
Uncomment the Google Chat import in `src/channels/index.ts`:
```typescript
import './gchat-v2.js';
import './gchat.js';
```
### Build
@@ -72,7 +72,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './gchat-v2.js'` in `src/channels/index.ts`
1. Comment out `import './gchat.js'` in `src/channels/index.ts`
2. Remove `GCHAT_CREDENTIALS` from `.env`
3. `npm uninstall @chat-adapter/gchat`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds GitHub support to NanoClaw v2 using the Chat SDK bridge. The age
## Phase 1: Pre-flight
Check if `src/channels/github-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/github.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @chat-adapter/github
Uncomment the GitHub import in `src/channels/index.ts`:
```typescript
import './github-v2.js';
import './github.js';
```
### Build
@@ -74,7 +74,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './github-v2.js'` in `src/channels/index.ts`
1. Comment out `import './github.js'` in `src/channels/index.ts`
2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env`
3. `npm uninstall @chat-adapter/github`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds iMessage support to NanoClaw v2 using the Chat SDK bridge. Suppo
## Phase 1: Pre-flight
Check if `src/channels/imessage-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/imessage.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install chat-adapter-imessage
Uncomment the iMessage import in `src/channels/index.ts`:
```typescript
import './imessage-v2.js';
import './imessage.js';
```
### Build
@@ -80,7 +80,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './imessage-v2.js'` in `src/channels/index.ts`
1. Comment out `import './imessage.js'` in `src/channels/index.ts`
2. Remove iMessage env vars from `.env`
3. `npm uninstall chat-adapter-imessage`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds Linear support to NanoClaw v2 using the Chat SDK bridge. The age
## Phase 1: Pre-flight
Check if `src/channels/linear-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/linear.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @chat-adapter/linear
Uncomment the Linear import in `src/channels/index.ts`:
```typescript
import './linear-v2.js';
import './linear.js';
```
### Build
@@ -71,7 +71,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './linear-v2.js'` in `src/channels/index.ts`
1. Comment out `import './linear.js'` in `src/channels/index.ts`
2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env`
3. `npm uninstall @chat-adapter/linear`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds Matrix support to NanoClaw v2 using the Chat SDK bridge. Works w
## Phase 1: Pre-flight
Check if `src/channels/matrix-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/matrix.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @beeper/chat-adapter-matrix
Uncomment the Matrix import in `src/channels/index.ts`:
```typescript
import './matrix-v2.js';
import './matrix.js';
```
### Build
@@ -71,7 +71,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './matrix-v2.js'` in `src/channels/index.ts`
1. Comment out `import './matrix.js'` in `src/channels/index.ts`
2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env`
3. `npm uninstall @beeper/chat-adapter-matrix`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds email support via Resend to NanoClaw v2 using the Chat SDK bridg
## Phase 1: Pre-flight
Check if `src/channels/resend-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/resend.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @resend/chat-sdk-adapter
Uncomment the Resend import in `src/channels/index.ts`:
```typescript
import './resend-v2.js';
import './resend.js';
```
### Build
@@ -73,7 +73,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './resend-v2.js'` in `src/channels/index.ts`
1. Comment out `import './resend.js'` in `src/channels/index.ts`
2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env`
3. `npm uninstall @resend/chat-sdk-adapter`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds Slack support to NanoClaw v2 using the Chat SDK bridge.
## Phase 1: Pre-flight
Check if `src/channels/slack-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/slack.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @chat-adapter/slack
Uncomment the Slack import in `src/channels/index.ts`:
```typescript
import './slack-v2.js';
import './slack.js';
```
### Build
@@ -75,7 +75,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './slack-v2.js'` in `src/channels/index.ts`
1. Comment out `import './slack.js'` in `src/channels/index.ts`
2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env`
3. `npm uninstall @chat-adapter/slack`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds Microsoft Teams support to NanoClaw v2 using the Chat SDK bridge
## Phase 1: Pre-flight
Check if `src/channels/teams-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/teams.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @chat-adapter/teams
Uncomment the Teams import in `src/channels/index.ts`:
```typescript
import './teams-v2.js';
import './teams.js';
```
### Build
@@ -69,7 +69,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './teams-v2.js'` in `src/channels/index.ts`
1. Comment out `import './teams.js'` in `src/channels/index.ts`
2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env`
3. `npm uninstall @chat-adapter/teams`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds Telegram support to NanoClaw v2 using the Chat SDK bridge.
## Phase 1: Pre-flight
Check if `src/channels/telegram-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/telegram.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @chat-adapter/telegram
Uncomment the Telegram import in `src/channels/index.ts`:
```typescript
import './telegram-v2.js';
import './telegram.js';
```
### Build
@@ -76,7 +76,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './telegram-v2.js'` in `src/channels/index.ts`
1. Comment out `import './telegram.js'` in `src/channels/index.ts`
2. Remove `TELEGRAM_BOT_TOKEN` from `.env`
3. `npm uninstall @chat-adapter/telegram`
4. Rebuild and restart
+3 -3
View File
@@ -9,7 +9,7 @@ This skill adds Cisco Webex support to NanoClaw v2 using the Chat SDK bridge.
## Phase 1: Pre-flight
Check if `src/channels/webex-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/webex.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @bitbasti/chat-adapter-webex
Uncomment the Webex import in `src/channels/index.ts`:
```typescript
import './webex-v2.js';
import './webex.js';
```
### Build
@@ -69,7 +69,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './webex-v2.js'` in `src/channels/index.ts`
1. Comment out `import './webex.js'` in `src/channels/index.ts`
2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env`
3. `npm uninstall @bitbasti/chat-adapter-webex`
4. Rebuild and restart
@@ -9,7 +9,7 @@ This skill adds WhatsApp support via the official Meta WhatsApp Business Cloud A
## Phase 1: Pre-flight
Check if `src/channels/whatsapp-cloud-v2.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
Check if `src/channels/whatsapp-cloud.ts` exists and the import is uncommented in `src/channels/index.ts`. If both are in place, skip to Phase 3.
## Phase 2: Apply Code Changes
@@ -24,7 +24,7 @@ npm install @chat-adapter/whatsapp
Uncomment the WhatsApp Cloud API import in `src/channels/index.ts`:
```typescript
import './whatsapp-cloud-v2.js';
import './whatsapp-cloud.js';
```
### Build
@@ -76,7 +76,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
## Removal
1. Comment out `import './whatsapp-cloud-v2.js'` in `src/channels/index.ts`
1. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts`
2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env`
3. `npm uninstall @chat-adapter/whatsapp`
4. Rebuild and restart
+30 -13
View File
@@ -242,26 +242,43 @@ Verify the proxy starts: `npm run dev` should show "Credential proxy listening"
## 5. Set Up Channels
AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
- WhatsApp (authenticates via QR code or pairing code)
- Telegram (authenticates via bot token from @BotFather)
- Slack (authenticates via Slack app with Socket Mode)
- Discord (authenticates via Discord bot token)
- Discord (bot token + public key)
- Slack (bot token + signing secret)
- Telegram (bot token from @BotFather)
- GitHub (PR/issue comment threads)
- Linear (issue comment threads)
- Microsoft Teams (Azure Bot)
- Google Chat (service account)
- WhatsApp Cloud API (Meta Business API)
- WhatsApp Baileys (QR code / pairing code)
- Resend (email)
- Matrix (any homeserver)
- Webex (bot token)
- iMessage (macOS local or Photon API)
**Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct.
**Delegate to each selected channel's own skill.** Each channel skill handles its own package installation, authentication, registration, and configuration. This avoids duplicating channel-specific logic.
For each selected channel, invoke its skill:
- **WhatsApp:** Invoke `/add-whatsapp`
- **Telegram:** Invoke `/add-telegram`
- **Slack:** Invoke `/add-slack`
- **Discord:** Invoke `/add-discord`
- **Slack:** Invoke `/add-slack-v2`
- **Telegram:** Invoke `/add-telegram-v2`
- **GitHub:** Invoke `/add-github-v2`
- **Linear:** Invoke `/add-linear-v2`
- **Microsoft Teams:** Invoke `/add-teams-v2`
- **Google Chat:** Invoke `/add-gchat-v2`
- **WhatsApp Cloud API:** Invoke `/add-whatsapp-cloud-v2`
- **WhatsApp Baileys:** Invoke `/add-whatsapp`
- **Resend:** Invoke `/add-resend-v2`
- **Matrix:** Invoke `/add-matrix-v2`
- **Webex:** Invoke `/add-webex-v2`
- **iMessage:** Invoke `/add-imessage-v2`
Each skill will:
1. Install the channel code (via `git merge` of the skill branch)
2. Collect credentials/tokens and write to `.env`
3. Authenticate (WhatsApp QR/pairing, or verify token-based connection)
4. Register the chat with the correct JID format
5. Build and verify
1. Install the Chat SDK adapter package
2. Uncomment the channel import in `src/channels/index.ts`
3. Collect credentials/tokens and write to `.env`
4. Build and verify
**After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages:
-96
View File
@@ -1,96 +0,0 @@
/**
* NanoClaw Agent Runner v2
*
* Runs inside a container. All IO goes through the session DB.
* No stdin, no stdout markers, no IPC files.
*
* Config:
* - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db)
* - AGENT_PROVIDER: 'claude' | 'mock' (default: claude)
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
* - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks
*
* Mount structure:
* /workspace/
* session.db ← session SQLite DB
* outbox/ ← outbound files
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
* .claude/ ← Claude SDK session data
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createProvider, type ProviderName } from './providers/factory.js';
import { runPollLoop } from './poll-loop.js';
function log(msg: string): void {
console.error(`[agent-runner] ${msg}`);
}
const CWD = '/workspace/agent';
const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md';
async function main(): Promise<void> {
const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName;
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
log(`Starting v2 agent-runner (provider: ${providerName})`);
const provider = createProvider(providerName, { assistantName });
// Load global CLAUDE.md as additional system context
let systemPrompt: string | undefined;
if (fs.existsSync(GLOBAL_CLAUDE_MD)) {
systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8');
log('Loaded global CLAUDE.md');
}
// Discover additional directories mounted at /workspace/extra/*
const additionalDirectories: 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()) {
additionalDirectories.push(fullPath);
}
}
if (additionalDirectories.length > 0) {
log(`Additional directories: ${additionalDirectories.join(', ')}`);
}
}
// MCP server path
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js');
// SDK env
const env: Record<string, string | undefined> = {
...process.env,
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
};
await runPollLoop({
provider,
cwd: CWD,
mcpServers: {
nanoclaw: {
command: 'node',
args: [mcpServerPath],
env: {
SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db',
},
},
},
systemPrompt,
env,
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
});
}
main().catch((err) => {
log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+61 -701
View File
@@ -1,736 +1,96 @@
/**
* NanoClaw Agent Runner
* Runs inside a container, receives config via stdin, outputs result to stdout
* NanoClaw Agent Runner v2
*
* 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
* Runs inside a container. All IO goes through the session DB.
* No stdin, no stdout markers, no IPC files.
*
* 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.
* Config:
* - SESSION_DB_PATH: path to session SQLite DB (default: /workspace/session.db)
* - AGENT_PROVIDER: 'claude' | 'mock' (default: claude)
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
* - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks
*
* Mount structure:
* /workspace/
* session.db ← session SQLite DB
* outbox/ ← outbound files
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
* .claude/ ← Claude SDK session data
*/
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import {
query,
HookCallback,
PreCompactHookInput,
} 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;
script?: string;
import { createProvider, type ProviderName } from './providers/factory.js';
import { runPollLoop } from './poll-loop.js';
function log(msg: string): void {
console.error(`[agent-runner] ${msg}`);
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
const CWD = '/workspace/agent';
const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md';
interface SessionEntry {
sessionId: string;
fullPath: string;
summary: string;
firstPrompt: string;
}
async function main(): Promise<void> {
const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName;
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
interface SessionsIndex {
entries: SessionEntry[];
}
log(`Starting v2 agent-runner (provider: ${providerName})`);
interface SDKUserMessage {
type: 'user';
message: { role: 'user'; content: string };
parent_tool_use_id: null;
session_id: string;
}
const provider = createProvider(providerName, { assistantName });
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 {};
};
}
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');
// Load global CLAUDE.md as additional system context
let systemPrompt: string | undefined;
if (fs.existsSync(GLOBAL_CLAUDE_MD)) {
systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8');
log('Loaded global CLAUDE.md');
}
// 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 additionalDirectories: 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);
additionalDirectories.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__*',
],
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',
},
},
},
hooks: {
PreCompact: [
{ hooks: [createPreCompactHook(containerInput.assistantName)] },
],
},
},
})) {
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,
});
if (additionalDirectories.length > 0) {
log(`Additional directories: ${additionalDirectories.join(', ')}`);
}
}
ipcPolling = false;
log(
`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`,
);
return { newSessionId, lastAssistantUuid, closedDuringQuery };
}
// MCP server path
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.js');
interface ScriptResult {
wakeAgent: boolean;
data?: unknown;
}
const SCRIPT_TIMEOUT_MS = 30_000;
async function runScript(script: string): Promise<ScriptResult | null> {
const scriptPath = '/tmp/task-script.sh';
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return new Promise((resolve) => {
execFile(
'bash',
[scriptPath],
{
timeout: SCRIPT_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
env: process.env,
},
(error, stdout, stderr) => {
if (stderr) {
log(`Script stderr: ${stderr.slice(0, 500)}`);
}
if (error) {
log(`Script error: ${error.message}`);
return resolve(null);
}
// Parse last non-empty line of stdout as JSON
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (!lastLine) {
log('Script produced no output');
return resolve(null);
}
try {
const result = JSON.parse(lastLine);
if (typeof result.wakeAgent !== 'boolean') {
log(
`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`,
);
return resolve(null);
}
resolve(result as ScriptResult);
} catch {
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
resolve(null);
}
},
);
});
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
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);
}
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
// No real secrets exist in the container environment.
const sdkEnv: Record<string, string | undefined> = {
// SDK env
const env: Record<string, string | undefined> = {
...process.env,
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
};
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');
}
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
const scriptResult = await runScript(containerInput.script);
if (!scriptResult || !scriptResult.wakeAgent) {
const reason = scriptResult
? 'wakeAgent=false'
: 'script error/no output';
log(`Script decided not to wake agent: ${reason}`);
writeOutput({
status: 'success',
result: null,
});
return;
}
// Script says wake agent — enrich prompt with script data
log(`Script wakeAgent=true, enriching prompt with data`);
prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`;
}
// 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);
}
await runPollLoop({
provider,
cwd: CWD,
mcpServers: {
nanoclaw: {
command: 'node',
args: [mcpServerPath],
env: {
SESSION_DB_PATH: process.env.SESSION_DB_PATH || '/workspace/session.db',
},
},
},
systemPrompt,
env,
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
});
}
main();
main().catch((err) => {
log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
+736
View File
@@ -0,0 +1,736 @@
/**
* 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 { execFile } from 'child_process';
import {
query,
HookCallback,
PreCompactHookInput,
} 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;
script?: 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 {};
};
}
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__*',
],
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',
},
},
},
hooks: {
PreCompact: [
{ hooks: [createPreCompactHook(containerInput.assistantName)] },
],
},
},
})) {
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 };
}
interface ScriptResult {
wakeAgent: boolean;
data?: unknown;
}
const SCRIPT_TIMEOUT_MS = 30_000;
async function runScript(script: string): Promise<ScriptResult | null> {
const scriptPath = '/tmp/task-script.sh';
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return new Promise((resolve) => {
execFile(
'bash',
[scriptPath],
{
timeout: SCRIPT_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
env: process.env,
},
(error, stdout, stderr) => {
if (stderr) {
log(`Script stderr: ${stderr.slice(0, 500)}`);
}
if (error) {
log(`Script error: ${error.message}`);
return resolve(null);
}
// Parse last non-empty line of stdout as JSON
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (!lastLine) {
log('Script produced no output');
return resolve(null);
}
try {
const result = JSON.parse(lastLine);
if (typeof result.wakeAgent !== 'boolean') {
log(
`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`,
);
return resolve(null);
}
resolve(result as ScriptResult);
} catch {
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
resolve(null);
}
},
);
});
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
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);
}
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
// No real secrets exist in the container environment.
const sdkEnv: Record<string, string | undefined> = {
...process.env,
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
};
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');
}
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
const scriptResult = await runScript(containerInput.script);
if (!scriptResult || !scriptResult.wakeAgent) {
const reason = scriptResult
? 'wakeAgent=false'
: 'script error/no output';
log(`Script decided not to wake agent: ${reason}`);
writeOutput({
status: 'success',
result: null,
});
return;
}
// Script says wake agent — enrich prompt with script data
log(`Script wakeAgent=true, enriching prompt with data`);
prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`;
}
// 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();
+3921 -2
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -21,11 +21,22 @@
"test:watch": "vitest"
},
"dependencies": {
"@beeper/chat-adapter-matrix": "^0.2.0",
"@bitbasti/chat-adapter-webex": "^0.1.0",
"@chat-adapter/discord": "^4.24.0",
"@chat-adapter/gchat": "^4.24.0",
"@chat-adapter/github": "^4.24.0",
"@chat-adapter/linear": "^4.24.0",
"@chat-adapter/slack": "^4.24.0",
"@chat-adapter/state-memory": "^4.24.0",
"@chat-adapter/teams": "^4.24.0",
"@chat-adapter/telegram": "^4.24.0",
"@chat-adapter/whatsapp": "^4.24.0",
"@onecli-sh/sdk": "^0.2.0",
"@resend/chat-sdk-adapter": "^0.1.1",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"chat-adapter-imessage": "^0.1.1",
"cron-parser": "5.5.0"
},
"devDependencies": {
+7 -7
View File
@@ -5,7 +5,7 @@
import { execSync } from 'child_process';
import path from 'path';
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import { commandExists } from './platform.js';
import { emitStatus } from './status.js';
@@ -101,31 +101,31 @@ export async function run(args: string[]): Promise<void> {
// Build
let buildOk = false;
logger.info({ runtime }, 'Building container');
log.info('Building container', { runtime });
try {
execSync(`${buildCmd} -t ${image} .`, {
cwd: path.join(projectRoot, 'container'),
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Container build succeeded');
log.info('Container build succeeded');
} catch (err) {
logger.error({ err }, 'Container build failed');
log.error('Container build failed', { err });
}
// Test
let testOk = false;
if (buildOk) {
logger.info('Testing container');
log.info('Testing container');
try {
const output = execSync(
`echo '{}' | ${runCmd} run -i --rm --entrypoint /bin/echo ${image} "Container OK"`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
);
testOk = output.includes('Container OK');
logger.info({ testOk }, 'Container test result');
log.info('Container test result', { testOk });
} catch {
logger.error('Container test failed');
log.error('Container test failed');
}
}
+4 -4
View File
@@ -8,14 +8,14 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
logger.info('Starting environment check');
log.info('Starting environment check');
const platform = getPlatform();
const wsl = isWSL();
@@ -66,7 +66,8 @@ export async function run(_args: string[]): Promise<void> {
}
}
logger.info(
log.info(
'Environment check complete',
{
platform,
wsl,
@@ -76,7 +77,6 @@ export async function run(_args: string[]): Promise<void> {
hasAuth,
hasRegisteredGroups,
},
'Environment check complete',
);
emitStatus('CHECK_ENVIRONMENT', {
+8 -8
View File
@@ -11,7 +11,7 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { list: boolean; limit: number } {
@@ -71,7 +71,7 @@ async function syncGroups(projectRoot: string): Promise<void> {
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
if (!hasWhatsAppAuth) {
logger.info('WhatsApp auth not found — skipping group sync');
log.info('WhatsApp auth not found — skipping group sync');
emitStatus('SYNC_GROUPS', {
BUILD: 'skipped',
SYNC: 'skipped',
@@ -84,7 +84,7 @@ async function syncGroups(projectRoot: string): Promise<void> {
}
// Build TypeScript first
logger.info('Building TypeScript');
log.info('Building TypeScript');
let buildOk = false;
try {
execSync('npm run build', {
@@ -92,9 +92,9 @@ async function syncGroups(projectRoot: string): Promise<void> {
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Build succeeded');
log.info('Build succeeded');
} catch {
logger.error('Build failed');
log.error('Build failed');
emitStatus('SYNC_GROUPS', {
BUILD: 'failed',
SYNC: 'skipped',
@@ -107,7 +107,7 @@ async function syncGroups(projectRoot: string): Promise<void> {
}
// Run sync script via a temp file to avoid shell escaping issues with node -e
logger.info('Fetching group metadata');
log.info('Fetching group metadata');
let syncOk = false;
try {
const syncScript = `
@@ -189,12 +189,12 @@ sock.ev.on('connection.update', async (update) => {
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
logger.info({ output: output.trim() }, 'Sync output');
log.info('Sync output', { output: output.trim() });
} finally {
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
}
} catch (err) {
logger.error({ err }, 'Sync failed');
log.error('Sync failed', { err });
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
+2 -2
View File
@@ -2,7 +2,7 @@
* Setup CLI entry point.
* Usage: npx tsx setup/index.ts --step <name> [args...]
*/
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
const STEPS: Record<
@@ -47,7 +47,7 @@ async function main(): Promise<void> {
await mod.run(stepArgs);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, step: stepName }, 'Setup step failed');
log.error('Setup step failed', { err, step: stepName });
emitStatus(stepName.toUpperCase(), {
STATUS: 'failed',
ERROR: message,
+10 -10
View File
@@ -6,7 +6,7 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import { isRoot } from './platform.js';
import { emitStatus } from './status.js';
@@ -32,7 +32,7 @@ export async function run(args: string[]): Promise<void> {
const configFile = path.join(configDir, 'mount-allowlist.json');
if (isRoot()) {
logger.warn(
log.warn(
'Running as root — mount allowlist will be written to root home directory',
);
}
@@ -40,9 +40,9 @@ export async function run(args: string[]): Promise<void> {
fs.mkdirSync(configDir, { recursive: true });
if (fs.existsSync(configFile) && !force) {
logger.info(
{ configFile },
log.info(
'Mount allowlist already exists — skipping (use --force to overwrite)',
{ configFile },
);
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
@@ -58,7 +58,7 @@ export async function run(args: string[]): Promise<void> {
let nonMainReadOnly = 'true';
if (empty) {
logger.info('Writing empty mount allowlist');
log.info('Writing empty mount allowlist');
const emptyConfig = {
allowedRoots: [],
blockedPatterns: [],
@@ -71,7 +71,7 @@ export async function run(args: string[]): Promise<void> {
try {
parsed = JSON.parse(json);
} catch {
logger.error('Invalid JSON input');
log.error('Invalid JSON input');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
@@ -91,13 +91,13 @@ export async function run(args: string[]): Promise<void> {
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
} else {
// Read from stdin
logger.info('Reading mount allowlist from stdin');
log.info('Reading mount allowlist from stdin');
const input = fs.readFileSync(0, 'utf-8');
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
try {
parsed = JSON.parse(input);
} catch {
logger.error('Invalid JSON from stdin');
log.error('Invalid JSON from stdin');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
@@ -117,9 +117,9 @@ export async function run(args: string[]): Promise<void> {
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
}
logger.info(
{ configFile, allowedRoots, nonMainReadOnly },
log.info(
'Allowlist configured',
{ configFile, allowedRoots, nonMainReadOnly },
);
emitStatus('CONFIGURE_MOUNTS', {
+10 -10
View File
@@ -8,9 +8,9 @@ import fs from 'fs';
import path from 'path';
import { STORE_DIR } from '../src/config.ts';
import { initDatabase, setRegisteredGroup } from '../src/db.ts';
import { initDatabase, setRegisteredGroup } from '../src/v1/db.ts';
import { isValidGroupFolder } from '../src/group-folder.ts';
import { logger } from '../src/logger.ts';
import { log } from '../src/log.js';
import { emitStatus } from './status.ts';
interface RegisterArgs {
@@ -90,7 +90,7 @@ export async function run(args: string[]): Promise<void> {
process.exit(4);
}
logger.info(parsed, 'Registering channel');
log.info('Registering channel', parsed);
// Ensure data and store directories exist (store/ may not exist on
// fresh installs that skip WhatsApp auth, which normally creates it)
@@ -109,7 +109,7 @@ export async function run(args: string[]): Promise<void> {
isMain: parsed.isMain,
});
logger.info('Wrote registration to SQLite');
log.info('Wrote registration to SQLite');
// Create group folders
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), {
@@ -133,9 +133,9 @@ export async function run(args: string[]): Promise<void> {
: path.join(projectRoot, 'groups', 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, groupClaudeMdPath);
logger.info(
{ file: groupClaudeMdPath, template: templatePath },
log.info(
'Created CLAUDE.md from template',
{ file: groupClaudeMdPath, template: templatePath },
);
}
}
@@ -143,9 +143,9 @@ export async function run(args: string[]): Promise<void> {
// Update assistant name in CLAUDE.md files if different from default
let nameUpdated = false;
if (parsed.assistantName !== 'Andy') {
logger.info(
{ from: 'Andy', to: parsed.assistantName },
log.info(
'Updating assistant name',
{ from: 'Andy', to: parsed.assistantName },
);
const groupsDir = path.join(projectRoot, 'groups');
@@ -163,7 +163,7 @@ export async function run(args: string[]): Promise<void> {
`You are ${parsed.assistantName}`,
);
fs.writeFileSync(mdFile, content);
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
log.info('Updated CLAUDE.md', { file: mdFile });
}
}
@@ -183,7 +183,7 @@ export async function run(args: string[]): Promise<void> {
} else {
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
}
logger.info('Set ASSISTANT_NAME in .env');
log.info('Set ASSISTANT_NAME in .env');
nameUpdated = true;
}
+21 -21
View File
@@ -9,7 +9,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import {
getPlatform,
getNodePath,
@@ -26,18 +26,18 @@ export async function run(_args: string[]): Promise<void> {
const nodePath = getNodePath();
const homeDir = os.homedir();
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
log.info('Setting up service', { platform, nodePath, projectRoot });
// Build first
logger.info('Building TypeScript');
log.info('Building TypeScript');
try {
execSync('npm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
logger.info('Build succeeded');
log.info('Build succeeded');
} catch {
logger.error('Build failed');
log.error('Build failed');
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
NODE_PATH: nodePath,
@@ -113,15 +113,15 @@ function setupLaunchd(
</plist>`;
fs.writeFileSync(plistPath, plist);
logger.info({ plistPath }, 'Wrote launchd plist');
log.info('Wrote launchd plist', { plistPath });
try {
execSync(`launchctl load ${JSON.stringify(plistPath)}`, {
stdio: 'ignore',
});
logger.info('launchctl load succeeded');
log.info('launchctl load succeeded');
} catch {
logger.warn('launchctl load failed (may already be loaded)');
log.warn('launchctl load failed (may already be loaded)');
}
// Verify
@@ -168,7 +168,7 @@ function killOrphanedProcesses(projectRoot: string): void {
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
stdio: 'ignore',
});
logger.info('Stopped any orphaned nanoclaw processes');
log.info('Stopped any orphaned nanoclaw processes');
} catch {
// pkill not available or no orphans
}
@@ -215,13 +215,13 @@ function setupSystemd(
if (runningAsRoot) {
unitPath = '/etc/systemd/system/nanoclaw.service';
systemctlPrefix = 'systemctl';
logger.info('Running as root — installing system-level systemd unit');
log.info('Running as root — installing system-level systemd unit');
} else {
// Check if user-level systemd session is available
try {
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
} catch {
logger.warn(
log.warn(
'systemd user session not available — falling back to nohup wrapper',
);
setupNohupFallback(projectRoot, nodePath, homeDir);
@@ -253,12 +253,12 @@ StandardError=append:${projectRoot}/logs/nanoclaw.error.log
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
fs.writeFileSync(unitPath, unit);
logger.info({ unitPath }, 'Wrote systemd unit');
log.info('Wrote systemd unit', { unitPath });
// Detect stale docker group before starting (user systemd only)
const dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
if (dockerGroupStale) {
logger.warn(
log.warn(
'Docker group not active in systemd session — user was likely added to docker group mid-session',
);
}
@@ -271,11 +271,11 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
if (!runningAsRoot) {
try {
execSync('loginctl enable-linger', { stdio: 'ignore' });
logger.info('Enabled loginctl linger for current user');
log.info('Enabled loginctl linger for current user');
} catch (err) {
logger.warn(
{ err },
log.warn(
'loginctl enable-linger failed — service may stop on SSH logout',
{ err },
);
}
}
@@ -284,19 +284,19 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
try {
execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl daemon-reload failed');
log.error('systemctl daemon-reload failed', { err });
}
try {
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl enable failed');
log.error('systemctl enable failed', { err });
}
try {
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl start failed');
log.error('systemctl start failed', { err });
}
// Verify
@@ -326,7 +326,7 @@ function setupNohupFallback(
nodePath: string,
homeDir: string,
): void {
logger.warn('No systemd detected — generating nohup wrapper script');
log.warn('No systemd detected — generating nohup wrapper script');
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
@@ -362,7 +362,7 @@ function setupNohupFallback(
const wrapper = lines.join('\n') + '\n';
fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
logger.info({ wrapperPath }, 'Wrote nohup wrapper script');
log.info('Wrote nohup wrapper script', { wrapperPath });
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'nohup',
+2 -2
View File
@@ -7,7 +7,7 @@ import fs from 'fs';
import path from 'path';
import { isValidTimezone } from '../src/timezone.js';
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
export async function run(args: string[]): Promise<void> {
@@ -53,7 +53,7 @@ export async function run(args: string[]): Promise<void> {
} else {
fs.writeFileSync(envFile, `TZ=${resolvedTz}\n`);
}
logger.info({ timezone: resolvedTz }, 'Set TZ in .env');
log.info('Set TZ in .env', { timezone: resolvedTz });
}
emitStatus('TIMEZONE', {
+4 -4
View File
@@ -13,7 +13,7 @@ import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js';
import { log } from '../src/log.js';
import {
getPlatform,
getServiceManager,
@@ -27,7 +27,7 @@ export async function run(_args: string[]): Promise<void> {
const platform = getPlatform();
const homeDir = os.homedir();
logger.info('Starting verification');
log.info('Starting verification');
// 1. Check service status
let service = 'not_found';
@@ -80,7 +80,7 @@ export async function run(_args: string[]): Promise<void> {
}
}
}
logger.info({ service }, 'Service status');
log.info('Service status', { service });
// 2. Check container runtime
let containerRuntime = 'none';
@@ -174,7 +174,7 @@ export async function run(_args: string[]): Promise<void> {
? 'success'
: 'failed';
logger.info({ status, channelAuth }, 'Verification complete');
log.info('Verification complete', { status, channelAuth });
emitStatus('VERIFY', {
SERVICE: service,
+3 -3
View File
@@ -8,7 +8,7 @@ import fs from 'fs';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
// Mock container runner
vi.mock('../container-runner-v2.js', () => ({
vi.mock('../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
resetContainerIdleTimer: vi.fn(),
isContainerRunning: vi.fn().mockReturnValue(false),
@@ -160,7 +160,7 @@ describe('channel + router integration', () => {
});
it('should route inbound message from adapter to session DB', async () => {
const { routeInbound } = await import('../router-v2.js');
const { routeInbound } = await import('../router.js');
const { findSession } = await import('../db/sessions.js');
const { sessionDbPath } = await import('../session-manager.js');
@@ -209,7 +209,7 @@ describe('channel + router integration', () => {
onAction: () => {},
}));
// Set up delivery adapter bridge (same pattern as index-v2.ts)
// Set up delivery adapter bridge (same pattern as index.ts)
setDeliveryAdapter({
async deliver(channelType, platformId, threadId, kind, content) {
const adapter = getChannelAdapter(channelType);
@@ -20,6 +20,6 @@ registerChannelAdapter('imessage', {
serverUrl: env.IMESSAGE_SERVER_URL,
apiKey: env.IMESSAGE_API_KEY,
});
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent' });
return createChatSdkBridge({ adapter: imessageAdapter as never, concurrency: 'concurrent' });
},
});
+12 -12
View File
@@ -2,40 +2,40 @@
// Each import triggers the channel module's registerChannelAdapter() call.
// discord
// import './discord-v2.js';
// import './discord.js';
// slack
// import './slack-v2.js';
// import './slack.js';
// telegram
// import './telegram-v2.js';
// import './telegram.js';
// github
// import './github-v2.js';
// import './github.js';
// linear
// import './linear-v2.js';
// import './linear.js';
// google chat
// import './gchat-v2.js';
// import './gchat.js';
// microsoft teams
// import './teams-v2.js';
// import './teams.js';
// whatsapp cloud api
// import './whatsapp-cloud-v2.js';
// import './whatsapp-cloud.js';
// resend (email)
// import './resend-v2.js';
// import './resend.js';
// matrix
// import './matrix-v2.js';
// import './matrix.js';
// webex
// import './webex-v2.js';
// import './webex.js';
// imessage
// import './imessage-v2.js';
// import './imessage.js';
// gmail (native, no Chat SDK)
@@ -11,7 +11,12 @@ import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('whatsapp-cloud', {
factory: () => {
const env = readEnvFile(['WHATSAPP_ACCESS_TOKEN', 'WHATSAPP_PHONE_NUMBER_ID', 'WHATSAPP_APP_SECRET', 'WHATSAPP_VERIFY_TOKEN']);
const env = readEnvFile([
'WHATSAPP_ACCESS_TOKEN',
'WHATSAPP_PHONE_NUMBER_ID',
'WHATSAPP_APP_SECRET',
'WHATSAPP_VERIFY_TOKEN',
]);
if (!env.WHATSAPP_ACCESS_TOKEN) return null;
const whatsappAdapter = createWhatsAppAdapter({
accessToken: env.WHATSAPP_ACCESS_TOKEN,
-277
View File
@@ -1,277 +0,0 @@
/**
* Container Runner v2
* Spawns agent containers with session folder + agent group folder mounts.
* The container runs the v2 agent-runner which polls the session DB.
*/
import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { OneCLI } from '@onecli-sh/sdk';
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { getAgentGroup } from './db/agent-groups.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import { log } from './log.js';
import { validateAdditionalMounts } from './mount-security.js';
import {
markContainerIdle,
markContainerRunning,
markContainerStopped,
sessionDbPath,
sessionDir,
} from './session-manager.js';
import type { AgentGroup, Session } from './types-v2.js';
const onecli = new OneCLI({ url: ONECLI_URL });
interface VolumeMount {
hostPath: string;
containerPath: string;
readonly: boolean;
}
/** Active containers tracked by session ID. */
const activeContainers = new Map<string, { process: ChildProcess; containerName: string }>();
export function getActiveContainerCount(): number {
return activeContainers.size;
}
export function isContainerRunning(sessionId: string): boolean {
return activeContainers.has(sessionId);
}
/**
* Wake up a container for a session. If already running, no-op.
* The container runs the v2 agent-runner which polls the session DB.
*/
export async function wakeContainer(session: Session): Promise<void> {
if (activeContainers.has(session.id)) {
log.debug('Container already running', { sessionId: session.id });
return;
}
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
log.error('Agent group not found', { agentGroupId: session.agent_group_id });
return;
}
const mounts = buildMounts(agentGroup, session);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-');
const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier);
log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName });
const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
activeContainers.set(session.id, { process: container, containerName });
markContainerRunning(session.id);
// Log stderr
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
// stdout is unused in v2 (all IO is via session DB)
container.stdout?.on('data', () => {});
// Idle timeout: kill container after IDLE_TIMEOUT of no activity
let idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT);
const resetIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT);
};
// Reset idle timer when the host detects new messages_out (called by delivery.ts)
const entry = activeContainers.get(session.id);
if (entry) {
(entry as { resetIdle?: () => void }).resetIdle = resetIdle;
}
container.on('close', (code) => {
clearTimeout(idleTimer);
activeContainers.delete(session.id);
markContainerStopped(session.id);
log.info('Container exited', { sessionId: session.id, code, containerName });
});
container.on('error', (err) => {
clearTimeout(idleTimer);
activeContainers.delete(session.id);
markContainerStopped(session.id);
log.error('Container spawn error', { sessionId: session.id, err });
});
}
/** Reset the idle timer for a session's container (called when messages_out are delivered). */
export function resetContainerIdleTimer(sessionId: string): void {
const entry = activeContainers.get(sessionId) as { resetIdle?: () => void } | undefined;
entry?.resetIdle?.();
}
/** Kill a container for a session. */
export function killContainer(sessionId: string, reason: string): void {
const entry = activeContainers.get(sessionId);
if (!entry) return;
log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
try {
stopContainer(entry.containerName);
} catch {
entry.process.kill('SIGKILL');
}
}
function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] {
const mounts: VolumeMount[] = [];
const projectRoot = process.cwd();
const sessDir = sessionDir(agentGroup.id, session.id);
const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
// Session folder at /workspace (contains session.db, outbox/, .claude/)
mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false });
// Agent group folder at /workspace/agent
fs.mkdirSync(groupDir, { recursive: true });
mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false });
// Global memory directory
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: !agentGroup.is_admin });
}
// Claude sessions directory (per agent group, shared across sessions)
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
fs.mkdirSync(claudeDir, { recursive: true });
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(
settingsFile,
JSON.stringify(
{
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
},
null,
2,
) + '\n',
);
}
// Sync container skills
const skillsSrc = path.join(projectRoot, 'container', 'skills');
const skillsDst = path.join(claudeDir, 'skills');
if (fs.existsSync(skillsSrc)) {
for (const skillDir of fs.readdirSync(skillsSrc)) {
const srcDir = path.join(skillsSrc, skillDir);
if (fs.statSync(srcDir).isDirectory()) {
fs.cpSync(srcDir, path.join(skillsDst, skillDir), { recursive: true });
}
}
}
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
// Agent-runner source (per agent group, recompiled on container startup)
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src');
if (fs.existsSync(agentRunnerSrc)) {
// Always copy — source files may have changed beyond just the index
fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true });
}
mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false });
// Admin: mount project root read-only
if (agentGroup.is_admin) {
mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', readonly: true });
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: true });
}
}
// Additional mounts from container config
const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {};
if (containerConfig.additionalMounts) {
const validated = validateAdditionalMounts(
containerConfig.additionalMounts,
agentGroup.name,
!!agentGroup.is_admin,
);
mounts.push(...validated);
}
return mounts;
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
session: Session,
agentGroup: AgentGroup,
agentIdentifier?: string,
): Promise<string[]> {
const args: string[] = ['run', '--rm', '--name', containerName];
// Environment
args.push('-e', `TZ=${TIMEZONE}`);
args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`);
args.push('-e', `SESSION_DB_PATH=/workspace/session.db`);
// Pass admin user ID and assistant name from messaging group/agent group
if (session.messaging_group_id) {
const mg = getMessagingGroup(session.messaging_group_id);
if (mg?.admin_user_id) {
args.push('-e', `NANOCLAW_ADMIN_USER_ID=${mg.admin_user_id}`);
}
}
if (agentGroup.name) {
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
}
// OneCLI gateway
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (onecliApplied) {
log.debug('OneCLI gateway applied', { containerName });
}
// Host gateway
args.push(...hostGatewayArgs());
// User mapping
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');
}
// Volume mounts
for (const mount of mounts) {
if (mount.readonly) {
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
} else {
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
}
}
// Override entrypoint: compile agent-runner source, run v2 entry point (no stdin)
args.push('--entrypoint', 'bash');
args.push(CONTAINER_IMAGE);
args.push(
'-c',
'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index-v2.js',
);
return args;
}
+191 -591
View File
@@ -1,150 +1,165 @@
/**
* Container Runner for NanoClaw
* Spawns agent execution in containers and handles IPC
* Container Runner v2
* Spawns agent containers with session folder + agent group folder mounts.
* The container runs the v2 agent-runner which polls the session DB.
*/
import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { OneCLI } from '@onecli-sh/sdk';
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { getAgentGroup } from './db/agent-groups.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import { log } from './log.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
import {
markContainerIdle,
markContainerRunning,
markContainerStopped,
sessionDbPath,
sessionDir,
} from './session-manager.js';
import type { AgentGroup, Session } from './types.js';
const onecli = new OneCLI({ url: ONECLI_URL });
// 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;
script?: 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 groupDir = resolveGroupFolderPath(group.folder);
/** Active containers tracked by session ID. */
const activeContainers = new Map<string, { process: ChildProcess; containerName: string }>();
if (isMain) {
// Main gets the project root read-only. Writable paths the agent needs
// (store, 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,
});
export function getActiveContainerCount(): number {
return activeContainers.size;
}
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Credentials are injected by the OneCLI gateway, never exposed to containers.
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
export function isContainerRunning(sessionId: string): boolean {
return activeContainers.has(sessionId);
}
// Main gets writable access to the store (SQLite DB) so it can
// query and write to the database directly.
const storeDir = path.join(projectRoot, 'store');
mounts.push({
hostPath: storeDir,
containerPath: '/workspace/project/store',
readonly: false,
});
// Main also gets its group folder as the working directory
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
// Global memory directory — writable for main so it can update shared context
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({
hostPath: globalDir,
containerPath: '/workspace/global',
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,
});
}
/**
* Wake up a container for a session. If already running, no-op.
* The container runs the v2 agent-runner which polls the session DB.
*/
export async function wakeContainer(session: Session): Promise<void> {
if (activeContainers.has(session.id)) {
log.debug('Container already running', { sessionId: session.id });
return;
}
// 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');
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
log.error('Agent group not found', { agentGroupId: session.agent_group_id });
return;
}
const mounts = buildMounts(agentGroup, session);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-');
const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier);
log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName });
const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
activeContainers.set(session.id, { process: container, containerName });
markContainerRunning(session.id);
// Log stderr
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
// stdout is unused in v2 (all IO is via session DB)
container.stdout?.on('data', () => {});
// Idle timeout: kill container after IDLE_TIMEOUT of no activity
let idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT);
const resetIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => killContainer(session.id, 'idle timeout'), IDLE_TIMEOUT);
};
// Reset idle timer when the host detects new messages_out (called by delivery.ts)
const entry = activeContainers.get(session.id);
if (entry) {
(entry as { resetIdle?: () => void }).resetIdle = resetIdle;
}
container.on('close', (code) => {
clearTimeout(idleTimer);
activeContainers.delete(session.id);
markContainerStopped(session.id);
log.info('Container exited', { sessionId: session.id, code, containerName });
});
container.on('error', (err) => {
clearTimeout(idleTimer);
activeContainers.delete(session.id);
markContainerStopped(session.id);
log.error('Container spawn error', { sessionId: session.id, err });
});
}
/** Reset the idle timer for a session's container (called when messages_out are delivered). */
export function resetContainerIdleTimer(sessionId: string): void {
const entry = activeContainers.get(sessionId) as { resetIdle?: () => void } | undefined;
entry?.resetIdle?.();
}
/** Kill a container for a session. */
export function killContainer(sessionId: string, reason: string): void {
const entry = activeContainers.get(sessionId);
if (!entry) return;
log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
try {
stopContainer(entry.containerName);
} catch {
entry.process.kill('SIGKILL');
}
}
function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] {
const mounts: VolumeMount[] = [];
const projectRoot = process.cwd();
const sessDir = sessionDir(agentGroup.id, session.id);
const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
// Session folder at /workspace (contains session.db, outbox/, .claude/)
mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false });
// Agent group folder at /workspace/agent
fs.mkdirSync(groupDir, { recursive: true });
mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false });
// Global memory directory
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: !agentGroup.is_admin });
}
// Claude sessions directory (per agent group, shared across sessions)
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
fs.mkdirSync(claudeDir, { recursive: true });
const settingsFile = path.join(claudeDir, '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',
},
},
@@ -154,61 +169,46 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
);
}
// 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');
// Sync container skills
const skillsSrc = path.join(projectRoot, 'container', 'skills');
const skillsDst = path.join(claudeDir, '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 });
if (fs.statSync(srcDir).isDirectory()) {
fs.cpSync(srcDir, path.join(skillsDst, skillDir), { recursive: true });
}
}
}
mounts.push({
hostPath: groupSessionsDir,
containerPath: '/home/node/.claude',
readonly: false,
});
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
// 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.
// Agent-runner source (per agent group, recompiled on container startup)
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src');
if (fs.existsSync(agentRunnerSrc)) {
const srcIndex = path.join(agentRunnerSrc, 'index.ts');
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
const needsCopy =
!fs.existsSync(groupAgentRunnerDir) ||
!fs.existsSync(cachedIndex) ||
(fs.existsSync(srcIndex) && fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
if (needsCopy) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
// Always copy — source files may have changed beyond just the index
fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true });
}
mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false });
// Admin: mount project root read-only
if (agentGroup.is_admin) {
mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', readonly: true });
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: 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);
// Additional mounts from container config
const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {};
if (containerConfig.additionalMounts) {
const validated = validateAdditionalMounts(
containerConfig.additionalMounts,
agentGroup.name,
!!agentGroup.is_admin,
);
mounts.push(...validated);
}
return mounts;
@@ -217,31 +217,38 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
session: Session,
agentGroup: AgentGroup,
agentIdentifier?: string,
): Promise<string[]> {
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
const args: string[] = ['run', '--rm', '--name', containerName];
// Pass host timezone so container's local time matches the user's
// Environment
args.push('-e', `TZ=${TIMEZONE}`);
args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`);
args.push('-e', `SESSION_DB_PATH=/workspace/session.db`);
// OneCLI gateway handles credential injection — containers never see real secrets.
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
const onecliApplied = await onecli.applyContainerConfig(args, {
addHostMapping: false, // Nanoclaw already handles host gateway
agent: agentIdentifier,
});
if (onecliApplied) {
logger.info({ containerName }, 'OneCLI gateway config applied');
} else {
logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials');
// Pass admin user ID and assistant name from messaging group/agent group
if (session.messaging_group_id) {
const mg = getMessagingGroup(session.messaging_group_id);
if (mg?.admin_user_id) {
args.push('-e', `NANOCLAW_ADMIN_USER_ID=${mg.admin_user_id}`);
}
}
if (agentGroup.name) {
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
}
// Runtime-specific args for host gateway resolution
// OneCLI gateway
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (onecliApplied) {
log.debug('OneCLI gateway applied', { containerName });
}
// Host gateway
args.push(...hostGatewayArgs());
// 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).
// User mapping
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
@@ -249,6 +256,7 @@ async function buildContainerArgs(
args.push('-e', 'HOME=/home/node');
}
// Volume mounts
for (const mount of mounts) {
if (mount.readonly) {
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
@@ -257,421 +265,13 @@ async function buildContainerArgs(
}
}
// Override entrypoint: compile agent-runner source, run v2 entry point (no stdin)
args.push('--entrypoint', 'bash');
args.push(CONTAINER_IMAGE);
args.push(
'-c',
'cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2 && ln -sf /app/node_modules /tmp/dist/node_modules && node /tmp/dist/index.js',
);
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()}`;
// Main group uses the default OneCLI agent; others use their own agent.
const agentIdentifier = input.isMain ? undefined : group.folder.toLowerCase().replace(/_/g, '-');
const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier);
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;
container.stdin.write(JSON.stringify(input));
container.stdin.end();
// 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');
try {
stopContainer(containerName);
} catch (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) {
// On error, log input metadata only — not the full prompt.
// Full input is only included at verbose level to avoid
// persisting user conversation content on every non-zero exit.
if (isVerbose) {
logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``);
} else {
logLines.push(
`=== Input Summary ===`,
`Prompt length: ${input.prompt.length} chars`,
`Session ID: ${input.sessionId || 'new'}`,
``,
);
}
logLines.push(
`=== 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;
script?: string | null;
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,
),
);
}
+14 -13
View File
@@ -1,12 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock logger
vi.mock('./logger.js', () => ({
logger: {
// Mock log
vi.mock('./log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
@@ -23,7 +24,7 @@ import {
ensureContainerRuntimeRunning,
cleanupOrphans,
} from './container-runtime.js';
import { logger } from './logger.js';
import { log } from './log.js';
beforeEach(() => {
vi.clearAllMocks();
@@ -67,7 +68,7 @@ describe('ensureContainerRuntimeRunning', () => {
stdio: 'pipe',
timeout: 10000,
});
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
expect(log.debug).toHaveBeenCalledWith('Container runtime already running');
});
it('throws when docker info fails', () => {
@@ -76,7 +77,7 @@ describe('ensureContainerRuntimeRunning', () => {
});
expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start');
expect(logger.error).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
});
@@ -99,9 +100,9 @@ describe('cleanupOrphans', () => {
expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, {
stdio: 'pipe',
});
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
expect(log.info).toHaveBeenCalledWith(
'Stopped orphaned containers',
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
);
});
@@ -111,7 +112,7 @@ describe('cleanupOrphans', () => {
cleanupOrphans();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(logger.info).not.toHaveBeenCalled();
expect(log.info).not.toHaveBeenCalled();
});
it('warns and continues when ps fails', () => {
@@ -121,9 +122,9 @@ describe('cleanupOrphans', () => {
cleanupOrphans(); // should not throw
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect(log.warn).toHaveBeenCalledWith(
'Failed to clean up orphaned containers',
expect.objectContaining({ err: expect.any(Error) }),
);
});
@@ -139,9 +140,9 @@ describe('cleanupOrphans', () => {
cleanupOrphans(); // should not throw
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
expect(log.info).toHaveBeenCalledWith(
'Stopped orphaned containers',
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
);
});
});
+5 -5
View File
@@ -5,7 +5,7 @@
import { execSync } from 'child_process';
import os from 'os';
import { logger } from './logger.js';
import { log } from './log.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker';
@@ -39,9 +39,9 @@ export function ensureContainerRuntimeRunning(): void {
stdio: 'pipe',
timeout: 10000,
});
logger.debug('Container runtime already running');
log.debug('Container runtime already running');
} catch (err) {
logger.error({ err }, 'Failed to reach container runtime');
log.error('Failed to reach container runtime', { err });
console.error('\n╔════════════════════════════════════════════════════════════════╗');
console.error('║ FATAL: Container runtime failed to start ║');
console.error('║ ║');
@@ -72,9 +72,9 @@ export function cleanupOrphans(): void {
}
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
log.info('Stopped orphaned containers', { count: orphans.length, names: orphans });
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
log.warn('Failed to clean up orphaned containers', { err });
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AgentGroup } from '../types-v2.js';
import type { AgentGroup } from '../types.js';
import { getDb } from './connection.js';
export function createAgentGroup(group: AgentGroup): void {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { MessagingGroup, MessagingGroupAgent } from '../types-v2.js';
import type { MessagingGroup, MessagingGroupAgent } from '../types.js';
import { getDb } from './connection.js';
// ── Messaging Groups ──
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PendingQuestion, Session } from '../types-v2.js';
import type { PendingQuestion, Session } from '../types.js';
import { getDb } from './connection.js';
// ── Sessions ──
+2 -2
View File
@@ -10,9 +10,9 @@ import { getRunningSessions, getActiveSessions, createPendingQuestion } from './
import { getAgentGroup } from './db/agent-groups.js';
import { log } from './log.js';
import { openSessionDb, sessionDir } from './session-manager.js';
import { resetContainerIdleTimer } from './container-runner-v2.js';
import { resetContainerIdleTimer } from './container-runner.js';
import type { OutboundFile } from './channels/adapter.js';
import type { Session } from './types-v2.js';
import type { Session } from './types.js';
const ACTIVE_POLL_MS = 1000;
const SWEEP_POLL_MS = 60_000;
+2 -2
View File
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { logger } from './logger.js';
import { log } from './log.js';
/**
* Parse the .env file and return values for the requested keys.
@@ -14,7 +14,7 @@ export function readEnvFile(keys: string[]): Record<string, string> {
try {
content = fs.readFileSync(envFile, 'utf-8');
} catch (err) {
logger.debug({ err }, '.env file not found, using defaults');
log.debug('.env file not found, using defaults', { err });
return {};
}
+6 -6
View File
@@ -25,10 +25,10 @@ import {
sessionsBaseDir,
} from './session-manager.js';
import { getSession, findSession } from './db/sessions.js';
import type { InboundEvent } from './router-v2.js';
import type { InboundEvent } from './router.js';
// Mock container runner to prevent actual Docker spawning
vi.mock('./container-runner-v2.js', () => ({
vi.mock('./container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
resetContainerIdleTimer: vi.fn(),
isContainerRunning: vi.fn().mockReturnValue(false),
@@ -202,8 +202,8 @@ describe('router', () => {
});
it('should route a message end-to-end', async () => {
const { routeInbound } = await import('./router-v2.js');
const { wakeContainer } = await import('./container-runner-v2.js');
const { routeInbound } = await import('./router.js');
const { wakeContainer } = await import('./container-runner.js');
const event: InboundEvent = {
channelType: 'discord',
@@ -237,7 +237,7 @@ describe('router', () => {
});
it('should auto-create messaging group for unknown platform', async () => {
const { routeInbound } = await import('./router-v2.js');
const { routeInbound } = await import('./router.js');
// This platform ID isn't registered — but since there's no agent configured for it,
// it should create the messaging group but not route (no agents configured)
@@ -262,7 +262,7 @@ describe('router', () => {
});
it('should route multiple messages to the same session', async () => {
const { routeInbound } = await import('./router-v2.js');
const { routeInbound } = await import('./router.js');
await routeInbound({
channelType: 'discord',
+2 -2
View File
@@ -13,8 +13,8 @@ import { getActiveSessions, updateSession } from './db/sessions.js';
import { getAgentGroup } from './db/agent-groups.js';
import { log } from './log.js';
import { openSessionDb, sessionDbPath } from './session-manager.js';
import { wakeContainer, isContainerRunning } from './container-runner-v2.js';
import type { Session } from './types-v2.js';
import { wakeContainer, isContainerRunning } from './container-runner.js';
import type { Session } from './types.js';
const SWEEP_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
-180
View File
@@ -1,180 +0,0 @@
/**
* NanoClaw v2 main entry point.
*
* Thin orchestrator: init DB, run migrations, start channel adapters,
* start delivery polls, start sweep, handle shutdown.
*/
import path from 'path';
import { DATA_DIR } from './config.js';
import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js';
import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messaging-groups.js';
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
import { startHostSweep, stopHostSweep } from './host-sweep.js';
import { routeInbound } from './router-v2.js';
import { getPendingQuestion, deletePendingQuestion, getSession } from './db/sessions.js';
import { writeSessionMessage } from './session-manager.js';
import { wakeContainer } from './container-runner-v2.js';
import { log } from './log.js';
// Channel imports — each triggers self-registration
import './channels/discord-v2.js';
import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js';
import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js';
async function main(): Promise<void> {
log.info('NanoClaw v2 starting');
// 1. Init central DB
const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath);
runMigrations(db);
log.info('Central DB ready', { path: dbPath });
// 2. Container runtime
ensureContainerRuntimeRunning();
cleanupOrphans();
// 3. Channel adapters
await initChannelAdapters((adapter: ChannelAdapter): ChannelSetup => {
const conversations = buildConversationConfigs(adapter.channelType);
return {
conversations,
onInbound(platformId, threadId, message) {
routeInbound({
channelType: adapter.channelType,
platformId,
threadId,
message: {
id: message.id,
kind: message.kind,
content: JSON.stringify(message.content),
timestamp: message.timestamp,
},
}).catch((err) => {
log.error('Failed to route inbound message', { channelType: adapter.channelType, err });
});
},
onMetadata(platformId, name, isGroup) {
log.info('Channel metadata discovered', {
channelType: adapter.channelType,
platformId,
name,
isGroup,
});
},
onAction(questionId, selectedOption, userId) {
handleQuestionResponse(questionId, selectedOption, userId).catch((err) => {
log.error('Failed to handle question response', { questionId, err });
});
},
};
});
// 4. Delivery adapter bridge — dispatches to channel adapters
setDeliveryAdapter({
async deliver(channelType, platformId, threadId, kind, content, files) {
const adapter = getChannelAdapter(channelType);
if (!adapter) {
log.warn('No adapter for channel type', { channelType });
return;
}
await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
},
async setTyping(channelType, platformId, threadId) {
const adapter = getChannelAdapter(channelType);
await adapter?.setTyping?.(platformId, threadId);
},
});
// 5. Start delivery polls
startActiveDeliveryPoll();
startSweepDeliveryPoll();
log.info('Delivery polls started');
// 6. Start host sweep
startHostSweep();
log.info('Host sweep started');
log.info('NanoClaw v2 running');
}
/** Build ConversationConfig[] for a channel type from the central DB. */
function buildConversationConfigs(channelType: string): ConversationConfig[] {
const groups = getMessagingGroupsByChannel(channelType);
const configs: ConversationConfig[] = [];
for (const mg of groups) {
const agents = getMessagingGroupAgents(mg.id);
for (const agent of agents) {
const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null;
configs.push({
platformId: mg.platform_id,
agentGroupId: agent.agent_group_id,
triggerPattern: triggerRules?.pattern,
requiresTrigger: triggerRules?.requiresTrigger ?? false,
sessionMode: agent.session_mode,
});
}
}
return configs;
}
/** Handle a user's response to an ask_user_question card. */
async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise<void> {
const pq = getPendingQuestion(questionId);
if (!pq) {
log.warn('Pending question not found (may have expired)', { questionId });
return;
}
const session = getSession(pq.session_id);
if (!session) {
log.warn('Session not found for pending question', { questionId, sessionId: pq.session_id });
deletePendingQuestion(questionId);
return;
}
// Write the response to the session DB as a system message
writeSessionMessage(session.agent_group_id, session.id, {
id: `qr-${questionId}-${Date.now()}`,
kind: 'system',
timestamp: new Date().toISOString(),
platformId: pq.platform_id,
channelType: pq.channel_type,
threadId: pq.thread_id,
content: JSON.stringify({
type: 'question_response',
questionId,
selectedOption,
userId,
}),
});
deletePendingQuestion(questionId);
log.info('Question response routed', { questionId, selectedOption, sessionId: session.id });
// Wake the container so the MCP tool's poll picks up the response
await wakeContainer(session);
}
/** Graceful shutdown. */
async function shutdown(signal: string): Promise<void> {
log.info('Shutdown signal received', { signal });
stopDeliveryPolls();
stopHostSweep();
await teardownChannelAdapters();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
main().catch((err) => {
log.fatal('Startup failed', { err });
process.exit(1);
});
+158 -625
View File
@@ -1,647 +1,180 @@
import fs from 'fs';
/**
* NanoClaw v2 main entry point.
*
* Thin orchestrator: init DB, run migrations, start channel adapters,
* start delivery polls, start sweep, handle shutdown.
*/
import path from 'path';
import { OneCLI } from '@onecli-sh/sdk';
import { DATA_DIR } from './config.js';
import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js';
import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messaging-groups.js';
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
import { startHostSweep, stopHostSweep } from './host-sweep.js';
import { routeInbound } from './router.js';
import { getPendingQuestion, deletePendingQuestion, getSession } from './db/sessions.js';
import { writeSessionMessage } from './session-manager.js';
import { wakeContainer } from './container-runner.js';
import { log } from './log.js';
import {
ASSISTANT_NAME,
DEFAULT_TRIGGER,
getTriggerPattern,
GROUPS_DIR,
IDLE_TIMEOUT,
MAX_MESSAGES_PER_PROMPT,
ONECLI_URL,
POLL_INTERVAL,
TIMEZONE,
} from './config.js';
import './channels/index.js';
import { getChannelFactory, getRegisteredChannelNames } from './channels/registry.js';
import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot } from './container-runner.js';
import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
import {
getAllChats,
getAllRegisteredGroups,
getAllSessions,
deleteSession,
getAllTasks,
getLastBotMessageTimestamp,
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 { restoreRemoteControl, startRemoteControl, stopRemoteControl } from './remote-control.js';
import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js';
import { startSessionCleanup } from './session-cleanup.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
// Channel imports — each triggers self-registration
import './channels/discord.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;
const channels: Channel[] = [];
const queue = new GroupQueue();
const onecli = new OneCLI({ url: ONECLI_URL });
function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
if (group.isMain) return;
const identifier = group.folder.toLowerCase().replace(/_/g, '-');
onecli.ensureAgent({ name: group.name, identifier }).then(
(res) => {
logger.info({ jid, identifier, created: res.created }, 'OneCLI agent ensured');
},
(err) => {
logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped');
},
);
}
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');
}
/**
* Return the message cursor for a group, recovering from the last bot reply
* if lastAgentTimestamp is missing (new group, corrupted state, restart).
*/
function getOrRecoverCursor(chatJid: string): string {
const existing = lastAgentTimestamp[chatJid];
if (existing) return existing;
const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME);
if (botTs) {
logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply');
lastAgentTimestamp[chatJid] = botTs;
saveState();
return botTs;
}
return '';
}
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 });
// Copy CLAUDE.md template into the new group folder so agents have
// identity and instructions from the first run. (Fixes #1391)
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(groupMdFile)) {
const templateFile = path.join(GROUPS_DIR, group.isMain ? 'main' : 'global', 'CLAUDE.md');
if (fs.existsSync(templateFile)) {
let content = fs.readFileSync(templateFile, 'utf-8');
if (ASSISTANT_NAME !== 'Andy') {
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
}
fs.writeFileSync(groupMdFile, content);
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
}
}
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group);
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) {
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
return true;
}
const isMainGroup = group.isMain === true;
const missedMessages = getMessagesSince(
chatJid,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
if (missedMessages.length === 0) return true;
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = missedMessages.some(
(m) =>
triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
}
const prompt = formatMessages(missedMessages, TIMEZONE);
// 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.length} chars`);
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.isMain === true;
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,
script: t.script || undefined,
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') {
// Detect stale/corrupt session — clear it so the next retry starts fresh.
// The session .jsonl can go missing after a crash mid-write, manual
// deletion, or disk-full. The existing backoff in group-queue.ts
// handles the retry; we just need to remove the broken session ID.
const isStaleSession =
sessionId && output.error && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error);
if (isStaleSession) {
logger.warn(
{ group: group.name, staleSessionId: sessionId, error: output.error },
'Stale session detected — clearing for next retry',
);
delete sessions[group.folder];
deleteSession(group.folder);
}
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 (default trigger: ${DEFAULT_TRIGGER})`);
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) {
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
continue;
}
const isMainGroup = group.isMain === true;
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 triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some(
(m) =>
triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) continue;
}
// Pull all messages since lastAgentTimestamp so non-trigger
// context that accumulated between triggers is included.
const allPending = getMessagesSince(
chatJid,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
const messagesToSend = allPending.length > 0 ? allPending : groupMessages;
const formatted = formatMessages(messagesToSend, TIMEZONE);
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 pending = getMessagesSince(chatJid, getOrRecoverCursor(chatJid), ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT);
if (pending.length > 0) {
logger.info({ group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages');
queue.enqueueMessageCheck(chatJid);
}
}
}
function ensureContainerSystemRunning(): void {
ensureContainerRuntimeRunning();
cleanupOrphans();
}
import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js';
import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js';
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
log.info('NanoClaw v2 starting');
// Ensure OneCLI agents exist for all registered groups.
// Recovers from missed creates (e.g. OneCLI was down at registration time).
for (const [jid, group] of Object.entries(registeredGroups)) {
ensureOneCLIAgent(jid, group);
}
// 1. Init central DB
const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath);
runMigrations(db);
log.info('Central DB ready', { path: dbPath });
restoreRemoteControl();
// 2. Container runtime
ensureContainerRuntimeRunning();
cleanupOrphans();
// 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'));
// 3. Channel adapters
await initChannelAdapters((adapter: ChannelAdapter): ChannelSetup => {
const conversations = buildConversationConfigs(adapter.channelType);
return {
conversations,
onInbound(platformId, threadId, message) {
routeInbound({
channelType: adapter.channelType,
platformId,
threadId,
message: {
id: message.id,
kind: message.kind,
content: JSON.stringify(message.content),
timestamp: message.timestamp,
},
}).catch((err) => {
log.error('Failed to route inbound message', { channelType: adapter.channelType, err });
});
},
onMetadata(platformId, name, isGroup) {
log.info('Channel metadata discovered', {
channelType: adapter.channelType,
platformId,
name,
isGroup,
});
},
onAction(questionId, selectedOption, userId) {
handleQuestionResponse(questionId, selectedOption, userId).catch((err) => {
log.error('Failed to handle question response', { questionId, err });
});
},
};
});
// Handle /remote-control and /remote-control-end commands
async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise<void> {
const group = registeredGroups[chatJid];
if (!group?.isMain) {
logger.warn({ chatJid, sender: msg.sender }, 'Remote control rejected: not main group');
return;
}
const channel = findChannel(channels, chatJid);
if (!channel) return;
if (command === '/remote-control') {
const result = await startRemoteControl(msg.sender, chatJid, process.cwd());
if (result.ok) {
await channel.sendMessage(chatJid, result.url);
} else {
await channel.sendMessage(chatJid, `Remote Control failed: ${result.error}`);
}
} else {
const result = stopRemoteControl();
if (result.ok) {
await channel.sendMessage(chatJid, 'Remote Control session ended.');
} else {
await channel.sendMessage(chatJid, result.error);
}
}
}
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => {
// Remote control commands — intercept before storage
const trimmed = msg.content.trim();
if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
logger.error({ err, chatJid }, 'Remote control command error'),
);
// 4. Delivery adapter bridge — dispatches to channel adapters
setDeliveryAdapter({
async deliver(channelType, platformId, threadId, kind, content, files) {
const adapter = getChannelAdapter(channelType);
if (!adapter) {
log.warn('No adapter for channel type', { channelType });
return;
}
// Sender allowlist drop mode: discard messages from denied senders before storing
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
const cfg = loadSenderAllowlist();
if (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) {
if (cfg.logDenied) {
logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)');
}
return;
}
}
storeMessage(msg);
await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
},
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
registeredGroups: () => registeredGroups,
};
async setTyping(channelType, platformId, threadId) {
const adapter = getChannelAdapter(channelType);
await adapter?.setTyping?.(platformId, threadId);
},
});
// Create and connect all registered channels.
// Each channel self-registers via the barrel import above.
// Factories return null when credentials are missing, so unconfigured channels are skipped.
for (const channelName of getRegisteredChannelNames()) {
const factory = getChannelFactory(channelName)!;
const channel = factory(channelOpts);
if (!channel) {
logger.warn(
{ channel: channelName },
'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
);
continue;
// 5. Start delivery polls
startActiveDeliveryPoll();
startSweepDeliveryPoll();
log.info('Delivery polls started');
// 6. Start host sweep
startHostSweep();
log.info('Host sweep started');
log.info('NanoClaw v2 running');
}
/** Build ConversationConfig[] for a channel type from the central DB. */
function buildConversationConfigs(channelType: string): ConversationConfig[] {
const groups = getMessagingGroupsByChannel(channelType);
const configs: ConversationConfig[] = [];
for (const mg of groups) {
const agents = getMessagingGroupAgents(mg.id);
for (const agent of agents) {
const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null;
configs.push({
platformId: mg.platform_id,
agentGroupId: agent.agent_group_id,
triggerPattern: triggerRules?.pattern,
requiresTrigger: triggerRules?.requiresTrigger ?? false,
sessionMode: agent.session_mode,
});
}
channels.push(channel);
await channel.connect();
}
if (channels.length === 0) {
logger.fatal('No channels connected');
process.exit(1);
}
// 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) {
logger.warn({ jid }, 'No channel owns 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,
syncGroups: async (force: boolean) => {
await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force)));
},
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
onTasksChanged: () => {
const tasks = getAllTasks();
const taskRows = tasks.map((t) => ({
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script || undefined,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
next_run: t.next_run,
}));
for (const group of Object.values(registeredGroups)) {
writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
}
},
});
startSessionCleanup();
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop().catch((err) => {
logger.fatal({ err }, 'Message loop crashed unexpectedly');
process.exit(1);
});
return configs;
}
// 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;
/** Handle a user's response to an ask_user_question card. */
async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise<void> {
const pq = getPendingQuestion(questionId);
if (!pq) {
log.warn('Pending question not found (may have expired)', { questionId });
return;
}
if (isDirectRun) {
main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1);
const session = getSession(pq.session_id);
if (!session) {
log.warn('Session not found for pending question', { questionId, sessionId: pq.session_id });
deletePendingQuestion(questionId);
return;
}
// Write the response to the session DB as a system message
writeSessionMessage(session.agent_group_id, session.id, {
id: `qr-${questionId}-${Date.now()}`,
kind: 'system',
timestamp: new Date().toISOString(),
platformId: pq.platform_id,
channelType: pq.channel_type,
threadId: pq.thread_id,
content: JSON.stringify({
type: 'question_response',
questionId,
selectedOption,
userId,
}),
});
deletePendingQuestion(questionId);
log.info('Question response routed', { questionId, selectedOption, sessionId: session.id });
// Wake the container so the MCP tool's poll picks up the response
await wakeContainer(session);
}
/** Graceful shutdown. */
async function shutdown(signal: string): Promise<void> {
log.info('Shutdown signal received', { signal });
stopDeliveryPolls();
stopHostSweep();
await teardownChannelAdapters();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
main().catch((err) => {
log.fatal('Startup failed', { err });
process.exit(1);
});
+26 -54
View File
@@ -10,8 +10,25 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { MOUNT_ALLOWLIST_PATH } from './config.js';
import { logger } from './logger.js';
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
import { log } from './log.js';
export interface AdditionalMount {
hostPath: string;
containerPath?: string;
readonly?: boolean;
}
export interface MountAllowlist {
allowedRoots: AllowedRoot[];
blockedPatterns: string[];
nonMainReadOnly: boolean;
}
export interface AllowedRoot {
path: string;
allowReadWrite: boolean;
description?: string;
}
// Cache the allowlist in memory - only reloads on process restart
let cachedAllowlist: MountAllowlist | null = null;
@@ -59,11 +76,7 @@ export function loadMountAllowlist(): MountAllowlist | null {
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
// Do NOT cache this as an error — file may be created later without restart.
// Only parse/structural errors are permanently cached.
logger.warn(
{ path: MOUNT_ALLOWLIST_PATH },
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
'Create the file to enable additional mounts.',
);
log.warn('Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.', { path: MOUNT_ALLOWLIST_PATH });
return null;
}
@@ -88,25 +101,12 @@ export function loadMountAllowlist(): MountAllowlist | null {
allowlist.blockedPatterns = mergedBlockedPatterns;
cachedAllowlist = allowlist;
logger.info(
{
path: MOUNT_ALLOWLIST_PATH,
allowedRoots: allowlist.allowedRoots.length,
blockedPatterns: allowlist.blockedPatterns.length,
},
'Mount allowlist loaded successfully',
);
log.info('Mount allowlist loaded successfully', { path: MOUNT_ALLOWLIST_PATH, allowedRoots: allowlist.allowedRoots.length, blockedPatterns: allowlist.blockedPatterns.length });
return cachedAllowlist;
} catch (err) {
allowlistLoadError = err instanceof Error ? err.message : String(err);
logger.error(
{
path: MOUNT_ALLOWLIST_PATH,
error: allowlistLoadError,
},
'Failed to load mount allowlist - additional mounts will be BLOCKED',
);
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', { path: MOUNT_ALLOWLIST_PATH, error: allowlistLoadError });
return null;
}
}
@@ -283,22 +283,11 @@ export function validateMount(mount: AdditionalMount, isMain: boolean): MountVal
if (!isMain && allowlist.nonMainReadOnly) {
// Non-main groups forced to read-only
effectiveReadonly = true;
logger.info(
{
mount: mount.hostPath,
},
'Mount forced to read-only for non-main group',
);
log.info('Mount forced to read-only for non-main group', { mount: mount.hostPath });
} else if (!allowedRoot.allowReadWrite) {
// Root doesn't allow read-write
effectiveReadonly = true;
logger.info(
{
mount: mount.hostPath,
root: allowedRoot.path,
},
'Mount forced to read-only - root does not allow read-write',
);
log.info('Mount forced to read-only - root does not allow read-write', { mount: mount.hostPath, root: allowedRoot.path });
} else {
// Read-write allowed
effectiveReadonly = false;
@@ -344,26 +333,9 @@ export function validateAdditionalMounts(
readonly: result.effectiveReadonly!,
});
logger.debug(
{
group: groupName,
hostPath: result.realHostPath,
containerPath: result.resolvedContainerPath,
readonly: result.effectiveReadonly,
reason: result.reason,
},
'Mount validated successfully',
);
log.debug('Mount validated successfully', { group: groupName, hostPath: result.realHostPath, containerPath: result.resolvedContainerPath, readonly: result.effectiveReadonly, reason: result.reason });
} else {
logger.warn(
{
group: groupName,
requestedPath: mount.hostPath,
containerPath: mount.containerPath,
reason: result.reason,
},
'Additional mount REJECTED',
);
log.warn('Additional mount REJECTED', { group: groupName, requestedPath: mount.hostPath, containerPath: mount.containerPath, reason: result.reason });
}
}
-111
View File
@@ -1,111 +0,0 @@
/**
* Inbound message routing for v2.
*
* Channel adapter event resolve messaging group resolve agent group
* resolve/create session write messages_in wake container
*/
import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js';
import { log } from './log.js';
import { resolveSession, writeSessionMessage } from './session-manager.js';
import { wakeContainer } from './container-runner-v2.js';
import { getSession } from './db/sessions.js';
import type { MessagingGroupAgent } from './types-v2.js';
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export interface InboundEvent {
channelType: string;
platformId: string;
threadId: string | null;
message: {
id: string;
kind: 'chat' | 'chat-sdk';
content: string; // JSON blob
timestamp: string;
};
}
/**
* Route an inbound message from a channel adapter to the correct session.
* Creates messaging group + session if they don't exist yet.
*/
export async function routeInbound(event: InboundEvent): Promise<void> {
// 1. Resolve messaging group
let mg = getMessagingGroupByPlatform(event.channelType, event.platformId);
if (!mg) {
// Auto-create messaging group (adapter already decided to forward this)
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
mg = {
id: mgId,
channel_type: event.channelType,
platform_id: event.platformId,
name: null,
is_group: 0,
admin_user_id: null,
created_at: new Date().toISOString(),
};
createMessagingGroup(mg);
log.info('Auto-created messaging group', {
id: mgId,
channelType: event.channelType,
platformId: event.platformId,
});
}
// 2. Resolve agent group via messaging_group_agents
const agents = getMessagingGroupAgents(mg.id);
if (agents.length === 0) {
log.warn('No agent groups configured for messaging group', {
messagingGroupId: mg.id,
platformId: event.platformId,
});
return;
}
// Pick the best matching agent (highest priority, trigger matching in future)
const match = pickAgent(agents, event);
if (!match) {
log.debug('No agent matched for message', { messagingGroupId: mg.id });
return;
}
// 3. Resolve or create session
const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, match.session_mode);
// 4. Write message to session DB
writeSessionMessage(session.agent_group_id, session.id, {
id: event.message.id || generateId(),
kind: event.message.kind,
timestamp: event.message.timestamp,
platformId: event.platformId,
channelType: event.channelType,
threadId: event.threadId,
content: event.message.content,
});
log.info('Message routed', {
sessionId: session.id,
agentGroup: match.agent_group_id,
kind: event.message.kind,
created,
});
// 5. Wake container
const freshSession = getSession(session.id);
if (freshSession) {
await wakeContainer(freshSession);
}
}
/**
* Pick the matching agent for an inbound event.
* Currently: highest priority agent. Future: trigger rule matching.
*/
function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): MessagingGroupAgent | null {
// Agents are already ordered by priority DESC from the DB query
// TODO: apply trigger_rules matching (pattern, mentionOnly, etc.)
return agents[0] ?? null;
}
+102 -34
View File
@@ -1,43 +1,111 @@
import { Channel, NewMessage } from './types.js';
import { formatLocalTime } from './timezone.js';
/**
* Inbound message routing for v2.
*
* Channel adapter event resolve messaging group resolve agent group
* resolve/create session write messages_in wake container
*/
import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js';
import { log } from './log.js';
import { resolveSession, writeSessionMessage } from './session-manager.js';
import { wakeContainer } from './container-runner.js';
import { getSession } from './db/sessions.js';
import type { MessagingGroupAgent } from './types.js';
export function escapeXml(s: string): string {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function formatMessages(messages: NewMessage[], timezone: string): string {
const lines = messages.map((m) => {
const displayTime = formatLocalTime(m.timestamp, timezone);
const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : '';
const replySnippet =
m.reply_to_message_content && m.reply_to_sender_name
? `\n <quoted_message from="${escapeXml(m.reply_to_sender_name)}">${escapeXml(m.reply_to_message_content)}</quoted_message>`
: '';
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}"${replyAttr}>${replySnippet}${escapeXml(m.content)}</message>`;
export interface InboundEvent {
channelType: string;
platformId: string;
threadId: string | null;
message: {
id: string;
kind: 'chat' | 'chat-sdk';
content: string; // JSON blob
timestamp: string;
};
}
/**
* Route an inbound message from a channel adapter to the correct session.
* Creates messaging group + session if they don't exist yet.
*/
export async function routeInbound(event: InboundEvent): Promise<void> {
// 1. Resolve messaging group
let mg = getMessagingGroupByPlatform(event.channelType, event.platformId);
if (!mg) {
// Auto-create messaging group (adapter already decided to forward this)
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
mg = {
id: mgId,
channel_type: event.channelType,
platform_id: event.platformId,
name: null,
is_group: 0,
admin_user_id: null,
created_at: new Date().toISOString(),
};
createMessagingGroup(mg);
log.info('Auto-created messaging group', {
id: mgId,
channelType: event.channelType,
platformId: event.platformId,
});
}
// 2. Resolve agent group via messaging_group_agents
const agents = getMessagingGroupAgents(mg.id);
if (agents.length === 0) {
log.warn('No agent groups configured for messaging group', {
messagingGroupId: mg.id,
platformId: event.platformId,
});
return;
}
// Pick the best matching agent (highest priority, trigger matching in future)
const match = pickAgent(agents, event);
if (!match) {
log.debug('No agent matched for message', { messagingGroupId: mg.id });
return;
}
// 3. Resolve or create session
const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, match.session_mode);
// 4. Write message to session DB
writeSessionMessage(session.agent_group_id, session.id, {
id: event.message.id || generateId(),
kind: event.message.kind,
timestamp: event.message.timestamp,
platformId: event.platformId,
channelType: event.channelType,
threadId: event.threadId,
content: event.message.content,
});
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
log.info('Message routed', {
sessionId: session.id,
agentGroup: match.agent_group_id,
kind: event.message.kind,
created,
});
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
// 5. Wake container
const freshSession = getSession(session.id);
if (freshSession) {
await wakeContainer(freshSession);
}
}
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
export function formatOutbound(rawText: string): string {
const text = stripInternalTags(rawText);
if (!text) return '';
return text;
}
export function routeOutbound(channels: Channel[], jid: string, text: string): Promise<void> {
const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
}
export function findChannel(channels: Channel[], jid: string): Channel | undefined {
return channels.find((c) => c.ownsJid(jid));
/**
* Pick the matching agent for an inbound event.
* Currently: highest priority agent. Future: trigger rule matching.
*/
function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): MessagingGroupAgent | null {
// Agents are already ordered by priority DESC from the DB query
// TODO: apply trigger_rules matching (pattern, mentionOnly, etc.)
return agents[0] ?? null;
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { DATA_DIR } from './config.js';
import { createSession, findSession, getSession, updateSession } from './db/sessions.js';
import { log } from './log.js';
import { SESSION_SCHEMA } from './db/schema.js';
import type { Session } from './types-v2.js';
import type { Session } from './types.js';
/** Root directory for all session data. */
export function sessionsBaseDir(): string {
+35 -13
View File
@@ -31,9 +31,9 @@ export class SqliteStateAdapter implements StateAdapter {
async get<T = unknown>(key: string): Promise<T | null> {
this.cleanup();
const row = this.db
.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?')
.get(key) as { value: string; expires_at: number | null } | undefined;
const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
| { value: string; expires_at: number | null }
| undefined;
if (!row) return null;
if (row.expires_at && row.expires_at < Date.now()) {
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
@@ -44,16 +44,22 @@ export class SqliteStateAdapter implements StateAdapter {
async set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<void> {
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
this.db.prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)').run(key, JSON.stringify(value), expiresAt);
this.db
.prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
.run(key, JSON.stringify(value), expiresAt);
}
async setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise<boolean> {
const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as { expires_at: number | null } | undefined;
const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
| { expires_at: number | null }
| undefined;
if (existing?.expires_at && existing.expires_at < Date.now()) {
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
}
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
const result = this.db.prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)').run(key, JSON.stringify(value), expiresAt);
const result = this.db
.prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
.run(key, JSON.stringify(value), expiresAt);
return result.changes > 0;
}
@@ -83,7 +89,9 @@ export class SqliteStateAdapter implements StateAdapter {
const token = crypto.randomUUID();
const expiresAt = now + ttlMs;
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(threadId, now);
const result = this.db.prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)').run(threadId, token, expiresAt);
const result = this.db
.prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)')
.run(threadId, token, expiresAt);
if (result.changes === 0) return null;
return { threadId, token, expiresAt };
}
@@ -94,7 +102,9 @@ export class SqliteStateAdapter implements StateAdapter {
async extendLock(lock: Lock, ttlMs: number): Promise<boolean> {
const newExpiry = Date.now() + ttlMs;
const result = this.db.prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?').run(newExpiry, lock.threadId, lock.token);
const result = this.db
.prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?')
.run(newExpiry, lock.threadId, lock.token);
if (result.changes > 0) {
lock.expiresAt = newExpiry;
return true;
@@ -110,9 +120,13 @@ export class SqliteStateAdapter implements StateAdapter {
async appendToList(key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }): Promise<void> {
const expiresAt = options?.ttlMs ? Date.now() + options.ttlMs : null;
const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as { maxIdx: number | null } | undefined;
const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as
| { maxIdx: number | null }
| undefined;
const nextIdx = (maxRow?.maxIdx ?? -1) + 1;
this.db.prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)').run(key, nextIdx, JSON.stringify(value), expiresAt);
this.db
.prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)')
.run(key, nextIdx, JSON.stringify(value), expiresAt);
if (options?.maxLength) {
const cutoff = nextIdx - options.maxLength;
if (cutoff >= 0) {
@@ -123,7 +137,11 @@ export class SqliteStateAdapter implements StateAdapter {
async getList<T = unknown>(key: string): Promise<T[]> {
const now = Date.now();
const rows = this.db.prepare('SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC').all(key, now) as { value: string }[];
const rows = this.db
.prepare(
'SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC',
)
.all(key, now) as { value: string }[];
return rows.map((r) => JSON.parse(r.value) as T);
}
@@ -137,7 +155,9 @@ export class SqliteStateAdapter implements StateAdapter {
async dequeue(threadId: string): Promise<QueueEntry | null> {
const key = `queue:${threadId}`;
const row = this.db.prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1').get(key) as { idx: number; value: string } | undefined;
const row = this.db
.prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1')
.get(key) as { idx: number; value: string } | undefined;
if (!row) return null;
this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx = ?').run(key, row.idx);
return JSON.parse(row.value) as QueueEntry;
@@ -145,7 +165,9 @@ export class SqliteStateAdapter implements StateAdapter {
async queueDepth(threadId: string): Promise<number> {
const key = `queue:${threadId}`;
const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as { count: number };
const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as {
count: number;
};
return row.count;
}
-90
View File
@@ -1,90 +0,0 @@
// ── Central DB entities ──
export interface AgentGroup {
id: string;
name: string;
folder: string;
is_admin: number; // 0 | 1
agent_provider: string | null;
container_config: string | null; // JSON: { additionalMounts, timeout }
created_at: string;
}
export interface MessagingGroup {
id: string;
channel_type: string;
platform_id: string;
name: string | null;
is_group: number; // 0 | 1
admin_user_id: string | null;
created_at: string;
}
export interface MessagingGroupAgent {
id: string;
messaging_group_id: string;
agent_group_id: string;
trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
response_scope: 'all' | 'triggered' | 'allowlisted';
session_mode: 'shared' | 'per-thread';
priority: number;
created_at: string;
}
export interface Session {
id: string;
agent_group_id: string;
messaging_group_id: string | null;
thread_id: string | null;
agent_provider: string | null;
status: 'active' | 'closed';
container_status: 'running' | 'idle' | 'stopped';
last_active: string | null;
created_at: string;
}
// ── Session DB entities ──
export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface MessageIn {
id: string;
kind: MessageInKind;
timestamp: string;
status: MessageInStatus;
status_changed: string | null;
process_after: string | null;
recurrence: string | null;
tries: number;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
export interface MessageOut {
id: string;
in_reply_to: string | null;
timestamp: string;
delivered: number; // 0 | 1
deliver_after: string | null;
recurrence: string | null;
kind: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
// ── Pending questions (central DB) ──
export interface PendingQuestion {
question_id: string;
session_id: string;
message_out_id: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
created_at: string;
}
+79 -101
View File
@@ -1,112 +1,90 @@
export interface AdditionalMount {
hostPath: string; // Absolute path on host (supports ~ for home)
containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value}
readonly?: boolean; // Default: true for safety
}
// ── Central DB entities ──
/**
* Mount Allowlist - Security configuration for additional mounts
* This file should be stored at ~/.config/nanoclaw/mount-allowlist.json
* and is NOT mounted into any container, making it tamper-proof from agents.
*/
export interface MountAllowlist {
// Directories that can be mounted into containers
allowedRoots: AllowedRoot[];
// Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg")
blockedPatterns: string[];
// If true, non-main groups can only mount read-only regardless of config
nonMainReadOnly: boolean;
}
export interface AllowedRoot {
// Absolute path or ~ for home (e.g., "~/projects", "/var/repos")
path: string;
// Whether read-write mounts are allowed under this root
allowReadWrite: boolean;
// Optional description for documentation
description?: string;
}
export interface ContainerConfig {
additionalMounts?: AdditionalMount[];
timeout?: number; // Default: 300000 (5 minutes)
}
export interface RegisteredGroup {
export interface AgentGroup {
id: string;
name: string;
folder: string;
trigger: string;
added_at: string;
containerConfig?: ContainerConfig;
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
}
export interface NewMessage {
id: string;
chat_jid: string;
sender: string;
sender_name: string;
content: string;
timestamp: string;
is_from_me?: boolean;
is_bot_message?: boolean;
thread_id?: string;
reply_to_message_id?: string;
reply_to_message_content?: string;
reply_to_sender_name?: string;
}
export interface ScheduledTask {
id: string;
group_folder: string;
chat_jid: string;
prompt: string;
script?: string | null;
schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string;
context_mode: 'group' | 'isolated';
next_run: string | null;
last_run: string | null;
last_result: string | null;
status: 'active' | 'paused' | 'completed';
is_admin: number; // 0 | 1
agent_provider: string | null;
container_config: string | null; // JSON: { additionalMounts, timeout }
created_at: string;
}
export interface TaskRunLog {
task_id: string;
run_at: string;
duration_ms: number;
status: 'success' | 'error';
result: string | null;
error: string | null;
export interface MessagingGroup {
id: string;
channel_type: string;
platform_id: string;
name: string | null;
is_group: number; // 0 | 1
admin_user_id: string | null;
created_at: string;
}
// --- Channel abstraction ---
export interface Channel {
name: string;
connect(): Promise<void>;
sendMessage(jid: string, text: string): Promise<void>;
isConnected(): boolean;
ownsJid(jid: string): boolean;
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;
// Optional: sync group/chat names from the platform.
syncGroups?(force: boolean): Promise<void>;
export interface MessagingGroupAgent {
id: string;
messaging_group_id: string;
agent_group_id: string;
trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
response_scope: 'all' | 'triggered' | 'allowlisted';
session_mode: 'shared' | 'per-thread';
priority: number;
created_at: string;
}
// Callback type that channels use to deliver inbound messages
export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
export interface Session {
id: string;
agent_group_id: string;
messaging_group_id: string | null;
thread_id: string | null;
agent_provider: string | null;
status: 'active' | 'closed';
container_status: 'running' | 'idle' | 'stopped';
last_active: string | null;
created_at: string;
}
// Callback for chat metadata discovery.
// name is optional — channels that deliver names inline (Telegram) pass it here;
// channels that sync names separately (via syncGroups) omit it.
export type OnChatMetadata = (
chatJid: string,
timestamp: string,
name?: string,
channel?: string,
isGroup?: boolean,
) => void;
// ── Session DB entities ──
export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface MessageIn {
id: string;
kind: MessageInKind;
timestamp: string;
status: MessageInStatus;
status_changed: string | null;
process_after: string | null;
recurrence: string | null;
tries: number;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
export interface MessageOut {
id: string;
in_reply_to: string | null;
timestamp: string;
delivered: number; // 0 | 1
deliver_after: string | null;
recurrence: string | null;
kind: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
// ── Pending questions (central DB) ──
export interface PendingQuestion {
question_id: string;
session_id: string;
message_out_id: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
created_at: string;
}
+62
View File
@@ -0,0 +1,62 @@
import os from 'os';
import path from 'path';
import { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'TZ']);
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 SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'sender-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 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 ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL;
export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
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 function buildTriggerPattern(trigger: string): RegExp {
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
}
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
export function getTriggerPattern(trigger?: string): RegExp {
const normalizedTrigger = trigger?.trim();
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
}
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.
function resolveConfigTimezone(): string {
const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
for (const tz of candidates) {
if (tz && isValidTimezone(tz)) return tz;
}
return 'UTC';
}
export const TIMEZONE = resolveConfigTimezone();
+677
View File
@@ -0,0 +1,677 @@
/**
* Container Runner for NanoClaw
* Spawns agent execution in containers and handles IPC
*/
import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { OneCLI } from '@onecli-sh/sdk';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
const onecli = new OneCLI({ url: ONECLI_URL });
// 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;
script?: 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 groupDir = resolveGroupFolderPath(group.folder);
if (isMain) {
// Main gets the project root read-only. Writable paths the agent needs
// (store, 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,
});
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Credentials are injected by the OneCLI gateway, never exposed to containers.
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
// Main gets writable access to the store (SQLite DB) so it can
// query and write to the database directly.
const storeDir = path.join(projectRoot, 'store');
mounts.push({
hostPath: storeDir,
containerPath: '/workspace/project/store',
readonly: false,
});
// Main also gets its group folder as the working directory
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
// Global memory directory — writable for main so it can update shared context
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({
hostPath: globalDir,
containerPath: '/workspace/global',
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,
});
// 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(agentRunnerSrc)) {
const srcIndex = path.join(agentRunnerSrc, 'index.ts');
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
const needsCopy =
!fs.existsSync(groupAgentRunnerDir) ||
!fs.existsSync(cachedIndex) ||
(fs.existsSync(srcIndex) && fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
if (needsCopy) {
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;
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
agentIdentifier?: string,
): Promise<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}`);
// OneCLI gateway handles credential injection — containers never see real secrets.
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
const onecliApplied = await onecli.applyContainerConfig(args, {
addHostMapping: false, // Nanoclaw already handles host gateway
agent: agentIdentifier,
});
if (onecliApplied) {
logger.info({ containerName }, 'OneCLI gateway config applied');
} else {
logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials');
}
// Runtime-specific args for host gateway resolution
args.push(...hostGatewayArgs());
// 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()}`;
// Main group uses the default OneCLI agent; others use their own agent.
const agentIdentifier = input.isMain ? undefined : group.folder.toLowerCase().replace(/_/g, '-');
const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier);
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;
container.stdin.write(JSON.stringify(input));
container.stdin.end();
// 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');
try {
stopContainer(containerName);
} catch (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) {
// On error, log input metadata only — not the full prompt.
// Full input is only included at verbose level to avoid
// persisting user conversation content on every non-zero exit.
if (isVerbose) {
logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``);
} else {
logLines.push(
`=== Input Summary ===`,
`Prompt length: ${input.prompt.length} chars`,
`Session ID: ${input.sessionId || 'new'}`,
``,
);
}
logLines.push(
`=== 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;
script?: string | null;
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,
),
);
}
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock logger
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock child_process — store the mock fn so tests can configure it
const mockExecSync = vi.fn();
vi.mock('child_process', () => ({
execSync: (...args: unknown[]) => mockExecSync(...args),
}));
import {
CONTAINER_RUNTIME_BIN,
readonlyMountArgs,
stopContainer,
ensureContainerRuntimeRunning,
cleanupOrphans,
} from './container-runtime.js';
import { logger } from './logger.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns -v flag with :ro suffix', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
});
});
describe('stopContainer', () => {
it('calls docker stop for valid container names', () => {
stopContainer('nanoclaw-test-123');
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, {
stdio: 'pipe',
});
});
it('rejects names with shell metacharacters', () => {
expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name');
expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name');
expect(() => stopContainer('foo`id`')).toThrow('Invalid container name');
expect(mockExecSync).not.toHaveBeenCalled();
});
});
// --- ensureContainerRuntimeRunning ---
describe('ensureContainerRuntimeRunning', () => {
it('does nothing when runtime is already running', () => {
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
});
it('throws when docker info fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('Cannot connect to the Docker daemon');
});
expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start');
expect(logger.error).toHaveBeenCalled();
});
});
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers', () => {
// docker ps returns container names, one per line
mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n');
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ps + 2 stop calls
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(2, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, {
stdio: 'pipe',
});
expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, {
stdio: 'pipe',
});
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
'Stopped orphaned containers',
);
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce('');
cleanupOrphans();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(logger.info).not.toHaveBeenCalled();
});
it('warns and continues when ps fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('docker not available');
});
cleanupOrphans(); // should not throw
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to clean up orphaned containers',
);
});
it('continues stopping remaining containers when one stop fails', () => {
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');
});
// Second stop succeeds
mockExecSync.mockReturnValueOnce('');
cleanupOrphans(); // should not throw
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
'Stopped orphaned containers',
);
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* Container runtime abstraction for NanoClaw.
* All runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import os from 'os';
import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker';
/** CLI args needed for the container to resolve the host gateway. */
export function hostGatewayArgs(): string[] {
// On Linux, host.docker.internal isn't built-in — add it explicitly
if (os.platform() === 'linux') {
return ['--add-host=host.docker.internal:host-gateway'];
}
return [];
}
/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
return ['-v', `${hostPath}:${containerPath}:ro`];
}
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
export function stopContainer(name: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Invalid container name: ${name}`);
}
execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' });
}
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
logger.debug('Container runtime already running');
} catch (err) {
logger.error({ err }, 'Failed to reach container runtime');
console.error('\n╔════════════════════════════════════════════════════════════════╗');
console.error('║ FATAL: Container runtime failed to start ║');
console.error('║ ║');
console.error('║ Agents cannot run without a container runtime. To fix: ║');
console.error('║ 1. Ensure Docker is installed and running ║');
console.error('║ 2. Run: docker info ║');
console.error('║ 3. Restart NanoClaw ║');
console.error('╚════════════════════════════════════════════════════════════════╝\n');
throw new Error('Container runtime is required but failed to start', {
cause: err,
});
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const orphans = output.trim().split('\n').filter(Boolean);
for (const name of orphans) {
try {
stopContainer(name);
} catch {
/* already stopped */
}
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
}
}
View File
+42
View File
@@ -0,0 +1,42 @@
import fs from 'fs';
import path from 'path';
import { logger } from './logger.js';
/**
* Parse the .env file and return values for the requested keys.
* Does NOT load anything into process.env callers decide what to
* do with the values. This keeps secrets out of the process environment
* so they don't leak to child processes.
*/
export function readEnvFile(keys: string[]): Record<string, string> {
const envFile = path.join(process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envFile, 'utf-8');
} catch (err) {
logger.debug({ err }, '.env file not found, using defaults');
return {};
}
const result: Record<string, string> = {};
const wanted = new Set(keys);
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
if (!wanted.has(key)) continue;
let value = trimmed.slice(eqIdx + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
if (value) result[key] = value;
}
return result;
}
+35
View File
@@ -0,0 +1,35 @@
import path from 'path';
import { describe, expect, it } from 'vitest';
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
describe('group folder validation', () => {
it('accepts normal group folder names', () => {
expect(isValidGroupFolder('main')).toBe(true);
expect(isValidGroupFolder('family-chat')).toBe(true);
expect(isValidGroupFolder('Team_42')).toBe(true);
});
it('rejects traversal and reserved names', () => {
expect(isValidGroupFolder('../../etc')).toBe(false);
expect(isValidGroupFolder('/tmp')).toBe(false);
expect(isValidGroupFolder('global')).toBe(false);
expect(isValidGroupFolder('')).toBe(false);
});
it('resolves safe paths under groups directory', () => {
const resolved = resolveGroupFolderPath('family-chat');
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true);
});
it('resolves safe paths under data ipc directory', () => {
const resolved = resolveGroupIpcPath('family-chat');
expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true);
});
it('throws for unsafe folder names', () => {
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
});
});
+44
View File
@@ -0,0 +1,44 @@
import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const RESERVED_FOLDERS = new Set(['global']);
export function isValidGroupFolder(folder: string): boolean {
if (!folder) return false;
if (folder !== folder.trim()) return false;
if (!GROUP_FOLDER_PATTERN.test(folder)) return false;
if (folder.includes('/') || folder.includes('\\')) return false;
if (folder.includes('..')) return false;
if (RESERVED_FOLDERS.has(folder.toLowerCase())) return false;
return true;
}
export function assertValidGroupFolder(folder: string): void {
if (!isValidGroupFolder(folder)) {
throw new Error(`Invalid group folder "${folder}"`);
}
}
function ensureWithinBase(baseDir: string, resolvedPath: string): void {
const rel = path.relative(baseDir, resolvedPath);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Path escapes base directory: ${resolvedPath}`);
}
}
export function resolveGroupFolderPath(folder: string): string {
assertValidGroupFolder(folder);
const groupPath = path.resolve(GROUPS_DIR, folder);
ensureWithinBase(GROUPS_DIR, groupPath);
return groupPath;
}
export function resolveGroupIpcPath(folder: string): string {
assertValidGroupFolder(folder);
const ipcBaseDir = path.resolve(DATA_DIR, 'ipc');
const ipcPath = path.resolve(ipcBaseDir, folder);
ensureWithinBase(ipcBaseDir, ipcPath);
return ipcPath;
}
+647
View File
@@ -0,0 +1,647 @@
import fs from 'fs';
import path from 'path';
import { OneCLI } from '@onecli-sh/sdk';
import {
ASSISTANT_NAME,
DEFAULT_TRIGGER,
getTriggerPattern,
GROUPS_DIR,
IDLE_TIMEOUT,
MAX_MESSAGES_PER_PROMPT,
ONECLI_URL,
POLL_INTERVAL,
TIMEZONE,
} from './config.js';
import './channels/index.js';
import { getChannelFactory, getRegisteredChannelNames } from './channels/registry.js';
import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot } from './container-runner.js';
import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
import {
getAllChats,
getAllRegisteredGroups,
getAllSessions,
deleteSession,
getAllTasks,
getLastBotMessageTimestamp,
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 { restoreRemoteControl, startRemoteControl, stopRemoteControl } from './remote-control.js';
import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js';
import { startSessionCleanup } from './session-cleanup.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;
const channels: Channel[] = [];
const queue = new GroupQueue();
const onecli = new OneCLI({ url: ONECLI_URL });
function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
if (group.isMain) return;
const identifier = group.folder.toLowerCase().replace(/_/g, '-');
onecli.ensureAgent({ name: group.name, identifier }).then(
(res) => {
logger.info({ jid, identifier, created: res.created }, 'OneCLI agent ensured');
},
(err) => {
logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped');
},
);
}
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');
}
/**
* Return the message cursor for a group, recovering from the last bot reply
* if lastAgentTimestamp is missing (new group, corrupted state, restart).
*/
function getOrRecoverCursor(chatJid: string): string {
const existing = lastAgentTimestamp[chatJid];
if (existing) return existing;
const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME);
if (botTs) {
logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply');
lastAgentTimestamp[chatJid] = botTs;
saveState();
return botTs;
}
return '';
}
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 });
// Copy CLAUDE.md template into the new group folder so agents have
// identity and instructions from the first run. (Fixes #1391)
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(groupMdFile)) {
const templateFile = path.join(GROUPS_DIR, group.isMain ? 'main' : 'global', 'CLAUDE.md');
if (fs.existsSync(templateFile)) {
let content = fs.readFileSync(templateFile, 'utf-8');
if (ASSISTANT_NAME !== 'Andy') {
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
}
fs.writeFileSync(groupMdFile, content);
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
}
}
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group);
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) {
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
return true;
}
const isMainGroup = group.isMain === true;
const missedMessages = getMessagesSince(
chatJid,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
if (missedMessages.length === 0) return true;
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = missedMessages.some(
(m) =>
triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
}
const prompt = formatMessages(missedMessages, TIMEZONE);
// 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.length} chars`);
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.isMain === true;
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,
script: t.script || undefined,
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') {
// Detect stale/corrupt session — clear it so the next retry starts fresh.
// The session .jsonl can go missing after a crash mid-write, manual
// deletion, or disk-full. The existing backoff in group-queue.ts
// handles the retry; we just need to remove the broken session ID.
const isStaleSession =
sessionId && output.error && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error);
if (isStaleSession) {
logger.warn(
{ group: group.name, staleSessionId: sessionId, error: output.error },
'Stale session detected — clearing for next retry',
);
delete sessions[group.folder];
deleteSession(group.folder);
}
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 (default trigger: ${DEFAULT_TRIGGER})`);
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) {
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
continue;
}
const isMainGroup = group.isMain === true;
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 triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some(
(m) =>
triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) continue;
}
// Pull all messages since lastAgentTimestamp so non-trigger
// context that accumulated between triggers is included.
const allPending = getMessagesSince(
chatJid,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
const messagesToSend = allPending.length > 0 ? allPending : groupMessages;
const formatted = formatMessages(messagesToSend, TIMEZONE);
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 pending = getMessagesSince(chatJid, getOrRecoverCursor(chatJid), ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT);
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();
// Ensure OneCLI agents exist for all registered groups.
// Recovers from missed creates (e.g. OneCLI was down at registration time).
for (const [jid, group] of Object.entries(registeredGroups)) {
ensureOneCLIAgent(jid, group);
}
restoreRemoteControl();
// 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'));
// Handle /remote-control and /remote-control-end commands
async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise<void> {
const group = registeredGroups[chatJid];
if (!group?.isMain) {
logger.warn({ chatJid, sender: msg.sender }, 'Remote control rejected: not main group');
return;
}
const channel = findChannel(channels, chatJid);
if (!channel) return;
if (command === '/remote-control') {
const result = await startRemoteControl(msg.sender, chatJid, process.cwd());
if (result.ok) {
await channel.sendMessage(chatJid, result.url);
} else {
await channel.sendMessage(chatJid, `Remote Control failed: ${result.error}`);
}
} else {
const result = stopRemoteControl();
if (result.ok) {
await channel.sendMessage(chatJid, 'Remote Control session ended.');
} else {
await channel.sendMessage(chatJid, result.error);
}
}
}
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => {
// Remote control commands — intercept before storage
const trimmed = msg.content.trim();
if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
logger.error({ err, chatJid }, 'Remote control command error'),
);
return;
}
// Sender allowlist drop mode: discard messages from denied senders before storing
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
const cfg = loadSenderAllowlist();
if (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) {
if (cfg.logDenied) {
logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)');
}
return;
}
}
storeMessage(msg);
},
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
registeredGroups: () => registeredGroups,
};
// Create and connect all registered channels.
// Each channel self-registers via the barrel import above.
// Factories return null when credentials are missing, so unconfigured channels are skipped.
for (const channelName of getRegisteredChannelNames()) {
const factory = getChannelFactory(channelName)!;
const channel = factory(channelOpts);
if (!channel) {
logger.warn(
{ channel: channelName },
'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
);
continue;
}
channels.push(channel);
await channel.connect();
}
if (channels.length === 0) {
logger.fatal('No channels connected');
process.exit(1);
}
// 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) {
logger.warn({ jid }, 'No channel owns 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,
syncGroups: async (force: boolean) => {
await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force)));
},
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
onTasksChanged: () => {
const tasks = getAllTasks();
const taskRows = tasks.map((t) => ({
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script || undefined,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
next_run: t.next_run,
}));
for (const group of Object.values(registeredGroups)) {
writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
}
},
});
startSessionCleanup();
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
View File
+405
View File
@@ -0,0 +1,405 @@
/**
* Mount Security Module for NanoClaw
*
* Validates additional mounts against an allowlist stored OUTSIDE the project root.
* This prevents container agents from modifying security configuration.
*
* Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { MOUNT_ALLOWLIST_PATH } from './config.js';
import { logger } from './logger.js';
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
// Cache the allowlist in memory - only reloads on process restart
let cachedAllowlist: MountAllowlist | null = null;
let allowlistLoadError: string | null = null;
/**
* Default blocked patterns - paths that should never be mounted
*/
const DEFAULT_BLOCKED_PATTERNS = [
'.ssh',
'.gnupg',
'.gpg',
'.aws',
'.azure',
'.gcloud',
'.kube',
'.docker',
'credentials',
'.env',
'.netrc',
'.npmrc',
'.pypirc',
'id_rsa',
'id_ed25519',
'private_key',
'.secret',
];
/**
* Load the mount allowlist from the external config location.
* Returns null if the file doesn't exist or is invalid.
* Result is cached in memory for the lifetime of the process.
*/
export function loadMountAllowlist(): MountAllowlist | null {
if (cachedAllowlist !== null) {
return cachedAllowlist;
}
if (allowlistLoadError !== null) {
// Already tried and failed, don't spam logs
return null;
}
try {
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
// Do NOT cache this as an error — file may be created later without restart.
// Only parse/structural errors are permanently cached.
logger.warn(
{ path: MOUNT_ALLOWLIST_PATH },
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
'Create the file to enable additional mounts.',
);
return null;
}
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
const allowlist = JSON.parse(content) as MountAllowlist;
// Validate structure
if (!Array.isArray(allowlist.allowedRoots)) {
throw new Error('allowedRoots must be an array');
}
if (!Array.isArray(allowlist.blockedPatterns)) {
throw new Error('blockedPatterns must be an array');
}
if (typeof allowlist.nonMainReadOnly !== 'boolean') {
throw new Error('nonMainReadOnly must be a boolean');
}
// Merge with default blocked patterns
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
allowlist.blockedPatterns = mergedBlockedPatterns;
cachedAllowlist = allowlist;
logger.info(
{
path: MOUNT_ALLOWLIST_PATH,
allowedRoots: allowlist.allowedRoots.length,
blockedPatterns: allowlist.blockedPatterns.length,
},
'Mount allowlist loaded successfully',
);
return cachedAllowlist;
} catch (err) {
allowlistLoadError = err instanceof Error ? err.message : String(err);
logger.error(
{
path: MOUNT_ALLOWLIST_PATH,
error: allowlistLoadError,
},
'Failed to load mount allowlist - additional mounts will be BLOCKED',
);
return null;
}
}
/**
* Expand ~ to home directory and resolve to absolute path
*/
function expandPath(p: string): string {
const homeDir = process.env.HOME || os.homedir();
if (p.startsWith('~/')) {
return path.join(homeDir, p.slice(2));
}
if (p === '~') {
return homeDir;
}
return path.resolve(p);
}
/**
* Get the real path, resolving symlinks.
* Returns null if the path doesn't exist.
*/
function getRealPath(p: string): string | null {
try {
return fs.realpathSync(p);
} catch {
return null;
}
}
/**
* Check if a path matches any blocked pattern
*/
function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null {
const pathParts = realPath.split(path.sep);
for (const pattern of blockedPatterns) {
// Check if any path component matches the pattern
for (const part of pathParts) {
if (part === pattern || part.includes(pattern)) {
return pattern;
}
}
// Also check if the full path contains the pattern
if (realPath.includes(pattern)) {
return pattern;
}
}
return null;
}
/**
* Check if a real path is under an allowed root
*/
function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null {
for (const root of allowedRoots) {
const expandedRoot = expandPath(root.path);
const realRoot = getRealPath(expandedRoot);
if (realRoot === null) {
// Allowed root doesn't exist, skip it
continue;
}
// Check if realPath is under realRoot
const relative = path.relative(realRoot, realPath);
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
return root;
}
}
return null;
}
/**
* Validate the container path to prevent escaping /workspace/extra/
*/
function isValidContainerPath(containerPath: string): boolean {
// Must not contain .. to prevent path traversal
if (containerPath.includes('..')) {
return false;
}
// Must not be absolute (it will be prefixed with /workspace/extra/)
if (containerPath.startsWith('/')) {
return false;
}
// Must not be empty
if (!containerPath || containerPath.trim() === '') {
return false;
}
// Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw")
if (containerPath.includes(':')) {
return false;
}
return true;
}
export interface MountValidationResult {
allowed: boolean;
reason: string;
realHostPath?: string;
resolvedContainerPath?: string;
effectiveReadonly?: boolean;
}
/**
* Validate a single additional mount against the allowlist.
* Returns validation result with reason.
*/
export function validateMount(mount: AdditionalMount, isMain: boolean): MountValidationResult {
const allowlist = loadMountAllowlist();
// If no allowlist, block all additional mounts
if (allowlist === null) {
return {
allowed: false,
reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
};
}
// Derive containerPath from hostPath basename if not specified
const containerPath = mount.containerPath || path.basename(mount.hostPath);
// Validate container path (cheap check)
if (!isValidContainerPath(containerPath)) {
return {
allowed: false,
reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`,
};
}
// Expand and resolve the host path
const expandedPath = expandPath(mount.hostPath);
const realPath = getRealPath(expandedPath);
if (realPath === null) {
return {
allowed: false,
reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
};
}
// Check against blocked patterns
const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns);
if (blockedMatch !== null) {
return {
allowed: false,
reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
};
}
// Check if under an allowed root
const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots);
if (allowedRoot === null) {
return {
allowed: false,
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
.map((r) => expandPath(r.path))
.join(', ')}`,
};
}
// Determine effective readonly status
const requestedReadWrite = mount.readonly === false;
let effectiveReadonly = true; // Default to readonly
if (requestedReadWrite) {
if (!isMain && allowlist.nonMainReadOnly) {
// Non-main groups forced to read-only
effectiveReadonly = true;
logger.info(
{
mount: mount.hostPath,
},
'Mount forced to read-only for non-main group',
);
} else if (!allowedRoot.allowReadWrite) {
// Root doesn't allow read-write
effectiveReadonly = true;
logger.info(
{
mount: mount.hostPath,
root: allowedRoot.path,
},
'Mount forced to read-only - root does not allow read-write',
);
} else {
// Read-write allowed
effectiveReadonly = false;
}
}
return {
allowed: true,
reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
realHostPath: realPath,
resolvedContainerPath: containerPath,
effectiveReadonly,
};
}
/**
* Validate all additional mounts for a group.
* Returns array of validated mounts (only those that passed validation).
* Logs warnings for rejected mounts.
*/
export function validateAdditionalMounts(
mounts: AdditionalMount[],
groupName: string,
isMain: boolean,
): Array<{
hostPath: string;
containerPath: string;
readonly: boolean;
}> {
const validatedMounts: Array<{
hostPath: string;
containerPath: string;
readonly: boolean;
}> = [];
for (const mount of mounts) {
const result = validateMount(mount, isMain);
if (result.allowed) {
validatedMounts.push({
hostPath: result.realHostPath!,
containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
readonly: result.effectiveReadonly!,
});
logger.debug(
{
group: groupName,
hostPath: result.realHostPath,
containerPath: result.resolvedContainerPath,
readonly: result.effectiveReadonly,
reason: result.reason,
},
'Mount validated successfully',
);
} else {
logger.warn(
{
group: groupName,
requestedPath: mount.hostPath,
containerPath: mount.containerPath,
reason: result.reason,
},
'Additional mount REJECTED',
);
}
}
return validatedMounts;
}
/**
* Generate a template allowlist file for users to customize
*/
export function generateAllowlistTemplate(): string {
const template: MountAllowlist = {
allowedRoots: [
{
path: '~/projects',
allowReadWrite: true,
description: 'Development projects',
},
{
path: '~/repos',
allowReadWrite: true,
description: 'Git repositories',
},
{
path: '~/Documents/work',
allowReadWrite: false,
description: 'Work documents (read-only)',
},
],
blockedPatterns: [
// Additional patterns beyond defaults
'password',
'secret',
'token',
],
nonMainReadOnly: true,
};
return JSON.stringify(template, null, 2);
}
+43
View File
@@ -0,0 +1,43 @@
import { Channel, NewMessage } from './types.js';
import { formatLocalTime } from './timezone.js';
export function escapeXml(s: string): string {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function formatMessages(messages: NewMessage[], timezone: string): string {
const lines = messages.map((m) => {
const displayTime = formatLocalTime(m.timestamp, timezone);
const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : '';
const replySnippet =
m.reply_to_message_content && m.reply_to_sender_name
? `\n <quoted_message from="${escapeXml(m.reply_to_sender_name)}">${escapeXml(m.reply_to_message_content)}</quoted_message>`
: '';
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}"${replyAttr}>${replySnippet}${escapeXml(m.content)}</message>`;
});
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
}
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
export function formatOutbound(rawText: string): string {
const text = stripInternalTags(rawText);
if (!text) return '';
return text;
}
export function routeOutbound(channels: Channel[], jid: string, text: string): Promise<void> {
const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
}
export function findChannel(channels: Channel[], jid: string): Channel | undefined {
return channels.find((c) => c.ownsJid(jid));
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { formatLocalTime, isValidTimezone, resolveTimezone } from './timezone.js';
// --- formatLocalTime ---
describe('formatLocalTime', () => {
it('converts UTC to local time display', () => {
// 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York');
expect(result).toContain('1:30');
expect(result).toContain('PM');
expect(result).toContain('Feb');
expect(result).toContain('2026');
});
it('handles different timezones', () => {
// Same UTC time should produce different local times
const utc = '2026-06-15T12:00:00.000Z';
const ny = formatLocalTime(utc, 'America/New_York');
const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
// NY is UTC-4 in summer (EDT), Tokyo is UTC+9
expect(ny).toContain('8:00');
expect(tokyo).toContain('9:00');
});
it('does not throw on invalid timezone, falls back to UTC', () => {
expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow();
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
// Should format as UTC (noon UTC = 12:00 PM)
expect(result).toContain('12:00');
expect(result).toContain('PM');
});
});
describe('isValidTimezone', () => {
it('accepts valid IANA identifiers', () => {
expect(isValidTimezone('America/New_York')).toBe(true);
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
});
it('rejects invalid timezone strings', () => {
expect(isValidTimezone('IST-2')).toBe(false);
expect(isValidTimezone('XYZ+3')).toBe(false);
});
it('rejects empty and garbage strings', () => {
expect(isValidTimezone('')).toBe(false);
expect(isValidTimezone('NotATimezone')).toBe(false);
});
});
describe('resolveTimezone', () => {
it('returns the timezone if valid', () => {
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
});
it('falls back to UTC for invalid timezone', () => {
expect(resolveTimezone('IST-2')).toBe('UTC');
expect(resolveTimezone('')).toBe('UTC');
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Check whether a timezone string is a valid IANA identifier
* that Intl.DateTimeFormat can use.
*/
export function isValidTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Return the given timezone if valid IANA, otherwise fall back to UTC.
*/
export function resolveTimezone(tz: string): string {
return isValidTimezone(tz) ? tz : 'UTC';
}
/**
* Convert a UTC ISO timestamp to a localized display string.
* Uses the Intl API (no external dependencies).
* Falls back to UTC if the timezone is invalid.
*/
export function formatLocalTime(utcIso: string, timezone: string): string {
const date = new Date(utcIso);
return date.toLocaleString('en-US', {
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
+112
View File
@@ -0,0 +1,112 @@
export interface AdditionalMount {
hostPath: string; // Absolute path on host (supports ~ for home)
containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value}
readonly?: boolean; // Default: true for safety
}
/**
* Mount Allowlist - Security configuration for additional mounts
* This file should be stored at ~/.config/nanoclaw/mount-allowlist.json
* and is NOT mounted into any container, making it tamper-proof from agents.
*/
export interface MountAllowlist {
// Directories that can be mounted into containers
allowedRoots: AllowedRoot[];
// Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg")
blockedPatterns: string[];
// If true, non-main groups can only mount read-only regardless of config
nonMainReadOnly: boolean;
}
export interface AllowedRoot {
// Absolute path or ~ for home (e.g., "~/projects", "/var/repos")
path: string;
// Whether read-write mounts are allowed under this root
allowReadWrite: boolean;
// Optional description for documentation
description?: string;
}
export interface ContainerConfig {
additionalMounts?: AdditionalMount[];
timeout?: number; // Default: 300000 (5 minutes)
}
export interface RegisteredGroup {
name: string;
folder: string;
trigger: string;
added_at: string;
containerConfig?: ContainerConfig;
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
}
export interface NewMessage {
id: string;
chat_jid: string;
sender: string;
sender_name: string;
content: string;
timestamp: string;
is_from_me?: boolean;
is_bot_message?: boolean;
thread_id?: string;
reply_to_message_id?: string;
reply_to_message_content?: string;
reply_to_sender_name?: string;
}
export interface ScheduledTask {
id: string;
group_folder: string;
chat_jid: string;
prompt: string;
script?: string | null;
schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string;
context_mode: 'group' | 'isolated';
next_run: string | null;
last_run: string | null;
last_result: string | null;
status: 'active' | 'paused' | 'completed';
created_at: string;
}
export interface TaskRunLog {
task_id: string;
run_at: string;
duration_ms: number;
status: 'success' | 'error';
result: string | null;
error: string | null;
}
// --- Channel abstraction ---
export interface Channel {
name: string;
connect(): Promise<void>;
sendMessage(jid: string, text: string): Promise<void>;
isConnected(): boolean;
ownsJid(jid: string): boolean;
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;
// Optional: sync group/chat names from the platform.
syncGroups?(force: boolean): Promise<void>;
}
// Callback type that channels use to deliver inbound messages
export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
// Callback for chat metadata discovery.
// name is optional — channels that deliver names inline (Telegram) pass it here;
// channels that sync names separately (via syncGroups) omit it.
export type OnChatMetadata = (
chatJid: string,
timestamp: string,
name?: string,
channel?: string,
isGroup?: boolean,
) => void;