diff --git a/.claude/settings.json b/.claude/settings.json index f859a6d..0967ef4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,10 +1 @@ -{ - "extraKnownMarketplaces": { - "nanoclaw-skills": { - "source": { - "source": "github", - "repo": "qwibitai/nanoclaw-skills" - } - } - } -} +{} diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index f4a3164..e46bd3e 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -39,7 +39,11 @@ git remote add discord https://github.com/qwibitai/nanoclaw-discord.git ```bash git fetch discord main -git merge discord/main +git merge discord/main || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: @@ -126,31 +130,18 @@ Wait for the user to provide the channel ID (format: `dc:1234567890123456`). ### Register the channel -Use the IPC register flow or register directly. The channel ID, name, and folder name are needed. +The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. For a main channel (responds to all messages): -```typescript -registerGroup("dc:", { - name: " #", - folder: "discord_main", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: false, - isMain: true, -}); +```bash +npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main ``` For additional channels (trigger-only): -```typescript -registerGroup("dc:", { - name: " #", - folder: "discord_", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: true, -}); +```bash +npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_" --trigger "@${ASSISTANT_NAME}" --channel discord ``` ## Phase 5: Verify diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index f77bbf7..781a0eb 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -40,7 +40,11 @@ git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git ```bash git fetch gmail main -git merge gmail/main +git merge gmail/main || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md index 53ef471..072bf7b 100644 --- a/.claude/skills/add-image-vision/SKILL.md +++ b/.claude/skills/add-image-vision/SKILL.md @@ -32,7 +32,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ```bash git fetch whatsapp skill/image-vision -git merge whatsapp/skill/image-vision +git merge whatsapp/skill/image-vision || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md index cd3736b..a01e530 100644 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ b/.claude/skills/add-pdf-reader/SKILL.md @@ -30,7 +30,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ```bash git fetch whatsapp skill/pdf-reader -git merge whatsapp/skill/pdf-reader +git merge whatsapp/skill/pdf-reader || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md index be725c3..de86768 100644 --- a/.claude/skills/add-reactions/SKILL.md +++ b/.claude/skills/add-reactions/SKILL.md @@ -37,7 +37,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ```bash git fetch whatsapp skill/reactions -git merge whatsapp/skill/reactions +git merge whatsapp/skill/reactions || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This adds: diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 4eb9225..4c86e19 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -35,7 +35,11 @@ git remote add slack https://github.com/qwibitai/nanoclaw-slack.git ```bash git fetch slack main -git merge slack/main +git merge slack/main || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: @@ -114,31 +118,18 @@ Wait for the user to provide the channel ID. ### Register the channel -Use the IPC register flow or register directly. The channel ID, name, and folder name are needed. +The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. For a main channel (responds to all messages): -```typescript -registerGroup("slack:", { - name: "", - folder: "slack_main", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: false, - isMain: true, -}); +```bash +npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main ``` For additional channels (trigger-only): -```typescript -registerGroup("slack:", { - name: "", - folder: "slack_", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: true, -}); +```bash +npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_" --trigger "@${ASSISTANT_NAME}" --channel slack ``` ## Phase 5: Verify diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index a2e29d7..10f25ab 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -39,7 +39,11 @@ git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git ```bash git fetch telegram main -git merge telegram/main +git merge telegram/main || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: @@ -129,31 +133,18 @@ Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234 ### Register the chat -Use the IPC register flow or register directly. The chat ID, name, and folder name are needed. +The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. For a main chat (responds to all messages): -```typescript -registerGroup("tg:", { - name: "", - folder: "telegram_main", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: false, - isMain: true, -}); +```bash +npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main ``` For additional chats (trigger-only): -```typescript -registerGroup("tg:", { - name: "", - folder: "telegram_", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: true, -}); +```bash +npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_" --trigger "@${ASSISTANT_NAME}" --channel telegram ``` ## Phase 5: Verify diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md index c3c0043..8ccec32 100644 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ b/.claude/skills/add-voice-transcription/SKILL.md @@ -41,7 +41,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ```bash git fetch whatsapp skill/voice-transcription -git merge whatsapp/skill/voice-transcription +git merge whatsapp/skill/voice-transcription || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 8ce68be..0774799 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -62,7 +62,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ```bash git fetch whatsapp main -git merge whatsapp/main +git merge whatsapp/main || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This merges in: diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index 13b5b89..614a979 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -9,12 +9,7 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion ## Workflow -1. **Install marketplace** - If feature skills aren't available yet, install the marketplace plugin: - ```bash - claude plugin install nanoclaw-skills@nanoclaw-skills --scope project - ``` - This is hot-loaded — all feature skills become immediately available. -2. **Understand the request** - Ask clarifying questions +1. **Understand the request** - Ask clarifying questions 3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually. 4. **Implement** - Make changes directly to the code 5. **Test guidance** - Tell user how to verify diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 544ee1d..d173927 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -58,7 +58,7 @@ Run `bash setup.sh` and parse the status block. - 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 DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, 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. @@ -122,19 +122,7 @@ AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key? **API key:** Tell user to add `ANTHROPIC_API_KEY=` to `.env`. -## 5. Install Skills Marketplace - -Register and install the NanoClaw skills marketplace plugin so all feature skills (channel integrations, add-ons) are available: - -```bash -claude plugin marketplace add qwibitai/nanoclaw-skills -claude plugin marketplace update nanoclaw-skills -claude plugin install nanoclaw-skills@nanoclaw-skills --scope project -``` - -The marketplace update ensures the local cache is fresh before installing. This is hot-loaded — no restart needed. All feature skills become immediately available. - -## 6. Set Up Channels +## 5. Set Up Channels AskUserQuestion (multiSelect): Which messaging channels do you want to enable? - WhatsApp (authenticates via QR code or pairing code) @@ -164,16 +152,16 @@ Each skill will: npm install && npm run build ``` -If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 7. +If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6. -## 7. Mount Allowlist +## 6. Mount Allowlist AskUserQuestion: Agent access to external directories? **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}'` -## 8. Start Service +## 7. Start Service If service already running: unload first. - macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` @@ -203,23 +191,23 @@ Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` - Linux: check `systemctl --user status nanoclaw`. - Re-run the service step after fixing. -## 9. Verify +## 8. Verify Run `npx tsx setup/index.ts --step verify` and parse the status block. **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 8 +- SERVICE=not_found → re-run step 7 - CREDENTIALS=missing → re-run step 4 - CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) -- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 6 +- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` 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: wrong Node path (re-run step 8), missing `.env` (step 4), missing channel credentials (re-invoke channel skill). +**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), missing channel credentials (re-invoke channel skill). **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`. diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md index 76851f3..ec18a09 100644 --- a/.claude/skills/use-local-whisper/SKILL.md +++ b/.claude/skills/use-local-whisper/SKILL.md @@ -75,7 +75,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ```bash git fetch whatsapp skill/local-whisper -git merge whatsapp/skill/local-whisper +git merge whatsapp/skill/local-whisper || { + git checkout --theirs package-lock.json + git add package-lock.json + git merge --continue +} ``` This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API. diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml index 20dada3..093130a 100644 --- a/.github/workflows/merge-forward-skills.yml +++ b/.github/workflows/merge-forward-skills.yml @@ -142,6 +142,7 @@ jobs: 'nanoclaw-discord', 'nanoclaw-slack', 'nanoclaw-gmail', + 'nanoclaw-docker-sandboxes', ]; const sha = context.sha.substring(0, 7); for (const repo of forks) { diff --git a/CLAUDE.md b/CLAUDE.md index 90c8910..318d6dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ systemctl --user restart nanoclaw ## Troubleshooting -**WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && git merge whatsapp/main && npm run build`) to install it. Existing auth credentials and groups are preserved. +**WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && (git merge whatsapp/main || { git checkout --theirs package-lock.json && git add package-lock.json && git merge --continue; }) && npm run build`) to install it. Existing auth credentials and groups are preserved. ## Container Build Cache diff --git a/README.md b/README.md index e0e167d..56d9331 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,27 @@ Discord  •   34.9k tokens, 17% of context window

-Using Claude Code, NanoClaw can dynamically rewrite its code to customize its feature set for your needs. -**New:** First AI assistant to support [Agent Swarms](https://code.claude.com/docs/en/agent-teams). Spin up teams of agents that collaborate in your chat. +--- + +

🐳 Now Runs in Docker Sandboxes

+

Every agent gets its own isolated container inside a micro VM.
Hypervisor-level isolation. Millisecond startup. No complex setup.

+ +**macOS (Apple Silicon)** +```bash +curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash +``` + +**Windows (WSL)** +```bash +curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash +``` + +> Currently supported on macOS (Apple Silicon) and Windows (x86). Linux support coming soon. + +

Read the announcement →  ·  Manual setup guide →

+ +--- ## Why I Built NanoClaw @@ -70,8 +88,8 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication, - **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated - **Scheduled tasks** - Recurring jobs that run Claude and can message you back - **Web access** - Search and fetch content from the Web -- **Container isolation** - Agents are sandboxed in Apple Container (macOS) or Docker (macOS/Linux) -- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks. NanoClaw is the first personal AI assistant to support agent swarms. +- **Container isolation** - Agents are sandboxed in [Docker Sandboxes](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes) (micro VM isolation), Apple Container (macOS), or Docker (macOS/Linux) +- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks - **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills ## Usage diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json index 89cee2c..9ae119b 100644 --- a/container/agent-runner/package-lock.json +++ b/container/agent-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "nanoclaw-agent-runner", "version": "1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" @@ -19,9 +19,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.68", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz", - "integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==", + "version": "0.2.76", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz", + "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index bf13328..42a994e 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" diff --git a/docs/SPEC.md b/docs/SPEC.md index d2b4723..598f34e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -358,7 +358,7 @@ export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); Groups can have additional directories mounted via `containerConfig` in the SQLite `registered_groups` table (stored as JSON in the `container_config` column). Example registration: ```typescript -registerGroup("1234567890@g.us", { +setRegisteredGroup("1234567890@g.us", { name: "Dev Team", folder: "whatsapp_dev-team", trigger: "@Andy", diff --git a/docs/docker-sandboxes.md b/docs/docker-sandboxes.md new file mode 100644 index 0000000..e887bad --- /dev/null +++ b/docs/docker-sandboxes.md @@ -0,0 +1,359 @@ +# Running NanoClaw in Docker Sandboxes (Manual Setup) + +This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation. + +## Architecture + +``` +Host (macOS / Windows WSL) +└── Docker Sandbox (micro VM with isolated kernel) + ├── NanoClaw process (Node.js) + │ ├── Channel adapters (WhatsApp, Telegram, etc.) + │ └── Container spawner → nested Docker daemon + └── Docker-in-Docker + └── nanoclaw-agent containers + └── Claude Agent SDK +``` + +Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary. + +The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically. + +> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies. + +## Prerequisites + +- **Docker Desktop v4.40+** with Sandbox support +- **Anthropic API key** (the sandbox proxy manages injection) +- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID +- For **WhatsApp**: a phone with WhatsApp installed + +Verify sandbox support: +```bash +docker sandbox version +``` + +## Step 1: Create the Sandbox + +On your host machine: + +```bash +# Create a workspace directory +mkdir -p ~/nanoclaw-workspace + +# Create a shell sandbox with the workspace mounted +docker sandbox create shell ~/nanoclaw-workspace +``` + +If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected: + +```bash +docker sandbox network proxy shell-nanoclaw-workspace \ + --bypass-host web.whatsapp.com \ + --bypass-host "*.whatsapp.com" \ + --bypass-host "*.whatsapp.net" +``` + +Telegram does not need proxy bypass. + +Enter the sandbox: +```bash +docker sandbox run shell-nanoclaw-workspace +``` + +## Step 2: Install Prerequisites + +Inside the sandbox: + +```bash +sudo apt-get update && sudo apt-get install -y build-essential python3 +npm config set strict-ssl false +``` + +## Step 3: Clone and Install NanoClaw + +NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path. + +```bash +# Clone to home first (virtiofs can corrupt git pack files during clone) +cd ~ +git clone https://github.com/qwibitai/nanoclaw.git + +# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`) +WORKSPACE=/Users/you/nanoclaw-workspace + +# Move into workspace so DinD mounts work +mv nanoclaw "$WORKSPACE/nanoclaw" +cd "$WORKSPACE/nanoclaw" + +# Install dependencies +npm install +npm install https-proxy-agent +``` + +## Step 4: Apply Proxy and Sandbox Patches + +NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions. + +### 4a. Dockerfile — proxy args for container image build + +`npm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`: + +Add these lines after the `FROM` line: + +```dockerfile +# Accept proxy build args +ARG http_proxy +ARG https_proxy +ARG no_proxy +ARG NODE_EXTRA_CA_CERTS +ARG npm_config_strict_ssl=true +RUN npm config set strict-ssl ${npm_config_strict_ssl} +``` + +And after the `RUN npm install` line: + +```dockerfile +RUN npm config set strict-ssl true +``` + +### 4b. Build script — forward proxy args + +Patch `container/build.sh` to pass proxy env vars to `docker build`: + +Add these `--build-arg` flags to the `docker build` command: + +```bash +--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \ +--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \ +--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \ +--build-arg npm_config_strict_ssl=false \ +``` + +### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix + +Three changes to `src/container-runner.ts`: + +**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file: + +```typescript +// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts) +const emptyEnvPath = path.join(DATA_DIR, 'empty-env'); +if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, ''); +// Use emptyEnvPath instead of '/dev/null' in the mount +``` + +**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants. + +**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers: + +```typescript +const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE; +if (caCertSrc) { + const certDir = path.join(DATA_DIR, 'ca-cert'); + fs.mkdirSync(certDir, { recursive: true }); + fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt')); + // Mount: certDir -> /workspace/ca-cert (read-only) + // Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container +} +``` + +### 4d. Container runtime — prevent self-termination + +In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname: + +```typescript +// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop +``` + +### 4e. Credential proxy — route through MITM proxy + +In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests: + +```typescript +import { HttpsProxyAgent } from 'https-proxy-agent'; + +const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy; +const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; +// Pass upstreamAgent to https.request() options +``` + +### 4f. Setup script — proxy build args + +Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b). + +## Step 5: Build + +```bash +npm run build +bash container/build.sh +``` + +## Step 6: Add a Channel + +### Telegram + +```bash +# Apply the Telegram skill +npx tsx scripts/apply-skill.ts .claude/skills/add-telegram + +# Rebuild after applying the skill +npm run build + +# Configure .env +cat > .env << EOF +TELEGRAM_BOT_TOKEN= +ASSISTANT_NAME=nanoclaw +ANTHROPIC_API_KEY=proxy-managed +EOF +mkdir -p data/env && cp .env data/env/env + +# Register your chat +npx tsx setup/index.ts --step register \ + --jid "tg:" \ + --name "My Chat" \ + --trigger "@nanoclaw" \ + --folder "telegram_main" \ + --channel telegram \ + --assistant-name "nanoclaw" \ + --is-main \ + --no-trigger-required +``` + +**To find your chat ID:** Send any message to your bot, then: +```bash +curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot/getUpdates" | python3 -m json.tool +``` + +**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot. + +**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild. + +### WhatsApp + +Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first. + +```bash +# Apply the WhatsApp skill +npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp + +# Rebuild +npm run build + +# Configure .env +cat > .env << EOF +ASSISTANT_NAME=nanoclaw +ANTHROPIC_API_KEY=proxy-managed +EOF +mkdir -p data/env && cp .env data/env/env + +# Authenticate (choose one): + +# QR code — scan with WhatsApp camera: +npx tsx src/whatsapp-auth.ts + +# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number: +npx tsx src/whatsapp-auth.ts --pairing-code --phone + +# Register your chat (JID = your phone number + @s.whatsapp.net) +npx tsx setup/index.ts --step register \ + --jid "@s.whatsapp.net" \ + --name "My Chat" \ + --trigger "@nanoclaw" \ + --folder "whatsapp_main" \ + --channel whatsapp \ + --assistant-name "nanoclaw" \ + --is-main \ + --no-trigger-required +``` + +**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild. + +### Both Channels + +Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately. + +## Step 7: Run + +```bash +npm start +``` + +You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically. + +## Networking Details + +### How the proxy works + +All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`: + +``` +Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com +``` + +**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client. + +### Shared paths for DinD mounts + +Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared": +- `/dev/null` → replace with an empty file in the project dir +- `/usr/local/share/ca-certificates/` → copy cert to project dir +- `/home/agent/` → clone to workspace instead + +### Git clone and virtiofs + +The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace. + +## Troubleshooting + +### npm install fails with SELF_SIGNED_CERT_IN_CHAIN +```bash +npm config set strict-ssl false +``` + +### Container build fails with proxy errors +```bash +docker build \ + --build-arg http_proxy=$http_proxy \ + --build-arg https_proxy=$https_proxy \ + -t nanoclaw-agent:latest container/ +``` + +### Agent containers fail with "path not shared" +All bind-mounted paths must be under the workspace directory. Check: +- Is NanoClaw cloned into the workspace? (not `/home/agent/`) +- Is the CA cert copied to the project root? +- Has the empty `.env` shadow file been created? + +### Agent containers can't reach Anthropic API +Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`. + +### WhatsApp error 405 +The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`. + +### WhatsApp "Connection failed" immediately +Proxy bypass not configured. From the **host**, run: +```bash +docker sandbox network proxy \ + --bypass-host web.whatsapp.com \ + --bypass-host "*.whatsapp.com" \ + --bypass-host "*.whatsapp.net" +``` + +### Telegram bot doesn't receive messages +1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`) +2. Check Group Privacy is disabled in @BotFather if using in groups + +### Git clone fails with "inflate: data stream error" +Clone to a non-workspace path first, then move: +```bash +cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw +``` + +### WhatsApp QR code doesn't display +Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`): +```bash +docker sandbox run shell-nanoclaw-workspace +# Then inside: +npx tsx src/whatsapp-auth.ts +``` diff --git a/package-lock.json b/package-lock.json index ef19a6c..ee97d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.15", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", @@ -1740,7 +1740,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2295,7 +2294,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2355,7 +2353,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2431,7 +2428,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2532,7 +2528,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 5fae6f4..97c3a6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.15", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index c3810cb..480cd9f 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 38.8k tokens, 19% of context window + + 40.5k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 38.8k + + 40.5k diff --git a/setup.sh b/setup.sh index ef7d683..c37f143 100755 --- a/setup.sh +++ b/setup.sh @@ -79,8 +79,8 @@ install_deps() { log "Running as root, using --unsafe-perm" fi - log "Running npm install $npm_flags" - if npm install $npm_flags >> "$LOG_FILE" 2>&1; then + log "Running npm ci $npm_flags" + if npm ci $npm_flags >> "$LOG_FILE" 2>&1; then DEPS_OK="true" log "npm install succeeded" else diff --git a/setup/register.ts b/setup/register.ts index 03ea7df..eeafa90 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -7,12 +7,11 @@ 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'; +import { STORE_DIR } from '../src/config.ts'; +import { initDatabase, setRegisteredGroup } from '../src/db.ts'; +import { isValidGroupFolder } from '../src/group-folder.ts'; +import { logger } from '../src/logger.ts'; +import { emitStatus } from './status.ts'; interface RegisterArgs { jid: string; @@ -98,41 +97,18 @@ export async function run(args: string[]): Promise { fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true }); fs.mkdirSync(STORE_DIR, { 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; + // Initialize database (creates schema + runs migrations) + initDatabase(); - 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, - is_main INTEGER DEFAULT 0 - )`); + setRegisteredGroup(parsed.jid, { + name: parsed.name, + folder: parsed.folder, + trigger: parsed.trigger, + added_at: new Date().toISOString(), + requiresTrigger: parsed.requiresTrigger, + isMain: parsed.isMain, + }); - const isMainInt = parsed.isMain ? 1 : 0; - - db.prepare( - `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, - ).run( - parsed.jid, - parsed.name, - parsed.folder, - parsed.trigger, - timestamp, - requiresTriggerInt, - isMainInt, - ); - - db.close(); logger.info('Wrote registration to SQLite'); // Create group folders diff --git a/setup/service.test.ts b/setup/service.test.ts index eb15db8..9168fe1 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -62,6 +62,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js WorkingDirectory=${projectRoot} Restart=always RestartSec=5 +KillMode=process Environment=HOME=${homeDir} Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin StandardOutput=append:${projectRoot}/logs/nanoclaw.log @@ -142,6 +143,16 @@ describe('systemd unit generation', () => { expect(unit).toContain('RestartSec=5'); }); + it('uses KillMode=process to preserve detached children', () => { + const unit = generateSystemdUnit( + '/usr/bin/node', + '/home/user/nanoclaw', + '/home/user', + false, + ); + expect(unit).toContain('KillMode=process'); + }); + it('sets correct ExecStart', () => { const unit = generateSystemdUnit( '/usr/bin/node', diff --git a/setup/service.ts b/setup/service.ts index 643c8c9..71b3c63 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -243,6 +243,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js WorkingDirectory=${projectRoot} Restart=always RestartSec=5 +KillMode=process Environment=HOME=${homeDir} Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin StandardOutput=append:${projectRoot}/logs/nanoclaw.log diff --git a/src/index.ts b/src/index.ts index bfdcdec..98682fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,11 @@ import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { + restoreRemoteControl, + startRemoteControl, + stopRemoteControl, +} from './remote-control.js'; import { isSenderAllowed, isTriggerAllowed, @@ -470,6 +475,7 @@ async function main(): Promise { initDatabase(); logger.info('Database initialized'); loadState(); + restoreRemoteControl(); // Start credential proxy (containers route API calls through this) const proxyServer = await startCredentialProxy( @@ -488,9 +494,60 @@ async function main(): Promise { process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); + // Handle /remote-control and /remote-control-end commands + async function handleRemoteControl( + command: string, + chatJid: string, + msg: NewMessage, + ): Promise { + const group = registeredGroups[chatJid]; + if (!group?.isMain) { + logger.warn( + { chatJid, sender: msg.sender }, + 'Remote control rejected: not main group', + ); + return; + } + + const channel = findChannel(channels, chatJid); + if (!channel) return; + + if (command === '/remote-control') { + const result = await startRemoteControl( + msg.sender, + chatJid, + process.cwd(), + ); + if (result.ok) { + await channel.sendMessage(chatJid, result.url); + } else { + await channel.sendMessage( + chatJid, + `Remote Control failed: ${result.error}`, + ); + } + } else { + const result = stopRemoteControl(); + if (result.ok) { + await channel.sendMessage(chatJid, 'Remote Control session ended.'); + } else { + await channel.sendMessage(chatJid, result.error); + } + } + } + // Channel callbacks (shared by all channels) const channelOpts = { onMessage: (chatJid: string, msg: NewMessage) => { + // Remote control commands — intercept before storage + const trimmed = msg.content.trim(); + if (trimmed === '/remote-control' || trimmed === '/remote-control-end') { + handleRemoteControl(trimmed, chatJid, msg).catch((err) => + logger.error({ err, chatJid }, 'Remote control command error'), + ); + return; + } + // Sender allowlist drop mode: discard messages from denied senders before storing if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { const cfg = loadSenderAllowlist(); diff --git a/src/remote-control.test.ts b/src/remote-control.test.ts new file mode 100644 index 0000000..24e1b11 --- /dev/null +++ b/src/remote-control.test.ts @@ -0,0 +1,397 @@ +import fs from 'fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock config before importing the module under test +vi.mock('./config.js', () => ({ + DATA_DIR: '/tmp/nanoclaw-rc-test', +})); + +// Mock child_process +const spawnMock = vi.fn(); +vi.mock('child_process', () => ({ + spawn: (...args: any[]) => spawnMock(...args), +})); + +import { + startRemoteControl, + stopRemoteControl, + restoreRemoteControl, + getActiveSession, + _resetForTesting, + _getStateFilePath, +} from './remote-control.js'; + +// --- Helpers --- + +function createMockProcess(pid = 12345) { + return { + pid, + unref: vi.fn(), + kill: vi.fn(), + stdin: { write: vi.fn(), end: vi.fn() }, + }; +} + +describe('remote-control', () => { + const STATE_FILE = _getStateFilePath(); + let readFileSyncSpy: ReturnType; + let writeFileSyncSpy: ReturnType; + let unlinkSyncSpy: ReturnType; + let mkdirSyncSpy: ReturnType; + let openSyncSpy: ReturnType; + let closeSyncSpy: ReturnType; + + // Track what readFileSync should return for the stdout file + let stdoutFileContent: string; + + beforeEach(() => { + _resetForTesting(); + spawnMock.mockReset(); + stdoutFileContent = ''; + + // Default fs mocks + mkdirSyncSpy = vi + .spyOn(fs, 'mkdirSync') + .mockImplementation(() => undefined as any); + writeFileSyncSpy = vi + .spyOn(fs, 'writeFileSync') + .mockImplementation(() => {}); + unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any); + closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {}); + + // readFileSync: return stdoutFileContent for the stdout file, state file, etc. + readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation((( + p: string, + ) => { + if (p.endsWith('remote-control.stdout')) return stdoutFileContent; + if (p.endsWith('remote-control.json')) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + return ''; + }) as any); + }); + + afterEach(() => { + _resetForTesting(); + vi.restoreAllMocks(); + }); + + // --- startRemoteControl --- + + describe('startRemoteControl', () => { + it('spawns claude remote-control and returns the URL', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + + // Simulate URL appearing in stdout file on first poll + stdoutFileContent = + 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + const result = await startRemoteControl('user1', 'tg:123', '/project'); + + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_abc123', + }); + expect(spawnMock).toHaveBeenCalledWith( + 'claude', + ['remote-control', '--name', 'NanoClaw Remote'], + expect.objectContaining({ cwd: '/project', detached: true }), + ); + expect(proc.unref).toHaveBeenCalled(); + }); + + it('uses file descriptors for stdout/stderr (not pipes)', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + const spawnCall = spawnMock.mock.calls[0]; + const options = spawnCall[2]; + // stdio[0] is 'pipe' so we can write 'y' to accept the prompt + expect(options.stdio[0]).toBe('pipe'); + expect(typeof options.stdio[1]).toBe('number'); + expect(typeof options.stdio[2]).toBe('number'); + }); + + it('closes file descriptors in parent after spawn', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + // Two openSync calls (stdout + stderr), two closeSync calls + expect(openSyncSpy).toHaveBeenCalledTimes(2); + expect(closeSyncSpy).toHaveBeenCalledTimes(2); + }); + + it('saves state to disk after capturing URL', async () => { + const proc = createMockProcess(99999); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + expect(writeFileSyncSpy).toHaveBeenCalledWith( + STATE_FILE, + expect.stringContaining('"pid":99999'), + ); + }); + + it('returns existing URL if session is already active', async () => { + const proc = createMockProcess(); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + // Second call should return existing URL without spawning + const result = await startRemoteControl('user2', 'tg:456', '/project'); + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_existing', + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('starts new session if existing process is dead', async () => { + const proc1 = createMockProcess(11111); + const proc2 = createMockProcess(22222); + spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2); + + // First start: process alive, URL found + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementation((() => true) as any); + stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n'; + await startRemoteControl('user1', 'tg:123', '/project'); + + // Old process (11111) is dead, new process (22222) is alive + killSpy.mockImplementation(((pid: number, sig: any) => { + if (pid === 11111 && (sig === 0 || sig === undefined)) { + throw new Error('ESRCH'); + } + return true; + }) as any); + + stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n'; + const result = await startRemoteControl('user1', 'tg:123', '/project'); + + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_second', + }); + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + it('returns error if process exits before URL', async () => { + const proc = createMockProcess(33333); + spawnMock.mockReturnValue(proc); + stdoutFileContent = ''; + + // Process is dead (poll will detect this) + vi.spyOn(process, 'kill').mockImplementation((() => { + throw new Error('ESRCH'); + }) as any); + + const result = await startRemoteControl('user1', 'tg:123', '/project'); + expect(result).toEqual({ + ok: false, + error: 'Process exited before producing URL', + }); + }); + + it('times out if URL never appears', async () => { + vi.useFakeTimers(); + const proc = createMockProcess(44444); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'no url here'; + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + const promise = startRemoteControl('user1', 'tg:123', '/project'); + + // Advance past URL_TIMEOUT_MS (30s), with enough steps for polls + for (let i = 0; i < 160; i++) { + await vi.advanceTimersByTimeAsync(200); + } + + const result = await promise; + expect(result).toEqual({ + ok: false, + error: 'Timed out waiting for Remote Control URL', + }); + + vi.useRealTimers(); + }); + + it('returns error if spawn throws', async () => { + spawnMock.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const result = await startRemoteControl('user1', 'tg:123', '/project'); + expect(result).toEqual({ + ok: false, + error: 'Failed to start: ENOENT', + }); + }); + }); + + // --- stopRemoteControl --- + + describe('stopRemoteControl', () => { + it('kills the process and clears state', async () => { + const proc = createMockProcess(55555); + spawnMock.mockReturnValue(proc); + stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n'; + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementation((() => true) as any); + + await startRemoteControl('user1', 'tg:123', '/project'); + + const result = stopRemoteControl(); + expect(result).toEqual({ ok: true }); + expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM'); + expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE); + expect(getActiveSession()).toBeNull(); + }); + + it('returns error when no session is active', () => { + const result = stopRemoteControl(); + expect(result).toEqual({ + ok: false, + error: 'No active Remote Control session', + }); + }); + }); + + // --- restoreRemoteControl --- + + describe('restoreRemoteControl', () => { + it('restores session if state file exists and process is alive', () => { + const session = { + pid: 77777, + url: 'https://claude.ai/code?bridge=env_restored', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + restoreRemoteControl(); + + const active = getActiveSession(); + expect(active).not.toBeNull(); + expect(active!.pid).toBe(77777); + expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored'); + }); + + it('clears state if process is dead', () => { + const session = { + pid: 88888, + url: 'https://claude.ai/code?bridge=env_dead', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + vi.spyOn(process, 'kill').mockImplementation((() => { + throw new Error('ESRCH'); + }) as any); + + restoreRemoteControl(); + + expect(getActiveSession()).toBeNull(); + expect(unlinkSyncSpy).toHaveBeenCalled(); + }); + + it('does nothing if no state file exists', () => { + // readFileSyncSpy default throws ENOENT for .json + restoreRemoteControl(); + expect(getActiveSession()).toBeNull(); + }); + + it('clears state on corrupted JSON', () => { + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return 'not json{{{'; + return ''; + }) as any); + + restoreRemoteControl(); + + expect(getActiveSession()).toBeNull(); + expect(unlinkSyncSpy).toHaveBeenCalled(); + }); + + // ** This is the key integration test: restore → stop must work ** + it('stopRemoteControl works after restoreRemoteControl', () => { + const session = { + pid: 77777, + url: 'https://claude.ai/code?bridge=env_restored', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementation((() => true) as any); + + restoreRemoteControl(); + expect(getActiveSession()).not.toBeNull(); + + const result = stopRemoteControl(); + expect(result).toEqual({ ok: true }); + expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM'); + expect(unlinkSyncSpy).toHaveBeenCalled(); + expect(getActiveSession()).toBeNull(); + }); + + it('startRemoteControl returns restored URL without spawning', () => { + const session = { + pid: 77777, + url: 'https://claude.ai/code?bridge=env_restored', + startedBy: 'user1', + startedInChat: 'tg:123', + startedAt: '2026-01-01T00:00:00.000Z', + }; + readFileSyncSpy.mockImplementation(((p: string) => { + if (p.endsWith('remote-control.json')) return JSON.stringify(session); + return ''; + }) as any); + vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + + restoreRemoteControl(); + + return startRemoteControl('user2', 'tg:456', '/project').then( + (result) => { + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_restored', + }); + expect(spawnMock).not.toHaveBeenCalled(); + }, + ); + }); + }); +}); diff --git a/src/remote-control.ts b/src/remote-control.ts new file mode 100644 index 0000000..2f0bdc4 --- /dev/null +++ b/src/remote-control.ts @@ -0,0 +1,224 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { logger } from './logger.js'; + +interface RemoteControlSession { + pid: number; + url: string; + startedBy: string; + startedInChat: string; + startedAt: string; +} + +let activeSession: RemoteControlSession | null = null; + +const URL_REGEX = /https:\/\/claude\.ai\/code\S+/; +const URL_TIMEOUT_MS = 30_000; +const URL_POLL_MS = 200; +const STATE_FILE = path.join(DATA_DIR, 'remote-control.json'); +const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout'); +const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr'); + +function saveState(session: RemoteControlSession): void { + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(session)); +} + +function clearState(): void { + try { + fs.unlinkSync(STATE_FILE); + } catch { + // ignore + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Restore session from disk on startup. + * If the process is still alive, adopt it. Otherwise, clean up. + */ +export function restoreRemoteControl(): void { + let data: string; + try { + data = fs.readFileSync(STATE_FILE, 'utf-8'); + } catch { + return; + } + + try { + const session: RemoteControlSession = JSON.parse(data); + if (session.pid && isProcessAlive(session.pid)) { + activeSession = session; + logger.info( + { pid: session.pid, url: session.url }, + 'Restored Remote Control session from previous run', + ); + } else { + clearState(); + } + } catch { + clearState(); + } +} + +export function getActiveSession(): RemoteControlSession | null { + return activeSession; +} + +/** @internal — exported for testing only */ +export function _resetForTesting(): void { + activeSession = null; +} + +/** @internal — exported for testing only */ +export function _getStateFilePath(): string { + return STATE_FILE; +} + +export async function startRemoteControl( + sender: string, + chatJid: string, + cwd: string, +): Promise<{ ok: true; url: string } | { ok: false; error: string }> { + if (activeSession) { + // Verify the process is still alive + if (isProcessAlive(activeSession.pid)) { + return { ok: true, url: activeSession.url }; + } + // Process died — clean up and start a new one + activeSession = null; + clearState(); + } + + // Redirect stdout/stderr to files so the process has no pipes to the parent. + // This prevents SIGPIPE when NanoClaw restarts. + fs.mkdirSync(DATA_DIR, { recursive: true }); + const stdoutFd = fs.openSync(STDOUT_FILE, 'w'); + const stderrFd = fs.openSync(STDERR_FILE, 'w'); + + let proc; + try { + proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], { + cwd, + stdio: ['pipe', stdoutFd, stderrFd], + detached: true, + }); + } catch (err: any) { + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + return { ok: false, error: `Failed to start: ${err.message}` }; + } + + // Auto-accept the "Enable Remote Control?" prompt + if (proc.stdin) { + proc.stdin.write('y\n'); + proc.stdin.end(); + } + + // Close FDs in the parent — the child inherited copies + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + + // Fully detach from parent + proc.unref(); + + const pid = proc.pid; + if (!pid) { + return { ok: false, error: 'Failed to get process PID' }; + } + + // Poll the stdout file for the URL + return new Promise((resolve) => { + const startTime = Date.now(); + + const poll = () => { + // Check if process died + if (!isProcessAlive(pid)) { + resolve({ ok: false, error: 'Process exited before producing URL' }); + return; + } + + // Check for URL in stdout file + let content = ''; + try { + content = fs.readFileSync(STDOUT_FILE, 'utf-8'); + } catch { + // File might not have content yet + } + + const match = content.match(URL_REGEX); + if (match) { + const session: RemoteControlSession = { + pid, + url: match[0], + startedBy: sender, + startedInChat: chatJid, + startedAt: new Date().toISOString(), + }; + activeSession = session; + saveState(session); + + logger.info( + { url: match[0], pid, sender, chatJid }, + 'Remote Control session started', + ); + resolve({ ok: true, url: match[0] }); + return; + } + + // Timeout check + if (Date.now() - startTime >= URL_TIMEOUT_MS) { + try { + process.kill(-pid, 'SIGTERM'); + } catch { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // already dead + } + } + resolve({ + ok: false, + error: 'Timed out waiting for Remote Control URL', + }); + return; + } + + setTimeout(poll, URL_POLL_MS); + }; + + poll(); + }); +} + +export function stopRemoteControl(): + | { + ok: true; + } + | { ok: false; error: string } { + if (!activeSession) { + return { ok: false, error: 'No active Remote Control session' }; + } + + const { pid } = activeSession; + try { + process.kill(pid, 'SIGTERM'); + } catch { + // already dead + } + activeSession = null; + clearState(); + logger.info({ pid }, 'Remote Control session stopped'); + return { ok: true }; +}