Merge branch 'main' into upstream-pr/refresh-tasks-snapshot
This commit is contained in:
@@ -1,10 +1 @@
|
|||||||
{
|
{}
|
||||||
"extraKnownMarketplaces": {
|
|
||||||
"nanoclaw-skills": {
|
|
||||||
"source": {
|
|
||||||
"source": "github",
|
|
||||||
"repo": "qwibitai/nanoclaw-skills"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ git remote add discord https://github.com/qwibitai/nanoclaw-discord.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch discord main
|
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:
|
This merges in:
|
||||||
@@ -126,31 +130,18 @@ Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
|
|||||||
|
|
||||||
### Register the channel
|
### 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):
|
For a main channel (responds to all messages):
|
||||||
|
|
||||||
```typescript
|
```bash
|
||||||
registerGroup("dc:<channel-id>", {
|
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main
|
||||||
name: "<server-name> #<channel-name>",
|
|
||||||
folder: "discord_main",
|
|
||||||
trigger: `@${ASSISTANT_NAME}`,
|
|
||||||
added_at: new Date().toISOString(),
|
|
||||||
requiresTrigger: false,
|
|
||||||
isMain: true,
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For additional channels (trigger-only):
|
For additional channels (trigger-only):
|
||||||
|
|
||||||
```typescript
|
```bash
|
||||||
registerGroup("dc:<channel-id>", {
|
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel discord
|
||||||
name: "<server-name> #<channel-name>",
|
|
||||||
folder: "discord_<channel-name>",
|
|
||||||
trigger: `@${ASSISTANT_NAME}`,
|
|
||||||
added_at: new Date().toISOString(),
|
|
||||||
requiresTrigger: true,
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 5: Verify
|
## Phase 5: Verify
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch gmail main
|
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:
|
This merges in:
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch whatsapp skill/image-vision
|
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:
|
This merges in:
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch whatsapp skill/pdf-reader
|
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:
|
This merges in:
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch whatsapp skill/reactions
|
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:
|
This adds:
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ git remote add slack https://github.com/qwibitai/nanoclaw-slack.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch slack main
|
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:
|
This merges in:
|
||||||
@@ -114,31 +118,18 @@ Wait for the user to provide the channel ID.
|
|||||||
|
|
||||||
### Register the channel
|
### 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):
|
For a main channel (responds to all messages):
|
||||||
|
|
||||||
```typescript
|
```bash
|
||||||
registerGroup("slack:<channel-id>", {
|
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main
|
||||||
name: "<channel-name>",
|
|
||||||
folder: "slack_main",
|
|
||||||
trigger: `@${ASSISTANT_NAME}`,
|
|
||||||
added_at: new Date().toISOString(),
|
|
||||||
requiresTrigger: false,
|
|
||||||
isMain: true,
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For additional channels (trigger-only):
|
For additional channels (trigger-only):
|
||||||
|
|
||||||
```typescript
|
```bash
|
||||||
registerGroup("slack:<channel-id>", {
|
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel slack
|
||||||
name: "<channel-name>",
|
|
||||||
folder: "slack_<channel-name>",
|
|
||||||
trigger: `@${ASSISTANT_NAME}`,
|
|
||||||
added_at: new Date().toISOString(),
|
|
||||||
requiresTrigger: true,
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 5: Verify
|
## Phase 5: Verify
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch telegram main
|
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:
|
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
|
### 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):
|
For a main chat (responds to all messages):
|
||||||
|
|
||||||
```typescript
|
```bash
|
||||||
registerGroup("tg:<chat-id>", {
|
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main
|
||||||
name: "<chat-name>",
|
|
||||||
folder: "telegram_main",
|
|
||||||
trigger: `@${ASSISTANT_NAME}`,
|
|
||||||
added_at: new Date().toISOString(),
|
|
||||||
requiresTrigger: false,
|
|
||||||
isMain: true,
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For additional chats (trigger-only):
|
For additional chats (trigger-only):
|
||||||
|
|
||||||
```typescript
|
```bash
|
||||||
registerGroup("tg:<chat-id>", {
|
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_<group-name>" --trigger "@${ASSISTANT_NAME}" --channel telegram
|
||||||
name: "<chat-name>",
|
|
||||||
folder: "telegram_<group-name>",
|
|
||||||
trigger: `@${ASSISTANT_NAME}`,
|
|
||||||
added_at: new Date().toISOString(),
|
|
||||||
requiresTrigger: true,
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 5: Verify
|
## Phase 5: Verify
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch whatsapp skill/voice-transcription
|
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:
|
This merges in:
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch whatsapp main
|
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:
|
This merges in:
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. **Install marketplace** - If feature skills aren't available yet, install the marketplace plugin:
|
1. **Understand the request** - Ask clarifying questions
|
||||||
```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
|
|
||||||
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.
|
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
|
4. **Implement** - Make changes directly to the code
|
||||||
5. **Test guidance** - Tell user how to verify
|
5. **Test guidance** - Tell user how to verify
|
||||||
|
|||||||
@@ -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`
|
- 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
|
- 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`
|
- 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.
|
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
|
||||||
- Record PLATFORM and IS_WSL for later steps.
|
- 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=<key>` to `.env`.
|
**API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`.
|
||||||
|
|
||||||
## 5. Install Skills Marketplace
|
## 5. Set Up Channels
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
|
AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
|
||||||
- WhatsApp (authenticates via QR code or pairing code)
|
- WhatsApp (authenticates via QR code or pairing code)
|
||||||
@@ -164,16 +152,16 @@ Each skill will:
|
|||||||
npm install && npm run build
|
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?
|
AskUserQuestion: Agent access to external directories?
|
||||||
|
|
||||||
**No:** `npx tsx setup/index.ts --step mounts -- --empty`
|
**No:** `npx tsx setup/index.ts --step mounts -- --empty`
|
||||||
**Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'`
|
**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.
|
If service already running: unload first.
|
||||||
- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
|
- 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`.
|
- Linux: check `systemctl --user status nanoclaw`.
|
||||||
- Re-run the service step after fixing.
|
- Re-run the service step after fixing.
|
||||||
|
|
||||||
## 9. Verify
|
## 8. Verify
|
||||||
|
|
||||||
Run `npx tsx setup/index.ts --step verify` and parse the status block.
|
Run `npx tsx setup/index.ts --step verify` and parse the status block.
|
||||||
|
|
||||||
**If STATUS=failed, fix each:**
|
**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=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
|
- CREDENTIALS=missing → re-run step 4
|
||||||
- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
|
- 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`
|
- 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`
|
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
|
||||||
|
|
||||||
## Troubleshooting
|
## 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`.
|
**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`.
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch whatsapp skill/local-whisper
|
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.
|
This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.
|
||||||
|
|||||||
1
.github/workflows/merge-forward-skills.yml
vendored
1
.github/workflows/merge-forward-skills.yml
vendored
@@ -142,6 +142,7 @@ jobs:
|
|||||||
'nanoclaw-discord',
|
'nanoclaw-discord',
|
||||||
'nanoclaw-slack',
|
'nanoclaw-slack',
|
||||||
'nanoclaw-gmail',
|
'nanoclaw-gmail',
|
||||||
|
'nanoclaw-docker-sandboxes',
|
||||||
];
|
];
|
||||||
const sha = context.sha.substring(0, 7);
|
const sha = context.sha.substring(0, 7);
|
||||||
for (const repo of forks) {
|
for (const repo of forks) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ systemctl --user restart nanoclaw
|
|||||||
|
|
||||||
## Troubleshooting
|
## 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
|
## Container Build Cache
|
||||||
|
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -12,9 +12,27 @@
|
|||||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||||
</p>
|
</p>
|
||||||
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.
|
---
|
||||||
|
|
||||||
|
<h2 align="center">🐳 Now Runs in Docker Sandboxes</h2>
|
||||||
|
<p align="center">Every agent gets its own isolated container inside a micro VM.<br>Hypervisor-level isolation. Millisecond startup. No complex setup.</p>
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">Read the announcement →</a> · <a href="docs/docker-sandboxes.md">Manual setup guide →</a></p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Why I Built NanoClaw
|
## 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
|
- **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
|
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
|
||||||
- **Web access** - Search and fetch content from the Web
|
- **Web access** - Search and fetch content from the Web
|
||||||
- **Container isolation** - Agents are sandboxed in Apple Container (macOS) or Docker (macOS/Linux)
|
- **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. NanoClaw is the first personal AI assistant to support agent swarms.
|
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
|
||||||
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
|
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
8
container/agent-runner/package-lock.json
generated
8
container/agent-runner/package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "nanoclaw-agent-runner",
|
"name": "nanoclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||||
"version": "0.2.68",
|
"version": "0.2.76",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz",
|
||||||
"integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==",
|
"integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==",
|
||||||
"license": "SEE LICENSE IN README.md",
|
"license": "SEE LICENSE IN README.md",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
|||||||
@@ -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:
|
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
|
```typescript
|
||||||
registerGroup("1234567890@g.us", {
|
setRegisteredGroup("1234567890@g.us", {
|
||||||
name: "Dev Team",
|
name: "Dev Team",
|
||||||
folder: "whatsapp_dev-team",
|
folder: "whatsapp_dev-team",
|
||||||
trigger: "@Andy",
|
trigger: "@Andy",
|
||||||
|
|||||||
359
docs/docker-sandboxes.md
Normal file
359
docs/docker-sandboxes.md
Normal file
@@ -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=<your-token-from-botfather>
|
||||||
|
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:<your-chat-id>" \
|
||||||
|
--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<TOKEN>/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 <phone-number-no-plus>
|
||||||
|
|
||||||
|
# Register your chat (JID = your phone number + @s.whatsapp.net)
|
||||||
|
npx tsx setup/index.ts --step register \
|
||||||
|
--jid "<phone>@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 <sandbox-name> \
|
||||||
|
--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
|
||||||
|
```
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
@@ -1740,7 +1740,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2295,7 +2294,6 @@
|
|||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -2355,7 +2353,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -2431,7 +2428,6 @@
|
|||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.18",
|
"@vitest/expect": "4.0.18",
|
||||||
"@vitest/mocker": "4.0.18",
|
"@vitest/mocker": "4.0.18",
|
||||||
@@ -2532,7 +2528,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.15",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -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="38.8k tokens, 19% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="40.5k tokens, 20% of context window">
|
||||||
<title>38.8k tokens, 19% of context window</title>
|
<title>40.5k tokens, 20% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">38.8k</text>
|
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">40.5k</text>
|
||||||
<text x="74" y="14">38.8k</text>
|
<text x="74" y="14">40.5k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
4
setup.sh
4
setup.sh
@@ -79,8 +79,8 @@ install_deps() {
|
|||||||
log "Running as root, using --unsafe-perm"
|
log "Running as root, using --unsafe-perm"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Running npm install $npm_flags"
|
log "Running npm ci $npm_flags"
|
||||||
if npm install $npm_flags >> "$LOG_FILE" 2>&1; then
|
if npm ci $npm_flags >> "$LOG_FILE" 2>&1; then
|
||||||
DEPS_OK="true"
|
DEPS_OK="true"
|
||||||
log "npm install succeeded"
|
log "npm install succeeded"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -7,12 +7,11 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import { STORE_DIR } from '../src/config.ts';
|
||||||
|
import { initDatabase, setRegisteredGroup } from '../src/db.ts';
|
||||||
import { STORE_DIR } from '../src/config.js';
|
import { isValidGroupFolder } from '../src/group-folder.ts';
|
||||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
import { logger } from '../src/logger.ts';
|
||||||
import { logger } from '../src/logger.js';
|
import { emitStatus } from './status.ts';
|
||||||
import { emitStatus } from './status.js';
|
|
||||||
|
|
||||||
interface RegisterArgs {
|
interface RegisterArgs {
|
||||||
jid: string;
|
jid: string;
|
||||||
@@ -98,41 +97,18 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
|
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
|
||||||
fs.mkdirSync(STORE_DIR, { recursive: true });
|
fs.mkdirSync(STORE_DIR, { recursive: true });
|
||||||
|
|
||||||
// Write to SQLite using parameterized queries (no SQL injection)
|
// Initialize database (creates schema + runs migrations)
|
||||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
initDatabase();
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0;
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
setRegisteredGroup(parsed.jid, {
|
||||||
// Ensure schema exists
|
name: parsed.name,
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
folder: parsed.folder,
|
||||||
jid TEXT PRIMARY KEY,
|
trigger: parsed.trigger,
|
||||||
name TEXT NOT NULL,
|
added_at: new Date().toISOString(),
|
||||||
folder TEXT NOT NULL UNIQUE,
|
requiresTrigger: parsed.requiresTrigger,
|
||||||
trigger_pattern TEXT NOT NULL,
|
isMain: parsed.isMain,
|
||||||
added_at TEXT NOT NULL,
|
});
|
||||||
container_config TEXT,
|
|
||||||
requires_trigger INTEGER DEFAULT 1,
|
|
||||||
is_main INTEGER DEFAULT 0
|
|
||||||
)`);
|
|
||||||
|
|
||||||
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');
|
logger.info('Wrote registration to SQLite');
|
||||||
|
|
||||||
// Create group folders
|
// Create group folders
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js
|
|||||||
WorkingDirectory=${projectRoot}
|
WorkingDirectory=${projectRoot}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
KillMode=process
|
||||||
Environment=HOME=${homeDir}
|
Environment=HOME=${homeDir}
|
||||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
||||||
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
||||||
@@ -142,6 +143,16 @@ describe('systemd unit generation', () => {
|
|||||||
expect(unit).toContain('RestartSec=5');
|
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', () => {
|
it('sets correct ExecStart', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js
|
|||||||
WorkingDirectory=${projectRoot}
|
WorkingDirectory=${projectRoot}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
KillMode=process
|
||||||
Environment=HOME=${homeDir}
|
Environment=HOME=${homeDir}
|
||||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
||||||
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
||||||
|
|||||||
57
src/index.ts
57
src/index.ts
@@ -46,6 +46,11 @@ import { GroupQueue } from './group-queue.js';
|
|||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
import { startIpcWatcher } from './ipc.js';
|
import { startIpcWatcher } from './ipc.js';
|
||||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||||
|
import {
|
||||||
|
restoreRemoteControl,
|
||||||
|
startRemoteControl,
|
||||||
|
stopRemoteControl,
|
||||||
|
} from './remote-control.js';
|
||||||
import {
|
import {
|
||||||
isSenderAllowed,
|
isSenderAllowed,
|
||||||
isTriggerAllowed,
|
isTriggerAllowed,
|
||||||
@@ -470,6 +475,7 @@ async function main(): Promise<void> {
|
|||||||
initDatabase();
|
initDatabase();
|
||||||
logger.info('Database initialized');
|
logger.info('Database initialized');
|
||||||
loadState();
|
loadState();
|
||||||
|
restoreRemoteControl();
|
||||||
|
|
||||||
// Start credential proxy (containers route API calls through this)
|
// Start credential proxy (containers route API calls through this)
|
||||||
const proxyServer = await startCredentialProxy(
|
const proxyServer = await startCredentialProxy(
|
||||||
@@ -488,9 +494,60 @@ async function main(): Promise<void> {
|
|||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
// Handle /remote-control and /remote-control-end commands
|
||||||
|
async function handleRemoteControl(
|
||||||
|
command: string,
|
||||||
|
chatJid: string,
|
||||||
|
msg: NewMessage,
|
||||||
|
): Promise<void> {
|
||||||
|
const group = registeredGroups[chatJid];
|
||||||
|
if (!group?.isMain) {
|
||||||
|
logger.warn(
|
||||||
|
{ chatJid, sender: msg.sender },
|
||||||
|
'Remote control rejected: not main group',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = findChannel(channels, chatJid);
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
if (command === '/remote-control') {
|
||||||
|
const result = await startRemoteControl(
|
||||||
|
msg.sender,
|
||||||
|
chatJid,
|
||||||
|
process.cwd(),
|
||||||
|
);
|
||||||
|
if (result.ok) {
|
||||||
|
await channel.sendMessage(chatJid, result.url);
|
||||||
|
} else {
|
||||||
|
await channel.sendMessage(
|
||||||
|
chatJid,
|
||||||
|
`Remote Control failed: ${result.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = stopRemoteControl();
|
||||||
|
if (result.ok) {
|
||||||
|
await channel.sendMessage(chatJid, 'Remote Control session ended.');
|
||||||
|
} else {
|
||||||
|
await channel.sendMessage(chatJid, result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Channel callbacks (shared by all channels)
|
// Channel callbacks (shared by all channels)
|
||||||
const channelOpts = {
|
const channelOpts = {
|
||||||
onMessage: (chatJid: string, msg: NewMessage) => {
|
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
|
// Sender allowlist drop mode: discard messages from denied senders before storing
|
||||||
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
||||||
const cfg = loadSenderAllowlist();
|
const cfg = loadSenderAllowlist();
|
||||||
|
|||||||
397
src/remote-control.test.ts
Normal file
397
src/remote-control.test.ts
Normal file
@@ -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<typeof vi.spyOn>;
|
||||||
|
let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let openSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let closeSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
224
src/remote-control.ts
Normal file
224
src/remote-control.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user