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:
@@ -246,13 +246,15 @@ cd .. && npm run build
|
||||
Wait for TypeScript compilation, then restart the service:
|
||||
|
||||
```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:
|
||||
|
||||
```bash
|
||||
sleep 2 && launchctl list | grep nanoclaw
|
||||
sleep 2 && launchctl list | grep nanoclaw # macOS
|
||||
# Linux: systemctl --user status nanoclaw
|
||||
```
|
||||
|
||||
### Step 5: Test Gmail Integration
|
||||
@@ -651,7 +653,8 @@ cd .. && npm run build
|
||||
Restart the service:
|
||||
|
||||
```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:
|
||||
@@ -726,5 +729,6 @@ To remove Gmail entirely:
|
||||
```bash
|
||||
cd container && ./build.sh && cd ..
|
||||
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
|
||||
```
|
||||
|
||||
@@ -235,13 +235,15 @@ Rebuild the main app and restart:
|
||||
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
sleep 3
|
||||
launchctl list | grep nanoclaw
|
||||
launchctl list | grep nanoclaw # macOS
|
||||
# Linux: systemctl --user status nanoclaw
|
||||
```
|
||||
|
||||
### 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
|
||||
3. Remove Web Research Tools section from groups/main/CLAUDE.md
|
||||
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)
|
||||
|
||||
@@ -321,11 +321,14 @@ Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.n
|
||||
```bash
|
||||
npm run build
|
||||
./container/build.sh # Required — MCP tool changed
|
||||
# macOS:
|
||||
launchctl unload ~/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
|
||||
|
||||
@@ -377,5 +380,5 @@ To remove Agent Swarm support while keeping basic Telegram:
|
||||
4. Remove `initBotPool` call from `main()`
|
||||
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
|
||||
7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist
|
||||
8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.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` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)
|
||||
|
||||
@@ -118,7 +118,8 @@ Tell the user:
|
||||
|
||||
```bash
|
||||
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
|
||||
@@ -183,10 +184,11 @@ tail -f logs/nanoclaw.log
|
||||
|
||||
### Bot not responding
|
||||
|
||||
1. Check `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
|
||||
2. Check chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`
|
||||
3. For non-main chats: message must include trigger pattern
|
||||
4. Service is running: `launchctl list | grep nanoclaw`
|
||||
Check:
|
||||
1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
|
||||
2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`)
|
||||
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
|
||||
|
||||
@@ -202,6 +204,36 @@ If `/chatid` doesn't work:
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -93,7 +93,8 @@ The container reads environment from `data/env/env`, not `.env` directly.
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
@@ -92,8 +92,11 @@ Always tell the user:
|
||||
```bash
|
||||
# Rebuild and restart
|
||||
npm run build
|
||||
# macOS:
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
# Linux:
|
||||
# systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Example Interaction
|
||||
|
||||
@@ -43,9 +43,11 @@ Set `LOG_LEVEL=debug` for verbose output:
|
||||
# For development
|
||||
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>
|
||||
<string>debug</string>
|
||||
# For systemd service (Linux), add to unit [Service] section:
|
||||
# Environment=LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Debug level shows:
|
||||
|
||||
@@ -5,54 +5,46 @@ description: Run initial NanoClaw setup. Use when user wants to install dependen
|
||||
|
||||
# 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.
|
||||
|
||||
**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_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
|
||||
- Record PLATFORM, 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.
|
||||
- Record APPLE_CONTAINER and DOCKER values for step 3
|
||||
|
||||
## 3. Container 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.
|
||||
|
||||
**If APPLE_CONTAINER=not_found**: Use Docker (the default). Proceed to install/start Docker below.
|
||||
- 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.
|
||||
- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker (default)
|
||||
|
||||
### 3a-docker. Install Docker
|
||||
|
||||
- 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:
|
||||
- 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.
|
||||
@@ -73,146 +65,122 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "
|
||||
|
||||
### 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 it's a cache issue (stale layers): run `docker builder prune -f`, then retry.
|
||||
- If Dockerfile syntax or missing files: diagnose from the log and fix.
|
||||
- Retry the build script after fixing.
|
||||
- Cache issue (stale layers): `docker builder prune -f` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). Retry.
|
||||
- Dockerfile syntax or missing files: diagnose from the log and fix, then retry.
|
||||
|
||||
**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)
|
||||
|
||||
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?
|
||||
|
||||
**Subscription:** Tell the user:
|
||||
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
|
||||
**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.
|
||||
|
||||
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 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.
|
||||
**API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`.
|
||||
|
||||
## 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)
|
||||
- **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.
|
||||
- **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 IS_HEADLESS=true AND IS_WSL=false → AskUserQuestion: Pairing code (recommended) vs QR code in terminal?
|
||||
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: QR code in browser (recommended) vs pairing code vs QR code in terminal?
|
||||
|
||||
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:**
|
||||
- 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.
|
||||
**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
|
||||
|
||||
## 6. Configure Trigger and Channel Type
|
||||
|
||||
First, determine the phone number situation. Get the bot's WhatsApp number from `store/auth/creds.json`:
|
||||
`node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
|
||||
Get bot's WhatsApp number: `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.
|
||||
|
||||
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).
|
||||
**Shared number:** Self-chat (recommended) or Solo group
|
||||
**Dedicated number:** DM with bot (recommended) or Solo group with bot
|
||||
|
||||
## 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`.
|
||||
|
||||
**For group (solo or with bot):**
|
||||
1. Run `./.claude/skills/setup/scripts/05-sync-groups.sh` (Bash timeout: 60000ms)
|
||||
2. **If BUILD=failed:** Read `logs/setup.log`, fix the TypeScript error, re-run.
|
||||
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.
|
||||
**Group:**
|
||||
1. `npx tsx setup/index.ts --step groups` (Bash timeout: 60000ms)
|
||||
2. BUILD=failed → fix TypeScript, re-run. GROUPS_IN_DB=0 → check logs.
|
||||
3. `npx tsx setup/index.ts --step groups -- --list` for pipe-separated JID|name lines.
|
||||
4. Present candidates as AskUserQuestion (names only, not JIDs).
|
||||
|
||||
## 8. Register Channel
|
||||
|
||||
Run `./.claude/skills/setup/scripts/06-register-channel.sh` with args:
|
||||
- `--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"
|
||||
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.
|
||||
|
||||
## 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`
|
||||
|
||||
**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`.
|
||||
**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}'`
|
||||
|
||||
## 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:**
|
||||
- Read `logs/setup.log` for the error.
|
||||
- Common fix: plist already loaded with different path. Unload the old one first, then re-run.
|
||||
- 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).
|
||||
- On Linux: check `systemctl --user status nanoclaw` for the error and fix accordingly.
|
||||
- Re-run the setup-service script after fixing.
|
||||
- macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`.
|
||||
- Linux: check `systemctl --user status nanoclaw`.
|
||||
- Re-run the service step after fixing.
|
||||
|
||||
## 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:**
|
||||
- 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=not_found → re-run step 10.
|
||||
- CREDENTIALS=missing → re-run step 4.
|
||||
- WHATSAPP_AUTH=not_found → re-run step 5.
|
||||
- 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.
|
||||
**If STATUS=failed, fix each:**
|
||||
- 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
|
||||
- CREDENTIALS=missing → re-run step 4
|
||||
- WHATSAPP_AUTH=not_found → re-run step 5
|
||||
- REGISTERED_GROUPS=0 → re-run steps 7-8
|
||||
- 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 (with or without trigger depending on channel type).
|
||||
|
||||
Show the log tail command: `tail -f logs/nanoclaw.log`
|
||||
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
|
||||
|
||||
## 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:** `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
|
||||
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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">✓</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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -49,8 +49,9 @@ npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
|
||||
|
||||
# 3. Rebuild host and restart service
|
||||
npm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
# Verify: launchctl list | grep nanoclaw shows PID and exit code 0
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -271,12 +272,14 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
|
||||
|
||||
```bash
|
||||
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:**
|
||||
```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
|
||||
@@ -342,7 +345,8 @@ echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
@@ -41,8 +41,15 @@ npm run build # Compile TypeScript
|
||||
|
||||
Service management:
|
||||
```bash
|
||||
# macOS (launchd)
|
||||
launchctl load ~/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
|
||||
|
||||
@@ -98,7 +98,7 @@ Users then run `/add-telegram` on their fork and get clean code that does exactl
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
Skills we'd love to see:
|
||||
Skills we'd like to see:
|
||||
|
||||
**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
|
||||
|
||||
@@ -112,10 +112,16 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
};
|
||||
}
|
||||
} 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);
|
||||
if (isNaN(date.getTime())) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ private_key, .secret
|
||||
- Container path validation (rejects `..` and absolute paths)
|
||||
- `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
|
||||
|
||||
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 |
|
||||
|------------|------------|----------------|
|
||||
| Project root access | `/workspace/project` (rw) | None |
|
||||
| Project root access | `/workspace/project` (ro) | None |
|
||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||
| Additional mounts | Configurable | Read-only unless allowed |
|
||||
|
||||
@@ -61,11 +61,11 @@ This is the **main channel**, which has elevated privileges.
|
||||
|
||||
## 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 |
|
||||
|----------------|-----------|--------|
|
||||
| `/workspace/project` | Project root | read-write |
|
||||
| `/workspace/project` | Project root | read-only |
|
||||
| `/workspace/group` | `groups/main/` | read-write |
|
||||
|
||||
Key paths inside the container:
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||
"setup": "tsx setup/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
<title>36.8k tokens, 18% of context window</title>
|
||||
<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>37.8k tokens, 19% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">36.8k</text>
|
||||
<text x="74" y="14">36.8k</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">37.8k</text>
|
||||
<text x="74" y="14">37.8k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
161
setup.sh
Executable file
161
setup.sh
Executable 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
123
setup/container.ts
Normal 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
103
setup/environment.test.ts
Normal 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
86
setup/environment.ts
Normal 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
195
setup/groups.ts
Normal 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
52
setup/index.ts
Normal 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
103
setup/mounts.ts
Normal 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
121
setup/platform.test.ts
Normal 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
130
setup/platform.ts
Normal 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
165
setup/register.test.ts
Normal 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
157
setup/register.ts
Normal 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
134
setup/service.test.ts
Normal 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
336
setup/service.ts
Normal 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
16
setup/status.ts
Normal 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
152
setup/verify.ts
Normal 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
310
setup/whatsapp-auth.ts
Normal 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">✓</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);
|
||||
}
|
||||
@@ -4,6 +4,19 @@ import path from 'path';
|
||||
import { executeFileOps } from '../file-ops.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', () => {
|
||||
let tmpDir: string;
|
||||
const originalCwd = process.cwd();
|
||||
@@ -90,4 +103,53 @@ describe('file-ops', () => {
|
||||
expect(result.success).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,65 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
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 {
|
||||
const resolved = path.resolve(projectRoot, relativePath);
|
||||
if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
|
||||
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
TIMEZONE: 'America/Los_Angeles',
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
@@ -14,8 +13,10 @@ import {
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
@@ -25,16 +26,6 @@ import { RegisteredGroup } from './types.js';
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
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 {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
@@ -64,27 +55,31 @@ function buildVolumeMounts(
|
||||
isMain: boolean,
|
||||
): VolumeMount[] {
|
||||
const mounts: VolumeMount[] = [];
|
||||
const homeDir = getHomeDir();
|
||||
const projectRoot = process.cwd();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
|
||||
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({
|
||||
hostPath: projectRoot,
|
||||
containerPath: '/workspace/project',
|
||||
readonly: false,
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: path.join(GROUPS_DIR, group.folder),
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
} else {
|
||||
// Other groups only get their own folder
|
||||
mounts.push({
|
||||
hostPath: path.join(GROUPS_DIR, group.folder),
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
@@ -146,7 +141,7 @@ function buildVolumeMounts(
|
||||
|
||||
// Per-group IPC namespace: each group gets its own IPC directory
|
||||
// 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, 'tasks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||
@@ -156,13 +151,18 @@ function buildVolumeMounts(
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Mount agent-runner source from host — recompiled on container startup.
|
||||
// Bypasses sticky build cache for code changes.
|
||||
// Copy agent-runner source into a per-group writable location so agents
|
||||
// can customize it (add tools, change behavior) without affecting other
|
||||
// groups. Recompiled on container startup via entrypoint.sh.
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
|
||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: agentRunnerSrc,
|
||||
hostPath: groupAgentRunnerDir,
|
||||
containerPath: '/app/src',
|
||||
readonly: true,
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// 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[] {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Run as host user so bind-mounted files are accessible.
|
||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||
// or when getuid is unavailable (native Windows without WSL).
|
||||
@@ -220,7 +223,7 @@ export async function runContainerAgent(
|
||||
): Promise<ContainerOutput> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, group.folder);
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
const mounts = buildVolumeMounts(group, input.isMain);
|
||||
@@ -251,7 +254,7 @@ export async function runContainerAgent(
|
||||
'Spawning container agent',
|
||||
);
|
||||
|
||||
const logsDir = path.join(GROUPS_DIR, group.folder, 'logs');
|
||||
const logsDir = path.join(groupDir, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -595,7 +598,7 @@ export function writeTasksSnapshot(
|
||||
}>,
|
||||
): void {
|
||||
// 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 });
|
||||
|
||||
// Main sees all tasks, others only see their own
|
||||
@@ -625,7 +628,7 @@ export function writeGroupsSnapshot(
|
||||
groups: AvailableGroup[],
|
||||
registeredJids: Set<string>,
|
||||
): void {
|
||||
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all groups; others see nothing (they can't activate groups)
|
||||
|
||||
26
src/db.ts
26
src/db.ts
@@ -3,6 +3,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
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';
|
||||
|
||||
let db: Database.Database;
|
||||
@@ -520,6 +522,13 @@ export function getRegisteredGroup(
|
||||
}
|
||||
| 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 {
|
||||
jid: row.jid,
|
||||
name: row.name,
|
||||
@@ -537,6 +546,9 @@ export function setRegisteredGroup(
|
||||
jid: string,
|
||||
group: RegisteredGroup,
|
||||
): void {
|
||||
if (!isValidGroupFolder(group.folder)) {
|
||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -565,6 +577,13 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
}>;
|
||||
const result: Record<string, RegisteredGroup> = {};
|
||||
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] = {
|
||||
name: row.name,
|
||||
folder: row.folder,
|
||||
@@ -629,7 +648,14 @@ function migrateJsonState(): void {
|
||||
> | null;
|
||||
if (groups) {
|
||||
for (const [jid, group] of Object.entries(groups)) {
|
||||
try {
|
||||
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
39
src/group-folder.test.ts
Normal 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
44
src/group-folder.ts
Normal 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;
|
||||
}
|
||||
14
src/index.ts
14
src/index.ts
@@ -3,7 +3,6 @@ import path from 'path';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DATA_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
MAIN_GROUP_FOLDER,
|
||||
POLL_INTERVAL,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
@@ -78,11 +78,21 @@ function saveState(): 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;
|
||||
setRegisteredGroup(jid, group);
|
||||
|
||||
// Create group folder
|
||||
const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder);
|
||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -301,6 +301,23 @@ describe('register_group authorization', () => {
|
||||
// registeredGroups should not have changed
|
||||
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 ---
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './config.js';
|
||||
import { AvailableGroup } from './container-runner.js';
|
||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
@@ -357,6 +358,13 @@ export async function processTaskIpc(
|
||||
break;
|
||||
}
|
||||
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, {
|
||||
name: data.name,
|
||||
folder: data.folder,
|
||||
|
||||
53
src/task-scheduler.test.ts
Normal file
53
src/task-scheduler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
MAIN_GROUP_FOLDER,
|
||||
SCHEDULER_POLL_INTERVAL,
|
||||
@@ -17,9 +15,11 @@ import {
|
||||
getDueTasks,
|
||||
getTaskById,
|
||||
logTaskRun,
|
||||
updateTask,
|
||||
updateTaskAfterRun,
|
||||
} from './db.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { RegisteredGroup, ScheduledTask } from './types.js';
|
||||
|
||||
@@ -36,7 +36,27 @@ async function runTask(
|
||||
deps: SchedulerDependencies,
|
||||
): Promise<void> {
|
||||
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 });
|
||||
|
||||
logger.info(
|
||||
@@ -222,3 +242,8 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
||||
|
||||
loop();
|
||||
}
|
||||
|
||||
/** @internal - for tests only. */
|
||||
export function _resetSchedulerLoopForTests(): void {
|
||||
schedulerRunning = false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts', 'skills-engine/**/*.test.ts'],
|
||||
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'skills-engine/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user