merge: resolve conflict with origin/main

Keep ASSISTANT_NAME import, drop removed GROUPS_DIR import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-23 00:03:20 +02:00
56 changed files with 2909 additions and 1539 deletions

View File

@@ -246,13 +246,15 @@ cd .. && npm run build
Wait for TypeScript compilation, then restart the service: Wait for TypeScript compilation, then restart the service:
```bash ```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```
Check that it started: Check that it started:
```bash ```bash
sleep 2 && launchctl list | grep nanoclaw sleep 2 && launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
``` ```
### Step 5: Test Gmail Integration ### Step 5: Test Gmail Integration
@@ -651,7 +653,8 @@ cd .. && npm run build
Restart the service: Restart the service:
```bash ```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```
Verify it started and check for email channel startup message: Verify it started and check for email channel startup message:
@@ -726,5 +729,6 @@ To remove Gmail entirely:
```bash ```bash
cd container && ./build.sh && cd .. cd container && ./build.sh && cd ..
npm run build npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```

View File

@@ -235,13 +235,15 @@ Rebuild the main app and restart:
```bash ```bash
npm run build npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```
Wait 3 seconds for service to start, then verify: Wait 3 seconds for service to start, then verify:
```bash ```bash
sleep 3 sleep 3
launchctl list | grep nanoclaw launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
``` ```
### 8. Test Integration ### 8. Test Integration
@@ -287,4 +289,4 @@ To remove Parallel AI integration:
2. Revert changes to container-runner.ts and agent-runner/src/index.ts 2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md 3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && npm run build` 4. Rebuild: `./container/build.sh && npm run build`
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` 5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)

View File

@@ -321,11 +321,14 @@ Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.n
```bash ```bash
npm run build npm run build
./container/build.sh # Required — MCP tool changed ./container/build.sh # Required — MCP tool changed
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart nanoclaw
``` ```
Must use `unload/load` (not just `kickstart`) because the plist env vars changed. Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed.
### Step 8: Test ### Step 8: Test
@@ -377,5 +380,5 @@ To remove Agent Swarm support while keeping basic Telegram:
4. Remove `initBotPool` call from `main()` 4. Remove `initBotPool` call from `main()`
5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts` 5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts`
6. Remove Agent Teams section from group CLAUDE.md files 6. Remove Agent Teams section from group CLAUDE.md files
7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist 7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit
8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` 8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)

View File

@@ -118,7 +118,8 @@ Tell the user:
```bash ```bash
npm run build npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```
## Phase 4: Registration ## Phase 4: Registration
@@ -183,10 +184,11 @@ tail -f logs/nanoclaw.log
### Bot not responding ### Bot not responding
1. Check `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` Check:
2. Check chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"` 1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
3. For non-main chats: message must include trigger pattern 2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`)
4. Service is running: `launchctl list | grep nanoclaw` 3. For non-main chats: message includes trigger pattern
4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
### Bot only responds to @mentions in groups ### Bot only responds to @mentions in groups
@@ -202,6 +204,36 @@ If `/chatid` doesn't work:
## After Setup ## After Setup
Ask the user: If running `npm run dev` while the service is active:
```bash
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user stop nanoclaw
# npm run dev
# systemctl --user start nanoclaw
```
> Would you like to add Agent Swarm support? Each subagent appears as a different bot in the Telegram group. If interested, run `/add-telegram-swarm`. ## Agent Swarms (Teams)
After completing the Telegram setup, ask the user:
> Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the `/add-telegram-swarm` skill.
## Removal
To remove Telegram integration:
1. Delete `src/channels/telegram.ts`
2. Remove `TelegramChannel` import and creation from `src/index.ts`
3. Remove `channels` array and revert to using `whatsapp` directly in `processGroupMessages`, scheduler deps, and IPC deps
4. Revert `getAvailableGroups()` filter to only include `@g.us` chats
5. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
6. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
7. Uninstall: `npm uninstall grammy`
8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)

View File

@@ -93,7 +93,8 @@ The container reads environment from `data/env/env`, not `.env` directly.
```bash ```bash
npm run build npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```
## Phase 4: Verify ## Phase 4: Verify

View File

@@ -92,8 +92,11 @@ Always tell the user:
```bash ```bash
# Rebuild and restart # Rebuild and restart
npm run build npm run build
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart nanoclaw
``` ```
## Example Interaction ## Example Interaction

View File

@@ -43,9 +43,11 @@ Set `LOG_LEVEL=debug` for verbose output:
# For development # For development
LOG_LEVEL=debug npm run dev LOG_LEVEL=debug npm run dev
# For launchd service, add to plist EnvironmentVariables: # For launchd service (macOS), add to plist EnvironmentVariables:
<key>LOG_LEVEL</key> <key>LOG_LEVEL</key>
<string>debug</string> <string>debug</string>
# For systemd service (Linux), add to unit [Service] section:
# Environment=LOG_LEVEL=debug
``` ```
Debug level shows: Debug level shows:

View File

@@ -5,54 +5,46 @@ description: Run initial NanoClaw setup. Use when user wants to install dependen
# NanoClaw Setup # NanoClaw Setup
Run setup scripts automatically. Only pause when user action is required (WhatsApp authentication, configuration choices). Scripts live in `.claude/skills/setup/scripts/` and emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`. Run setup steps automatically. Only pause when user action is required (WhatsApp authentication, configuration choices). Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step <name>` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`.
**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. scanning a QR code, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work. **Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. scanning a QR code, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work.
**UX Note:** Use `AskUserQuestion` for all user-facing questions. **UX Note:** Use `AskUserQuestion` for all user-facing questions.
## 1. Check Environment ## 1. Bootstrap (Node.js + Dependencies)
Run `./.claude/skills/setup/scripts/01-check-environment.sh` and parse the status block. Run `bash setup.sh` and parse the status block.
- If NODE_OK=false → Node.js is missing or too old. Ask the user if they'd like you to install it:
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
- After installing Node, re-run `bash setup.sh`
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules` and `package-lock.json`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
- Record PLATFORM and IS_WSL for later steps.
## 2. Check Environment
Run `npx tsx setup/index.ts --step environment` and parse the status block.
- If HAS_AUTH=true → note that WhatsApp auth exists, offer to skip step 5 - If HAS_AUTH=true → note that WhatsApp auth exists, offer to skip step 5
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure - If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
- Record PLATFORM, APPLE_CONTAINER, and DOCKER values for step 3 - Record APPLE_CONTAINER and DOCKER values for step 3
**If NODE_OK=false:**
Node.js is missing or too old. Ask the user if they'd like you to install it. Offer options based on platform:
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
If brew/nvm aren't installed, install them first (`/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` for brew, `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` for nvm). After installing Node, re-run the environment check to confirm NODE_OK=true.
## 2. Install Dependencies
Run `./.claude/skills/setup/scripts/02-install-deps.sh` and parse the status block.
**If failed:** Read the tail of `logs/setup.log` to diagnose. Common fixes to try automatically:
1. Delete `node_modules` and `package-lock.json`, then re-run the script
2. If permission errors: suggest running with corrected permissions
3. If specific package fails to build (native modules like better-sqlite3): install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry
Only ask the user for help if multiple retries fail with the same error.
## 3. Container Runtime ## 3. Container Runtime
### 3a. Choose runtime ### 3a. Choose runtime
Check the preflight results for `APPLE_CONTAINER` and `DOCKER`. Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1.
**If APPLE_CONTAINER=installed** (macOS only): Ask the user which runtime they'd like to use — Docker (default, cross-platform) or Apple Container (native macOS). If they choose Apple Container, run `/convert-to-apple-container` now before continuing, then skip to 3b. - PLATFORM=linux → Docker (only option)
- PLATFORM=macos + APPLE_CONTAINER=installed → Ask user: Docker (default, cross-platform) or Apple Container (native macOS)? If Apple Container, run `/convert-to-apple-container` now, then skip to 3c.
**If APPLE_CONTAINER=not_found**: Use Docker (the default). Proceed to install/start Docker below. - PLATFORM=macos + APPLE_CONTAINER=not_found Docker (default)
### 3a-docker. Install Docker ### 3a-docker. Install Docker
- DOCKER=running → continue to 3b - DOCKER=running → continue to 3b
- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`. If still not running, tell the user Docker is starting up and poll a few more times. - DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`.
- DOCKER=not_found → **ask the user for confirmation before installing.** Tell them Docker is required for running agents and ask if they'd like you to install it. If confirmed: - DOCKER=not_found → **ask the user for confirmation before installing.** Tell them Docker is required for running agents and ask if they'd like you to install it. If confirmed:
- macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop - macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop
- Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership. - Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership.
@@ -73,146 +65,122 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "
### 3c. Build and test ### 3c. Build and test
Run `./.claude/skills/setup/scripts/03-setup-container.sh --runtime <chosen>` and parse the status block. Run `npx tsx setup/index.ts --step container -- --runtime <chosen>` and parse the status block.
**If BUILD_OK=false:** Read `logs/setup.log` tail for the build error. **If BUILD_OK=false:** Read `logs/setup.log` tail for the build error.
- If it's a cache issue (stale layers): run `docker builder prune -f`, then retry. - Cache issue (stale layers): `docker builder prune -f` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). Retry.
- If Dockerfile syntax or missing files: diagnose from the log and fix. - Dockerfile syntax or missing files: diagnose from the log and fix, then retry.
- Retry the build script after fixing.
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. **If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
## 4. Claude Authentication (No Script) ## 4. Claude Authentication (No Script)
If HAS_ENV=true from step 1, read `.env` and check if it already has `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If so, confirm with user: "You already have Claude credentials configured. Want to keep them or reconfigure?" If keeping, skip to step 5. If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure?
AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key? AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key?
**Subscription:** Tell the user: **Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=<token>` to `.env`. Do NOT collect the token in chat.
1. Open another terminal and run: `claude setup-token`
2. Copy the token it outputs
3. Add it to the `.env` file in the project root: `CLAUDE_CODE_OAUTH_TOKEN=<token>`
4. Let me know when done
Do NOT ask the user to paste the token into the chat. Do NOT use AskUserQuestion to collect the token. Just tell them what to do, then wait for confirmation that they've added it to `.env`. Once confirmed, verify the `.env` file has the key. **API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`.
**API key:** Tell the user to add `ANTHROPIC_API_KEY=<key>` to the `.env` file in the project root, then let you know when done. Once confirmed, verify the `.env` file has the key.
## 5. WhatsApp Authentication ## 5. WhatsApp Authentication
If HAS_AUTH=true from step 1, confirm with user: "WhatsApp credentials already exist. Want to keep them or re-authenticate?" If keeping, skip to step 6. If HAS_AUTH=true, confirm: keep or re-authenticate?
AskUserQuestion: QR code in browser (recommended) vs pairing code vs QR code in terminal? **Choose auth method based on environment (from step 2):**
- **QR browser:** Run `./.claude/skills/setup/scripts/04-auth-whatsapp.sh --method qr-browser` (Bash timeout: 150000ms) If IS_HEADLESS=true AND IS_WSL=false → AskUserQuestion: Pairing code (recommended) vs QR code in terminal?
- **Pairing code:** Ask for phone number first (country code, no + or spaces, e.g. 14155551234). Run `./.claude/skills/setup/scripts/04-auth-whatsapp.sh --method pairing-code --phone NUMBER` (Bash timeout: 150000ms). Display the PAIRING_CODE from the status block with instructions. Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: QR code in browser (recommended) vs pairing code vs QR code in terminal?
- **QR terminal:** Run `./.claude/skills/setup/scripts/04-auth-whatsapp.sh --method qr-terminal`. Tell user to run `cd PROJECT_PATH && npm run auth` in another terminal. Wait for confirmation.
If AUTH_STATUS=already_authenticated → skip ahead. - **QR browser:** `npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser` (Bash timeout: 150000ms)
- **Pairing code:** Ask for phone number first. `npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone NUMBER` (Bash timeout: 150000ms). Display PAIRING_CODE.
- **QR terminal:** `npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal`. Tell user to run `npm run auth` in another terminal.
**If failed:** **If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
- qr_timeout → QR expired. Automatically re-run the auth script to generate a fresh QR. Tell user a new QR is ready.
- logged_out → Delete `store/auth/` and re-run auth automatically.
- 515 → Stream error during pairing. The auth script handles reconnection, but if it persists, re-run the auth script.
- timeout → Auth took too long. Ask user if they scanned/entered the code, offer to retry.
## 6. Configure Trigger and Channel Type ## 6. Configure Trigger and Channel Type
First, determine the phone number situation. Get the bot's WhatsApp number from `store/auth/creds.json`: Get bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
`node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
AskUserQuestion: Does the bot share your personal WhatsApp number, or does it have its own dedicated phone number? AskUserQuestion: Shared number or dedicated? → AskUserQuestion: Trigger word? → AskUserQuestion: Main channel type?
AskUserQuestion: What trigger word? (default: Andy). In group chats, messages starting with @TriggerWord go to Claude. In the main channel, no prefix needed. **Shared number:** Self-chat (recommended) or Solo group
**Dedicated number:** DM with bot (recommended) or Solo group with bot
AskUserQuestion: Main channel type? (options depend on phone number setup)
**If bot shares user's number (same phone):**
1. Self-chat (chat with yourself) — Recommended. You message yourself and the bot responds.
2. Solo group (just you) — A group where you're the only member. Good if you want message history separate from self-chat.
**If bot has its own dedicated phone number:**
1. DM with the bot — Recommended. You message the bot's number directly.
2. Solo group with the bot — A group with just you and the bot, no one else.
Do NOT show options that don't apply to the user's setup. For example, don't offer "DM with the bot" if the bot shares the user's number (you can't DM yourself on WhatsApp).
## 7. Sync and Select Group (If Group Channel) ## 7. Sync and Select Group (If Group Channel)
**For personal chat:** The JID is the bot's own phone number from step 6. Construct as `NUMBER@s.whatsapp.net`. **Personal chat:** JID = `NUMBER@s.whatsapp.net`
**DM with bot:** Ask for bot's number, JID = `NUMBER@s.whatsapp.net`
**For DM with bot's dedicated number:** Ask for the bot's phone number, construct JID as `NUMBER@s.whatsapp.net`. **Group:**
1. `npx tsx setup/index.ts --step groups` (Bash timeout: 60000ms)
**For group (solo or with bot):** 2. BUILD=failed → fix TypeScript, re-run. GROUPS_IN_DB=0 → check logs.
1. Run `./.claude/skills/setup/scripts/05-sync-groups.sh` (Bash timeout: 60000ms) 3. `npx tsx setup/index.ts --step groups -- --list` for pipe-separated JID|name lines.
2. **If BUILD=failed:** Read `logs/setup.log`, fix the TypeScript error, re-run. 4. Present candidates as AskUserQuestion (names only, not JIDs).
3. **If GROUPS_IN_DB=0:** Check `logs/setup.log` for the sync output. Common causes: WhatsApp auth expired (re-run step 5), connection timeout (re-run sync script with longer timeout).
4. Run `./.claude/skills/setup/scripts/05b-list-groups.sh` to get groups (pipe-separated JID|name lines). Do NOT display the output to the user.
5. Pick the most likely candidates (e.g. groups with the trigger word or "NanoClaw" in the name, small/solo groups) and present them as AskUserQuestion options — show names only, not JIDs. Include an "Other" option if their group isn't listed. If they pick Other, search by name in the DB or re-run with a higher limit.
## 8. Register Channel ## 8. Register Channel
Run `./.claude/skills/setup/scripts/06-register-channel.sh` with args: Run `npx tsx setup/index.ts --step register -- --jid "JID" --name "main" --trigger "@TriggerWord" --folder "main"` plus `--no-trigger-required` if personal/DM/solo, `--assistant-name "Name"` if not Andy.
- `--jid "JID"` — from step 7
- `--name "main"` — always "main" for the first channel
- `--trigger "@TriggerWord"` — from step 6
- `--folder "main"` — always "main" for the first channel
- `--no-trigger-required` — if personal chat, DM, or solo group
- `--assistant-name "Name"` — if trigger word differs from "Andy"
## 9. Mount Allowlist ## 9. Mount Allowlist
AskUserQuestion: Want the agent to access directories outside the NanoClaw project? (Git repos, project folders, documents, etc.) AskUserQuestion: Agent access to external directories?
**If no:** Run `./.claude/skills/setup/scripts/07-configure-mounts.sh --empty` **No:** `npx tsx setup/index.ts --step mounts -- --empty`
**Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'`
**If yes:** Collect directory paths and permissions (read-write vs read-only). Ask about non-main group read-only restriction (recommended: yes). Build the JSON and pipe it to the script:
`echo '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}' | ./.claude/skills/setup/scripts/07-configure-mounts.sh`
Tell user how to grant a group access: add `containerConfig.additionalMounts` to their entry in `data/registered_groups.json`.
## 10. Start Service ## 10. Start Service
If the service is already running (check `launchctl list | grep nanoclaw` on macOS), unload it first: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` — then proceed with a clean install. If service already running: unload first.
- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
- Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root)
Run `./.claude/skills/setup/scripts/08-setup-service.sh` and parse the status block. Run `npx tsx setup/index.ts --step service` and parse the status block.
**If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper.
**If DOCKER_GROUP_STALE=true:** The user was added to the docker group after their session started — the systemd service can't reach the Docker socket. Ask user to run these two commands:
1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock`
2. Persistent fix (re-applies after every Docker restart):
```bash
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF'
[Service]
ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock
EOF
sudo systemctl daemon-reload
```
Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step.
**If SERVICE_LOADED=false:** **If SERVICE_LOADED=false:**
- Read `logs/setup.log` for the error. - Read `logs/setup.log` for the error.
- Common fix: plist already loaded with different path. Unload the old one first, then re-run. - macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`.
- On macOS: check `launchctl list | grep nanoclaw` to see if it's loaded with an error status. If the PID column is `-` and the status column is non-zero, the service is crashing. Read `logs/nanoclaw.error.log` for the crash reason and fix it (common: wrong Node path, missing .env, missing auth). - Linux: check `systemctl --user status nanoclaw`.
- On Linux: check `systemctl --user status nanoclaw` for the error and fix accordingly. - Re-run the service step after fixing.
- Re-run the setup-service script after fixing.
## 11. Verify ## 11. Verify
Run `./.claude/skills/setup/scripts/09-verify.sh` and parse the status block. Run `npx tsx setup/index.ts --step verify` and parse the status block.
**If STATUS=failed, fix each failing component:** **If STATUS=failed, fix each:**
- SERVICE=stopped → run `npm run build` first, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux). Re-check. - SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
- SERVICE=not_found → re-run step 10. - SERVICE=not_found → re-run step 10
- CREDENTIALS=missing → re-run step 4. - CREDENTIALS=missing → re-run step 4
- WHATSAPP_AUTH=not_found → re-run step 5. - WHATSAPP_AUTH=not_found → re-run step 5
- REGISTERED_GROUPS=0 → re-run steps 7-8. - REGISTERED_GROUPS=0 → re-run steps 7-8
- MOUNT_ALLOWLIST=missing → run `./.claude/skills/setup/scripts/07-configure-mounts.sh --empty` to create a default. - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
After fixing, re-run `09-verify.sh` to confirm everything passes. Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
Tell user to test: send a message in their registered chat (with or without trigger depending on channel type).
Show the log tail command: `tail -f logs/nanoclaw.log`
## Troubleshooting ## Troubleshooting
**Service not starting:** Check `logs/nanoclaw.error.log`. Common causes: wrong Node path in plist (re-run step 10), missing `.env` (re-run step 4), missing WhatsApp auth (re-run step 5). **Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 10), missing `.env` (step 4), missing auth (step 5).
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — start it with the appropriate command for your runtime. Check container logs in `groups/main/logs/container-*.log`. **Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.
**No response to messages:** Verify the trigger pattern matches. Main channel and personal/solo chats don't need a prefix. Check the registered JID in the database: `sqlite3 store/messages.db "SELECT * FROM registered_groups"`. Check `logs/nanoclaw.log`. **No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `npx tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`.
**Messages sent but not received (DMs):** WhatsApp may use LID (Linked Identity) JIDs. Check logs for LID translation. Verify the registered JID has no device suffix (should be `number@s.whatsapp.net`, not `number:0@s.whatsapp.net`). **WhatsApp disconnected:** `npm run auth` then rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux).
**WhatsApp disconnected:** Run `npm run auth` to re-authenticate, then `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`. **Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
**Unload service:** `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`

View File

@@ -1,108 +0,0 @@
#!/bin/bash
set -euo pipefail
# 01-check-environment.sh — Detect OS, Node, container runtimes, existing config
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [check-environment] $*" >> "$LOG_FILE"; }
log "Starting environment check"
# Detect platform
UNAME=$(uname -s)
case "$UNAME" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
log "Platform: $PLATFORM ($UNAME)"
# Check Node
NODE_OK="false"
NODE_VERSION="not_found"
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node --version 2>/dev/null | sed 's/^v//')
MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1)
if [ "$MAJOR" -ge 20 ] 2>/dev/null; then
NODE_OK="true"
fi
log "Node $NODE_VERSION found (major=$MAJOR, ok=$NODE_OK)"
else
log "Node not found"
fi
# Check Apple Container
APPLE_CONTAINER="not_found"
if command -v container >/dev/null 2>&1; then
APPLE_CONTAINER="installed"
log "Apple Container: installed ($(which container))"
else
log "Apple Container: not found"
fi
# Check Docker
DOCKER="not_found"
if command -v docker >/dev/null 2>&1; then
if docker info >/dev/null 2>&1; then
DOCKER="running"
log "Docker: running"
else
DOCKER="installed_not_running"
log "Docker: installed but not running"
fi
else
log "Docker: not found"
fi
# Check existing config
HAS_ENV="false"
if [ -f "$PROJECT_ROOT/.env" ]; then
HAS_ENV="true"
log ".env file found"
fi
HAS_AUTH="false"
if [ -d "$PROJECT_ROOT/store/auth" ] && [ "$(ls -A "$PROJECT_ROOT/store/auth" 2>/dev/null)" ]; then
HAS_AUTH="true"
log "WhatsApp auth credentials found"
fi
HAS_REGISTERED_GROUPS="false"
if [ -f "$PROJECT_ROOT/data/registered_groups.json" ]; then
HAS_REGISTERED_GROUPS="true"
log "Registered groups config found (JSON)"
elif [ -f "$PROJECT_ROOT/store/messages.db" ]; then
RG_COUNT=$(sqlite3 "$PROJECT_ROOT/store/messages.db" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo "0")
if [ "$RG_COUNT" -gt 0 ] 2>/dev/null; then
HAS_REGISTERED_GROUPS="true"
log "Registered groups found in database ($RG_COUNT)"
fi
fi
log "Environment check complete"
# Output structured status block
cat <<EOF
=== NANOCLAW SETUP: CHECK_ENVIRONMENT ===
PLATFORM: $PLATFORM
NODE_VERSION: $NODE_VERSION
NODE_OK: $NODE_OK
APPLE_CONTAINER: $APPLE_CONTAINER
DOCKER: $DOCKER
HAS_ENV: $HAS_ENV
HAS_AUTH: $HAS_AUTH
HAS_REGISTERED_GROUPS: $HAS_REGISTERED_GROUPS
STATUS: success
LOG: logs/setup.log
=== END ===
EOF
# Exit 2 if Node is missing or too old
if [ "$NODE_OK" = "false" ]; then
exit 2
fi

View File

@@ -1,62 +0,0 @@
#!/bin/bash
set -euo pipefail
# 02-install-deps.sh — Run npm install and verify key packages
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [install-deps] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
log "Running npm install"
if npm install >> "$LOG_FILE" 2>&1; then
log "npm install succeeded"
else
log "npm install failed"
cat <<EOF
=== NANOCLAW SETUP: INSTALL_DEPS ===
PACKAGES: failed
STATUS: failed
ERROR: npm_install_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Verify key packages
MISSING=""
for pkg in @whiskeysockets/baileys better-sqlite3 pino qrcode; do
if [ ! -d "$PROJECT_ROOT/node_modules/$pkg" ]; then
MISSING="$MISSING $pkg"
fi
done
if [ -n "$MISSING" ]; then
log "Missing packages after install:$MISSING"
cat <<EOF
=== NANOCLAW SETUP: INSTALL_DEPS ===
PACKAGES: failed
STATUS: failed
ERROR: missing_packages:$MISSING
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
log "All key packages verified"
cat <<EOF
=== NANOCLAW SETUP: INSTALL_DEPS ===
PACKAGES: installed
STATUS: success
LOG: logs/setup.log
=== END ===
EOF

View File

@@ -1,150 +0,0 @@
#!/bin/bash
set -euo pipefail
# 03-setup-container.sh — Build container image and verify with test run
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [setup-container] $*" >> "$LOG_FILE"; }
# Parse args
RUNTIME=""
while [[ $# -gt 0 ]]; do
case $1 in
--runtime) RUNTIME="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$RUNTIME" ]; then
log "ERROR: --runtime flag is required (apple-container|docker)"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: unknown
IMAGE: nanoclaw-agent:latest
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: missing_runtime_flag
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
IMAGE="nanoclaw-agent:latest"
# Determine build/run commands based on runtime
case "$RUNTIME" in
apple-container)
if ! command -v container >/dev/null 2>&1; then
log "Apple Container runtime not found"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: apple-container
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: runtime_not_available
LOG: logs/setup.log
=== END ===
EOF
exit 2
fi
BUILD_CMD="container build"
RUN_CMD="container"
;;
docker)
if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
log "Docker runtime not available or not running"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: docker
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: runtime_not_available
LOG: logs/setup.log
=== END ===
EOF
exit 2
fi
BUILD_CMD="docker build"
RUN_CMD="docker"
;;
*)
log "Unknown runtime: $RUNTIME"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: $RUNTIME
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: unknown_runtime
LOG: logs/setup.log
=== END ===
EOF
exit 4
;;
esac
log "Building container with $RUNTIME"
# Build
BUILD_OK="false"
if (cd "$PROJECT_ROOT/container" && $BUILD_CMD -t "$IMAGE" .) >> "$LOG_FILE" 2>&1; then
BUILD_OK="true"
log "Container build succeeded"
else
log "Container build failed"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: $RUNTIME
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: build_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Test
TEST_OK="false"
log "Testing container with echo command"
TEST_OUTPUT=$(echo '{}' | $RUN_CMD run -i --rm --entrypoint /bin/echo "$IMAGE" "Container OK" 2>>"$LOG_FILE") || true
if echo "$TEST_OUTPUT" | grep -q "Container OK"; then
TEST_OK="true"
log "Container test passed"
else
log "Container test failed: $TEST_OUTPUT"
fi
STATUS="success"
if [ "$BUILD_OK" = "false" ] || [ "$TEST_OK" = "false" ]; then
STATUS="failed"
fi
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: $RUNTIME
IMAGE: $IMAGE
BUILD_OK: $BUILD_OK
TEST_OK: $TEST_OK
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
if [ "$STATUS" = "failed" ]; then
exit 1
fi

View File

@@ -1,347 +0,0 @@
#!/bin/bash
set -euo pipefail
# 04-auth-whatsapp.sh — Full WhatsApp auth flow with polling
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [auth-whatsapp] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Parse args
METHOD=""
PHONE=""
while [[ $# -gt 0 ]]; do
case $1 in
--method) METHOD="$2"; shift 2 ;;
--phone) PHONE="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$METHOD" ]; then
log "ERROR: --method flag is required"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: unknown
AUTH_STATUS: failed
STATUS: failed
ERROR: missing_method_flag
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
# Background process PID for cleanup
AUTH_PID=""
cleanup() {
if [ -n "$AUTH_PID" ] && kill -0 "$AUTH_PID" 2>/dev/null; then
log "Cleaning up auth process (PID $AUTH_PID)"
kill "$AUTH_PID" 2>/dev/null || true
wait "$AUTH_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# Helper: poll a file for a pattern
# Usage: poll_file FILE PATTERN TIMEOUT_SECS INTERVAL_SECS
poll_file() {
local file="$1" pattern="$2" timeout="$3" interval="$4"
local elapsed=0
while [ "$elapsed" -lt "$timeout" ]; do
if [ -f "$file" ]; then
local content
content=$(cat "$file" 2>/dev/null || echo "")
if echo "$content" | grep -qE "$pattern"; then
echo "$content"
return 0
fi
fi
sleep "$interval"
elapsed=$((elapsed + interval))
done
return 1
}
# Helper: get phone number from auth creds if available
get_phone_number() {
if [ -f "$PROJECT_ROOT/store/auth/creds.json" ]; then
node -e "
const c = require('./store/auth/creds.json');
if (c.me && c.me.id) {
const phone = c.me.id.split(':')[0].split('@')[0];
process.stdout.write(phone);
}
" 2>/dev/null || true
fi
}
clean_stale_state() {
log "Cleaning stale auth state"
rm -rf "$PROJECT_ROOT/store/auth" "$PROJECT_ROOT/store/qr-data.txt" "$PROJECT_ROOT/store/auth-status.txt"
}
emit_status() {
local auth_status="$1" status="$2" error="${3:-}" pairing_code="${4:-}"
local phone_number
phone_number=$(get_phone_number)
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: $METHOD
AUTH_STATUS: $auth_status
EOF
[ -n "$pairing_code" ] && echo "PAIRING_CODE: $pairing_code"
[ -n "$phone_number" ] && echo "PHONE_NUMBER: $phone_number"
echo "STATUS: $status"
[ -n "$error" ] && echo "ERROR: $error"
cat <<EOF
LOG: logs/setup.log
=== END ===
EOF
}
case "$METHOD" in
qr-browser)
log "Starting QR browser auth flow"
clean_stale_state
# Start auth in background
npm run auth >> "$LOG_FILE" 2>&1 &
AUTH_PID=$!
log "Auth process started (PID $AUTH_PID)"
# Poll for QR data or already_authenticated
log "Polling for QR data (15s timeout)"
QR_READY="false"
for i in $(seq 1 15); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
if [ "$STATUS_CONTENT" = "already_authenticated" ]; then
log "Already authenticated"
emit_status "already_authenticated" "success"
exit 0
fi
fi
if [ -f "$PROJECT_ROOT/store/qr-data.txt" ]; then
QR_READY="true"
break
fi
# Check if auth process died early
if ! kill -0 "$AUTH_PID" 2>/dev/null; then
log "Auth process exited prematurely"
emit_status "failed" "failed" "auth_process_crashed"
exit 1
fi
sleep 1
done
if [ "$QR_READY" = "false" ]; then
log "Timeout waiting for QR data"
emit_status "failed" "failed" "qr_timeout"
exit 3
fi
# Generate QR SVG and inject into HTML template
log "Generating QR SVG"
node -e "
const QR = require('qrcode');
const fs = require('fs');
const qrData = fs.readFileSync('store/qr-data.txt', 'utf8');
QR.toString(qrData, { type: 'svg' }, (err, svg) => {
if (err) process.exit(1);
const template = fs.readFileSync('.claude/skills/setup/scripts/qr-auth.html', 'utf8');
fs.writeFileSync('store/qr-auth.html', template.replace('{{QR_SVG}}', svg));
});
" >> "$LOG_FILE" 2>&1
# Open in browser (macOS)
if command -v open >/dev/null 2>&1; then
open "$PROJECT_ROOT/store/qr-auth.html"
log "Opened QR auth page in browser"
else
log "WARNING: 'open' command not found, cannot open browser"
fi
# Poll for completion (120s, 2s intervals)
log "Polling for auth completion (120s timeout)"
for i in $(seq 1 60); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
case "$STATUS_CONTENT" in
authenticated|already_authenticated)
log "Authentication successful: $STATUS_CONTENT"
# Replace QR page with success page so browser auto-refresh shows it
cat > "$PROJECT_ROOT/store/qr-auth.html" <<'SUCCESSEOF'
<!DOCTYPE html>
<html><head><title>NanoClaw - Connected!</title>
<style>
body { font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { color: #27ae60; margin: 0 0 8px; }
p { color: #666; }
.check { font-size: 64px; margin-bottom: 16px; }
</style></head><body>
<div class="card">
<div class="check">&#10003;</div>
<h2>Connected to WhatsApp</h2>
<p>You can close this tab.</p>
</div>
<script>localStorage.removeItem('nanoclaw_qr_start');</script>
</body></html>
SUCCESSEOF
emit_status "$STATUS_CONTENT" "success"
exit 0
;;
failed:logged_out)
log "Auth failed: logged out"
emit_status "failed" "failed" "logged_out"
exit 1
;;
failed:qr_timeout)
log "Auth failed: QR timeout"
emit_status "failed" "failed" "qr_timeout"
exit 1
;;
failed:515)
log "Auth failed: 515 stream error"
emit_status "failed" "failed" "515"
exit 1
;;
failed:*)
log "Auth failed: $STATUS_CONTENT"
emit_status "failed" "failed" "${STATUS_CONTENT#failed:}"
exit 1
;;
esac
fi
sleep 2
done
log "Timeout waiting for auth completion"
emit_status "failed" "failed" "timeout"
exit 3
;;
pairing-code)
if [ -z "$PHONE" ]; then
log "ERROR: --phone is required for pairing-code method"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: pairing-code
AUTH_STATUS: failed
STATUS: failed
ERROR: missing_phone_number
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
log "Starting pairing code auth flow (phone: $PHONE)"
clean_stale_state
# Start auth with pairing code in background
npx tsx src/whatsapp-auth.ts --pairing-code --phone "$PHONE" >> "$LOG_FILE" 2>&1 &
AUTH_PID=$!
log "Auth process started (PID $AUTH_PID)"
# Poll for pairing code or already_authenticated
log "Polling for pairing code (15s timeout)"
PAIRING_CODE=""
for i in $(seq 1 15); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
case "$STATUS_CONTENT" in
already_authenticated)
log "Already authenticated"
emit_status "already_authenticated" "success"
exit 0
;;
pairing_code:*)
PAIRING_CODE="${STATUS_CONTENT#pairing_code:}"
log "Got pairing code: $PAIRING_CODE"
break
;;
failed:*)
log "Auth failed early: $STATUS_CONTENT"
emit_status "failed" "failed" "${STATUS_CONTENT#failed:}"
exit 1
;;
esac
fi
sleep 1
done
if [ -z "$PAIRING_CODE" ]; then
log "Timeout waiting for pairing code"
emit_status "failed" "failed" "pairing_code_timeout"
exit 3
fi
# Poll for completion (120s, 2s intervals)
log "Polling for auth completion (120s timeout)"
for i in $(seq 1 60); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
case "$STATUS_CONTENT" in
authenticated|already_authenticated)
log "Authentication successful: $STATUS_CONTENT"
emit_status "$STATUS_CONTENT" "success" "" "$PAIRING_CODE"
exit 0
;;
failed:logged_out)
log "Auth failed: logged out"
emit_status "failed" "failed" "logged_out" "$PAIRING_CODE"
exit 1
;;
failed:*)
log "Auth failed: $STATUS_CONTENT"
emit_status "failed" "failed" "${STATUS_CONTENT#failed:}" "$PAIRING_CODE"
exit 1
;;
esac
fi
sleep 2
done
log "Timeout waiting for auth completion"
emit_status "failed" "failed" "timeout" "$PAIRING_CODE"
exit 3
;;
qr-terminal)
log "QR terminal method selected — manual flow"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: qr-terminal
AUTH_STATUS: manual
PROJECT_PATH: $PROJECT_ROOT
STATUS: manual
LOG: logs/setup.log
=== END ===
EOF
exit 0
;;
*)
log "Unknown auth method: $METHOD"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: $METHOD
AUTH_STATUS: failed
STATUS: failed
ERROR: unknown_method
LOG: logs/setup.log
=== END ===
EOF
exit 4
;;
esac

View File

@@ -1,141 +0,0 @@
#!/bin/bash
set -euo pipefail
# 05-sync-groups.sh — Connect to WhatsApp, fetch group metadata, write to DB, exit.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [sync-groups] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Build TypeScript
log "Building TypeScript"
BUILD="failed"
if npm run build >> "$LOG_FILE" 2>&1; then
BUILD="success"
log "Build succeeded"
else
log "Build failed"
cat <<EOF
=== NANOCLAW SETUP: SYNC_GROUPS ===
BUILD: failed
SYNC: skipped
GROUPS_IN_DB: 0
STATUS: failed
ERROR: build_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Directly connect, fetch groups, write to DB, exit
log "Fetching group metadata directly"
SYNC="failed"
SYNC_OUTPUT=$(node -e "
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
// Timeout after 30s
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
" --input-type=module 2>&1) || true
log "Sync output: $SYNC_OUTPUT"
if echo "$SYNC_OUTPUT" | grep -q "SYNCED:"; then
SYNC="success"
fi
# Check for groups in DB
GROUPS_IN_DB=0
if [ -f "$PROJECT_ROOT/store/messages.db" ]; then
GROUPS_IN_DB=$(sqlite3 "$PROJECT_ROOT/store/messages.db" "SELECT COUNT(*) FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'" 2>/dev/null || echo "0")
log "Groups found in DB: $GROUPS_IN_DB"
fi
STATUS="success"
if [ "$SYNC" != "success" ]; then
STATUS="failed"
fi
cat <<EOF
=== NANOCLAW SETUP: SYNC_GROUPS ===
BUILD: $BUILD
SYNC: $SYNC
GROUPS_IN_DB: $GROUPS_IN_DB
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
if [ "$STATUS" = "failed" ]; then
exit 1
fi

View File

@@ -1,18 +0,0 @@
#!/bin/bash
set -euo pipefail
# 05b-list-groups.sh — Query WhatsApp groups from the database.
# Output: pipe-separated JID|name lines, most recent first.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
DB_PATH="$PROJECT_ROOT/store/messages.db"
LIMIT="${1:-30}"
if [ ! -f "$DB_PATH" ]; then
echo "ERROR: database not found" >&2
exit 1
fi
sqlite3 "$DB_PATH" "SELECT jid, name FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid ORDER BY last_message_time DESC LIMIT $LIMIT"

View File

@@ -1,106 +0,0 @@
#!/bin/bash
set -euo pipefail
# 06-register-channel.sh — Write channel registration config, create group folders
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [register-channel] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Parse args
JID=""
NAME=""
TRIGGER=""
FOLDER=""
REQUIRES_TRIGGER="true"
ASSISTANT_NAME="Andy"
while [[ $# -gt 0 ]]; do
case $1 in
--jid) JID="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--trigger) TRIGGER="$2"; shift 2 ;;
--folder) FOLDER="$2"; shift 2 ;;
--no-trigger-required) REQUIRES_TRIGGER="false"; shift ;;
--assistant-name) ASSISTANT_NAME="$2"; shift 2 ;;
*) shift ;;
esac
done
# Validate required args
if [ -z "$JID" ] || [ -z "$NAME" ] || [ -z "$TRIGGER" ] || [ -z "$FOLDER" ]; then
log "ERROR: Missing required args (--jid, --name, --trigger, --folder)"
cat <<EOF
=== NANOCLAW SETUP: REGISTER_CHANNEL ===
STATUS: failed
ERROR: missing_required_args
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
log "Registering channel: jid=$JID name=$NAME trigger=$TRIGGER folder=$FOLDER requiresTrigger=$REQUIRES_TRIGGER"
# Create data directory
mkdir -p "$PROJECT_ROOT/data"
# Write directly to SQLite (the DB and schema exist from the sync-groups step)
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')
DB_PATH="$PROJECT_ROOT/store/messages.db"
REQUIRES_TRIGGER_INT=$( [ "$REQUIRES_TRIGGER" = "true" ] && echo 1 || echo 0 )
sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES ('$JID', '$NAME', '$FOLDER', '$TRIGGER', '$TIMESTAMP', NULL, $REQUIRES_TRIGGER_INT);"
log "Wrote registration to SQLite"
# Create group folders
mkdir -p "$PROJECT_ROOT/groups/$FOLDER/logs"
log "Created groups/$FOLDER/logs/"
# Update assistant name in CLAUDE.md files if different from default
NAME_UPDATED="false"
if [ "$ASSISTANT_NAME" != "Andy" ]; then
log "Updating assistant name from Andy to $ASSISTANT_NAME"
for md_file in groups/global/CLAUDE.md groups/main/CLAUDE.md; do
if [ -f "$PROJECT_ROOT/$md_file" ]; then
sed -i '' "s/^# Andy$/# $ASSISTANT_NAME/" "$PROJECT_ROOT/$md_file"
sed -i '' "s/You are Andy/You are $ASSISTANT_NAME/g" "$PROJECT_ROOT/$md_file"
log "Updated $md_file"
else
log "WARNING: $md_file not found, skipping name update"
fi
done
# Add ASSISTANT_NAME to .env so config.ts picks it up
ENV_FILE="$PROJECT_ROOT/.env"
if [ -f "$ENV_FILE" ] && grep -q '^ASSISTANT_NAME=' "$ENV_FILE"; then
sed "s|^ASSISTANT_NAME=.*|ASSISTANT_NAME=\"$ASSISTANT_NAME\"|" "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
echo "ASSISTANT_NAME=\"$ASSISTANT_NAME\"" >> "$ENV_FILE"
fi
log "Set ASSISTANT_NAME=$ASSISTANT_NAME in .env"
NAME_UPDATED="true"
fi
cat <<EOF
=== NANOCLAW SETUP: REGISTER_CHANNEL ===
JID: $JID
NAME: $NAME
FOLDER: $FOLDER
TRIGGER: $TRIGGER
REQUIRES_TRIGGER: $REQUIRES_TRIGGER
ASSISTANT_NAME: $ASSISTANT_NAME
NAME_UPDATED: $NAME_UPDATED
STATUS: success
LOG: logs/setup.log
=== END ===
EOF

View File

@@ -1,80 +0,0 @@
#!/bin/bash
set -euo pipefail
# 07-configure-mounts.sh — Write mount allowlist config file
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [configure-mounts] $*" >> "$LOG_FILE"; }
CONFIG_DIR="$HOME/.config/nanoclaw"
CONFIG_FILE="$CONFIG_DIR/mount-allowlist.json"
# Parse args
EMPTY_MODE="false"
while [[ $# -gt 0 ]]; do
case $1 in
--empty) EMPTY_MODE="true"; shift ;;
*) shift ;;
esac
done
# Create config directory
mkdir -p "$CONFIG_DIR"
log "Ensured config directory: $CONFIG_DIR"
if [ "$EMPTY_MODE" = "true" ]; then
log "Writing empty mount allowlist"
cat > "$CONFIG_FILE" <<'JSONEOF'
{
"allowedRoots": [],
"blockedPatterns": [],
"nonMainReadOnly": true
}
JSONEOF
ALLOWED_ROOTS=0
NON_MAIN_READ_ONLY="true"
else
# Read JSON from stdin
log "Reading mount allowlist from stdin"
INPUT=$(cat)
# Validate JSON
if ! echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{JSON.parse(d)}catch(e){process.exit(1)}})" 2>/dev/null; then
log "ERROR: Invalid JSON input"
cat <<EOF
=== NANOCLAW SETUP: CONFIGURE_MOUNTS ===
PATH: $CONFIG_FILE
ALLOWED_ROOTS: 0
NON_MAIN_READ_ONLY: unknown
STATUS: failed
ERROR: invalid_json
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
echo "$INPUT" > "$CONFIG_FILE"
log "Wrote mount allowlist from stdin"
# Extract values
ALLOWED_ROOTS=$(node -e "const d=require('$CONFIG_FILE');console.log((d.allowedRoots||[]).length)" 2>/dev/null || echo "0")
NON_MAIN_READ_ONLY=$(node -e "const d=require('$CONFIG_FILE');console.log(d.nonMainReadOnly===false?'false':'true')" 2>/dev/null || echo "true")
fi
log "Allowlist configured: $ALLOWED_ROOTS roots, nonMainReadOnly=$NON_MAIN_READ_ONLY"
cat <<EOF
=== NANOCLAW SETUP: CONFIGURE_MOUNTS ===
PATH: $CONFIG_FILE
ALLOWED_ROOTS: $ALLOWED_ROOTS
NON_MAIN_READ_ONLY: $NON_MAIN_READ_ONLY
STATUS: success
LOG: logs/setup.log
=== END ===
EOF

View File

@@ -1,197 +0,0 @@
#!/bin/bash
set -euo pipefail
# 08-setup-service.sh — Generate and load service manager config
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [setup-service] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Parse args
PLATFORM=""
while [[ $# -gt 0 ]]; do
case $1 in
--platform) PLATFORM="$2"; shift 2 ;;
*) shift ;;
esac
done
# Auto-detect platform
if [ -z "$PLATFORM" ]; then
case "$(uname -s)" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
fi
NODE_PATH=$(which node)
PROJECT_PATH="$PROJECT_ROOT"
HOME_PATH="$HOME"
log "Setting up service: platform=$PLATFORM node=$NODE_PATH project=$PROJECT_PATH"
# Build first
log "Building TypeScript"
if ! npm run build >> "$LOG_FILE" 2>&1; then
log "Build failed"
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: unknown
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
STATUS: failed
ERROR: build_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Create logs directory
mkdir -p "$PROJECT_PATH/logs"
case "$PLATFORM" in
macos)
PLIST_PATH="$HOME_PATH/Library/LaunchAgents/com.nanoclaw.plist"
log "Generating launchd plist at $PLIST_PATH"
mkdir -p "$HOME_PATH/Library/LaunchAgents"
cat > "$PLIST_PATH" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>${NODE_PATH}</string>
<string>${PROJECT_PATH}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${PROJECT_PATH}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${HOME_PATH}/.local/bin</string>
<key>HOME</key>
<string>${HOME_PATH}</string>
</dict>
<key>StandardOutPath</key>
<string>${PROJECT_PATH}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${PROJECT_PATH}/logs/nanoclaw.error.log</string>
</dict>
</plist>
PLISTEOF
log "Loading launchd service"
if launchctl load "$PLIST_PATH" >> "$LOG_FILE" 2>&1; then
log "launchctl load succeeded"
else
log "launchctl load failed (may already be loaded)"
fi
# Verify
SERVICE_LOADED="false"
if launchctl list 2>/dev/null | grep -q "com.nanoclaw"; then
SERVICE_LOADED="true"
log "Service verified as loaded"
else
log "Service not found in launchctl list"
fi
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: launchd
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
PLIST_PATH: $PLIST_PATH
SERVICE_LOADED: $SERVICE_LOADED
STATUS: success
LOG: logs/setup.log
=== END ===
EOF
;;
linux)
UNIT_DIR="$HOME_PATH/.config/systemd/user"
UNIT_PATH="$UNIT_DIR/nanoclaw.service"
mkdir -p "$UNIT_DIR"
log "Generating systemd unit at $UNIT_PATH"
cat > "$UNIT_PATH" <<UNITEOF
[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${NODE_PATH} ${PROJECT_PATH}/dist/index.js
WorkingDirectory=${PROJECT_PATH}
Restart=always
RestartSec=5
Environment=HOME=${HOME_PATH}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${HOME_PATH}/.local/bin
StandardOutput=append:${PROJECT_PATH}/logs/nanoclaw.log
StandardError=append:${PROJECT_PATH}/logs/nanoclaw.error.log
[Install]
WantedBy=default.target
UNITEOF
log "Enabling and starting systemd service"
systemctl --user daemon-reload >> "$LOG_FILE" 2>&1 || true
systemctl --user enable nanoclaw >> "$LOG_FILE" 2>&1 || true
systemctl --user start nanoclaw >> "$LOG_FILE" 2>&1 || true
# Verify
SERVICE_LOADED="false"
if systemctl --user is-active nanoclaw >/dev/null 2>&1; then
SERVICE_LOADED="true"
log "Service verified as active"
else
log "Service not active"
fi
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: systemd
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
UNIT_PATH: $UNIT_PATH
SERVICE_LOADED: $SERVICE_LOADED
STATUS: success
LOG: logs/setup.log
=== END ===
EOF
;;
*)
log "Unsupported platform: $PLATFORM"
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: unknown
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
STATUS: failed
ERROR: unsupported_platform
LOG: logs/setup.log
=== END ===
EOF
exit 1
;;
esac

View File

@@ -1,109 +0,0 @@
#!/bin/bash
set -euo pipefail
# 09-verify.sh — End-to-end health check of the full installation
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [verify] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
log "Starting verification"
# Detect platform
case "$(uname -s)" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
# 1. Check service status
SERVICE="not_found"
if [ "$PLATFORM" = "macos" ]; then
if launchctl list 2>/dev/null | grep -q "com.nanoclaw"; then
# Check if it has a PID (actually running)
LAUNCHCTL_LINE=$(launchctl list 2>/dev/null | grep "com.nanoclaw" || true)
PID_FIELD=$(echo "$LAUNCHCTL_LINE" | awk '{print $1}')
if [ "$PID_FIELD" != "-" ] && [ -n "$PID_FIELD" ]; then
SERVICE="running"
else
SERVICE="stopped"
fi
fi
elif [ "$PLATFORM" = "linux" ]; then
if systemctl --user is-active nanoclaw >/dev/null 2>&1; then
SERVICE="running"
elif systemctl --user list-unit-files 2>/dev/null | grep -q "nanoclaw"; then
SERVICE="stopped"
fi
fi
log "Service: $SERVICE"
# 2. Check container runtime
CONTAINER_RUNTIME="none"
if command -v container >/dev/null 2>&1; then
CONTAINER_RUNTIME="apple-container"
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
CONTAINER_RUNTIME="docker"
fi
log "Container runtime: $CONTAINER_RUNTIME"
# 3. Check credentials
CREDENTIALS="missing"
if [ -f "$PROJECT_ROOT/.env" ]; then
if grep -qE "^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=" "$PROJECT_ROOT/.env" 2>/dev/null; then
CREDENTIALS="configured"
fi
fi
log "Credentials: $CREDENTIALS"
# 4. Check WhatsApp auth
WHATSAPP_AUTH="not_found"
if [ -d "$PROJECT_ROOT/store/auth" ] && [ "$(ls -A "$PROJECT_ROOT/store/auth" 2>/dev/null)" ]; then
WHATSAPP_AUTH="authenticated"
fi
log "WhatsApp auth: $WHATSAPP_AUTH"
# 5. Check registered groups (in SQLite — the JSON file gets migrated away on startup)
REGISTERED_GROUPS=0
if [ -f "$PROJECT_ROOT/store/messages.db" ]; then
REGISTERED_GROUPS=$(sqlite3 "$PROJECT_ROOT/store/messages.db" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo "0")
fi
log "Registered groups: $REGISTERED_GROUPS"
# 6. Check mount allowlist
MOUNT_ALLOWLIST="missing"
if [ -f "$HOME/.config/nanoclaw/mount-allowlist.json" ]; then
MOUNT_ALLOWLIST="configured"
fi
log "Mount allowlist: $MOUNT_ALLOWLIST"
# Determine overall status
STATUS="success"
if [ "$SERVICE" != "running" ] || [ "$CREDENTIALS" = "missing" ] || [ "$WHATSAPP_AUTH" = "not_found" ] || [ "$REGISTERED_GROUPS" -eq 0 ] 2>/dev/null; then
STATUS="failed"
fi
log "Verification complete: $STATUS"
cat <<EOF
=== NANOCLAW SETUP: VERIFY ===
SERVICE: $SERVICE
CONTAINER_RUNTIME: $CONTAINER_RUNTIME
CREDENTIALS: $CREDENTIALS
WHATSAPP_AUTH: $WHATSAPP_AUTH
REGISTERED_GROUPS: $REGISTERED_GROUPS
MOUNT_ALLOWLIST: $MOUNT_ALLOWLIST
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
if [ "$STATUS" = "failed" ]; then
exit 1
fi

View File

@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html><head><title>NanoClaw - WhatsApp Auth</title>
<meta http-equiv="refresh" content="3">
<style>
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { margin: 0 0 8px; }
.timer { font-size: 18px; color: #666; margin: 12px 0; }
.timer.urgent { color: #e74c3c; font-weight: bold; }
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
svg { width: 280px; height: 280px; }
</style></head><body>
<div class="card">
<h2>Scan with WhatsApp</h2>
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
<div id="qr">{{QR_SVG}}</div>
<div class="instructions">Settings → Linked Devices → Link a Device</div>
</div>
<script>
// Persist start time across auto-refreshes
var startKey = 'nanoclaw_qr_start';
var start = localStorage.getItem(startKey);
if (!start) { start = Date.now().toString(); localStorage.setItem(startKey, start); }
var elapsed = Math.floor((Date.now() - parseInt(start)) / 1000);
var remaining = Math.max(0, 60 - elapsed);
var countdown = document.getElementById('countdown');
var timer = document.getElementById('timer');
countdown.textContent = remaining;
if (remaining <= 10) timer.classList.add('urgent');
if (remaining <= 0) {
timer.textContent = 'QR code expired — a new one will appear shortly';
timer.classList.add('urgent');
localStorage.removeItem(startKey);
}
</script></body></html>

View File

@@ -49,8 +49,9 @@ npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
# 3. Rebuild host and restart service # 3. Rebuild host and restart service
npm run build npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Verify: launchctl list | grep nanoclaw shows PID and exit code 0 # Linux: systemctl --user restart nanoclaw
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
``` ```
## Configuration ## Configuration
@@ -271,12 +272,14 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
```bash ```bash
npm run build npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```
**Verify success:** **Verify success:**
```bash ```bash
launchctl list | grep nanoclaw # Should show PID and exit code 0 or - launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status nanoclaw
``` ```
## Usage via WhatsApp ## Usage via WhatsApp
@@ -342,7 +345,8 @@ echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts
```bash ```bash
npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
launchctl kickstart -k gui/$(id -u)/com.nanoclaw launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
``` ```
### Browser Lock Files ### Browser Lock Files

View File

@@ -41,8 +41,15 @@ npm run build # Compile TypeScript
Service management: Service management:
```bash ```bash
# macOS (launchd)
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
# Linux (systemd)
systemctl --user start nanoclaw
systemctl --user stop nanoclaw
systemctl --user restart nanoclaw
``` ```
## Container Build Cache ## Container Build Cache

View File

@@ -98,7 +98,7 @@ Users then run `/add-telegram` on their fork and get clean code that does exactl
### RFS (Request for Skills) ### RFS (Request for Skills)
Skills we'd love to see: Skills we'd like to see:
**Communication Channels** **Communication Channels**
- `/add-telegram` - Add Telegram as channel. Should give the user option to replace WhatsApp or add as additional channel. Also should be possible to add it as a control channel (where it can trigger actions) or just a channel that can be used in actions triggered elsewhere - `/add-telegram` - Add Telegram as channel. Should give the user option to replace WhatsApp or add as additional channel. Also should be possible to add it as a control channel (where it can trigger actions) or just a channel that can be used in actions triggered elsewhere

View File

@@ -112,10 +112,16 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
}; };
} }
} else if (args.schedule_type === 'once') { } else if (args.schedule_type === 'once') {
if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) {
return {
content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }],
isError: true,
};
}
const date = new Date(args.schedule_value); const date = new Date(args.schedule_value);
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return { return {
content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use ISO 8601 format like "2026-02-01T15:30:00.000Z".` }], content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }],
isError: true, isError: true,
}; };
} }

View File

@@ -40,6 +40,10 @@ private_key, .secret
- Container path validation (rejects `..` and absolute paths) - Container path validation (rejects `..` and absolute paths)
- `nonMainReadOnly` option forces read-only for non-main groups - `nonMainReadOnly` option forces read-only for non-main groups
**Read-Only Project Root:**
The main group's project root is mounted read-only. Writable paths the agent needs (group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart.
### 3. Session Isolation ### 3. Session Isolation
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`: Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
@@ -82,7 +86,7 @@ const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
| Capability | Main Group | Non-Main Group | | Capability | Main Group | Non-Main Group |
|------------|------------|----------------| |------------|------------|----------------|
| Project root access | `/workspace/project` (rw) | None | | Project root access | `/workspace/project` (ro) | None |
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) | | Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
| Global memory | Implicit via project | `/workspace/global` (ro) | | Global memory | Implicit via project | `/workspace/global` (ro) |
| Additional mounts | Configurable | Read-only unless allowed | | Additional mounts | Configurable | Read-only unless allowed |

View File

@@ -61,11 +61,11 @@ This is the **main channel**, which has elevated privileges.
## Container Mounts ## Container Mounts
Main has access to the entire project: Main has read-only access to the project and read-write access to its group folder:
| Container Path | Host Path | Access | | Container Path | Host Path | Access |
|----------------|-----------|--------| |----------------|-----------|--------|
| `/workspace/project` | Project root | read-write | | `/workspace/project` | Project root | read-only |
| `/workspace/group` | `groups/main/` | read-write | | `/workspace/group` | `groups/main/` | read-write |
Key paths inside the container: Key paths inside the container:

View File

@@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"",
"setup": "tsx setup/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="36.8k tokens, 18% of context window"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="37.8k tokens, 19% of context window">
<title>36.8k tokens, 18% of context window</title> <title>37.8k tokens, 19% of context window</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text> <text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text> <text x="26" y="14">tokens</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">36.8k</text> <text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">37.8k</text>
<text x="74" y="14">36.8k</text> <text x="74" y="14">37.8k</text>
</g> </g>
</g> </g>
</a> </a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

161
setup.sh Executable file
View File

@@ -0,0 +1,161 @@
#!/bin/bash
set -euo pipefail
# setup.sh — Bootstrap script for NanoClaw
# Handles Node.js/npm setup, then hands off to the Node.js setup modules.
# This is the only bash script in the setup flow.
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bootstrap] $*" >> "$LOG_FILE"; }
# --- Platform detection ---
detect_platform() {
local uname_s
uname_s=$(uname -s)
case "$uname_s" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
IS_WSL="false"
if [ "$PLATFORM" = "linux" ] && [ -f /proc/version ]; then
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
IS_WSL="true"
fi
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
log "Platform: $PLATFORM, WSL: $IS_WSL, Root: $IS_ROOT"
}
# --- Node.js check ---
check_node() {
NODE_OK="false"
NODE_VERSION="not_found"
NODE_PATH_FOUND=""
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node --version 2>/dev/null | sed 's/^v//')
NODE_PATH_FOUND=$(command -v node)
local major
major=$(echo "$NODE_VERSION" | cut -d. -f1)
if [ "$major" -ge 20 ] 2>/dev/null; then
NODE_OK="true"
fi
log "Node $NODE_VERSION at $NODE_PATH_FOUND (major=$major, ok=$NODE_OK)"
else
log "Node not found"
fi
}
# --- npm install ---
install_deps() {
DEPS_OK="false"
NATIVE_OK="false"
if [ "$NODE_OK" = "false" ]; then
log "Skipping npm install — Node not available"
return
fi
cd "$PROJECT_ROOT"
# npm install with --unsafe-perm if root (needed for native modules)
local npm_flags=""
if [ "$IS_ROOT" = "true" ]; then
npm_flags="--unsafe-perm"
log "Running as root, using --unsafe-perm"
fi
log "Running npm install $npm_flags"
if npm install $npm_flags >> "$LOG_FILE" 2>&1; then
DEPS_OK="true"
log "npm install succeeded"
else
log "npm install failed"
return
fi
# Verify native module (better-sqlite3)
log "Verifying native modules"
if node -e "require('better-sqlite3')" >> "$LOG_FILE" 2>&1; then
NATIVE_OK="true"
log "better-sqlite3 loads OK"
else
log "better-sqlite3 failed to load"
fi
}
# --- Build tools check ---
check_build_tools() {
HAS_BUILD_TOOLS="false"
if [ "$PLATFORM" = "macos" ]; then
if xcode-select -p >/dev/null 2>&1; then
HAS_BUILD_TOOLS="true"
fi
elif [ "$PLATFORM" = "linux" ]; then
if command -v gcc >/dev/null 2>&1 && command -v make >/dev/null 2>&1; then
HAS_BUILD_TOOLS="true"
fi
fi
log "Build tools: $HAS_BUILD_TOOLS"
}
# --- Main ---
log "=== Bootstrap started ==="
detect_platform
check_node
install_deps
check_build_tools
# Emit status block
STATUS="success"
if [ "$NODE_OK" = "false" ]; then
STATUS="node_missing"
elif [ "$DEPS_OK" = "false" ]; then
STATUS="deps_failed"
elif [ "$NATIVE_OK" = "false" ]; then
STATUS="native_failed"
fi
cat <<EOF
=== NANOCLAW SETUP: BOOTSTRAP ===
PLATFORM: $PLATFORM
IS_WSL: $IS_WSL
IS_ROOT: $IS_ROOT
NODE_VERSION: $NODE_VERSION
NODE_OK: $NODE_OK
NODE_PATH: ${NODE_PATH_FOUND:-not_found}
DEPS_OK: $DEPS_OK
NATIVE_OK: $NATIVE_OK
HAS_BUILD_TOOLS: $HAS_BUILD_TOOLS
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
log "=== Bootstrap completed: $STATUS ==="
if [ "$NODE_OK" = "false" ]; then
exit 2
fi
if [ "$DEPS_OK" = "false" ] || [ "$NATIVE_OK" = "false" ]; then
exit 1
fi

123
setup/container.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* Step: container — Build container image and verify with test run.
* Replaces 03-setup-container.sh
*/
import { execSync } from 'child_process';
import path from 'path';
import { logger } from '../src/logger.js';
import { commandExists } from './platform.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { runtime: string } {
let runtime = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--runtime' && args[i + 1]) {
runtime = args[i + 1];
i++;
}
}
return { runtime };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { runtime } = parseArgs(args);
const image = 'nanoclaw-agent:latest';
const logFile = path.join(projectRoot, 'logs', 'setup.log');
if (!runtime) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: 'unknown',
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'missing_runtime_flag',
LOG: 'logs/setup.log',
});
process.exit(4);
}
// Validate runtime availability
if (runtime === 'apple-container' && !commandExists('container')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
if (runtime === 'docker') {
if (!commandExists('docker')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
try {
execSync('docker info', { stdio: 'ignore' });
} catch {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
}
if (!['apple-container', 'docker'].includes(runtime)) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'unknown_runtime', LOG: 'logs/setup.log',
});
process.exit(4);
}
const buildCmd = runtime === 'apple-container' ? 'container build' : 'docker build';
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
// Build
let buildOk = false;
logger.info({ runtime }, 'Building container');
try {
execSync(`${buildCmd} -t ${image} .`, {
cwd: path.join(projectRoot, 'container'),
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Container build succeeded');
} catch (err) {
logger.error({ err }, 'Container build failed');
}
// Test
let testOk = false;
if (buildOk) {
logger.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');
} catch {
logger.error('Container test failed');
}
}
const status = buildOk && testOk ? 'success' : 'failed';
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: buildOk,
TEST_OK: testOk,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

103
setup/environment.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import Database from 'better-sqlite3';
/**
* Tests for the environment check step.
*
* Verifies: config detection, Docker/AC detection, DB queries.
*/
describe('environment detection', () => {
it('detects platform correctly', async () => {
const { getPlatform } = await import('./platform.js');
const platform = getPlatform();
expect(['macos', 'linux', 'unknown']).toContain(platform);
});
});
describe('registered groups DB query', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
});
it('returns 0 for empty table', () => {
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
expect(row.count).toBe(0);
});
it('returns correct count after inserts', () => {
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run('123@g.us', 'Group 1', 'group-1', '@Andy', '2024-01-01T00:00:00.000Z', 1);
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run('456@g.us', 'Group 2', 'group-2', '@Andy', '2024-01-01T00:00:00.000Z', 1);
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
expect(row.count).toBe(2);
});
});
describe('credentials detection', () => {
it('detects ANTHROPIC_API_KEY in env content', () => {
const content = 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(true);
});
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(true);
});
it('returns false when no credentials', () => {
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(false);
});
});
describe('Docker detection logic', () => {
it('commandExists returns boolean', async () => {
const { commandExists } = await import('./platform.js');
expect(typeof commandExists('docker')).toBe('boolean');
expect(typeof commandExists('nonexistent_binary_xyz')).toBe('boolean');
});
});
describe('WhatsApp auth detection', () => {
it('detects non-empty auth directory logic', () => {
// Simulate the check: directory exists and has files
const hasAuth = (authDir: string) => {
try {
return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
} catch {
return false;
}
};
// Non-existent directory
expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false);
});
});

86
setup/environment.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Step: environment — Detect OS, Node, container runtimes, existing config.
* Replaces 01-check-environment.sh
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.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');
const platform = getPlatform();
const wsl = isWSL();
const headless = isHeadless();
// Check Apple Container
let appleContainer: 'installed' | 'not_found' = 'not_found';
if (commandExists('container')) {
appleContainer = 'installed';
}
// Check Docker
let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found';
if (commandExists('docker')) {
try {
const { execSync } = await import('child_process');
execSync('docker info', { stdio: 'ignore' });
docker = 'running';
} catch {
docker = 'installed_not_running';
}
}
// Check existing config
const hasEnv = fs.existsSync(path.join(projectRoot, '.env'));
const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth =
fs.existsSync(authDir) &&
fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false;
// Check JSON file first (pre-migration)
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
hasRegisteredGroups = true;
} else {
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
if (row.count > 0) hasRegisteredGroups = true;
db.close();
} catch {
// Table might not exist yet
}
}
}
logger.info({ platform, wsl, appleContainer, docker, hasEnv, hasAuth, hasRegisteredGroups },
'Environment check complete');
emitStatus('CHECK_ENVIRONMENT', {
PLATFORM: platform,
IS_WSL: wsl,
IS_HEADLESS: headless,
APPLE_CONTAINER: appleContainer,
DOCKER: docker,
HAS_ENV: hasEnv,
HAS_AUTH: hasAuth,
HAS_REGISTERED_GROUPS: hasRegisteredGroups,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

195
setup/groups.ts Normal file
View File

@@ -0,0 +1,195 @@
/**
* Step: groups — Connect to WhatsApp, fetch group metadata, write to DB.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { list: boolean; limit: number } {
let list = false;
let limit = 30;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--list') list = true;
if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; }
}
return { list, limit };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
await listGroups(limit);
return;
}
await syncGroups(projectRoot);
}
async function listGroups(limit: number): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
if (!fs.existsSync(dbPath)) {
console.error('ERROR: database not found');
process.exit(1);
}
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(
`SELECT jid, name FROM chats
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
).all(limit) as Array<{ jid: string; name: string }>;
db.close();
for (const row of rows) {
console.log(`${row.jid}|${row.name}`);
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// Build TypeScript first
logger.info('Building TypeScript');
let buildOk = false;
try {
execSync('npm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Build succeeded');
} catch {
logger.error('Build failed');
emitStatus('SYNC_GROUPS', {
BUILD: 'failed',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Run inline sync script via node
logger.info('Fetching group metadata');
let syncOk = false;
try {
const syncScript = `
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
`;
const output = execSync(`node --input-type=module -e ${JSON.stringify(syncScript)}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
logger.info({ output: output.trim() }, 'Sync output');
} catch (err) {
logger.error({ err }, 'Sync failed');
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
let groupsInDb = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
).get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
}
}
const status = syncOk ? 'success' : 'failed';
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

52
setup/index.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Setup CLI entry point.
* Usage: npx tsx setup/index.ts --step <name> [args...]
*/
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
const STEPS: Record<string, () => Promise<{ run: (args: string[]) => Promise<void> }>> = {
environment: () => import('./environment.js'),
container: () => import('./container.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
};
async function main(): Promise<void> {
const args = process.argv.slice(2);
const stepIdx = args.indexOf('--step');
if (stepIdx === -1 || !args[stepIdx + 1]) {
console.error(`Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`);
process.exit(1);
}
const stepName = args[stepIdx + 1];
const stepArgs = args.filter((a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--');
const loader = STEPS[stepName];
if (!loader) {
console.error(`Unknown step: ${stepName}`);
console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`);
process.exit(1);
}
try {
const mod = await loader();
await mod.run(stepArgs);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, step: stepName }, 'Setup step failed');
emitStatus(stepName.toUpperCase(), {
STATUS: 'failed',
ERROR: message,
});
process.exit(1);
}
}
main();

103
setup/mounts.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Step: mounts — Write mount allowlist config file.
* Replaces 07-configure-mounts.sh
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { logger } from '../src/logger.js';
import { isRoot } from './platform.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { empty: boolean; json: string } {
let empty = false;
let json = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--empty') empty = true;
if (args[i] === '--json' && args[i + 1]) { json = args[i + 1]; i++; }
}
return { empty, json };
}
export async function run(args: string[]): Promise<void> {
const { empty, json } = parseArgs(args);
const homeDir = os.homedir();
const configDir = path.join(homeDir, '.config', 'nanoclaw');
const configFile = path.join(configDir, 'mount-allowlist.json');
if (isRoot()) {
logger.warn('Running as root — mount allowlist will be written to root home directory');
}
fs.mkdirSync(configDir, { recursive: true });
let allowedRoots = 0;
let nonMainReadOnly = 'true';
if (empty) {
logger.info('Writing empty mount allowlist');
const emptyConfig = {
allowedRoots: [],
blockedPatterns: [],
nonMainReadOnly: true,
};
fs.writeFileSync(configFile, JSON.stringify(emptyConfig, null, 2) + '\n');
} else if (json) {
// Validate JSON with JSON.parse (not piped through shell)
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
try {
parsed = JSON.parse(json);
} catch {
logger.error('Invalid JSON input');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
NON_MAIN_READ_ONLY: 'unknown',
STATUS: 'failed',
ERROR: 'invalid_json',
LOG: 'logs/setup.log',
});
process.exit(4);
return; // unreachable but satisfies TS
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
} else {
// Read from stdin
logger.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');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
NON_MAIN_READ_ONLY: 'unknown',
STATUS: 'failed',
ERROR: 'invalid_json',
LOG: 'logs/setup.log',
});
process.exit(4);
return;
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
}
logger.info({ configFile, allowedRoots, nonMainReadOnly }, 'Allowlist configured');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: allowedRoots,
NON_MAIN_READ_ONLY: nonMainReadOnly,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

121
setup/platform.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import {
getPlatform,
isWSL,
isRoot,
isHeadless,
hasSystemd,
getServiceManager,
commandExists,
getNodeVersion,
getNodeMajorVersion,
} from './platform.js';
// --- getPlatform ---
describe('getPlatform', () => {
it('returns a valid platform string', () => {
const result = getPlatform();
expect(['macos', 'linux', 'unknown']).toContain(result);
});
});
// --- isWSL ---
describe('isWSL', () => {
it('returns a boolean', () => {
expect(typeof isWSL()).toBe('boolean');
});
it('checks /proc/version for WSL markers', () => {
// On non-WSL Linux, should return false
// On WSL, should return true
// Just verify it doesn't throw
const result = isWSL();
expect(typeof result).toBe('boolean');
});
});
// --- isRoot ---
describe('isRoot', () => {
it('returns a boolean', () => {
expect(typeof isRoot()).toBe('boolean');
});
});
// --- isHeadless ---
describe('isHeadless', () => {
it('returns a boolean', () => {
expect(typeof isHeadless()).toBe('boolean');
});
});
// --- hasSystemd ---
describe('hasSystemd', () => {
it('returns a boolean', () => {
expect(typeof hasSystemd()).toBe('boolean');
});
it('checks /proc/1/comm', () => {
// On systemd systems, should return true
// Just verify it doesn't throw
const result = hasSystemd();
expect(typeof result).toBe('boolean');
});
});
// --- getServiceManager ---
describe('getServiceManager', () => {
it('returns a valid service manager', () => {
const result = getServiceManager();
expect(['launchd', 'systemd', 'none']).toContain(result);
});
it('matches the detected platform', () => {
const platform = getPlatform();
const result = getServiceManager();
if (platform === 'macos') {
expect(result).toBe('launchd');
} else {
expect(['systemd', 'none']).toContain(result);
}
});
});
// --- commandExists ---
describe('commandExists', () => {
it('returns true for node', () => {
expect(commandExists('node')).toBe(true);
});
it('returns false for nonexistent command', () => {
expect(commandExists('this_command_does_not_exist_xyz_123')).toBe(false);
});
});
// --- getNodeVersion ---
describe('getNodeVersion', () => {
it('returns a version string', () => {
const version = getNodeVersion();
expect(version).not.toBeNull();
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
});
// --- getNodeMajorVersion ---
describe('getNodeMajorVersion', () => {
it('returns at least 20', () => {
const major = getNodeMajorVersion();
expect(major).not.toBeNull();
expect(major!).toBeGreaterThanOrEqual(20);
});
});

130
setup/platform.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* Cross-platform detection utilities for NanoClaw setup.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
export type Platform = 'macos' | 'linux' | 'unknown';
export type ServiceManager = 'launchd' | 'systemd' | 'none';
export function getPlatform(): Platform {
const platform = os.platform();
if (platform === 'darwin') return 'macos';
if (platform === 'linux') return 'linux';
return 'unknown';
}
export function isWSL(): boolean {
if (os.platform() !== 'linux') return false;
try {
const release = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
return release.includes('microsoft') || release.includes('wsl');
} catch {
return false;
}
}
export function isRoot(): boolean {
return process.getuid?.() === 0;
}
export function isHeadless(): boolean {
// No display server available
if (getPlatform() === 'linux') {
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
}
// macOS is never headless in practice (even SSH sessions can open URLs)
return false;
}
export function hasSystemd(): boolean {
if (getPlatform() !== 'linux') return false;
try {
// Check if systemd is PID 1
const init = fs.readFileSync('/proc/1/comm', 'utf-8').trim();
return init === 'systemd';
} catch {
return false;
}
}
/**
* Open a URL in the default browser, cross-platform.
* Returns true if the command was attempted, false if no method available.
*/
export function openBrowser(url: string): boolean {
try {
const platform = getPlatform();
if (platform === 'macos') {
execSync(`open ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
if (platform === 'linux') {
// Try xdg-open first, then wslview for WSL
if (commandExists('xdg-open')) {
execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
if (isWSL() && commandExists('wslview')) {
execSync(`wslview ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
// WSL without wslview: try cmd.exe
if (isWSL()) {
try {
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
} catch {
// cmd.exe not available
}
}
}
} catch {
// Command failed
}
return false;
}
export function getServiceManager(): ServiceManager {
const platform = getPlatform();
if (platform === 'macos') return 'launchd';
if (platform === 'linux') {
if (hasSystemd()) return 'systemd';
return 'none';
}
return 'none';
}
export function getNodePath(): string {
try {
return execSync('command -v node', { encoding: 'utf-8' }).trim();
} catch {
return process.execPath;
}
}
export function commandExists(name: string): boolean {
try {
execSync(`command -v ${name}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
export function getNodeVersion(): string | null {
try {
const version = execSync('node --version', { encoding: 'utf-8' }).trim();
return version.replace(/^v/, '');
} catch {
return null;
}
}
export function getNodeMajorVersion(): number | null {
const version = getNodeVersion();
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return isNaN(major) ? null : major;
}

165
setup/register.test.ts Normal file
View File

@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
/**
* Tests for the register step.
*
* Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates.
*/
function createTestDb(): Database.Database {
const db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
return db;
}
describe('parameterized SQL registration', () => {
let db: Database.Database;
beforeEach(() => {
db = createTestDb();
});
it('registers a group with parameterized query', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('123@g.us', 'Test Group', 'test-group', '@Andy', '2024-01-01T00:00:00.000Z', 1);
const row = db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get('123@g.us') as {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
requires_trigger: number;
};
expect(row.jid).toBe('123@g.us');
expect(row.name).toBe('Test Group');
expect(row.folder).toBe('test-group');
expect(row.trigger_pattern).toBe('@Andy');
expect(row.requires_trigger).toBe(1);
});
it('handles apostrophes in group names safely', () => {
const name = "O'Brien's Group";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('456@g.us', name, 'obriens-group', '@Andy', '2024-01-01T00:00:00.000Z', 0);
const row = db.prepare('SELECT name FROM registered_groups WHERE jid = ?').get('456@g.us') as {
name: string;
};
expect(row.name).toBe(name);
});
it('prevents SQL injection in JID field', () => {
const maliciousJid = "'; DROP TABLE registered_groups; --";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
// Table should still exist and have the row
const count = db.prepare('SELECT COUNT(*) as count FROM registered_groups').get() as {
count: number;
};
expect(count.count).toBe(1);
const row = db.prepare('SELECT jid FROM registered_groups').get() as { jid: string };
expect(row.jid).toBe(maliciousJid);
});
it('handles requiresTrigger=false', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('789@s.whatsapp.net', 'Personal', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 0);
const row = db.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { requires_trigger: number };
expect(row.requires_trigger).toBe(0);
});
it('upserts on conflict', () => {
const stmt = db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
);
stmt.run('123@g.us', 'Original', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 1);
stmt.run('123@g.us', 'Updated', 'main', '@Bot', '2024-02-01T00:00:00.000Z', 0);
const rows = db.prepare('SELECT * FROM registered_groups').all();
expect(rows).toHaveLength(1);
const row = rows[0] as { name: string; trigger_pattern: string; requires_trigger: number };
expect(row.name).toBe('Updated');
expect(row.trigger_pattern).toBe('@Bot');
expect(row.requires_trigger).toBe(0);
});
});
describe('file templating', () => {
it('replaces assistant name in CLAUDE.md content', () => {
let content = '# Andy\n\nYou are Andy, a personal assistant.';
content = content.replace(/^# Andy$/m, '# Nova');
content = content.replace(/You are Andy/g, 'You are Nova');
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
});
it('handles names with special regex characters', () => {
let content = '# Andy\n\nYou are Andy.';
const newName = 'C.L.A.U.D.E';
content = content.replace(/^# Andy$/m, `# ${newName}`);
content = content.replace(/You are Andy/g, `You are ${newName}`);
expect(content).toContain('# C.L.A.U.D.E');
expect(content).toContain('You are C.L.A.U.D.E.');
});
it('updates .env ASSISTANT_NAME line', () => {
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
envContent = envContent.replace(
/^ASSISTANT_NAME=.*$/m,
'ASSISTANT_NAME="Nova"',
);
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
expect(envContent).toContain('SOME_KEY=value');
});
it('appends ASSISTANT_NAME to .env if not present', () => {
let envContent = 'SOME_KEY=value\n';
if (!envContent.includes('ASSISTANT_NAME=')) {
envContent += '\nASSISTANT_NAME="Nova"';
}
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
});
});

157
setup/register.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* Step: register — Write channel registration config, create group folders.
* Replaces 06-register-channel.sh
*
* Fixes: SQL injection (parameterized queries), sed -i '' (uses fs directly).
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { isValidGroupFolder } from '../src/group-folder.js';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
interface RegisterArgs {
jid: string;
name: string;
trigger: string;
folder: string;
requiresTrigger: boolean;
assistantName: string;
}
function parseArgs(args: string[]): RegisterArgs {
const result: RegisterArgs = {
jid: '',
name: '',
trigger: '',
folder: '',
requiresTrigger: true,
assistantName: 'Andy',
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--jid': result.jid = args[++i] || ''; break;
case '--name': result.name = args[++i] || ''; break;
case '--trigger': result.trigger = args[++i] || ''; break;
case '--folder': result.folder = args[++i] || ''; break;
case '--no-trigger-required': result.requiresTrigger = false; break;
case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break;
}
}
return result;
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const parsed = parseArgs(args);
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'missing_required_args',
LOG: 'logs/setup.log',
});
process.exit(4);
}
if (!isValidGroupFolder(parsed.folder)) {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'invalid_folder',
LOG: 'logs/setup.log',
});
process.exit(4);
}
logger.info(parsed, 'Registering channel');
// Ensure data directory exists
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
// Write to SQLite using parameterized queries (no SQL injection)
const dbPath = path.join(STORE_DIR, 'messages.db');
const timestamp = new Date().toISOString();
const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0;
const db = new Database(dbPath);
// Ensure schema exists
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(parsed.jid, parsed.name, parsed.folder, parsed.trigger, timestamp, requiresTriggerInt);
db.close();
logger.info('Wrote registration to SQLite');
// Create group folders
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true });
// 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 }, 'Updating assistant name');
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'),
];
for (const mdFile of mdFiles) {
if (fs.existsSync(mdFile)) {
let content = fs.readFileSync(mdFile, 'utf-8');
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
fs.writeFileSync(mdFile, content);
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
}
}
// Update .env
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
let envContent = fs.readFileSync(envFile, 'utf-8');
if (envContent.includes('ASSISTANT_NAME=')) {
envContent = envContent.replace(
/^ASSISTANT_NAME=.*$/m,
`ASSISTANT_NAME="${parsed.assistantName}"`,
);
} else {
envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`;
}
fs.writeFileSync(envFile, envContent);
} else {
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
}
logger.info('Set ASSISTANT_NAME in .env');
nameUpdated = true;
}
emitStatus('REGISTER_CHANNEL', {
JID: parsed.jid,
NAME: parsed.name,
FOLDER: parsed.folder,
TRIGGER: parsed.trigger,
REQUIRES_TRIGGER: parsed.requiresTrigger,
ASSISTANT_NAME: parsed.assistantName,
NAME_UPDATED: nameUpdated,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

134
setup/service.test.ts Normal file
View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
/**
* Tests for service configuration generation.
*
* These tests verify the generated content of plist/systemd/nohup configs
* without actually loading services.
*/
// Helper: generate a plist string the same way service.ts does
function generatePlist(nodePath: string, projectRoot: string, homeDir: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
<string>${projectRoot}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${projectRoot}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin</string>
<key>HOME</key>
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
</dict>
</plist>`;
}
function generateSystemdUnit(
nodePath: string,
projectRoot: string,
homeDir: string,
isSystem: boolean,
): string {
return `[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot}
Restart=always
RestartSec=5
Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
[Install]
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
}
describe('plist generation', () => {
it('contains the correct label', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('<string>com.nanoclaw</string>');
});
it('uses the correct node path', () => {
const plist = generatePlist('/opt/node/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('<string>/opt/node/bin/node</string>');
});
it('points to dist/index.js', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('/home/user/nanoclaw/dist/index.js');
});
it('sets log paths', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('nanoclaw.log');
expect(plist).toContain('nanoclaw.error.log');
});
});
describe('systemd unit generation', () => {
it('user unit uses default.target', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
expect(unit).toContain('WantedBy=default.target');
});
it('system unit uses multi-user.target', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', true);
expect(unit).toContain('WantedBy=multi-user.target');
});
it('contains restart policy', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
expect(unit).toContain('Restart=always');
expect(unit).toContain('RestartSec=5');
});
it('sets correct ExecStart', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/srv/nanoclaw', '/home/user', false);
expect(unit).toContain('ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js');
});
});
describe('WSL nohup fallback', () => {
it('generates a valid wrapper script', () => {
const projectRoot = '/home/user/nanoclaw';
const nodePath = '/usr/bin/node';
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
// Simulate what service.ts generates
const wrapper = `#!/bin/bash
set -euo pipefail
cd ${JSON.stringify(projectRoot)}
nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/nanoclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/nanoclaw.error.log &
echo $! > ${JSON.stringify(pidFile)}`;
expect(wrapper).toContain('#!/bin/bash');
expect(wrapper).toContain('nohup');
expect(wrapper).toContain(nodePath);
expect(wrapper).toContain('nanoclaw.pid');
});
});

336
setup/service.ts Normal file
View File

@@ -0,0 +1,336 @@
/**
* Step: service — Generate and load service manager config.
* Replaces 08-setup-service.sh
*
* Fixes: Root→system systemd, WSL nohup fallback, no `|| true` swallowing errors.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from '../src/logger.js';
import {
getPlatform,
getNodePath,
getServiceManager,
hasSystemd,
isRoot,
isWSL,
} from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const nodePath = getNodePath();
const homeDir = os.homedir();
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
// Build first
logger.info('Building TypeScript');
try {
execSync('npm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
logger.info('Build succeeded');
} catch {
logger.error('Build failed');
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
if (platform === 'macos') {
setupLaunchd(projectRoot, nodePath, homeDir);
} else if (platform === 'linux') {
setupLinux(projectRoot, nodePath, homeDir);
} else {
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG: 'logs/setup.log',
});
process.exit(1);
}
}
function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): void {
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', 'com.nanoclaw.plist');
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
<string>${projectRoot}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${projectRoot}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin</string>
<key>HOME</key>
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
</dict>
</plist>`;
fs.writeFileSync(plistPath, plist);
logger.info({ plistPath }, 'Wrote launchd plist');
try {
execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore' });
logger.info('launchctl load succeeded');
} catch {
logger.warn('launchctl load failed (may already be loaded)');
}
// Verify
let serviceLoaded = false;
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
serviceLoaded = output.includes('com.nanoclaw');
} catch {
// launchctl list failed
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'launchd',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
PLIST_PATH: plistPath,
SERVICE_LOADED: serviceLoaded,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
function setupLinux(projectRoot: string, nodePath: string, homeDir: string): void {
const serviceManager = getServiceManager();
if (serviceManager === 'systemd') {
setupSystemd(projectRoot, nodePath, homeDir);
} else {
// WSL without systemd or other Linux without systemd
setupNohupFallback(projectRoot, nodePath, homeDir);
}
}
/**
* Kill any orphaned nanoclaw node processes left from previous runs or debugging.
* Prevents WhatsApp "conflict" disconnects when two instances connect simultaneously.
*/
function killOrphanedProcesses(projectRoot: string): void {
try {
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
stdio: 'ignore',
});
logger.info('Stopped any orphaned nanoclaw processes');
} catch {
// pkill not available or no orphans
}
}
/**
* Detect stale docker group membership in the user systemd session.
*
* When a user is added to the docker group mid-session, the user systemd
* daemon (user@UID.service) keeps the old group list from login time.
* Docker works in the terminal but not in the service context.
*
* Only relevant on Linux with user-level systemd (not root, not macOS, not WSL nohup).
*/
function checkDockerGroupStale(): boolean {
try {
execSync('systemd-run --user --pipe --wait docker info', {
stdio: 'pipe',
timeout: 10000,
});
return false; // Docker works from systemd session
} catch {
// Check if docker works from the current shell (to distinguish stale group vs broken docker)
try {
execSync('docker info', { stdio: 'pipe', timeout: 5000 });
return true; // Works in shell but not systemd session → stale group
} catch {
return false; // Docker itself is not working, different issue
}
}
}
function setupSystemd(projectRoot: string, nodePath: string, homeDir: string): void {
const runningAsRoot = isRoot();
// Root uses system-level service, non-root uses user-level
let unitPath: string;
let systemctlPrefix: string;
if (runningAsRoot) {
unitPath = '/etc/systemd/system/nanoclaw.service';
systemctlPrefix = 'systemctl';
logger.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('systemd user session not available — falling back to nohup wrapper');
setupNohupFallback(projectRoot, nodePath, homeDir);
return;
}
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
fs.mkdirSync(unitDir, { recursive: true });
unitPath = path.join(unitDir, 'nanoclaw.service');
systemctlPrefix = 'systemctl --user';
}
const unit = `[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot}
Restart=always
RestartSec=5
Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
[Install]
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
fs.writeFileSync(unitPath, unit);
logger.info({ unitPath }, 'Wrote systemd unit');
// Detect stale docker group before starting (user systemd only)
const dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
if (dockerGroupStale) {
logger.warn(
'Docker group not active in systemd session — user was likely added to docker group mid-session',
);
}
// Kill orphaned nanoclaw processes to avoid WhatsApp conflict errors
killOrphanedProcesses(projectRoot);
// Enable and start
try {
execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl daemon-reload failed');
}
try {
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl enable failed');
}
try {
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl start failed');
}
// Verify
let serviceLoaded = false;
try {
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
serviceLoaded = true;
} catch {
// Not active
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
UNIT_PATH: unitPath,
SERVICE_LOADED: serviceLoaded,
...(dockerGroupStale ? { DOCKER_GROUP_STALE: true } : {}),
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
function setupNohupFallback(projectRoot: string, nodePath: string, homeDir: string): void {
logger.warn('No systemd detected — generating nohup wrapper script');
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
const lines = [
'#!/bin/bash',
'# start-nanoclaw.sh — Start NanoClaw without systemd',
`# To stop: kill \\$(cat ${pidFile})`,
'',
'set -euo pipefail',
'',
`cd ${JSON.stringify(projectRoot)}`,
'',
'# Stop existing instance if running',
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`,
' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then',
' echo "Stopping existing NanoClaw (PID $OLD_PID)..."',
' kill "$OLD_PID" 2>/dev/null || true',
' sleep 2',
' fi',
'fi',
'',
'echo "Starting NanoClaw..."',
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`,
` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`,
'',
`echo $! > ${JSON.stringify(pidFile)}`,
'echo "NanoClaw started (PID $!)"',
`echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`,
];
const wrapper = lines.join('\n') + '\n';
fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
logger.info({ wrapperPath }, 'Wrote nohup wrapper script');
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'nohup',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
WRAPPER_PATH: wrapperPath,
SERVICE_LOADED: false,
FALLBACK: 'wsl_no_systemd',
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

16
setup/status.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Structured status block output for setup steps.
* Each step emits a block that the SKILL.md LLM can parse.
*/
export function emitStatus(
step: string,
fields: Record<string, string | number | boolean>,
): void {
const lines = [`=== NANOCLAW SETUP: ${step} ===`];
for (const [key, value] of Object.entries(fields)) {
lines.push(`${key}: ${value}`);
}
lines.push('=== END ===');
console.log(lines.join('\n'));
}

152
setup/verify.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Step: verify — End-to-end health check of the full installation.
* Replaces 09-verify.sh
*
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.js';
import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const homeDir = os.homedir();
logger.info('Starting verification');
// 1. Check service status
let service = 'not_found';
const mgr = getServiceManager();
if (mgr === 'launchd') {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
if (output.includes('com.nanoclaw')) {
// Check if it has a PID (actually running)
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
service = pidField !== '-' && pidField ? 'running' : 'stopped';
}
}
} catch {
// launchctl not available
}
} else if (mgr === 'systemd') {
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
try {
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
service = 'running';
} catch {
try {
const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8' });
if (output.includes('nanoclaw')) {
service = 'stopped';
}
} catch {
// systemctl not available
}
}
} else {
// Check for nohup PID file
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
if (fs.existsSync(pidFile)) {
try {
const pid = fs.readFileSync(pidFile, 'utf-8').trim();
if (pid) {
execSync(`kill -0 ${pid}`, { stdio: 'ignore' });
service = 'running';
}
} catch {
service = 'stopped';
}
}
}
logger.info({ service }, 'Service status');
// 2. Check container runtime
let containerRuntime = 'none';
try {
execSync('command -v container', { stdio: 'ignore' });
containerRuntime = 'apple-container';
} catch {
try {
execSync('docker info', { stdio: 'ignore' });
containerRuntime = 'docker';
} catch {
// No runtime
}
}
// 3. Check credentials
let credentials = 'missing';
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8');
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) {
credentials = 'configured';
}
}
// 4. Check WhatsApp auth
let whatsappAuth = 'not_found';
const authDir = path.join(projectRoot, 'store', 'auth');
if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) {
whatsappAuth = 'authenticated';
}
// 5. Check registered groups (using better-sqlite3, not sqlite3 CLI)
let registeredGroups = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
registeredGroups = row.count;
db.close();
} catch {
// Table might not exist
}
}
// 6. Check mount allowlist
let mountAllowlist = 'missing';
if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) {
mountAllowlist = 'configured';
}
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
whatsappAuth !== 'not_found' &&
registeredGroups > 0
? 'success'
: 'failed';
logger.info({ status }, 'Verification complete');
emitStatus('VERIFY', {
SERVICE: service,
CONTAINER_RUNTIME: containerRuntime,
CREDENTIALS: credentials,
WHATSAPP_AUTH: whatsappAuth,
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

310
setup/whatsapp-auth.ts Normal file
View File

@@ -0,0 +1,310 @@
/**
* Step: whatsapp-auth — Full WhatsApp auth flow with polling.
* Replaces 04-auth-whatsapp.sh
*/
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { logger } from '../src/logger.js';
import { openBrowser, isHeadless } from './platform.js';
import { emitStatus } from './status.js';
const QR_AUTH_TEMPLATE = `<!DOCTYPE html>
<html><head><title>NanoClaw - WhatsApp Auth</title>
<meta http-equiv="refresh" content="3">
<style>
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { margin: 0 0 8px; }
.timer { font-size: 18px; color: #666; margin: 12px 0; }
.timer.urgent { color: #e74c3c; font-weight: bold; }
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
svg { width: 280px; height: 280px; }
</style></head><body>
<div class="card">
<h2>Scan with WhatsApp</h2>
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
<div id="qr">{{QR_SVG}}</div>
<div class="instructions">Settings \\u2192 Linked Devices \\u2192 Link a Device</div>
</div>
<script>
var startKey = 'nanoclaw_qr_start';
var start = localStorage.getItem(startKey);
if (!start) { start = Date.now().toString(); localStorage.setItem(startKey, start); }
var elapsed = Math.floor((Date.now() - parseInt(start)) / 1000);
var remaining = Math.max(0, 60 - elapsed);
var countdown = document.getElementById('countdown');
var timer = document.getElementById('timer');
countdown.textContent = remaining;
if (remaining <= 10) timer.classList.add('urgent');
if (remaining <= 0) {
timer.textContent = 'QR code expired \\u2014 a new one will appear shortly';
timer.classList.add('urgent');
localStorage.removeItem(startKey);
}
</script></body></html>`;
const SUCCESS_HTML = `<!DOCTYPE html>
<html><head><title>NanoClaw - Connected!</title>
<style>
body { font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { color: #27ae60; margin: 0 0 8px; }
p { color: #666; }
.check { font-size: 64px; margin-bottom: 16px; }
</style></head><body>
<div class="card">
<div class="check">&#10003;</div>
<h2>Connected to WhatsApp</h2>
<p>You can close this tab.</p>
</div>
<script>localStorage.removeItem('nanoclaw_qr_start');</script>
</body></html>`;
function parseArgs(args: string[]): { method: string; phone: string } {
let method = '';
let phone = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--method' && args[i + 1]) { method = args[i + 1]; i++; }
if (args[i] === '--phone' && args[i + 1]) { phone = args[i + 1]; i++; }
}
return { method, phone };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readFileSafe(filePath: string): string {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return '';
}
}
function getPhoneNumber(projectRoot: string): string {
try {
const creds = JSON.parse(
fs.readFileSync(path.join(projectRoot, 'store', 'auth', 'creds.json'), 'utf-8'),
);
if (creds.me?.id) {
return creds.me.id.split(':')[0].split('@')[0];
}
} catch {
// Not available yet
}
return '';
}
function emitAuthStatus(
method: string,
authStatus: string,
status: string,
extra: Record<string, string> = {},
): void {
const fields: Record<string, string> = {
AUTH_METHOD: method,
AUTH_STATUS: authStatus,
...extra,
STATUS: status,
LOG: 'logs/setup.log',
};
emitStatus('AUTH_WHATSAPP', fields);
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { method, phone } = parseArgs(args);
const statusFile = path.join(projectRoot, 'store', 'auth-status.txt');
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
if (!method) {
emitAuthStatus('unknown', 'failed', 'failed', { ERROR: 'missing_method_flag' });
process.exit(4);
}
// qr-terminal is a manual flow
if (method === 'qr-terminal') {
emitAuthStatus('qr-terminal', 'manual', 'manual', { PROJECT_PATH: projectRoot });
return;
}
if (method === 'pairing-code' && !phone) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'missing_phone_number' });
process.exit(4);
}
if (!['qr-browser', 'pairing-code'].includes(method)) {
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'unknown_method' });
process.exit(4);
}
// Clean stale state
logger.info({ method }, 'Starting WhatsApp auth');
try { fs.rmSync(path.join(projectRoot, 'store', 'auth'), { recursive: true, force: true }); } catch { /* ok */ }
try { fs.unlinkSync(qrFile); } catch { /* ok */ }
try { fs.unlinkSync(statusFile); } catch { /* ok */ }
// Start auth process in background
const authArgs = method === 'pairing-code'
? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone]
: ['src/whatsapp-auth.ts'];
const authProc = spawn('npx', ['tsx', ...authArgs], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
const logFile = path.join(projectRoot, 'logs', 'setup.log');
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
authProc.stdout?.pipe(logStream);
authProc.stderr?.pipe(logStream);
// Cleanup on exit
const cleanup = () => {
try { authProc.kill(); } catch { /* ok */ }
};
process.on('exit', cleanup);
try {
if (method === 'qr-browser') {
await handleQrBrowser(projectRoot, statusFile, qrFile);
} else {
await handlePairingCode(projectRoot, statusFile, phone);
}
} finally {
cleanup();
process.removeListener('exit', cleanup);
}
}
async function handleQrBrowser(
projectRoot: string,
statusFile: string,
qrFile: string,
): Promise<void> {
// Poll for QR data (15s)
let qrReady = false;
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('qr-browser', 'already_authenticated', 'success');
return;
}
if (fs.existsSync(qrFile)) {
qrReady = true;
break;
}
await sleep(1000);
}
if (!qrReady) {
emitAuthStatus('qr-browser', 'failed', 'failed', { ERROR: 'qr_timeout' });
process.exit(3);
}
// Generate QR SVG and HTML
const qrData = fs.readFileSync(qrFile, 'utf-8');
try {
const svg = execSync(
`node -e "const QR=require('qrcode');const data=${JSON.stringify(qrData)};QR.toString(data,{type:'svg'},(e,s)=>{if(e)process.exit(1);process.stdout.write(s)})"`,
{ cwd: projectRoot, encoding: 'utf-8' },
);
const html = QR_AUTH_TEMPLATE.replace('{{QR_SVG}}', svg);
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
fs.writeFileSync(htmlPath, html);
// Open in browser (cross-platform)
if (!isHeadless()) {
const opened = openBrowser(htmlPath);
if (!opened) {
logger.warn('Could not open browser — display QR in terminal as fallback');
}
} else {
logger.info('Headless environment — QR HTML saved but browser not opened');
}
} catch (err) {
logger.error({ err }, 'Failed to generate QR HTML');
}
// Poll for completion (120s)
await pollAuthCompletion('qr-browser', statusFile, projectRoot);
}
async function handlePairingCode(
projectRoot: string,
statusFile: string,
phone: string,
): Promise<void> {
// Poll for pairing code (15s)
let pairingCode = '';
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('pairing-code', 'already_authenticated', 'success');
return;
}
if (statusContent.startsWith('pairing_code:')) {
pairingCode = statusContent.replace('pairing_code:', '');
break;
}
if (statusContent.startsWith('failed:')) {
emitAuthStatus('pairing-code', 'failed', 'failed', {
ERROR: statusContent.replace('failed:', ''),
});
process.exit(1);
}
await sleep(1000);
}
if (!pairingCode) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'pairing_code_timeout' });
process.exit(3);
}
// Emit pairing code immediately so the caller can display it to the user
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', { PAIRING_CODE: pairingCode });
// Poll for completion (120s)
await pollAuthCompletion('pairing-code', statusFile, projectRoot, pairingCode);
}
async function pollAuthCompletion(
method: string,
statusFile: string,
projectRoot: string,
pairingCode?: string,
): Promise<void> {
const extra: Record<string, string> = {};
if (pairingCode) extra.PAIRING_CODE = pairingCode;
for (let i = 0; i < 60; i++) {
const content = readFileSafe(statusFile);
if (content === 'authenticated' || content === 'already_authenticated') {
// Write success page if qr-auth.html exists
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
if (fs.existsSync(htmlPath)) {
fs.writeFileSync(htmlPath, SUCCESS_HTML);
}
const phoneNumber = getPhoneNumber(projectRoot);
if (phoneNumber) extra.PHONE_NUMBER = phoneNumber;
emitAuthStatus(method, content, 'success', extra);
return;
}
if (content.startsWith('failed:')) {
const error = content.replace('failed:', '');
emitAuthStatus(method, 'failed', 'failed', { ERROR: error, ...extra });
process.exit(1);
}
await sleep(2000);
}
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'timeout', ...extra });
process.exit(3);
}

View File

@@ -4,6 +4,19 @@ import path from 'path';
import { executeFileOps } from '../file-ops.js'; import { executeFileOps } from '../file-ops.js';
import { createTempDir, cleanup } from './test-helpers.js'; import { createTempDir, cleanup } from './test-helpers.js';
function shouldSkipSymlinkTests(err: unknown): boolean {
return !!(
err &&
typeof err === 'object' &&
'code' in err &&
(
(err as { code?: string }).code === 'EPERM' ||
(err as { code?: string }).code === 'EACCES' ||
(err as { code?: string }).code === 'ENOSYS'
)
);
}
describe('file-ops', () => { describe('file-ops', () => {
let tmpDir: string; let tmpDir: string;
const originalCwd = process.cwd(); const originalCwd = process.cwd();
@@ -90,4 +103,53 @@ describe('file-ops', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0); expect(result.errors.length).toBeGreaterThan(0);
}); });
it('move rejects symlink escape to outside project root', () => {
const outsideDir = createTempDir();
try {
fs.symlinkSync(outsideDir, path.join(tmpDir, 'linkdir'));
} catch (err) {
cleanup(outsideDir);
if (shouldSkipSymlinkTests(err)) return;
throw err;
}
fs.writeFileSync(path.join(tmpDir, 'source.ts'), 'content');
const result = executeFileOps([
{ type: 'move', from: 'source.ts', to: 'linkdir/pwned.ts' },
], tmpDir);
expect(result.success).toBe(false);
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'source.ts'))).toBe(true);
expect(fs.existsSync(path.join(outsideDir, 'pwned.ts'))).toBe(false);
cleanup(outsideDir);
});
it('delete rejects symlink escape to outside project root', () => {
const outsideDir = createTempDir();
const outsideFile = path.join(outsideDir, 'victim.ts');
fs.writeFileSync(outsideFile, 'secret');
try {
fs.symlinkSync(outsideDir, path.join(tmpDir, 'linkdir'));
} catch (err) {
cleanup(outsideDir);
if (shouldSkipSymlinkTests(err)) return;
throw err;
}
const result = executeFileOps([
{ type: 'delete', path: 'linkdir/victim.ts' },
], tmpDir);
expect(result.success).toBe(false);
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
expect(fs.existsSync(outsideFile)).toBe(true);
cleanup(outsideDir);
});
}); });

View File

@@ -2,11 +2,65 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import type { FileOperation, FileOpsResult } from './types.js'; import type { FileOperation, FileOpsResult } from './types.js';
function isWithinRoot(rootPath: string, targetPath: string): boolean {
return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep);
}
function nearestExistingPathOrSymlink(candidateAbsPath: string): string {
let current = candidateAbsPath;
while (true) {
try {
fs.lstatSync(current);
return current;
} catch {
const parent = path.dirname(current);
if (parent === current) {
throw new Error(`Invalid file operation path: "${candidateAbsPath}"`);
}
current = parent;
}
}
}
function resolveRealPathWithSymlinkAwareAnchor(candidateAbsPath: string): string {
const anchorPath = nearestExistingPathOrSymlink(candidateAbsPath);
const anchorStat = fs.lstatSync(anchorPath);
let realAnchor: string;
if (anchorStat.isSymbolicLink()) {
const linkTarget = fs.readlinkSync(anchorPath);
const linkResolved = path.resolve(path.dirname(anchorPath), linkTarget);
realAnchor = fs.realpathSync(linkResolved);
} else {
realAnchor = fs.realpathSync(anchorPath);
}
const relativeRemainder = path.relative(anchorPath, candidateAbsPath);
return relativeRemainder
? path.resolve(realAnchor, relativeRemainder)
: realAnchor;
}
function safePath(projectRoot: string, relativePath: string): string | null { function safePath(projectRoot: string, relativePath: string): string | null {
const resolved = path.resolve(projectRoot, relativePath); if (typeof relativePath !== 'string' || relativePath.trim() === '') {
if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
return null; return null;
} }
const root = path.resolve(projectRoot);
const resolved = path.resolve(root, relativePath);
if (!isWithinRoot(root, resolved)) {
return null;
}
if (resolved === root) {
return null;
}
const realRoot = fs.realpathSync(root);
const realParent = resolveRealPathWithSymlinkAwareAnchor(path.dirname(resolved));
if (!isWithinRoot(realRoot, realParent)) {
return null;
}
return resolved; return resolved;
} }

View File

@@ -14,6 +14,7 @@ vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/nanoclaw-test-data', DATA_DIR: '/tmp/nanoclaw-test-data',
GROUPS_DIR: '/tmp/nanoclaw-test-groups', GROUPS_DIR: '/tmp/nanoclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min IDLE_TIMEOUT: 1800000, // 30min
TIMEZONE: 'America/Los_Angeles',
})); }));
// Mock logger // Mock logger

View File

@@ -4,7 +4,6 @@
*/ */
import { ChildProcess, exec, spawn } from 'child_process'; import { ChildProcess, exec, spawn } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
import { import {
@@ -14,8 +13,10 @@ import {
DATA_DIR, DATA_DIR,
GROUPS_DIR, GROUPS_DIR,
IDLE_TIMEOUT, IDLE_TIMEOUT,
TIMEZONE,
} from './config.js'; } from './config.js';
import { readEnvFile } from './env.js'; import { readEnvFile } from './env.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { validateAdditionalMounts } from './mount-security.js'; import { validateAdditionalMounts } from './mount-security.js';
@@ -25,16 +26,6 @@ import { RegisteredGroup } from './types.js';
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
function getHomeDir(): string {
const home = process.env.HOME || os.homedir();
if (!home) {
throw new Error(
'Unable to determine home directory: HOME environment variable is not set and os.homedir() returned empty',
);
}
return home;
}
export interface ContainerInput { export interface ContainerInput {
prompt: string; prompt: string;
sessionId?: string; sessionId?: string;
@@ -64,27 +55,31 @@ function buildVolumeMounts(
isMain: boolean, isMain: boolean,
): VolumeMount[] { ): VolumeMount[] {
const mounts: VolumeMount[] = []; const mounts: VolumeMount[] = [];
const homeDir = getHomeDir();
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const groupDir = resolveGroupFolderPath(group.folder);
if (isMain) { if (isMain) {
// Main gets the entire project root mounted // Main gets the project root read-only. Writable paths the agent needs
// (group folder, IPC, .claude/) are mounted separately below.
// Read-only prevents the agent from modifying host application code
// (src/, dist/, package.json, etc.) which would bypass the sandbox
// entirely on next restart.
mounts.push({ mounts.push({
hostPath: projectRoot, hostPath: projectRoot,
containerPath: '/workspace/project', containerPath: '/workspace/project',
readonly: false, readonly: true,
}); });
// Main also gets its group folder as the working directory // Main also gets its group folder as the working directory
mounts.push({ mounts.push({
hostPath: path.join(GROUPS_DIR, group.folder), hostPath: groupDir,
containerPath: '/workspace/group', containerPath: '/workspace/group',
readonly: false, readonly: false,
}); });
} else { } else {
// Other groups only get their own folder // Other groups only get their own folder
mounts.push({ mounts.push({
hostPath: path.join(GROUPS_DIR, group.folder), hostPath: groupDir,
containerPath: '/workspace/group', containerPath: '/workspace/group',
readonly: false, readonly: false,
}); });
@@ -146,7 +141,7 @@ function buildVolumeMounts(
// Per-group IPC namespace: each group gets its own IPC directory // Per-group IPC namespace: each group gets its own IPC directory
// This prevents cross-group privilege escalation via IPC // This prevents cross-group privilege escalation via IPC
const groupIpcDir = path.join(DATA_DIR, 'ipc', group.folder); const groupIpcDir = resolveGroupIpcPath(group.folder);
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
@@ -156,13 +151,18 @@ function buildVolumeMounts(
readonly: false, readonly: false,
}); });
// Mount agent-runner source from host — recompiled on container startup. // Copy agent-runner source into a per-group writable location so agents
// Bypasses sticky build cache for code changes. // 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 agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
mounts.push({ mounts.push({
hostPath: agentRunnerSrc, hostPath: groupAgentRunnerDir,
containerPath: '/app/src', containerPath: '/app/src',
readonly: true, readonly: false,
}); });
// Additional mounts validated against external allowlist (tamper-proof from containers) // Additional mounts validated against external allowlist (tamper-proof from containers)
@@ -189,6 +189,9 @@ function readSecrets(): Record<string, string> {
function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] { function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName]; const args: string[] = ['run', '-i', '--rm', '--name', containerName];
// Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`);
// Run as host user so bind-mounted files are accessible. // 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), // Skip when running as root (uid 0), as the container's node user (uid 1000),
// or when getuid is unavailable (native Windows without WSL). // or when getuid is unavailable (native Windows without WSL).
@@ -220,7 +223,7 @@ export async function runContainerAgent(
): Promise<ContainerOutput> { ): Promise<ContainerOutput> {
const startTime = Date.now(); const startTime = Date.now();
const groupDir = path.join(GROUPS_DIR, group.folder); const groupDir = resolveGroupFolderPath(group.folder);
fs.mkdirSync(groupDir, { recursive: true }); fs.mkdirSync(groupDir, { recursive: true });
const mounts = buildVolumeMounts(group, input.isMain); const mounts = buildVolumeMounts(group, input.isMain);
@@ -251,7 +254,7 @@ export async function runContainerAgent(
'Spawning container agent', 'Spawning container agent',
); );
const logsDir = path.join(GROUPS_DIR, group.folder, 'logs'); const logsDir = path.join(groupDir, 'logs');
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -595,7 +598,7 @@ export function writeTasksSnapshot(
}>, }>,
): void { ): void {
// Write filtered tasks to the group's IPC directory // Write filtered tasks to the group's IPC directory
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true }); fs.mkdirSync(groupIpcDir, { recursive: true });
// Main sees all tasks, others only see their own // Main sees all tasks, others only see their own
@@ -625,7 +628,7 @@ export function writeGroupsSnapshot(
groups: AvailableGroup[], groups: AvailableGroup[],
registeredJids: Set<string>, registeredJids: Set<string>,
): void { ): void {
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true }); fs.mkdirSync(groupIpcDir, { recursive: true });
// Main sees all groups; others see nothing (they can't activate groups) // Main sees all groups; others see nothing (they can't activate groups)

View File

@@ -3,6 +3,8 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js';
import { isValidGroupFolder } from './group-folder.js';
import { logger } from './logger.js';
import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js'; import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js';
let db: Database.Database; let db: Database.Database;
@@ -520,6 +522,13 @@ export function getRegisteredGroup(
} }
| undefined; | undefined;
if (!row) return undefined; if (!row) return undefined;
if (!isValidGroupFolder(row.folder)) {
logger.warn(
{ jid: row.jid, folder: row.folder },
'Skipping registered group with invalid folder',
);
return undefined;
}
return { return {
jid: row.jid, jid: row.jid,
name: row.name, name: row.name,
@@ -537,6 +546,9 @@ export function setRegisteredGroup(
jid: string, jid: string,
group: RegisteredGroup, group: RegisteredGroup,
): void { ): void {
if (!isValidGroupFolder(group.folder)) {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
db.prepare( db.prepare(
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
@@ -565,6 +577,13 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
}>; }>;
const result: Record<string, RegisteredGroup> = {}; const result: Record<string, RegisteredGroup> = {};
for (const row of rows) { for (const row of rows) {
if (!isValidGroupFolder(row.folder)) {
logger.warn(
{ jid: row.jid, folder: row.folder },
'Skipping registered group with invalid folder',
);
continue;
}
result[row.jid] = { result[row.jid] = {
name: row.name, name: row.name,
folder: row.folder, folder: row.folder,
@@ -629,7 +648,14 @@ function migrateJsonState(): void {
> | null; > | null;
if (groups) { if (groups) {
for (const [jid, group] of Object.entries(groups)) { for (const [jid, group] of Object.entries(groups)) {
try {
setRegisteredGroup(jid, group); setRegisteredGroup(jid, group);
} catch (err) {
logger.warn(
{ jid, folder: group.folder, err },
'Skipping migrated registered group with invalid folder',
);
}
} }
} }
} }

39
src/group-folder.test.ts Normal file
View File

@@ -0,0 +1,39 @@
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
src/group-folder.ts Normal file
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;
}

View File

@@ -3,7 +3,6 @@ import path from 'path';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
DATA_DIR,
IDLE_TIMEOUT, IDLE_TIMEOUT,
MAIN_GROUP_FOLDER, MAIN_GROUP_FOLDER,
POLL_INTERVAL, POLL_INTERVAL,
@@ -33,6 +32,7 @@ import {
storeMessage, storeMessage,
} from './db.js'; } from './db.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath } from './group-folder.js';
import { startIpcWatcher } from './ipc.js'; import { startIpcWatcher } from './ipc.js';
import { findChannel, formatMessages, formatOutbound } from './router.js'; import { findChannel, formatMessages, formatOutbound } from './router.js';
import { startSchedulerLoop } from './task-scheduler.js'; import { startSchedulerLoop } from './task-scheduler.js';
@@ -78,11 +78,21 @@ function saveState(): void {
} }
function registerGroup(jid: string, group: RegisteredGroup): void { 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; registeredGroups[jid] = group;
setRegisteredGroup(jid, group); setRegisteredGroup(jid, group);
// Create group folder // Create group folder
const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder);
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
logger.info( logger.info(

View File

@@ -301,6 +301,23 @@ describe('register_group authorization', () => {
// registeredGroups should not have changed // registeredGroups should not have changed
expect(groups['new@g.us']).toBeUndefined(); expect(groups['new@g.us']).toBeUndefined();
}); });
it('main group cannot register with unsafe folder path', async () => {
await processTaskIpc(
{
type: 'register_group',
jid: 'new@g.us',
name: 'New Group',
folder: '../../outside',
trigger: '@Andy',
},
'main',
true,
deps,
);
expect(groups['new@g.us']).toBeUndefined();
});
}); });
// --- refresh_groups authorization --- // --- refresh_groups authorization ---

View File

@@ -11,6 +11,7 @@ import {
} from './config.js'; } from './config.js';
import { AvailableGroup } from './container-runner.js'; import { AvailableGroup } from './container-runner.js';
import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
import { isValidGroupFolder } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { RegisteredGroup } from './types.js'; import { RegisteredGroup } from './types.js';
@@ -357,6 +358,13 @@ export async function processTaskIpc(
break; break;
} }
if (data.jid && data.name && data.folder && data.trigger) { if (data.jid && data.name && data.folder && data.trigger) {
if (!isValidGroupFolder(data.folder)) {
logger.warn(
{ sourceGroup, folder: data.folder },
'Invalid register_group request - unsafe folder name',
);
break;
}
deps.registerGroup(data.jid, { deps.registerGroup(data.jid, {
name: data.name, name: data.name,
folder: data.folder, folder: data.folder,

View File

@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { _initTestDatabase, createTask, getTaskById } from './db.js';
import {
_resetSchedulerLoopForTests,
startSchedulerLoop,
} from './task-scheduler.js';
describe('task scheduler', () => {
beforeEach(() => {
_initTestDatabase();
_resetSchedulerLoopForTests();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('pauses due tasks with invalid group folders to prevent retry churn', async () => {
createTask({
id: 'task-invalid-folder',
group_folder: '../../outside',
chat_jid: 'bad@g.us',
prompt: 'run',
schedule_type: 'once',
schedule_value: '2026-02-22T00:00:00.000Z',
context_mode: 'isolated',
next_run: new Date(Date.now() - 60_000).toISOString(),
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
const enqueueTask = vi.fn(
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
void fn();
},
);
startSchedulerLoop({
registeredGroups: () => ({}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
await vi.advanceTimersByTimeAsync(10);
const task = getTaskById('task-invalid-folder');
expect(task?.status).toBe('paused');
});
});

View File

@@ -1,11 +1,9 @@
import { ChildProcess } from 'child_process'; import { ChildProcess } from 'child_process';
import { CronExpressionParser } from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
GROUPS_DIR,
IDLE_TIMEOUT, IDLE_TIMEOUT,
MAIN_GROUP_FOLDER, MAIN_GROUP_FOLDER,
SCHEDULER_POLL_INTERVAL, SCHEDULER_POLL_INTERVAL,
@@ -17,9 +15,11 @@ import {
getDueTasks, getDueTasks,
getTaskById, getTaskById,
logTaskRun, logTaskRun,
updateTask,
updateTaskAfterRun, updateTaskAfterRun,
} from './db.js'; } from './db.js';
import { GroupQueue } from './group-queue.js'; import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { RegisteredGroup, ScheduledTask } from './types.js'; import { RegisteredGroup, ScheduledTask } from './types.js';
@@ -36,7 +36,27 @@ async function runTask(
deps: SchedulerDependencies, deps: SchedulerDependencies,
): Promise<void> { ): Promise<void> {
const startTime = Date.now(); const startTime = Date.now();
const groupDir = path.join(GROUPS_DIR, task.group_folder); let groupDir: string;
try {
groupDir = resolveGroupFolderPath(task.group_folder);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
// Stop retry churn for malformed legacy rows.
updateTask(task.id, { status: 'paused' });
logger.error(
{ taskId: task.id, groupFolder: task.group_folder, error },
'Task has invalid group folder',
);
logTaskRun({
task_id: task.id,
run_at: new Date().toISOString(),
duration_ms: Date.now() - startTime,
status: 'error',
result: null,
error,
});
return;
}
fs.mkdirSync(groupDir, { recursive: true }); fs.mkdirSync(groupDir, { recursive: true });
logger.info( logger.info(
@@ -222,3 +242,8 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
loop(); loop();
} }
/** @internal - for tests only. */
export function _resetSchedulerLoopForTests(): void {
schedulerRunning = false;
}

View File

@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['src/**/*.test.ts', 'skills-engine/**/*.test.ts'], include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'skills-engine/**/*.test.ts'],
}, },
}); });