From 06918f35e076435b6bcac795d5513724ee793b5b Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Mon, 20 Apr 2026 12:11:51 +0300 Subject: [PATCH 01/47] =?UTF-8?q?feat(channels):=20add=20Signal=20channel?= =?UTF-8?q?=20adapter=20(v2)=20=E2=80=94=20skill=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the /add-signal-v2 skill: a native Signal channel adapter wrapping signal-sdk (signal-cli under the hood). No bot API — NanoClaw registers as a full Signal account on a dedicated number or as a linked device. Features: text, group & DM routing, voice transcription via whisper.cpp, attachments, emoji reactions, @mention detection, quote-reply detection. Troubleshooting note updated: GroupV2 group ID lives at envelope.dataMessage.groupV2.id — not groupInfo.groupId (GroupV1/legacy). --- .claude/skills/add-signal-v2/SKILL.md | 338 ++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 .claude/skills/add-signal-v2/SKILL.md diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md new file mode 100644 index 0000000..1cfd5da --- /dev/null +++ b/.claude/skills/add-signal-v2/SKILL.md @@ -0,0 +1,338 @@ +# Add Signal Channel (v2) + +Adds Signal messaging support to NanoClaw v2 using `signal-sdk` (a TypeScript +wrapper around `signal-cli`). Unlike Telegram/Discord, Signal has no bot API — +NanoClaw registers as a full Signal account on a dedicated phone number. + +**Two registration paths:** +- **New number (recommended):** Register a dedicated SIM or VoIP number as a + standalone Signal account. NanoClaw owns the number entirely. +- **Linked device:** Join an existing Signal account as a secondary device via + QR code. Simpler, but NanoClaw shares your personal number. + +Both paths are documented below. The new-number path is battle-tested. + +--- + +## Pre-flight + +Check if `src/channels/signal.ts` exists and the import is uncommented in +`src/channels/index.ts`. If both are in place, skip to Registration. + +## Install + +### 1. Check Java + +Java 17+ is required. Check: + +```bash +java -version +``` + +If missing: +- **RHEL/CentOS/Fedora:** `sudo dnf install -y java-17-openjdk` +- **Debian/Ubuntu:** `sudo apt-get install -y default-jre` +- **macOS:** `brew install --cask temurin@17` + +Java 17–25 all work. Java 25 (RHEL9 default) is confirmed working. + +### 2. Install signal-cli + +The `signal-sdk` npm package bundles signal-cli, but the bundled version is +often outdated. Install the latest standalone binary: + +```bash +SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])") +curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ + | tar -xz -C ~/.local +ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli +signal-cli --version +``` + +> **Note:** The Linux native tarball extracts a single binary directly to +> `~/.local/signal-cli` (not into a subdirectory). The symlink above handles this. + +### 3. Install signal-sdk + +```bash +npm install signal-sdk +``` + +### 4. Enable the adapter + +Uncomment the Signal import in `src/channels/index.ts`: + +```typescript +import './signal.js'; +``` + +### 5. Build + +Always build with Node 22 (nvm): + +```bash +source ~/.nvm/nvm.sh && nvm use 22 && npm run build +``` + +--- + +## Path A: Register a new Signal number + +Use this if you have a dedicated SIM or VoIP number for NanoClaw. + +> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP +> providers are blocked even for voice calls. If registration fails with an auth +> error, try a different provider or a physical SIM. + +### Step 1: Request SMS verification + +Signal requires a CAPTCHA on first registration: + +1. Open `https://signalcaptchas.org/registration/generate.html` in a browser +2. Solve the captcha +3. Right-click the **"Open Signal"** button → **Copy Link** +4. The link starts with `signalcaptcha://...` + +```bash +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER register \ + --captcha "PASTE_CAPTCHA_TOKEN_HERE" +``` + +The captcha token is everything after `signalcaptcha://` in the copied link. + +### Step 2: Voice call fallback (VoIP numbers without SMS) + +If your number cannot receive SMS, wait ~60 seconds after the SMS request then +request a voice call: + +```bash +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER register --voice \ + --captcha "SAME_CAPTCHA_TOKEN" +``` + +Signal will call your number and read a 6-digit code. + +> The captcha token from Step 1 is reusable for the voice retry — no need to +> solve a new one. + +### Step 3: Verify + +```bash +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER verify CODE +``` + +No output = success. + +### Step 4: Set profile name (optional) + +> ⚠ signal-sdk holds an exclusive lock on `data/signal/` while nanoclaw is +> running. Stop the service before running signal-cli commands, then restart. + +```bash +systemctl --user stop nanoclaw +SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ + signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" +systemctl --user start nanoclaw +``` + +To set an avatar too: +```bash +signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" --avatar /path/to/avatar.jpg +``` + +--- + +## Path B: Link as secondary device + +Use this to join an existing Signal account as a secondary device. + +```bash +mkdir -p data/signal +export SIGNAL_CLI_CONFIG_PATH=$(pwd)/data/signal +npx signal-sdk link -n "NanoClaw" -a +YOURNUMBER +``` + +This prints a QR code. On your phone: **Settings → Linked Devices → Link New Device**. +Scan the code within ~30 seconds. + +--- + +## Configure environment + +Add to `.env`: + +```bash +SIGNAL_PHONE_NUMBER=+YOURNUMBER +``` + +--- + +## Wire to an agent + +### DMs + +After the service starts, **send any message** to the Signal number from your +personal Signal app. The router auto-creates a `messaging_groups` row. Then: + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" +``` + +Pass the `id` and `platform_id` to `/init-first-agent` or wire manually. + +**Important:** DM `platform_id` is UUID-based, not phone-based: +- DM: `signal:3de71d7f-ffa3-437e-b4db-097534bccd46` (UUID of sender) +- Group: `signal:UwaIz6bc09Olg1pBr/XeBuQf6z3fyCZoNj/Y3Tpe3hI=` (base64 group ID) + +### Groups + +Add the Signal number to a group from your phone. Send any message — the router +auto-creates the group's `messaging_groups` row. Wire it: + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" +``` + +Then insert the wiring (replace IDs): + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR IGNORE INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) +VALUES + ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); +" +``` + +Use `session_mode='isolated'` for groups so each group has its own session. + +### Grant user access + +Users who message via Signal need to be granted membership. Without this, +messages are silently dropped with `not_member`. After first contact: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); +INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); +" +``` + +Find the UUID from `messaging_groups.platform_id` or the `users` table. + +--- + +## Voice Transcription (optional) + +Inbound voice messages are automatically transcribed using a local whisper.cpp +binary. The feature degrades gracefully — if whisper-cli or ffmpeg is missing, +voice messages are still delivered as attachments with no transcript. + +### Install whisper.cpp on Linux + +```bash +# Build from source (requires git, cmake, make, gcc) +git clone https://github.com/ggerganov/whisper.cpp +cd whisper.cpp +cmake -B build && cmake --build build --config Release -j$(nproc) +sudo cp build/bin/whisper-cli /usr/local/bin/whisper-cli +``` + +Or install ffmpeg + Python openai-whisper (slower but easier): +```bash +sudo dnf install -y ffmpeg # or: sudo apt install ffmpeg +pip3 install openai-whisper +# then set WHISPER_BIN=whisper and WHISPER_MODEL=base in .env +``` + +On macOS: `brew install whisper-cpp ffmpeg` + +### Download a model + +```bash +mkdir -p data/models +curl -L -o data/models/ggml-base.bin \ + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" +``` + +Larger models trade speed for accuracy: `ggml-small.bin` (466 MB), `ggml-medium.bin` (1.5 GB). + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | +| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | + +### Verify + +```bash +ffmpeg -version >/dev/null && echo "ffmpeg OK" || echo "ffmpeg missing" +whisper-cli --version 2>/dev/null && echo "whisper-cli OK" || echo "whisper-cli missing" +ls data/models/ggml-*.bin 2>/dev/null || echo "no model — download one" +``` + +--- + +## Channel Info + +- **type**: `signal` +- **terminology**: "Chats" (1:1) and "groups" (multi-member). No threads. +- **supports-threads**: no +- **inbound**: text, reactions (forwarded to agent with emoji + targetTimestamp), images, files, voice (transcribed via whisper-cli if installed) +- **outbound**: text, reactions (sendReaction), file attachments +- **platform-id-format**: + - DM: `signal:{UUID}` — sender's Signal UUID, **not** their phone number + - Group: `signal:{base64GroupId}` +- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` + as shown above. +- **config-lock**: signal-sdk holds an exclusive lock on `data/signal/` while + nanoclaw is running. Stop the service before running any `signal-cli` commands. + +--- + +## Troubleshooting + +**`Config file is in use by another instance`** — nanoclaw is running and +signal-sdk has the lock. Stop the service, run the command, restart. + +**Messages dropped with `not_member`** — the Signal user hasn't been granted +membership. See "Grant user access" above. This affects every new Signal user, +including the owner's Signal identity (which is separate from their Telegram +identity even if it's the same person). + +**Captcha required** — Signal requires captcha for new registrations. Go to +`https://signalcaptchas.org/registration/generate.html`, solve it, right-click +"Open Signal", copy the link, extract the token after `signalcaptcha://`. + +**`Invalid verification method: Before requesting voice verification…`** — +You must request SMS first, wait ~60 seconds, then request voice. Both can use +the same captcha token. + +**`The provided model identifier is invalid` (Bedrock)** — unrelated to Signal; +this is a LiteLLM model ID issue. + +**Group replies going to DM instead of group** — modern Signal groups use +GroupV2. The adapter must extract the group ID from +`envelope?.dataMessage?.groupV2?.id` (not just `groupInfo?.groupId`, which is +GroupV1/legacy). Check `src/channels/signal.ts` and confirm the groupId +extraction falls through to `groupV2.id`. + +**Java not found** — install Java 17+ (see Install step 1). + +**QR code expired (Path B)** — QR codes expire in ~30 seconds. Re-run the +link command to generate a new one. + +**signal-cli binary location** — The native Linux tarball extracts directly to +`~/.local/signal-cli` (a single file, not a directory). The system `aws` or +other tools named `signal-cli` won't be in PATH by default; check +`~/.local/bin/signal-cli`. From bc0b55946101b9b2050cd451ff0181872d3cda6b Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Mon, 20 Apr 2026 12:27:10 +0300 Subject: [PATCH 02/47] docs(add-signal-v2): document XDG attachment path behaviour and voice fix signal-sdk launches signal-cli without --config, so attachments land at ~/.local/share/signal-cli/attachments/ (XDG default) rather than data/signal/. Document this in the Channel Info section and add a troubleshooting entry explaining the symptom (voice messages silently skipped, no transcript), how to confirm (ps aux | grep signal-cli), and the automatic fallback the adapter uses. --- .claude/skills/add-signal-v2/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md index 1cfd5da..e6b8165 100644 --- a/.claude/skills/add-signal-v2/SKILL.md +++ b/.claude/skills/add-signal-v2/SKILL.md @@ -297,6 +297,11 @@ ls data/models/ggml-*.bin 2>/dev/null || echo "no model — download one" as shown above. - **config-lock**: signal-sdk holds an exclusive lock on `data/signal/` while nanoclaw is running. Stop the service before running any `signal-cli` commands. +- **attachment storage**: signal-sdk launches signal-cli **without** a `--config` + flag, so signal-cli stores attachments at the XDG default + (`~/.local/share/signal-cli/attachments/`), not under `data/signal/`. The + adapter checks both locations. Verify with: + `ps aux | grep signal-cli` — if there is no `-c` argument, XDG default is in use. --- @@ -327,6 +332,15 @@ GroupV2. The adapter must extract the group ID from GroupV1/legacy). Check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`. +**Voice messages / attachments silently skipped, no transcript** — signal-sdk +launches signal-cli without a `--config` flag, so attachments land at the XDG +default (`~/.local/share/signal-cli/attachments/`) rather than under +`data/signal/`. Confirm with `ps aux | grep signal-cli` — if there is no `-c` +argument in the process line, the XDG default is in use. The adapter falls back +to that location automatically. If you still see no "Signal attachment saved" +log lines, add a debug log around the `if (!storedPath) continue` guard in +`src/channels/signal.ts` to inspect `att.storedFilename` and `att.id`. + **Java not found** — install Java 17+ (see Install step 1). **QR code expired (Path B)** — QR codes expire in ~30 seconds. Re-run the From ad97829151449a2667c790fc513aef3c5ec534f2 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Mon, 20 Apr 2026 13:17:57 +0300 Subject: [PATCH 03/47] docs(add-signal-v2): replace inline voice section with reference to add-voice-transcription-free-whisper Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-signal-v2/SKILL.md | 49 +++------------------------ 1 file changed, 4 insertions(+), 45 deletions(-) diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md index e6b8165..06edd17 100644 --- a/.claude/skills/add-signal-v2/SKILL.md +++ b/.claude/skills/add-signal-v2/SKILL.md @@ -233,53 +233,12 @@ Find the UUID from `messaging_groups.platform_id` or the `users` table. ## Voice Transcription (optional) -Inbound voice messages are automatically transcribed using a local whisper.cpp -binary. The feature degrades gracefully — if whisper-cli or ffmpeg is missing, +Inbound voice messages are automatically transcribed using a local whisper +binary. The feature degrades gracefully — if whisper or ffmpeg is missing, voice messages are still delivered as attachments with no transcript. -### Install whisper.cpp on Linux - -```bash -# Build from source (requires git, cmake, make, gcc) -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp -cmake -B build && cmake --build build --config Release -j$(nproc) -sudo cp build/bin/whisper-cli /usr/local/bin/whisper-cli -``` - -Or install ffmpeg + Python openai-whisper (slower but easier): -```bash -sudo dnf install -y ffmpeg # or: sudo apt install ffmpeg -pip3 install openai-whisper -# then set WHISPER_BIN=whisper and WHISPER_MODEL=base in .env -``` - -On macOS: `brew install whisper-cpp ffmpeg` - -### Download a model - -```bash -mkdir -p data/models -curl -L -o data/models/ggml-base.bin \ - "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" -``` - -Larger models trade speed for accuracy: `ggml-small.bin` (466 MB), `ggml-medium.bin` (1.5 GB). - -### Environment variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | -| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | - -### Verify - -```bash -ffmpeg -version >/dev/null && echo "ffmpeg OK" || echo "ffmpeg missing" -whisper-cli --version 2>/dev/null && echo "whisper-cli OK" || echo "whisper-cli missing" -ls data/models/ggml-*.bin 2>/dev/null || echo "no model — download one" -``` +See `/add-voice-transcription-free-whisper` for full setup instructions +(ffmpeg, whisper backends, model download, environment variables, troubleshooting). --- From 61ca43d19313e92631674a208b14c321bd26584b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:12 +0000 Subject: [PATCH 04/47] fix(discord): resolve user ID from DM interactions for approval clicks Discord puts the clicking user at interaction.member.user for guild interactions but interaction.user for DM interactions. The Gateway handler only checked interaction.member, so DM button clicks resolved to an empty user ID and were silently rejected as unauthorized. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e0..6c9f802 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -105,7 +105,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let setupConfig: ChannelSetup; let gatewayAbort: AbortController | null = null; - async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { + async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -162,6 +162,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter content: serialized, timestamp: message.metadata.dateSent.toISOString(), isMention, + isGroup, }; } @@ -195,13 +196,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true)); }); // @mention in an unsubscribed thread — SDK-confirmed bot mention. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true)); }); // DMs — by definition addressed to the bot. Thread id flows through @@ -216,7 +217,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id, }); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false)); }); // Plain messages in unsubscribed threads. @@ -231,7 +232,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // flood gate. chat.onNewMessage(/./, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true)); }); // Handle button clicks (ask_user_question) @@ -501,7 +502,10 @@ async function handleForwardedEvent( // type 3 = MessageComponent (button/select) if (interaction.type === 3) { const customId = (interaction.data as Record)?.custom_id as string; - const user = (interaction.member as Record)?.user as Record | undefined; + // In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly. + const user = + ((interaction.member as Record)?.user as Record | undefined) ?? + (interaction.user as Record | undefined); const interactionId = interaction.id as string; const interactionToken = interaction.token as string; From d121cd1cd6b12a764ae79be479bc8d7950fddea4 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:23 +0000 Subject: [PATCH 05/47] fix(router): pass isGroup from adapter through to messaging group creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router hardcoded is_group=0 when auto-creating messaging groups, causing channel mentions to be misclassified as DMs. The Chat SDK bridge knows which handler fired (onDirectMessage vs onNewMention) so thread the signal through InboundMessage → InboundEvent → router. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 4 ++++ src/index.ts | 1 + src/router.ts | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index d8d8f9d..82247a1 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -56,6 +56,8 @@ export interface InboundEvent { * See InboundMessage.isMention for the full explanation. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; }; replyTo?: DeliveryAddress; } @@ -81,6 +83,8 @@ export interface InboundMessage { * router falls back to text-match against agent_group_name. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; } /** A file attachment to deliver alongside a message. */ diff --git a/src/index.ts b/src/index.ts index d3de4d9..ea9fba6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ async function main(): Promise { content: JSON.stringify(message.content), timestamp: message.timestamp, isMention: message.isMention, + isGroup: message.isGroup, }, }).catch((err) => { log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); diff --git a/src/router.ts b/src/router.ts index 538c270..3cf0192 100644 --- a/src/router.ts +++ b/src/router.ts @@ -170,7 +170,7 @@ export async function routeInbound(event: InboundEvent): Promise { channel_type: event.channelType, platform_id: event.platformId, name: null, - is_group: 0, + is_group: event.message.isGroup ? 1 : 0, unknown_sender_policy: 'request_approval', denied_at: null, created_at: new Date().toISOString(), From 15f30682d79a4fd2721990ee6b126178da41afc0 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:34 +0000 Subject: [PATCH 06/47] fix(approvals): show human-readable names in approval cards Channel and sender approval cards showed raw platform IDs (e.g. discord:1475578393738219540:...) instead of readable context. Extract sender name from the event content for channel approvals, and use the channel type name for sender approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/permissions/channel-approval.ts | 20 ++++++++++++++++---- src/modules/permissions/sender-approval.ts | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index caef815..e4b2142 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -101,13 +101,25 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) return; } - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; - const isGroup = originMg?.is_group === 1; + const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; + + // Extract sender name from the event content for a human-readable card. + let senderName: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + senderName = (parsed.senderName ?? parsed.sender) as string | undefined; + } catch { + // non-critical — fall through to generic wording + } const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; const question = isGroup - ? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?` - : `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`; + ? senderName + ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : senderName + ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` + : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; createPendingChannelApproval({ messaging_group_id: messagingGroupId, diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index e08123a..a20e14f 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -88,7 +88,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const approvalId = generateId(); const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity; - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; + const originName = originMg?.name ?? `a ${originChannelType} channel`; const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; From 40f5683c3660d8f63982bfcb9f29f1157607a12e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:45 +0000 Subject: [PATCH 07/47] fix(approvals): show correct post-click labels on channel/sender cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender only checked pending_questions and pending_approvals, missing the channel and sender approval tables. Approval button clicks showed the raw value ("approve") instead of the selectedLabel ("✅ Wired"). Extend the lookup to also check pending_channel_approvals and pending_sender_approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/sessions.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/db/sessions.ts b/src/db/sessions.ts index bdca8a6..e9461ca 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -1,5 +1,5 @@ import type { PendingApproval, PendingQuestion, Session } from '../types.js'; -import { getDb } from './connection.js'; +import { getDb, hasTable } from './connection.js'; // ── Sessions ── @@ -192,6 +192,35 @@ export function getAskQuestionRender( const a = getDb().prepare('SELECT title, options_json FROM pending_approvals WHERE approval_id = ?').get(id) as | { title: string; options_json: string } | undefined; - if (!a || !a.title) return undefined; - return { title: a.title, options: JSON.parse(a.options_json) }; + if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; + + // Channel-registration approval — options are fixed constants. + if (hasTable(getDb(), 'pending_channel_approvals')) { + const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); + if (c) { + return { + title: '📣 Channel registration', + options: [ + { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, + { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, + ], + }; + } + } + + // Unknown-sender approval — options are fixed constants. + if (hasTable(getDb(), 'pending_sender_approvals')) { + const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); + if (s) { + return { + title: '👤 New sender', + options: [ + { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, + { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, + ], + }; + } + } + + return undefined; } From 539af750d461a344b57ea3d80707fe781e95d873 Mon Sep 17 00:00:00 2001 From: cheats1314 <3030240693@qq.com> Date: Thu, 23 Apr 2026 22:22:18 +0800 Subject: [PATCH 08/47] fix(setup): detect registered groups from v2 central db Align the environment check with the v2 setup flow so existing wired agent groups are detected from data/v2.db instead of the retired v1 store. This prevents setup from reporting no registered groups on valid v2 installs and adds regression coverage for both v2 and pre-migration state. Co-Authored-By: Claude Opus 4.7 --- setup/environment.test.ts | 97 +++++++++++++++++++++------------------ setup/environment.ts | 47 ++++++++++--------- 2 files changed, 78 insertions(+), 66 deletions(-) diff --git a/setup/environment.test.ts b/setup/environment.test.ts index deda62f..7765693 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import Database from 'better-sqlite3'; @@ -17,58 +19,63 @@ describe('environment detection', () => { }); }); -describe('registered groups DB query', () => { - let db: Database.Database; +describe('detectRegisteredGroups', () => { + let tempDir: string; beforeEach(() => { - db = new Database(':memory:'); - db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, - name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - container_config TEXT, - requires_trigger INTEGER DEFAULT 1 - )`); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-')); + fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true }); }); - it('returns 0 for empty table', () => { - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(0); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('returns correct count after inserts', () => { - db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '123@g.us', - 'Group 1', - 'group-1', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + it('returns false when no registration state exists', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + expect(detectRegisteredGroups(tempDir)).toBe(false); + }); - db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '456@g.us', - 'Group 2', - 'group-2', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + it('detects pre-migration registered_groups.json', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]'); + expect(detectRegisteredGroups(tempDir)).toBe(true); + }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(2); + it('returns false for an empty v2 central DB', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + const db = new Database(path.join(tempDir, 'data', 'v2.db')); + db.exec(` + CREATE TABLE agent_groups (id TEXT PRIMARY KEY); + CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL, + agent_group_id TEXT NOT NULL + ); + `); + db.close(); + + expect(detectRegisteredGroups(tempDir)).toBe(false); + }); + + it('detects wired agent groups in the v2 central DB', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + const db = new Database(path.join(tempDir, 'data', 'v2.db')); + db.exec(` + CREATE TABLE agent_groups (id TEXT PRIMARY KEY); + CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL, + agent_group_id TEXT NOT NULL + ); + `); + db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1'); + db.prepare( + 'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)', + ).run('mga-1', 'mg-1', 'ag-1'); + db.close(); + + expect(detectRegisteredGroups(tempDir)).toBe(true); }); }); diff --git a/setup/environment.ts b/setup/environment.ts index 4a83665..6986396 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -7,11 +7,35 @@ import path from 'path'; import Database from 'better-sqlite3'; -import { STORE_DIR } from '../src/config.js'; import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +export function detectRegisteredGroups(projectRoot: string): boolean { + if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { + return true; + } + + const dbPath = path.join(projectRoot, 'data', 'v2.db'); + if (!fs.existsSync(dbPath)) return false; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const row = db + .prepare( + `SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag + JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`, + ) + .get() as { count: number }; + return row.count > 0; + } catch { + return false; + } finally { + db?.close(); + } +} + export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); @@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise { const authDir = path.join(projectRoot, 'store', 'auth'); const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; - let hasRegisteredGroups = false; - // Check JSON file first (pre-migration) - if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { - hasRegisteredGroups = true; - } else { - // Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed) - const dbPath = path.join(STORE_DIR, 'messages.db'); - if (fs.existsSync(dbPath)) { - try { - const db = new Database(dbPath, { readonly: true }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - if (row.count > 0) hasRegisteredGroups = true; - db.close(); - } catch { - // Table might not exist yet - } - } - } + const hasRegisteredGroups = detectRegisteredGroups(projectRoot); // Check for existing OpenClaw installation const homedir = (await import('os')).homedir(); From bee80b007200833eef4f87780a770092e95d7330 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:02 +0000 Subject: [PATCH 09/47] fix(container): clear orphan heartbeat before spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a container exits, its .heartbeat file is left behind with the mtime of its last SDK activity. When the same session spawns a new container, the host sweep's ceiling check reads that stale mtime and kills the freshly-spawned container within seconds — before the new instance has had time to touch the file itself. The sweep already has a carve-out for "no heartbeat file" (treated as a fresh spawn, given grace), so simply removing the orphan at spawn time restores the intended semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 71e2064..8815b11 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -36,7 +36,7 @@ import { type ProviderContainerContribution, type VolumeMount, } from './providers/provider-container-registry.js'; -import { markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; +import { heartbeatPath, markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); @@ -131,6 +131,12 @@ async function spawnContainer(session: Session): Promise { log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); + // Clear any orphan heartbeat from a previous container instance — the + // sweep's ceiling check treats a missing file as "fresh spawn, give grace" + // (host-sweep.ts line 87). Without this, the stale mtime can trigger an + // immediate kill before the new container touches the file itself. + fs.rmSync(heartbeatPath(agentGroup.id, session.id), { force: true }); + const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }); activeContainers.set(session.id, { process: container, containerName }); From 209061f54f6a8804ad6fd50f4ddf7d5a140b408e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:16 +0000 Subject: [PATCH 10/47] fix(sweep): wake before reset + idempotent retry for orphan claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a container exits with an unresolved processing_ack claim, the sweep's crashed-container cleanup would reset the matching inbound message with tries++ and a future process_after. dueCount then dropped to 0, so the wake step never fired — and the next sweep tick found the same orphan claim, bumped tries again, and pushed process_after further out. The message reached MAX_TRIES and was marked failed without any container ever being spawned. Two changes: 1. Reorder sweep so the wake step runs before crashed-container cleanup. A fresh container clears orphan 'processing' rows on its own startup (container/agent-runner/src/db/connection.ts), so once we get it running the claim resolves itself. 2. Make resetStuckProcessingRows idempotent: if a message already has process_after set to a future time, skip the retry bump. The wake path will pick it up when the backoff elapses. Requires returning process_after from getMessageForRetry. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/session-db.ts | 8 ++++---- src/host-sweep.ts | 34 ++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/db/session-db.ts b/src/db/session-db.ts index aea255d..48e9297 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -139,10 +139,10 @@ export function getMessageForRetry( db: Database.Database, messageId: string, status: string, -): { id: string; tries: number } | undefined { - return db.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?').get(messageId, status) as - | { id: string; tries: number } - | undefined; +): { id: string; tries: number; processAfter: string | null } | undefined { + return db + .prepare('SELECT id, tries, process_after as processAfter FROM messages_in WHERE id = ? AND status = ?') + .get(messageId, status) as { id: string; tries: number; processAfter: string | null } | undefined; } export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void { diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 1a2901c..4dc2fb7 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -159,23 +159,31 @@ async function sweepSession(session: Session): Promise { syncProcessingAcks(inDb, outDb); } - const alive = isContainerRunning(session.id); - - // 2. Crashed-container cleanup: processing rows left behind get retried. - if (!alive && outDb) { - resetStuckProcessingRows(inDb, outDb, session, 'container not running'); + // 2. Wake a container if work is due and nothing is running. Ordered + // before the crashed-container cleanup so a fresh container gets a chance + // to clean its own orphan processing_ack rows on startup (see + // container/agent-runner/src/db/connection.ts). Otherwise the reset path + // would keep bumping process_after into the future, dueCount would stay 0, + // and the wake would never fire. + const dueCount = countDueMessages(inDb); + if (dueCount > 0 && !isContainerRunning(session.id)) { + log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); + await wakeContainer(session); } + const alive = isContainerRunning(session.id); + // 3. Running-container SLA: absolute ceiling + per-claim stuck rules. if (alive && outDb) { enforceRunningContainerSla(inDb, outDb, session, agentGroup.id); } - // 4. Wake a container if new work is due and nothing is running. - const dueCount = countDueMessages(inDb); - if (dueCount > 0 && !isContainerRunning(session.id)) { - log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - await wakeContainer(session); + // 4. Crashed-container cleanup: processing rows left behind get retried. + // Only fires when wake in step 2 didn't pick up the work (no due messages, + // or wake failed). resetStuckProcessingRows itself is idempotent — it + // skips messages already scheduled for a future retry. + if (!alive && outDb) { + resetStuckProcessingRows(inDb, outDb, session, 'container not running'); } // 5. Recurrence fanout for completed recurring tasks. @@ -246,10 +254,16 @@ function resetStuckProcessingRows( reason: string, ): void { const claims = getProcessingClaims(outDb); + const now = Date.now(); for (const { message_id } of claims) { const msg = getMessageForRetry(inDb, message_id, 'pending'); if (!msg) continue; + // Already rescheduled for a future retry — don't bump tries again. The + // wake path (sweep step 2) will fire when process_after elapses and a + // fresh container will clean the orphan claim on startup. + if (msg.processAfter && Date.parse(msg.processAfter) > now) continue; + if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id); log.warn('Message marked as failed after max retries', { From 237876c2c6f7012fcbd6d8505b8b8e5dea33b2d3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:56 +0000 Subject: [PATCH 11/47] chore(format): wrap session-manager import in container-runner Pre-commit prettier reformatted this in the working tree but didn't re-stage. Keeping it in a separate commit to avoid amending a prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 8815b11..fca88c4 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -36,7 +36,13 @@ import { type ProviderContainerContribution, type VolumeMount, } from './providers/provider-container-registry.js'; -import { heartbeatPath, markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; +import { + heartbeatPath, + markContainerRunning, + markContainerStopped, + sessionDir, + writeSessionRouting, +} from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); From ff277c0d492face410ae0b789dbe4259723fb207 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 16:56:21 +0000 Subject: [PATCH 12/47] fix(chat-sdk-bridge): encode option index in callback_data for Telegram 64-byte cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask_question cards failed to deliver on Telegram whenever any option had a non-trivial value (e.g. an ISO datetime, a URL, or a long token). Telegram limits inline-keyboard callback_data to 64 bytes, and the previous encoding embedded both the questionId and the full option value in each button's actionId plus a second copy as value, producing payloads well over the cap. The adapter threw ValidationError, delivery was marked permanently failed, and the agent sat waiting on an answer that never reached the user. Fix: - Button id is now `ncq::` and button value is the stringified index. Callback payloads shrink from ~100 bytes to ~40 and fit Telegram's cap for any option list with <100 items. - Both callback-decode sites (Chat SDK `onAction` for Telegram/Slack/ etc., and the Discord Gateway interaction handler) resolve the index back to the real option value via `getAskQuestionRender(questionId)` before dispatching to the host's onAction — so response handlers (pending_questions, pending_approvals) are unchanged and still receive the canonical value. - `resolveSelectedOption` helper has a backward-compat fallback: non-numeric tails are treated as literal values so any card delivered under the old encoding still resolves if the user clicks it after deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 42 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e0..7123c0f 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig { * chunk boundary will render as two independent blocks on the receiving * platform, which is the same behavior as manually re-opening a fence. */ +/** + * Decode the actual option value from a button callback. Buttons are encoded + * with an integer index (to keep under Telegram's 64-byte callback_data cap), + * and the real value is looked up via `getAskQuestionRender(questionId)`. + * Falls back to treating the tail as a literal value so old in-flight cards + * (encoded before this shortening landed) still resolve. + */ +function resolveSelectedOption( + render: { options: NormalizedOption[] } | undefined, + eventValue: string | undefined, + tail: string | undefined, +): string { + const candidate = eventValue ?? tail ?? ''; + if (render && /^\d+$/.test(candidate)) { + const idx = Number(candidate); + if (render.options[idx]) return render.options[idx].value; + } + return candidate; +} + export function splitForLimit(text: string, limit: number): string[] { if (text.length <= limit) return [text]; const chunks: string[] = []; @@ -240,11 +260,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const parts = event.actionId.split(':'); if (parts.length < 3) return; const questionId = parts[1]; - const selectedOption = event.value || ''; + const tail = parts.slice(2).join(':'); const userId = event.user?.userId || ''; // Resolve render metadata BEFORE dispatching onAction (which deletes the row). const render = getAskQuestionRender(questionId); + // New format: button id/value is an integer index into options (kept + // short to fit Telegram's 64-byte callback_data cap). Old format: + // the full value is embedded in actionId/value directly. + const selectedOption = resolveSelectedOption(render, event.value, tail); const title = render?.title ?? '❓ Question'; const matched = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; @@ -348,8 +372,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter children: [ CardText(question), Actions( - options.map((opt) => - Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }), + // Encode button id/value with the option index rather than the + // full value. Telegram caps callback_data at 64 bytes, and + // long values (e.g. ISO datetimes, URLs) push the JSON payload + // well past that. The onAction handlers resolve the index back + // to the real value via getAskQuestionRender(questionId). + options.map((opt, idx) => + Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }), ), ), ], @@ -507,12 +536,12 @@ async function handleForwardedEvent( // Parse the selected option from custom_id let questionId: string | undefined; - let selectedOption: string | undefined; + let tail: string | undefined; if (customId?.startsWith('ncq:')) { const colonIdx = customId.indexOf(':', 4); // after "ncq:" if (colonIdx !== -1) { questionId = customId.slice(4, colonIdx); - selectedOption = customId.slice(colonIdx + 1); + tail = customId.slice(colonIdx + 1); } } @@ -521,6 +550,9 @@ async function handleForwardedEvent( ((interaction.message as Record)?.embeds as Array>) || []; const originalDescription = (originalEmbeds[0]?.description as string) || ''; const render = questionId ? getAskQuestionRender(questionId) : undefined; + // Discord custom_id mirrors the new index-based encoding (see Button + // construction). Decode back to the real option value for downstream. + const selectedOption = resolveSelectedOption(render, tail, tail); const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); const matchedOpt = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId; From 97868af5a7529da909eb4e2bc43760f71722957a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 17:05:41 +0000 Subject: [PATCH 13/47] fix(delivery): make pending_questions/approvals insert idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createPendingQuestion and createPendingApproval both run before the adapter delivery call. When delivery fails and the retry loop reinvokes deliverMessage with the same questionId/approvalId, the second attempt hit UNIQUE constraint on the pending_questions.question_id (or pending_approvals.approval_id) and threw — so the retry never reached the send step, and every subsequent retry failed the same way until max-attempts marked the message permanently failed. Switch both inserts to INSERT OR IGNORE. Return bool indicating whether a new row was actually inserted so delivery.ts can avoid logging "Pending question created" twice for the same card. Symptom that surfaced this: a send-layer ValidationError on one attempt followed by SqliteError on every subsequent attempt, with the user seeing neither the card nor a follow-up. Seen in conjunction with the Telegram 64-byte callback_data limit (fixed separately in #1942/chat-sdk-bridge), but the idempotency gap applies to any transient delivery failure — rate limits, network blips, adapter 5xx — and is worth fixing on its own. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/sessions.ts | 25 +++++++++++++++++++------ src/delivery.ts | 6 ++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/db/sessions.ts b/src/db/sessions.ts index bdca8a6..af765f9 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -97,10 +97,16 @@ export function deleteSession(id: string): void { // ── Pending Questions ── -export function createPendingQuestion(pq: PendingQuestion): void { - getDb() +/** + * Insert a pending question row. Idempotent: when delivery fails and retries, + * the second attempt calls this with the same question_id — without `OR + * IGNORE` that would throw UNIQUE and prevent the retry from reaching the + * actual send step. Returns true if a new row was inserted. + */ +export function createPendingQuestion(pq: PendingQuestion): boolean { + const result = getDb() .prepare( - `INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at) + `INSERT OR IGNORE INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at) VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @title, @options_json, @created_at)`, ) .run({ @@ -114,6 +120,7 @@ export function createPendingQuestion(pq: PendingQuestion): void { options_json: JSON.stringify(pq.options), created_at: pq.created_at, }); + return result.changes > 0; } export function getPendingQuestion(questionId: string): PendingQuestion | undefined { @@ -131,16 +138,21 @@ export function deletePendingQuestion(questionId: string): void { // ── Pending Approvals ── +/** + * Insert a pending approval row. Idempotent for the same reason as + * createPendingQuestion: delivery retries with the same approval_id must not + * fail on UNIQUE before the send step gets a chance to succeed. + */ export function createPendingApproval( pa: Partial & Pick< PendingApproval, 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json' >, -): void { - getDb() +): boolean { + const result = getDb() .prepare( - `INSERT INTO pending_approvals + `INSERT OR IGNORE INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, title, options_json) @@ -159,6 +171,7 @@ export function createPendingApproval( status: 'pending', ...pa, }); + return result.changes > 0; } export function getPendingApproval(approvalId: string): PendingApproval | undefined { diff --git a/src/delivery.ts b/src/delivery.ts index 2e193d4..036153a 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -321,7 +321,7 @@ async function deliverMessage( questionId: content.questionId, }); } else { - createPendingQuestion({ + const inserted = createPendingQuestion({ question_id: content.questionId, session_id: session.id, message_out_id: msg.id, @@ -332,7 +332,9 @@ async function deliverMessage( options: normalizeOptions(rawOptions as never), created_at: new Date().toISOString(), }); - log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + if (inserted) { + log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + } } } From 0ec56b732dafad275015261ac3ca574f61b3b052 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 21:35:00 +0300 Subject: [PATCH 14/47] docs(add-codex): add skill for installing Codex provider from providers branch Mirrors the /add-opencode and /add-ollama-provider pattern. Copies the add-codex SKILL.md from the providers branch onto trunk so the skill is discoverable without a manual branch copy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 164 ++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .claude/skills/add-codex/SKILL.md diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md new file mode 100644 index 0000000..a5484d5 --- /dev/null +++ b/.claude/skills/add-codex/SKILL.md @@ -0,0 +1,164 @@ +--- +name: add-codex +description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner). +--- + +# Codex agent provider + +NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`). + +Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image. + +The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in. + +## Install + +### Pre-flight + +If all of the following are already present, skip to **Configuration**: + +- `src/providers/codex.ts` +- `container/agent-runner/src/providers/codex.ts` +- `container/agent-runner/src/providers/codex-app-server.ts` +- `container/agent-runner/src/providers/codex.factory.test.ts` +- `import './codex.js';` line in `src/providers/index.ts` +- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts` +- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile` + +Missing pieces — continue below. All steps are idempotent; re-running is safe. + +### 1. Fetch the providers branch + +```bash +git fetch origin providers +``` + +### 2. Copy the Codex source files + +Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed): + +```bash +git show origin/providers:src/providers/codex.ts > src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts +git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts +``` + +### 3. Append the self-registration imports + +Each barrel gets one line — alphabetical placement keeps diffs small. + +`src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +`container/agent-runner/src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +### 4. Add the Codex CLI to the container Dockerfile + +Two edits to `container/Dockerfile`, both idempotent (skip if already present): + +**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`: + +```dockerfile +ARG CODEX_VERSION=0.121.0 +``` + +**(b)** In the `pnpm install -g` block (around line 80), append `"@openai/codex@${CODEX_VERSION}"` to the list: + +```dockerfile + pnpm install -g \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "@openai/codex@${CODEX_VERSION}" \ + "agent-browser@${AGENT_BROWSER_VERSION}" \ + "vercel@${VERCEL_VERSION}" +``` + +Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`. + +### 5. Build + +```bash +pnpm run build # host +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck +./container/build.sh # agent image +``` + +## Configuration + +Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup. + +### Option A — ChatGPT subscription (recommended for individuals) + +On the host (not inside the container), run Codex's OAuth login: + +```bash +codex login +``` + +This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched. + +No `.env` variables required for this mode. + +### Option B — API key (recommended for CI or API billing) + +```env +OPENAI_API_KEY=sk-... +CODEX_MODEL=gpt-5.4-mini +``` + +The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription. + +### Option C — BYO OpenAI-compatible endpoint (experimental) + +Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc. + +```env +OPENAI_API_KEY=... +OPENAI_BASE_URL=https://api.groq.com/openai/v1 +CODEX_MODEL=llama-3.3-70b-versatile +``` + +Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration. + +**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing. + +### Per group / per session + +Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `codex` for groups or sessions that should use Codex. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). + +`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group. + +Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers. + +## Operational notes + +- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions. +- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config. +- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error. +- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode). +- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped. +- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has. + +## Verify + +```bash +grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK" +grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK" +grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK" +cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd - +``` + +After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like: + +- `init` event with a stable thread ID as continuation +- One or more `activity` / `progress` events during the turn +- `result` event with the model's reply + +If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. From e5a7a330843f1e5373e0849c2a78a0ff13672759 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 21:38:16 +0300 Subject: [PATCH 15/47] =?UTF-8?q?docs(add-codex):=20fix=20Dockerfile=20ins?= =?UTF-8?q?tall=20step=20=E2=80=94=20separate=20RUN=20block,=20not=20combi?= =?UTF-8?q?ned=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior instruction told users to append "@openai/codex@${CODEX_VERSION}" to a single combined `pnpm install -g` block. That block no longer exists on main — the Dockerfile splits each global CLI (vercel, agent-browser, claude-code) into its own RUN layer for cache granularity. Update the skill to add a standalone RUN block for Codex that matches the existing pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index a5484d5..17910b7 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -70,14 +70,11 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present): ARG CODEX_VERSION=0.121.0 ``` -**(b)** In the `pnpm install -g` block (around line 80), append `"@openai/codex@${CODEX_VERSION}"` to the list: +**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call: ```dockerfile - pnpm install -g \ - "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ - "@openai/codex@${CODEX_VERSION}" \ - "agent-browser@${AGENT_BROWSER_VERSION}" \ - "vercel@${VERCEL_VERSION}" +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@openai/codex@${CODEX_VERSION}" ``` Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`. From c6d2f45f93d3189d0206aecb614e44e64da5afb5 Mon Sep 17 00:00:00 2001 From: Doug Daniels Date: Thu, 23 Apr 2026 14:37:10 -0400 Subject: [PATCH 16/47] feat: add Signal channel adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Signal adapter using signal-cli TCP JSON-RPC daemon. No Chat SDK bridge or npm dependencies — uses only Node.js builtins. Features: - DM and group message support - Voice message detection (placeholder text; transcription via /add-voice-transcription skill) - Typing indicators (DMs only) - Mention detection via text match - Managed daemon lifecycle (auto-start/stop signal-cli) - Echo suppression for outbound messages Also fixes init-first-agent.ts to skip channel-prefixing for phone numbers (+...) and Signal group IDs (group:...), which are native platform IDs that adapters send without a channel prefix. Install via /add-signal skill. Uses /init-first-agent for channel wiring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-signal/SKILL.md | 121 +++++ scripts/init-first-agent.ts | 24 +- src/channels/index.ts | 1 + src/channels/signal.test.ts | 627 ++++++++++++++++++++++++ src/channels/signal.ts | 744 +++++++++++++++++++++++++++++ 5 files changed, 1513 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/add-signal/SKILL.md create mode 100644 src/channels/signal.test.ts create mode 100644 src/channels/signal.ts diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md new file mode 100644 index 0000000..92c7800 --- /dev/null +++ b/.claude/skills/add-signal/SKILL.md @@ -0,0 +1,121 @@ +--- +name: add-signal +description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge. +--- + +# Add Signal Channel + +Adds Signal messaging support via a native adapter that communicates with a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon using JSON-RPC. + +## Prerequisites + +- **signal-cli** installed and a Signal account linked + - macOS: `brew install signal-cli` + - Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) + - Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) + +## Install + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/signal.ts` exists +- `src/channels/signal.test.ts` exists +- `src/channels/index.ts` contains `import './signal.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the skill branch + +```bash +git fetch origin skill/signal +``` + +### 2. Copy the adapter and tests + +```bash +git show origin/skill/signal:src/channels/signal.ts > src/channels/signal.ts +git show origin/skill/signal:src/channels/signal.test.ts > src/channels/signal.test.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './signal.js'; +``` + +### 4. Build + +```bash +pnpm run build +``` + +No npm packages to install — the adapter uses only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). + +## Credentials + +Add to `.env`: + +```env +SIGNAL_ACCOUNT=+1YOURNUMBER +``` + +### Optional settings + +```env +# TCP daemon host and port (default: 127.0.0.1:7583) +SIGNAL_HTTP_HOST=127.0.0.1 +SIGNAL_HTTP_PORT=7583 + +# Whether NanoClaw manages the daemon lifecycle (default: true) +# Set to false if you run signal-cli daemon externally +SIGNAL_MANAGE_DAEMON=true + +# signal-cli data directory (default: ~/.local/share/signal-cli) +SIGNAL_DATA_DIR=~/.local/share/signal-cli +``` + +### Sync to container + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +### Restart + +```bash +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw + +# Linux +systemctl --user restart nanoclaw +``` + +## Next Steps + +Run `/init-first-agent` to create an agent and wire it to your Signal DM. Signal is direct-addressable — your phone number is the platform ID: + +- **User ID**: your Signal phone number (e.g. `+15551234567`) +- **Platform ID**: same as user ID for DMs (e.g. `+15551234567`) +- **For group chats**: use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups` + +`/init-first-agent` handles user creation, owner role, agent group, messaging group wiring, and the welcome DM. It's idempotent — safe to run again for additional agents. + +## Channel Info + +| Field | Value | +|-------|-------| +| **Type** | `signal` | +| **Thread support** | No (Signal has no thread model) | +| **Platform ID format** | DM: `+15555550123` / Group: `group:` | +| **Mention detection** | Text-match against agent group name (no SDK-level mentions) | +| **Typing indicators** | DMs only | +| **Typical use** | Personal assistant via Signal DMs or small group chats | +| **Isolation** | Recommended: one agent per Signal account | + +### Voice Messages + +Voice attachments are detected but not transcribed by default. The agent receives `[Voice Message]` as the message text. Run `/add-voice-transcription` to enable automatic local transcription via parakeet-mlx. diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index dcb99b5..fc61b9c 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -137,13 +137,29 @@ function namespacedUserId(channel: string, raw: string): string { return raw.includes(':') ? raw : `${channel}:${raw}`; } +/** + * Determine whether a platform ID needs a channel-type prefix. + * + * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their + * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". + * The router stores `channel_type` and `platform_id` in separate columns, but + * Chat SDK adapters send the prefixed form as the platform_id, so this script + * must match that format. + * + * Native adapters (Signal, WhatsApp) use their own ID formats and send them + * as-is — no channel prefix. Signal sends raw phone numbers (+15551234567) + * for DMs and "group:" for group chats. WhatsApp sends JIDs containing + * '@' (@s.whatsapp.net, @g.us). Prefixing these would cause + * a mismatch between what the adapter sends and what the DB stores, breaking + * message routing. + */ function namespacedPlatformId(channel: string, raw: string): string { if (raw.startsWith(`${channel}:`)) return raw; - // Adapters using native JID format (WhatsApp: @s.whatsapp.net, - // @g.us) store platform_id without a channel prefix. The '@' is - // the discriminator — telegram/discord platform_ids don't contain it - // except after a channel prefix, which is already handled above. + // Native WhatsApp JIDs contain '@' — no prefix needed. if (raw.includes('@')) return raw; + // Native Signal IDs: phone numbers (+...) and group IDs (group:...). + if (raw.startsWith('+') || raw.startsWith('group:')) return raw; + // Chat SDK adapters — add the channel prefix. return `${channel}:${raw}`; } diff --git a/src/channels/index.ts b/src/channels/index.ts index e9b3bd1..b75016f 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,3 +7,4 @@ // self-registration import below. import './cli.js'; +import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts new file mode 100644 index 0000000..c7ffff1 --- /dev/null +++ b/src/channels/signal.test.ts @@ -0,0 +1,627 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() })); +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); +vi.mock('../log.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), + execFileSync: vi.fn(), +})); + +// --- TCP socket mock --- + +import { EventEmitter } from 'events'; + +const tcpRef = vi.hoisted(() => ({ + rpcResponses: new Map(), + fakeSocket: null as any, +})); + +function createFakeSocket(): EventEmitter & { + write: ReturnType; + destroy: ReturnType; + destroyed: boolean; +} { + const sock = new EventEmitter() as any; + sock.destroyed = false; + sock.destroy = vi.fn(() => { + sock.destroyed = true; + sock.emit('close'); + }); + sock.write = vi.fn((data: string) => { + try { + const req = JSON.parse(data.trim()); + const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true }; + const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'; + setImmediate(() => sock.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + return sock; +} + +vi.mock('node:net', () => ({ + createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => { + const sock = createFakeSocket(); + tcpRef.fakeSocket = sock; + if (cb) setImmediate(cb); + return sock; + }), +})); + +import type { ChannelSetup } from './adapter.js'; +import { createSignalAdapter } from './signal.js'; + +// --- Test helpers --- + +function createMockSetup() { + return { + onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType, + onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType, + onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType, + onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType, + }; +} + +function createAdapter() { + return createSignalAdapter({ + cliPath: 'signal-cli', + account: '+15551234567', + tcpHost: '127.0.0.1', + tcpPort: 7583, + manageDaemon: false, + signalDataDir: '/tmp/signal-cli-test-data', + }); +} + +function getRpcCalls(): Array<{ + method: string; + params: Record; + id: string; +}> { + if (!tcpRef.fakeSocket) return []; + return tcpRef.fakeSocket.write.mock.calls + .map((c: any[]) => { + try { + return JSON.parse(c[0].trim()); + } catch { + return null; + } + }) + .filter(Boolean); +} + +function getRpcCallsForMethod(method: string) { + return getRpcCalls().filter((c) => c.method === method); +} + +function pushEvent(envelope: Record) { + if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected'); + const notification = + JSON.stringify({ + jsonrpc: '2.0', + method: 'receive', + params: { envelope }, + }) + '\n'; + tcpRef.fakeSocket.emit('data', Buffer.from(notification)); +} + +// --- Tests --- + +describe('SignalAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + tcpRef.rpcResponses.clear(); + tcpRef.fakeSocket = null; + tcpRef.rpcResponses.set('send', { timestamp: 1234567890 }); + tcpRef.rpcResponses.set('sendTyping', {}); + }); + + afterEach(() => { + try { + tcpRef.fakeSocket?.destroy(); + } catch { + // already closed + } + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('connects when daemon is reachable', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + expect(adapter.isConnected()).toBe(true); + expect(tcpRef.fakeSocket).not.toBeNull(); + + await adapter.teardown(); + }); + + it('isConnected() returns false before setup', () => { + const adapter = createAdapter(); + expect(adapter.isConnected()).toBe(false); + }); + + it('disconnects cleanly', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + await adapter.teardown(); + expect(adapter.isConnected()).toBe(false); + }); + + it('throws NetworkError if daemon is unreachable', async () => { + const { createConnection } = await import('node:net'); + vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => { + const sock = createFakeSocket(); + setImmediate(() => sock.emit('error', new Error('Connection refused'))); + return sock as any; + }); + + const adapter = createAdapter(); + await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/); + }); + }); + + // --- Inbound message handling --- + + describe('inbound message handling', () => { + it('delivers DM via onInbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'Hello from Signal', + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + id: '1700000000000', + kind: 'chat', + content: expect.objectContaining({ + text: 'Hello from Signal', + sender: '+15555550123', + senderName: 'Alice', + }), + }), + ); + + await adapter.teardown(); + }); + + it('delivers group message with group platformId', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { + timestamp: 1700000000000, + message: 'Group hello', + groupInfo: { groupId: 'abc123', groupName: 'Family' }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true); + expect(cfg.onInbound).toHaveBeenCalledWith( + 'group:abc123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Group hello', + sender: '+15555550999', + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips sync messages (own outbound)', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'My own message', + destination: '+15555550123', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('processes Note to Self sync messages as inbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'Hello Bee', + destinationNumber: '+15551234567', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15551234567', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Hello Bee', + senderName: 'Me', + isFromMe: true, + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips empty messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: ' ' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips echoed outbound messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips messages with attachments but no text', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Quote context --- + + describe('quote context', () => { + it('populates reply_to fields from quoted messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'I disagree', + quote: { + id: 1699999999000, + authorNumber: '+15555550888', + text: 'Pineapple belongs on pizza', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'I disagree', + replyToSenderName: '+15555550888', + replyToMessageContent: 'Pineapple belongs on pizza', + replyToMessageId: '1699999999000', + }), + }), + ); + + await adapter.teardown(); + }); + }); + + // --- deliver --- + + describe('deliver', () => { + it('sends DM via TCP RPC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + recipient: ['+15555550123'], + message: 'Hello', + account: '+15551234567', + }), + ); + + await adapter.teardown(); + }); + + it('sends group message via groupId', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('group:abc123', null, { + kind: 'text', + content: { text: 'Group msg' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + groupId: 'abc123', + message: 'Group msg', + }), + ); + + await adapter.teardown(); + }); + + it('chunks long messages', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + const longText = 'x'.repeat(5000); + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: longText }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(1); + + await adapter.teardown(); + }); + + it('extracts text from string content', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: 'Plain string content', + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Plain string content'); + + await adapter.teardown(); + }); + }); + + // --- Text styles --- + + describe('text styles', () => { + it('sends bold text with textStyle parameter', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:BOLD']); + + await adapter.teardown(); + }); + + it('sends inline code with MONOSPACE style', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Run `npm test` now' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Run npm test now'); + expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']); + + await adapter.teardown(); + }); + + it('sends plain text without textStyle', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'No formatting here' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('No formatting here'); + expect(last.params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + + it('falls back to original markup when textStyle is rejected', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + let sendCount = 0; + tcpRef.fakeSocket.write.mockImplementation((data: string) => { + try { + const req = JSON.parse(data.trim()); + if (req.method === 'send') { + sendCount++; + if (sendCount === 1) { + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + error: { message: 'Unknown parameter: textStyle' }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + return; + } + } + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + result: { ok: true }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBe(2); + expect(sendCalls[1].params.message).toBe('Hello **world**'); + expect(sendCalls[1].params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing indicator for DMs', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('+15555550123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1); + + await adapter.teardown(); + }); + + it('skips typing for groups', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('group:abc123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0); + + await adapter.teardown(); + }); + }); + + // --- Adapter properties --- + + describe('adapter properties', () => { + it('has channelType "signal"', () => { + const adapter = createAdapter(); + expect(adapter.channelType).toBe('signal'); + }); + + it('does not support threads', () => { + const adapter = createAdapter(); + expect(adapter.supportsThreads).toBe(false); + }); + }); +}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts new file mode 100644 index 0000000..300b7a6 --- /dev/null +++ b/src/channels/signal.ts @@ -0,0 +1,744 @@ +/** + * Signal channel adapter for NanoClaw v2. + * + * Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging. + * Requires signal-cli (https://github.com/AsamK/signal-cli) installed + * and a linked account. + * + * Ported from v1 — see v1 source for commit history. + */ +import { execFileSync, spawn } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { createConnection, type Socket } from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; +import { registerChannelAdapter } from './channel-registry.js'; +import { readEnvFile } from '../env.js'; +import { log } from '../log.js'; + +// --------------------------------------------------------------------------- +// Signal CLI daemon management +// --------------------------------------------------------------------------- + +interface DaemonHandle { + stop: () => void; + exited: Promise; + isExited: () => boolean; +} + +function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle { + const args: string[] = []; + if (account) args.push('-a', account); + args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout'); + args.push('--receive-mode', 'on-start'); + + const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let exited = false; + + const exitedPromise = new Promise((resolve) => { + child.once('exit', (code, signal) => { + exited = true; + if (code !== 0 && code !== null) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + log.error('signal-cli daemon exited', { reason }); + } + resolve(); + }); + child.on('error', (err) => { + exited = true; + log.error('signal-cli spawn error', { err }); + resolve(); + }); + }); + + child.stdout?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() }); + } + }); + child.stderr?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (!line.trim()) continue; + if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { + log.warn('signal-cli stderr', { line: line.trim() }); + } else { + log.debug('signal-cli stderr', { line: line.trim() }); + } + } + }); + + return { + stop: () => { + if (!child.killed && !exited) child.kill('SIGTERM'); + }, + exited: exitedPromise, + isExited: () => exited, + }; +} + +// --------------------------------------------------------------------------- +// TCP JSON-RPC client for signal-cli daemon (--tcp mode) +// +// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket. +// Requests are sent as JSON + newline; responses and push notifications +// (inbound messages) arrive the same way. +// --------------------------------------------------------------------------- + +const RPC_TIMEOUT_MS = 15_000; + +class SignalTcpClient { + private socket: Socket | null = null; + private buffer = ''; + private pending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; + } + >(); + private onNotification: ((method: string, params: unknown) => void) | null = null; + + constructor( + private host: string, + private port: number, + ) {} + + connect(onNotification?: (method: string, params: unknown) => void): Promise { + this.onNotification = onNotification ?? null; + return new Promise((resolve, reject) => { + const sock = createConnection(this.port, this.host, () => { + this.socket = sock; + resolve(); + }); + sock.on('error', (err) => { + if (!this.socket) { + reject(err); + return; + } + log.warn('Signal TCP socket error', { err }); + }); + sock.on('data', (chunk) => this.onData(chunk)); + sock.on('close', () => { + this.socket = null; + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(new Error('Signal TCP connection closed')); + } + this.pending.clear(); + }); + }); + } + + async rpc(method: string, params?: Record): Promise { + if (!this.socket) throw new Error('Signal TCP not connected'); + const id = Math.random().toString(36).slice(2); + const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n'; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Signal RPC timeout: ${method}`)); + }, RPC_TIMEOUT_MS); + + this.pending.set(id, { + resolve: resolve as (v: unknown) => void, + reject, + timer, + }); + this.socket!.write(msg); + }); + } + + close() { + this.socket?.destroy(); + this.socket = null; + } + + isConnected(): boolean { + return this.socket !== null && !this.socket.destroyed; + } + + private onData(chunk: Buffer) { + this.buffer += chunk.toString(); + let newlineIdx = this.buffer.indexOf('\n'); + while (newlineIdx !== -1) { + const line = this.buffer.slice(0, newlineIdx).trim(); + this.buffer = this.buffer.slice(newlineIdx + 1); + if (line) this.handleLine(line); + newlineIdx = this.buffer.indexOf('\n'); + } + } + + private handleLine(line: string) { + let parsed: any; + try { + parsed = JSON.parse(line); + } catch { + log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) }); + return; + } + + if (parsed.id && this.pending.has(parsed.id)) { + const p = this.pending.get(parsed.id)!; + this.pending.delete(parsed.id); + clearTimeout(p.timer); + if (parsed.error) { + p.reject(new Error(parsed.error.message ?? 'Signal RPC error')); + } else { + p.resolve(parsed.result); + } + return; + } + + if (parsed.method && this.onNotification) { + this.onNotification(parsed.method, parsed.params); + } + } +} + +async function signalTcpCheck(host: string, port: number): Promise { + return new Promise((resolve) => { + const sock = createConnection(port, host, () => { + sock.destroy(); + resolve(true); + }); + sock.on('error', () => resolve(false)); + setTimeout(() => { + sock.destroy(); + resolve(false); + }, 5000); + }); +} + +// --------------------------------------------------------------------------- +// Echo cache +// --------------------------------------------------------------------------- + +const ECHO_TTL_MS = 10_000; + +class EchoCache { + private entries = new Map(); + + remember(text: string) { + const key = text.trim(); + if (!key) return; + this.entries.set(key, Date.now()); + this.cleanup(); + } + + isEcho(text: string): boolean { + const key = text.trim(); + if (!key) return false; + const ts = this.entries.get(key); + if (!ts) return false; + if (Date.now() - ts > ECHO_TTL_MS) { + this.entries.delete(key); + return false; + } + this.entries.delete(key); + return true; + } + + private cleanup() { + const now = Date.now(); + for (const [key, ts] of this.entries) { + if (now - ts > ECHO_TTL_MS) this.entries.delete(key); + } + } +} + +// --------------------------------------------------------------------------- +// Signal envelope types +// --------------------------------------------------------------------------- + +interface SignalQuote { + id?: number; + authorNumber?: string; + authorUuid?: string; + text?: string; +} + +interface SignalDataMessage { + timestamp?: number; + message?: string; + groupInfo?: { groupId?: string; groupName?: string; type?: string }; + quote?: SignalQuote; + attachments?: Array<{ + id?: string; + contentType?: string; + filename?: string; + size?: number; + }>; +} + +interface SignalEnvelope { + source?: string; + sourceName?: string; + sourceNumber?: string; + sourceUuid?: string; + dataMessage?: SignalDataMessage; + syncMessage?: { + sentMessage?: SignalDataMessage & { + destination?: string; + destinationNumber?: string; + }; + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function chunkText(text: string, limit: number): string[] { + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= limit) { + chunks.push(remaining); + break; + } + let splitAt = remaining.lastIndexOf('\n', limit); + if (splitAt <= 0) splitAt = limit; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n/, ''); + } + return chunks; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Signal text styles — convert Markdown to Signal's offset-based formatting +// --------------------------------------------------------------------------- + +interface SignalTextStyle { + style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER'; + start: number; + length: number; +} + +interface StyledText { + text: string; + textStyles: SignalTextStyle[]; +} + +function parseSignalStyles(input: string): StyledText { + const styles: SignalTextStyle[] = []; + + const patterns: Array<{ + regex: RegExp; + style: SignalTextStyle['style']; + }> = [ + { regex: /```([\s\S]*?)```/g, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/g, style: 'MONOSPACE' }, + { regex: /\*\*(.+?)\*\*/g, style: 'BOLD' }, + { regex: /\*(.+?)\*/g, style: 'BOLD' }, + { regex: /_(.+?)_/g, style: 'ITALIC' }, + { regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH' }, + { regex: /\|\|(.+?)\|\|/g, style: 'SPOILER' }, + ]; + + let text = input; + + for (const { regex, style } of patterns) { + const nextText: string[] = []; + let lastIndex = 0; + let offset = 0; + + for (const match of text.matchAll(regex)) { + const fullMatch = match[0]; + const innerText = match[1]; + const matchStart = match.index!; + + nextText.push(text.slice(lastIndex, matchStart)); + const plainStart = matchStart - offset; + + nextText.push(innerText); + styles.push({ style, start: plainStart, length: innerText.length }); + + const stripped = fullMatch.length - innerText.length; + offset += stripped; + lastIndex = matchStart + fullMatch.length; + } + + nextText.push(text.slice(lastIndex)); + text = nextText.join(''); + } + + return { text, textStyles: styles }; +} + +// --------------------------------------------------------------------------- +// SignalAdapter — v2 ChannelAdapter implementation +// --------------------------------------------------------------------------- + +/** + * Platform ID format: + * DM: phone number or UUID (e.g. "+15555550123") + * Group: "group:" (e.g. "group:abc123") + * + * channelType is always "signal". The router combines channelType + platformId + * to look up or create the messaging_group. + */ +export function createSignalAdapter(config: { + cliPath: string; + account: string; + tcpHost: string; + tcpPort: number; + manageDaemon: boolean; + signalDataDir: string; +}): ChannelAdapter { + let daemon: DaemonHandle | null = null; + let tcp: SignalTcpClient | null = null; + let connected = false; + const echoCache = new EchoCache(); + let setup: ChannelSetup | null = null; + + // -- inbound handling -- + + function handleNotification(method: string, params: unknown): void { + if (method === 'receive') { + const envelope = (params as any)?.envelope; + if (envelope) { + handleEnvelope(envelope).catch((err) => { + log.error('Signal: error handling envelope', { err }); + }); + } + } + } + + async function handleEnvelope(envelope: SignalEnvelope): Promise { + if (!setup) return; + + // Sync messages (sent from another device) + const syncSent = envelope.syncMessage?.sentMessage; + if (syncSent) { + const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim(); + // "Note to Self" — destination is our own account + if (dest === config.account) { + const text = (syncSent.message ?? '').trim(); + if (!text) return; + if (echoCache.isEcho(text)) return; + const platformId = config.account; + const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); + + setup.onMetadata(platformId, 'Note to Self', false); + + const msg: InboundMessage = { + id: String(syncSent.timestamp ?? Date.now()), + kind: 'chat', + content: { + text, + sender: config.account, + senderId: `signal:${config.account}`, + senderName: 'Me', + isFromMe: true, + ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + return; + } + // Other sync messages are our outbound — skip + return; + } + + const dataMessage = envelope.dataMessage; + if (!dataMessage) return; + + const text = (dataMessage.message ?? '').trim(); + + // Check for voice attachments + const hasVoice = !text && dataMessage.attachments?.some((a) => a.contentType?.startsWith('audio/')); + + if (!text && !hasVoice) return; + + const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); + if (!sender) return; + + if (text && echoCache.isEcho(text)) { + log.debug('Signal: skipping echo'); + return; + } + + const senderName = (envelope.sourceName?.trim() || sender).trim(); + const groupInfo = dataMessage.groupInfo; + const isGroup = Boolean(groupInfo?.groupId); + const groupId = groupInfo?.groupId; + + const platformId = isGroup ? `group:${groupId}` : sender; + const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); + + const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); + + setup.onMetadata(platformId, chatName, isGroup); + + let content = text; + + // Voice attachment — log path, deliver placeholder text. + // v2 does not have built-in transcription; a future MCP tool could handle this. + if (hasVoice) { + const audio = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/')); + if (audio?.id) { + const attachmentPath = join(config.signalDataDir, 'attachments', audio.id); + if (existsSync(attachmentPath)) { + log.info('Signal: voice attachment received', { + platformId, + attachmentId: audio.id, + path: attachmentPath, + }); + content = '[Voice Message]'; + } else { + log.warn('Signal: voice attachment file not found', { + id: audio.id, + path: attachmentPath, + }); + content = '[Voice Message - file not found]'; + } + } else { + content = '[Voice Message]'; + } + } + + const msg: InboundMessage = { + id: String(dataMessage.timestamp ?? Date.now()), + kind: 'chat', + content: { + text: content, + sender, + senderId: `signal:${sender}`, + senderName, + ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + + log.info('Signal message received', { platformId, sender: senderName }); + } + + function quoteToContent(quote: SignalQuote): Record { + return { + replyToSenderName: quote.authorNumber ?? 'someone', + replyToMessageContent: quote.text || undefined, + replyToMessageId: quote.id ? String(quote.id) : undefined, + }; + } + + // -- send helpers -- + + async function sendText(platformId: string, text: string): Promise { + if (!connected || !tcp) return; + + echoCache.remember(text); + + const MAX_CHUNK = 4000; + const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); + + for (const chunk of chunks) { + try { + const { text: plainText, textStyles } = parseSignalStyles(chunk); + const params: Record = { message: plainText }; + if (config.account) params.account = config.account; + if (textStyles.length > 0) { + params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`); + } + + if (platformId.startsWith('group:')) { + params.groupId = platformId.slice('group:'.length); + } else { + params.recipient = [platformId]; + } + + try { + await tcp.rpc('send', params); + } catch (styledErr) { + if (textStyles.length > 0) { + log.debug('Signal: textStyle rejected, retrying with markup'); + delete params.textStyle; + params.message = chunk; + await tcp.rpc('send', params); + } else { + throw styledErr; + } + } + } catch (err) { + log.error('Signal: send failed', { platformId, err }); + } + } + + log.info('Signal message sent', { platformId, length: text.length }); + } + + async function waitForDaemon(): Promise { + const maxWait = 30_000; + const pollInterval = 1000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + if (daemon?.isExited()) return false; + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (ok) return true; + await sleep(pollInterval); + } + return false; + } + + // -- adapter -- + + const adapter: ChannelAdapter = { + name: 'signal', + channelType: 'signal', + supportsThreads: false, + + async setup(cfg: ChannelSetup): Promise { + setup = cfg; + + if (config.manageDaemon) { + daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort); + const ready = await waitForDaemon(); + if (!ready) { + daemon.stop(); + throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?'); + } + } else { + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (!ok) { + const err = new Error( + `Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`, + ); + (err as any).name = 'NetworkError'; + throw err; + } + } + + tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); + await tcp.connect(handleNotification); + + try { + await tcp.rpc('updateProfile', { + name: 'NanoClaw', + account: config.account, + }); + } catch { + log.debug('Signal: could not set profile name'); + } + + try { + await tcp.rpc('updateConfiguration', { + typingIndicators: true, + account: config.account, + }); + } catch { + log.debug('Signal: could not enable typing indicators'); + } + + connected = true; + log.info('Signal channel connected', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + + async teardown(): Promise { + connected = false; + tcp?.close(); + tcp = null; + if (daemon && config.manageDaemon) { + daemon.stop(); + await daemon.exited; + } + daemon = null; + log.info('Signal channel disconnected'); + }, + + isConnected(): boolean { + return connected; + }, + + async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + const content = message.content as Record | string | undefined; + let text: string | null = null; + if (typeof content === 'string') { + text = content; + } else if (content && typeof content === 'object' && typeof content.text === 'string') { + text = content.text; + } + if (!text) return undefined; + + await sendText(platformId, text); + return undefined; + }, + + async setTyping(platformId: string, _threadId: string | null): Promise { + if (!connected || !tcp) return; + if (platformId.startsWith('group:')) return; + + try { + const params: Record = { recipient: [platformId] }; + if (config.account) params.account = config.account; + await tcp.rpc('sendTyping', params); + } catch (err) { + log.debug('Signal: typing indicator failed', { platformId, err }); + } + }, + }; + + return adapter; +} + +// --------------------------------------------------------------------------- +// Self-registration +// --------------------------------------------------------------------------- + +const DEFAULT_TCP_HOST = '127.0.0.1'; +const DEFAULT_TCP_PORT = 7583; + +registerChannelAdapter('signal', { + factory: () => { + const envVars = readEnvFile([ + 'SIGNAL_ACCOUNT', + 'SIGNAL_HTTP_HOST', + 'SIGNAL_HTTP_PORT', + 'SIGNAL_MANAGE_DAEMON', + 'SIGNAL_DATA_DIR', + ]); + + const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || ''; + if (!account) { + log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel'); + return null; + } + + const cliPath = 'signal-cli'; + const tcpHost = process.env.SIGNAL_HTTP_HOST || envVars.SIGNAL_HTTP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_HTTP_PORT || envVars.SIGNAL_HTTP_PORT || String(DEFAULT_TCP_PORT), 10); + const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; + + const signalDataDir = + process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + + if (manageDaemon && cliPath === 'signal-cli') { + try { + execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); + } catch { + log.debug('Signal: signal-cli binary not found, skipping channel'); + return null; + } + } + + return createSignalAdapter({ + cliPath, + account, + tcpHost, + tcpPort, + manageDaemon, + signalDataDir, + }); + }, +}); From bd032c2b83236e39041e4c8b9b9dae5658ff1887 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:35:59 +0000 Subject: [PATCH 17/47] chore: bump version to 2.0.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77920c4..e358b1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.7", + "version": "2.0.8", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 2861009d95eaf9ffda3f587e1b1740be78a539d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:36:03 +0000 Subject: [PATCH 18/47] =?UTF-8?q?docs:=20update=20token=20count=20to=20129?= =?UTF-8?q?k=20tokens=20=C2=B7=2064%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 3fc904e..fd25267 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 128k tokens, 64% of context window + + 129k tokens, 64% of context window @@ -15,8 +15,8 @@ tokens - - 128k + + 129k From 5d32efbce4fc49de4e827792d7cbc05ae6439a07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:37:49 +0000 Subject: [PATCH 19/47] chore: bump version to 2.0.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e358b1d..098e01f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.8", + "version": "2.0.9", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 5f3bd9c880a06881fa66896d5f182df3eb3d97d5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:27 +0300 Subject: [PATCH 20/47] fix(signal): address review feedback from #1953 Correctness fixes: - parseSignalStyles now uses a recursive walker so nested styles (e.g. **bold with `code` inside**) produce correct offsets against the final plain text. Previous impl recorded styles against intermediate text and didn't reindex when later passes stripped prefix characters. - *single-asterisk* maps to ITALIC (was BOLD, divergent from standard Markdown). _underscore_ also maps to ITALIC. - EchoCache keys on (platformId, text) so an outbound "hi" to Alice no longer drops a real "hi" inbound from Bob. - On TCP socket close, flip adapter connected=false and log a warning so operators see lost daemon connections instead of silently failing sends. - signalTcpCheck clears its 5s timeout on success so successful checks don't leak a setTimeout handle. Config hygiene: - Rename SIGNAL_HTTP_HOST/PORT to SIGNAL_TCP_HOST/PORT (transport is TCP JSON-RPC, not HTTP) and add SIGNAL_CLI_PATH for non-PATH installs. - Remove unused readFileSync import. - Log a warning in deliver() when outbound files are dropped (native adapter doesn't forward attachments to signal-cli yet). Tests: - Nested style offset correctness - *italic* and _italic_ ITALIC mapping - Cross-recipient echo isolation - Same-recipient echo still suppressed - isConnected() flips on socket close - Outbound-files warn-and-drop path SKILL.md realigned to the add-telegram / add-whatsapp template: fetches from the `channels` branch (not a `skill/*` branch), lists pre-flight idempotency checks, adds Features / Troubleshooting sections. Added VERIFY.md and REMOVE.md siblings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-signal/REMOVE.md | 13 ++ .claude/skills/add-signal/SKILL.md | 103 ++++++++------ .claude/skills/add-signal/VERIFY.md | 5 + src/channels/signal.test.ts | 159 ++++++++++++++++++++++ src/channels/signal.ts | 199 +++++++++++++++++++--------- 5 files changed, 375 insertions(+), 104 deletions(-) create mode 100644 .claude/skills/add-signal/REMOVE.md create mode 100644 .claude/skills/add-signal/VERIFY.md diff --git a/.claude/skills/add-signal/REMOVE.md b/.claude/skills/add-signal/REMOVE.md new file mode 100644 index 0000000..db37ade --- /dev/null +++ b/.claude/skills/add-signal/REMOVE.md @@ -0,0 +1,13 @@ +# Remove Signal + +1. Comment out `import './signal.js'` in `src/channels/index.ts` +2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env` +3. Rebuild and restart + +If you also want to unlink the Signal account from `signal-cli`: + +```bash +signal-cli -a +1YOURNUMBER removeDevice --deviceId +``` + +(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.) diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 92c7800..e6d41aa 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -5,38 +5,40 @@ description: Add Signal channel integration via signal-cli TCP daemon. Native ad # Add Signal Channel -Adds Signal messaging support via a native adapter that communicates with a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon using JSON-RPC. +Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge, no npm deps — only Node.js builtins. ## Prerequisites -- **signal-cli** installed and a Signal account linked - - macOS: `brew install signal-cli` - - Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) - - Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) +`signal-cli` installed and a Signal account linked: + +- macOS: `brew install signal-cli` +- Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) +- Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) ## Install +NanoClaw doesn't ship channels in trunk. This skill copies the Signal adapter and its tests in from the `channels` branch. + ### Pre-flight (idempotent) Skip to **Credentials** if all of these are already in place: -- `src/channels/signal.ts` exists -- `src/channels/signal.test.ts` exists +- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist - `src/channels/index.ts` contains `import './signal.js';` Otherwise continue. Every step below is safe to re-run. -### 1. Fetch the skill branch +### 1. Fetch the channels branch ```bash -git fetch origin skill/signal +git fetch origin channels ``` ### 2. Copy the adapter and tests ```bash -git show origin/skill/signal:src/channels/signal.ts > src/channels/signal.ts -git show origin/skill/signal:src/channels/signal.test.ts > src/channels/signal.test.ts +git show origin/channels:src/channels/signal.ts > src/channels/signal.ts +git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts ``` ### 3. Append the self-registration import @@ -59,30 +61,31 @@ No npm packages to install — the adapter uses only Node.js builtins (`node:net Add to `.env`: -```env +```bash SIGNAL_ACCOUNT=+1YOURNUMBER ``` ### Optional settings -```env +```bash # TCP daemon host and port (default: 127.0.0.1:7583) -SIGNAL_HTTP_HOST=127.0.0.1 -SIGNAL_HTTP_PORT=7583 +SIGNAL_TCP_HOST=127.0.0.1 +SIGNAL_TCP_PORT=7583 -# Whether NanoClaw manages the daemon lifecycle (default: true) -# Set to false if you run signal-cli daemon externally +# Path to the signal-cli binary (default: resolved on PATH) +SIGNAL_CLI_PATH=/usr/local/bin/signal-cli + +# Whether NanoClaw manages the daemon lifecycle (default: true). +# Set to false if you run signal-cli daemon externally. SIGNAL_MANAGE_DAEMON=true # signal-cli data directory (default: ~/.local/share/signal-cli) SIGNAL_DATA_DIR=~/.local/share/signal-cli ``` -### Sync to container +**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network. -```bash -mkdir -p data/env && cp .env data/env/env -``` +Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Restart @@ -96,26 +99,50 @@ systemctl --user restart nanoclaw ## Next Steps -Run `/init-first-agent` to create an agent and wire it to your Signal DM. Signal is direct-addressable — your phone number is the platform ID: +If you're in the middle of `/setup`, return to the setup flow now. -- **User ID**: your Signal phone number (e.g. `+15551234567`) -- **Platform ID**: same as user ID for DMs (e.g. `+15551234567`) -- **For group chats**: use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups` - -`/init-first-agent` handles user creation, owner role, agent group, messaging group wiring, and the welcome DM. It's idempotent — safe to run again for additional agents. +Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. Signal is direct-addressable — your phone number is the platform ID. ## Channel Info -| Field | Value | -|-------|-------| -| **Type** | `signal` | -| **Thread support** | No (Signal has no thread model) | -| **Platform ID format** | DM: `+15555550123` / Group: `group:` | -| **Mention detection** | Text-match against agent group name (no SDK-level mentions) | -| **Typing indicators** | DMs only | -| **Typical use** | Personal assistant via Signal DMs or small group chats | -| **Isolation** | Recommended: one agent per Signal account | +- **type**: `signal` +- **terminology**: Signal has "chats" (1:1 DMs) and "groups." +- **how-to-find-id**: DMs use your phone number (e.g. `+15555550123`). Groups use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups`. +- **supports-threads**: no +- **typical-use**: Personal assistant via Signal DMs or small group chats +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically be separate. -### Voice Messages +### Features -Voice attachments are detected but not transcribed by default. The agent receives `[Voice Message]` as the message text. Run `/add-voice-transcription` to enable automatic local transcription via parakeet-mlx. +- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles) +- Quoted replies — `replyTo*` fields populated from Signal quotes +- Typing indicators — DMs only (Signal doesn't support group typing) +- Echo suppression — outbound messages are matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops +- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true` +- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx + +Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions. + +## Troubleshooting + +### Daemon not reachable + +```bash +grep "Signal" logs/nanoclaw.log | tail +``` + +If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`: +- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`) +- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting + +If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`. + +### Bot not responding + +1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` +2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) + +### Lost connection mid-session + +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped us. There's no auto-reconnect yet — restart the service to re-establish. diff --git a/.claude/skills/add-signal/VERIFY.md b/.claude/skills/add-signal/VERIFY.md new file mode 100644 index 0000000..b1ae851 --- /dev/null +++ b/.claude/skills/add-signal/VERIFY.md @@ -0,0 +1,5 @@ +# Verify Signal + +Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds. + +If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`. diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index c7ffff1..f5dabfa 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -583,6 +583,165 @@ describe('SignalAdapter', () => { await adapter.teardown(); }); + + it('tracks nested styles with correct offsets', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: '**bold with `code` inside**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('bold with code inside'); + // BOLD covers the full inner span, MONOSPACE points at "code" in the + // final plain text (offset 10, length 4) — not the intermediate text. + const styles = (last.params.textStyle as string[]).slice().sort(); + expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); + + await adapter.teardown(); + }); + + it('maps *single-asterisk* to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello *world*' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:ITALIC']); + + await adapter.teardown(); + }); + + it('maps _underscore_ to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'hey _there_' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('hey there'); + expect(last.params.textStyle).toEqual(['4:5:ITALIC']); + + await adapter.teardown(); + }); + }); + + // --- Echo cache --- + + describe('echo cache', () => { + it('does not drop same-text inbound from a different recipient', async () => { + // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from + // a different DM. Bob's message must still route — the earlier echo key + // was scoped to Alice. + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { timestamp: 1700000000000, message: 'Hello' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550999', + null, + expect.objectContaining({ + content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), + }), + ); + + await adapter.teardown(); + }); + + it('still skips echo on the same recipient', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Connection drop --- + + describe('connection drop', () => { + it('flips isConnected to false when the socket closes', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + // Simulate the daemon dropping the TCP connection. + tcpRef.fakeSocket.destroy(); + await new Promise((r) => setTimeout(r, 20)); + + expect(adapter.isConnected()).toBe(false); + + await adapter.teardown(); + }); + }); + + // --- Outbound files --- + + describe('outbound files', () => { + it('logs a warning and drops unsupported file attachments', async () => { + const { log } = await import('../log.js'); + const warnMock = log.warn as unknown as ReturnType; + + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + warnMock.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'with an attachment' }, + files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + expect(warnMock).toHaveBeenCalledWith( + 'Signal: outbound files not supported, dropping', + expect.objectContaining({ platformId: '+15555550123', count: 1 }), + ); + + await adapter.teardown(); + }); }); // --- setTyping --- diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 300b7a6..20cba81 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -8,7 +8,7 @@ * Ported from v1 — see v1 source for commit history. */ import { execFileSync, spawn } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { createConnection, type Socket } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -100,14 +100,19 @@ class SignalTcpClient { } >(); private onNotification: ((method: string, params: unknown) => void) | null = null; + private onClose: (() => void) | null = null; constructor( private host: string, private port: number, ) {} - connect(onNotification?: (method: string, params: unknown) => void): Promise { - this.onNotification = onNotification ?? null; + connect(handlers?: { + onNotification?: (method: string, params: unknown) => void; + onClose?: () => void; + }): Promise { + this.onNotification = handlers?.onNotification ?? null; + this.onClose = handlers?.onClose ?? null; return new Promise((resolve, reject) => { const sock = createConnection(this.port, this.host, () => { this.socket = sock; @@ -122,12 +127,14 @@ class SignalTcpClient { }); sock.on('data', (chunk) => this.onData(chunk)); sock.on('close', () => { + const wasConnected = this.socket !== null; this.socket = null; for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('Signal TCP connection closed')); } this.pending.clear(); + if (wasConnected) this.onClose?.(); }); }); } @@ -201,15 +208,17 @@ class SignalTcpClient { async function signalTcpCheck(host: string, port: number): Promise { return new Promise((resolve) => { - const sock = createConnection(port, host, () => { + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); sock.destroy(); - resolve(true); - }); - sock.on('error', () => resolve(false)); - setTimeout(() => { - sock.destroy(); - resolve(false); - }, 5000); + resolve(result); + }; + const sock = createConnection(port, host, () => finish(true)); + sock.on('error', () => finish(false)); + const timer = setTimeout(() => finish(false), 5000); }); } @@ -219,19 +228,35 @@ async function signalTcpCheck(host: string, port: number): Promise { const ECHO_TTL_MS = 10_000; +/** + * Per-recipient dedup for messages we sent ourselves. + * + * signal-cli echoes our own outbound back via syncMessage (and, for Note to + * Self, via sentMessage-with-self-destination). Without dedup, the agent sees + * its own replies as new inbound and loops. We remember `(platformId, text)` + * briefly after every send, and drop the first match within TTL. + * + * Keying on text alone is not enough: if we send "hi" to Alice and Bob then + * sends "hi" from a different chat, Bob's real message gets silently dropped. + */ class EchoCache { private entries = new Map(); - remember(text: string) { - const key = text.trim(); - if (!key) return; - this.entries.set(key, Date.now()); + private keyFor(platformId: string, text: string): string { + return `${platformId}\x00${text.trim()}`; + } + + remember(platformId: string, text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.entries.set(this.keyFor(platformId, trimmed), Date.now()); this.cleanup(); } - isEcho(text: string): boolean { - const key = text.trim(); - if (!key) return false; + isEcho(platformId: string, text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + const key = this.keyFor(platformId, trimmed); const ts = this.entries.get(key); if (!ts) return false; if (Date.now() - ts > ECHO_TTL_MS) { @@ -242,7 +267,7 @@ class EchoCache { return true; } - private cleanup() { + private cleanup(): void { const now = Date.now(); for (const [key, ts] of this.entries) { if (now - ts > ECHO_TTL_MS) this.entries.delete(key); @@ -325,49 +350,61 @@ interface StyledText { textStyles: SignalTextStyle[]; } +/** + * Convert Markdown-ish input to Signal's offset-based style ranges. + * + * Walks the input recursively: at each level we find the leftmost matching + * pattern, descend into its captured inner text (so `**bold with \`code\` + * inside**` stays bold-plus-monospace rather than leaking stripped markers), + * then continue past the match. Style offsets are recorded against the + * *output* text length as it's built, so nested styles always point at the + * right span of the final plain text. + */ function parseSignalStyles(input: string): StyledText { const styles: SignalTextStyle[] = []; - const patterns: Array<{ - regex: RegExp; - style: SignalTextStyle['style']; - }> = [ - { regex: /```([\s\S]*?)```/g, style: 'MONOSPACE' }, - { regex: /`([^`]+)`/g, style: 'MONOSPACE' }, - { regex: /\*\*(.+?)\*\*/g, style: 'BOLD' }, - { regex: /\*(.+?)\*/g, style: 'BOLD' }, - { regex: /_(.+?)_/g, style: 'ITALIC' }, - { regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH' }, - { regex: /\|\|(.+?)\|\|/g, style: 'SPOILER' }, + // Ordering matters: longer/greedier delimiters first so `` ``` `` beats + // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on + // whitespace so `*` isn't mistakenly opened on " * " in list-like text. + const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ + { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/, style: 'MONOSPACE' }, + { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, + { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, + { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, + { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, + { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, ]; - let text = input; - - for (const { regex, style } of patterns) { - const nextText: string[] = []; - let lastIndex = 0; - let offset = 0; - - for (const match of text.matchAll(regex)) { - const fullMatch = match[0]; - const innerText = match[1]; - const matchStart = match.index!; - - nextText.push(text.slice(lastIndex, matchStart)); - const plainStart = matchStart - offset; - - nextText.push(innerText); - styles.push({ style, start: plainStart, length: innerText.length }); - - const stripped = fullMatch.length - innerText.length; - offset += stripped; - lastIndex = matchStart + fullMatch.length; + function walk(segment: string, outputBase: number): string { + let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; + for (const { regex, style } of patterns) { + const m = regex.exec(segment); + if (!m) continue; + if (earliest === null || m.index < earliest.start) { + earliest = { start: m.index, match: m, style }; + } } + if (!earliest) return segment; - nextText.push(text.slice(lastIndex)); - text = nextText.join(''); + const before = segment.slice(0, earliest.start); + const fullMatch = earliest.match[0]; + const inner = earliest.match[1]; + const afterStart = earliest.start + fullMatch.length; + const after = segment.slice(afterStart); + + const innerOut = walk(inner, outputBase + before.length); + styles.push({ + style: earliest.style, + start: outputBase + before.length, + length: innerOut.length, + }); + const afterOut = walk(after, outputBase + before.length + innerOut.length); + + return before + innerOut + afterOut; } + const text = walk(input, 0); return { text, textStyles: styles }; } @@ -421,8 +458,8 @@ export function createSignalAdapter(config: { if (dest === config.account) { const text = (syncSent.message ?? '').trim(); if (!text) return; - if (echoCache.isEcho(text)) return; const platformId = config.account; + if (echoCache.isEcho(platformId, text)) return; const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); setup.onMetadata(platformId, 'Note to Self', false); @@ -460,17 +497,17 @@ export function createSignalAdapter(config: { const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); if (!sender) return; - if (text && echoCache.isEcho(text)) { - log.debug('Signal: skipping echo'); - return; - } - const senderName = (envelope.sourceName?.trim() || sender).trim(); const groupInfo = dataMessage.groupInfo; const isGroup = Boolean(groupInfo?.groupId); const groupId = groupInfo?.groupId; const platformId = isGroup ? `group:${groupId}` : sender; + + if (text && echoCache.isEcho(platformId, text)) { + log.debug('Signal: skipping echo', { platformId }); + return; + } const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); @@ -534,7 +571,7 @@ export function createSignalAdapter(config: { async function sendText(platformId: string, text: string): Promise { if (!connected || !tcp) return; - echoCache.remember(text); + echoCache.remember(platformId, text); const MAX_CHUNK = 4000; const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); @@ -617,7 +654,22 @@ export function createSignalAdapter(config: { } tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect(handleNotification); + await tcp.connect({ + onNotification: handleNotification, + // Signal the adapter that the daemon dropped us. No auto-reconnect yet + // — subsequent deliver/setTyping calls short-circuit on `connected` + // and log rather than throw into the retry loop. Operators see this in + // logs/nanoclaw.log and can restart the service. + onClose: () => { + if (!connected) return; + connected = false; + log.warn('Signal channel lost TCP connection to signal-cli daemon', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + }); try { await tcp.rpc('updateProfile', { @@ -662,6 +714,17 @@ export function createSignalAdapter(config: { }, async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + if (message.files && message.files.length > 0) { + // Native adapter doesn't yet forward file uploads to signal-cli's + // `send --attachment`. Don't silently swallow — operators need to see + // that an attachment was requested but not sent. + log.warn('Signal: outbound files not supported, dropping', { + platformId, + count: message.files.length, + filenames: message.files.map((f) => f.filename), + }); + } + const content = message.content as Record | string | undefined; let text: string | null = null; if (typeof content === 'string') { @@ -703,8 +766,9 @@ registerChannelAdapter('signal', { factory: () => { const envVars = readEnvFile([ 'SIGNAL_ACCOUNT', - 'SIGNAL_HTTP_HOST', - 'SIGNAL_HTTP_PORT', + 'SIGNAL_TCP_HOST', + 'SIGNAL_TCP_PORT', + 'SIGNAL_CLI_PATH', 'SIGNAL_MANAGE_DAEMON', 'SIGNAL_DATA_DIR', ]); @@ -715,14 +779,17 @@ registerChannelAdapter('signal', { return null; } - const cliPath = 'signal-cli'; - const tcpHost = process.env.SIGNAL_HTTP_HOST || envVars.SIGNAL_HTTP_HOST || DEFAULT_TCP_HOST; - const tcpPort = parseInt(process.env.SIGNAL_HTTP_PORT || envVars.SIGNAL_HTTP_PORT || String(DEFAULT_TCP_PORT), 10); + const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; + const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10); const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; const signalDataDir = process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + // Only check for `signal-cli` on PATH when the operator left cliPath at + // the default AND asked us to manage the daemon. A custom absolute path + // is treated as an explicit promise and spawn will surface its own ENOENT. if (manageDaemon && cliPath === 'signal-cli') { try { execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); From f351e460083b6128a9ee6d8d5108bb17706bd0fc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:47 +0300 Subject: [PATCH 21/47] refactor(approvals): persist title+options on channel/sender approval tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender used to hardcode the card title and option labels for pending_channel_approvals and pending_sender_approvals in the DB-access layer, duplicating wording that already lived in the approval modules. That caused a visible drift between the initial card title — picked per event in channel-approval.ts ("📣 Bot mentioned in new chat" vs. "💬 New direct message") — and the post-click render, which always showed the constant "📣 Channel registration". Mirror the pattern already used by pending_approvals: add title / options_json columns on both pending_*_approvals tables via migration 013, have the approval modules write them at creation time, and let getAskQuestionRender just SELECT. - Migration 013 ALTERs the two tables to add title + options_json. - PendingChannelApproval / PendingSenderApproval types and their create functions grow the two fields. - channel-approval.ts / sender-approval.ts normalize options once and pass both title and options_json into the insert. - getAskQuestionRender drops the hardcoded render objects and reads the stored values. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../013-approval-render-metadata.ts | 27 ++++++++++++++++ src/db/migrations/index.ts | 2 ++ src/db/sessions.ts | 32 ++++++------------- src/modules/permissions/channel-approval.ts | 5 ++- .../db/pending-channel-approvals.ts | 8 +++-- .../db/pending-sender-approvals.ts | 10 ++++-- src/modules/permissions/sender-approval.ts | 5 ++- 7 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 src/db/migrations/013-approval-render-metadata.ts diff --git a/src/db/migrations/013-approval-render-metadata.ts b/src/db/migrations/013-approval-render-metadata.ts new file mode 100644 index 0000000..3a1af28 --- /dev/null +++ b/src/db/migrations/013-approval-render-metadata.ts @@ -0,0 +1,27 @@ +/** + * Persist ask_question render metadata (title + options_json) on + * `pending_channel_approvals` and `pending_sender_approvals`, mirroring the + * columns migration 003 / module-approvals-title-options added to + * `pending_approvals`. + * + * Before this, `getAskQuestionRender` hardcoded the title + option labels + * for these two tables in the DB-access layer — duplicating wording that + * also lived in the approval modules and causing a visible drift between + * the initial card title ("📣 Bot mentioned in new chat" / "💬 New direct + * message", chosen per event) and the post-click render ("📣 Channel + * registration", constant). Storing the render metadata alongside the row + * lets both sides read from the same source. + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration013: Migration = { + version: 13, + name: 'approval-render-metadata', + up(db: Database.Database) { + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 33e6963..b46e678 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -9,6 +9,7 @@ import { migration009 } from './009-drop-pending-credentials.js'; import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; +import { migration013 } from './013-approval-render-metadata.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -29,6 +30,7 @@ const migrations: Migration[] = [ migration010, migration011, migration012, + migration013, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/sessions.ts b/src/db/sessions.ts index e9461ca..5c53ad5 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -194,32 +194,20 @@ export function getAskQuestionRender( | undefined; if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; - // Channel-registration approval — options are fixed constants. + // Channel-registration + unknown-sender approvals persist title/options_json + // the same way pending_approvals does — just SELECT and return. if (hasTable(getDb(), 'pending_channel_approvals')) { - const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); - if (c) { - return { - title: '📣 Channel registration', - options: [ - { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, - { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, - ], - }; - } + const c = getDb() + .prepare('SELECT title, options_json FROM pending_channel_approvals WHERE messaging_group_id = ?') + .get(id) as { title: string; options_json: string } | undefined; + if (c?.title) return { title: c.title, options: JSON.parse(c.options_json) }; } - // Unknown-sender approval — options are fixed constants. if (hasTable(getDb(), 'pending_sender_approvals')) { - const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); - if (s) { - return { - title: '👤 New sender', - options: [ - { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, - { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, - ], - }; - } + const s = getDb().prepare('SELECT title, options_json FROM pending_sender_approvals WHERE id = ?').get(id) as + | { title: string; options_json: string } + | undefined; + if (s?.title) return { title: s.title, options: JSON.parse(s.options_json) }; } return undefined; diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index e4b2142..8ab41bc 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -120,6 +120,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) : senderName ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingChannelApproval({ messaging_group_id: messagingGroupId, @@ -127,6 +128,8 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -151,7 +154,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) questionId: messagingGroupId, title, question, - options: normalizeOptions(APPROVAL_OPTIONS), + options, }), ); log.info('Channel registration card delivered', { diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d3e665a..d402074 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -17,6 +17,10 @@ export interface PendingChannelApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingChannelApproval(row: PendingChannelApproval): void { @@ -24,11 +28,11 @@ export function createPendingChannelApproval(row: PendingChannelApproval): void .prepare( `INSERT INTO pending_channel_approvals ( messaging_group_id, agent_group_id, original_message, - approver_user_id, created_at + approver_user_id, created_at, title, options_json ) VALUES ( @messaging_group_id, @agent_group_id, @original_message, - @approver_user_id, @created_at + @approver_user_id, @created_at, @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/db/pending-sender-approvals.ts b/src/modules/permissions/db/pending-sender-approvals.ts index 77a5699..4d32bf4 100644 --- a/src/modules/permissions/db/pending-sender-approvals.ts +++ b/src/modules/permissions/db/pending-sender-approvals.ts @@ -19,6 +19,10 @@ export interface PendingSenderApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingSenderApproval(row: PendingSenderApproval): void { @@ -26,11 +30,13 @@ export function createPendingSenderApproval(row: PendingSenderApproval): void { .prepare( `INSERT INTO pending_sender_approvals ( id, messaging_group_id, agent_group_id, sender_identity, - sender_name, original_message, approver_user_id, created_at + sender_name, original_message, approver_user_id, created_at, + title, options_json ) VALUES ( @id, @messaging_group_id, @agent_group_id, @sender_identity, - @sender_name, @original_message, @approver_user_id, @created_at + @sender_name, @original_message, @approver_user_id, @created_at, + @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index a20e14f..fb3e24e 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -92,6 +92,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingSenderApproval({ id: approvalId, @@ -102,6 +103,8 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): original_message: JSON.stringify(event), approver_user_id: target.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -126,7 +129,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): questionId: approvalId, title, question, - options: APPROVAL_OPTIONS, + options, }), ); log.info('Unknown-sender approval card delivered', { From 2fd2bf3bdee3405b96e4db19ed71a771d36a588c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:56:31 +0300 Subject: [PATCH 22/47] chore(signal): move adapter source to channels branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal adapter source (src/channels/signal.ts + signal.test.ts) now lives on the `channels` branch alongside all other channel adapters, per the trunk/channels split documented in CLAUDE.md and CONTRIBUTING.md ("Trunk does not ship any specific channel adapter"). The /add-signal skill fetches the file from origin/channels like every other channel. This PR to main therefore carries only: - .claude/skills/add-signal/{SKILL,VERIFY,REMOVE}.md — the skill itself - scripts/init-first-agent.ts — unrelated infra fix that benefits any native-ID channel (Signal, WhatsApp) by skipping the channel-prefix on platform IDs that already have their own format The fixed adapter source + tests were pushed to the channels branch in a parallel commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/index.ts | 1 - src/channels/signal.test.ts | 786 ---------------------------------- src/channels/signal.ts | 811 ------------------------------------ 3 files changed, 1598 deletions(-) delete mode 100644 src/channels/signal.test.ts delete mode 100644 src/channels/signal.ts diff --git a/src/channels/index.ts b/src/channels/index.ts index b75016f..e9b3bd1 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,4 +7,3 @@ // self-registration import below. import './cli.js'; -import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts deleted file mode 100644 index f5dabfa..0000000 --- a/src/channels/signal.test.ts +++ /dev/null @@ -1,786 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() })); -vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); -vi.mock('../log.js', () => ({ - log: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock('node:child_process', () => ({ - spawn: vi.fn(), - execFileSync: vi.fn(), -})); - -// --- TCP socket mock --- - -import { EventEmitter } from 'events'; - -const tcpRef = vi.hoisted(() => ({ - rpcResponses: new Map(), - fakeSocket: null as any, -})); - -function createFakeSocket(): EventEmitter & { - write: ReturnType; - destroy: ReturnType; - destroyed: boolean; -} { - const sock = new EventEmitter() as any; - sock.destroyed = false; - sock.destroy = vi.fn(() => { - sock.destroyed = true; - sock.emit('close'); - }); - sock.write = vi.fn((data: string) => { - try { - const req = JSON.parse(data.trim()); - const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true }; - const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'; - setImmediate(() => sock.emit('data', Buffer.from(response))); - } catch { - /* ignore */ - } - }); - return sock; -} - -vi.mock('node:net', () => ({ - createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => { - const sock = createFakeSocket(); - tcpRef.fakeSocket = sock; - if (cb) setImmediate(cb); - return sock; - }), -})); - -import type { ChannelSetup } from './adapter.js'; -import { createSignalAdapter } from './signal.js'; - -// --- Test helpers --- - -function createMockSetup() { - return { - onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType, - onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType, - onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType, - onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType, - }; -} - -function createAdapter() { - return createSignalAdapter({ - cliPath: 'signal-cli', - account: '+15551234567', - tcpHost: '127.0.0.1', - tcpPort: 7583, - manageDaemon: false, - signalDataDir: '/tmp/signal-cli-test-data', - }); -} - -function getRpcCalls(): Array<{ - method: string; - params: Record; - id: string; -}> { - if (!tcpRef.fakeSocket) return []; - return tcpRef.fakeSocket.write.mock.calls - .map((c: any[]) => { - try { - return JSON.parse(c[0].trim()); - } catch { - return null; - } - }) - .filter(Boolean); -} - -function getRpcCallsForMethod(method: string) { - return getRpcCalls().filter((c) => c.method === method); -} - -function pushEvent(envelope: Record) { - if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected'); - const notification = - JSON.stringify({ - jsonrpc: '2.0', - method: 'receive', - params: { envelope }, - }) + '\n'; - tcpRef.fakeSocket.emit('data', Buffer.from(notification)); -} - -// --- Tests --- - -describe('SignalAdapter', () => { - beforeEach(() => { - vi.clearAllMocks(); - tcpRef.rpcResponses.clear(); - tcpRef.fakeSocket = null; - tcpRef.rpcResponses.set('send', { timestamp: 1234567890 }); - tcpRef.rpcResponses.set('sendTyping', {}); - }); - - afterEach(() => { - try { - tcpRef.fakeSocket?.destroy(); - } catch { - // already closed - } - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('connects when daemon is reachable', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - expect(adapter.isConnected()).toBe(true); - expect(tcpRef.fakeSocket).not.toBeNull(); - - await adapter.teardown(); - }); - - it('isConnected() returns false before setup', () => { - const adapter = createAdapter(); - expect(adapter.isConnected()).toBe(false); - }); - - it('disconnects cleanly', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - expect(adapter.isConnected()).toBe(true); - - await adapter.teardown(); - expect(adapter.isConnected()).toBe(false); - }); - - it('throws NetworkError if daemon is unreachable', async () => { - const { createConnection } = await import('node:net'); - vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => { - const sock = createFakeSocket(); - setImmediate(() => sock.emit('error', new Error('Connection refused'))); - return sock as any; - }); - - const adapter = createAdapter(); - await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/); - }); - }); - - // --- Inbound message handling --- - - describe('inbound message handling', () => { - it('delivers DM via onInbound', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - message: 'Hello from Signal', - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550123', - null, - expect.objectContaining({ - id: '1700000000000', - kind: 'chat', - content: expect.objectContaining({ - text: 'Hello from Signal', - sender: '+15555550123', - senderName: 'Alice', - }), - }), - ); - - await adapter.teardown(); - }); - - it('delivers group message with group platformId', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550999', - sourceName: 'Bob', - dataMessage: { - timestamp: 1700000000000, - message: 'Group hello', - groupInfo: { groupId: 'abc123', groupName: 'Family' }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true); - expect(cfg.onInbound).toHaveBeenCalledWith( - 'group:abc123', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'Group hello', - sender: '+15555550999', - }), - }), - ); - - await adapter.teardown(); - }); - - it('skips sync messages (own outbound)', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15551234567', - syncMessage: { - sentMessage: { - timestamp: 1700000000000, - message: 'My own message', - destination: '+15555550123', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('processes Note to Self sync messages as inbound', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15551234567', - syncMessage: { - sentMessage: { - timestamp: 1700000000000, - message: 'Hello Bee', - destinationNumber: '+15551234567', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15551234567', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'Hello Bee', - senderName: 'Me', - isFromMe: true, - }), - }), - ); - - await adapter.teardown(); - }); - - it('skips empty messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: ' ' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('skips echoed outbound messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Echo test' }, - }); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('skips messages with attachments but no text', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - }); - - // --- Quote context --- - - describe('quote context', () => { - it('populates reply_to fields from quoted messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - message: 'I disagree', - quote: { - id: 1699999999000, - authorNumber: '+15555550888', - text: 'Pineapple belongs on pizza', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550123', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'I disagree', - replyToSenderName: '+15555550888', - replyToMessageContent: 'Pineapple belongs on pizza', - replyToMessageId: '1699999999000', - }), - }), - ); - - await adapter.teardown(); - }); - }); - - // --- deliver --- - - describe('deliver', () => { - it('sends DM via TCP RPC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - - const last = sendCalls[sendCalls.length - 1]; - expect(last.params).toEqual( - expect.objectContaining({ - recipient: ['+15555550123'], - message: 'Hello', - account: '+15551234567', - }), - ); - - await adapter.teardown(); - }); - - it('sends group message via groupId', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('group:abc123', null, { - kind: 'text', - content: { text: 'Group msg' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params).toEqual( - expect.objectContaining({ - groupId: 'abc123', - message: 'Group msg', - }), - ); - - await adapter.teardown(); - }); - - it('chunks long messages', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - const longText = 'x'.repeat(5000); - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: longText }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(1); - - await adapter.teardown(); - }); - - it('extracts text from string content', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: 'Plain string content', - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Plain string content'); - - await adapter.teardown(); - }); - }); - - // --- Text styles --- - - describe('text styles', () => { - it('sends bold text with textStyle parameter', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello **world**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Hello world'); - expect(last.params.textStyle).toEqual(['6:5:BOLD']); - - await adapter.teardown(); - }); - - it('sends inline code with MONOSPACE style', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Run `npm test` now' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Run npm test now'); - expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']); - - await adapter.teardown(); - }); - - it('sends plain text without textStyle', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'No formatting here' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('No formatting here'); - expect(last.params.textStyle).toBeUndefined(); - - await adapter.teardown(); - }); - - it('falls back to original markup when textStyle is rejected', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - let sendCount = 0; - tcpRef.fakeSocket.write.mockImplementation((data: string) => { - try { - const req = JSON.parse(data.trim()); - if (req.method === 'send') { - sendCount++; - if (sendCount === 1) { - const response = - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - error: { message: 'Unknown parameter: textStyle' }, - }) + '\n'; - setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); - return; - } - } - const response = - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - result: { ok: true }, - }) + '\n'; - setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); - } catch { - /* ignore */ - } - }); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello **world**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBe(2); - expect(sendCalls[1].params.message).toBe('Hello **world**'); - expect(sendCalls[1].params.textStyle).toBeUndefined(); - - await adapter.teardown(); - }); - - it('tracks nested styles with correct offsets', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: '**bold with `code` inside**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('bold with code inside'); - // BOLD covers the full inner span, MONOSPACE points at "code" in the - // final plain text (offset 10, length 4) — not the intermediate text. - const styles = (last.params.textStyle as string[]).slice().sort(); - expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); - - await adapter.teardown(); - }); - - it('maps *single-asterisk* to ITALIC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello *world*' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Hello world'); - expect(last.params.textStyle).toEqual(['6:5:ITALIC']); - - await adapter.teardown(); - }); - - it('maps _underscore_ to ITALIC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'hey _there_' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('hey there'); - expect(last.params.textStyle).toEqual(['4:5:ITALIC']); - - await adapter.teardown(); - }); - }); - - // --- Echo cache --- - - describe('echo cache', () => { - it('does not drop same-text inbound from a different recipient', async () => { - // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from - // a different DM. Bob's message must still route — the earlier echo key - // was scoped to Alice. - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello' }, - }); - - pushEvent({ - sourceNumber: '+15555550999', - sourceName: 'Bob', - dataMessage: { timestamp: 1700000000000, message: 'Hello' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550999', - null, - expect.objectContaining({ - content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), - }), - ); - - await adapter.teardown(); - }); - - it('still skips echo on the same recipient', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Echo test' }, - }); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - }); - - // --- Connection drop --- - - describe('connection drop', () => { - it('flips isConnected to false when the socket closes', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - expect(adapter.isConnected()).toBe(true); - - // Simulate the daemon dropping the TCP connection. - tcpRef.fakeSocket.destroy(); - await new Promise((r) => setTimeout(r, 20)); - - expect(adapter.isConnected()).toBe(false); - - await adapter.teardown(); - }); - }); - - // --- Outbound files --- - - describe('outbound files', () => { - it('logs a warning and drops unsupported file attachments', async () => { - const { log } = await import('../log.js'); - const warnMock = log.warn as unknown as ReturnType; - - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - warnMock.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'with an attachment' }, - files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - expect(warnMock).toHaveBeenCalledWith( - 'Signal: outbound files not supported, dropping', - expect.objectContaining({ platformId: '+15555550123', count: 1 }), - ); - - await adapter.teardown(); - }); - }); - - // --- setTyping --- - - describe('setTyping', () => { - it('sends typing indicator for DMs', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.setTyping!('+15555550123', null); - - expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1); - - await adapter.teardown(); - }); - - it('skips typing for groups', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.setTyping!('group:abc123', null); - - expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0); - - await adapter.teardown(); - }); - }); - - // --- Adapter properties --- - - describe('adapter properties', () => { - it('has channelType "signal"', () => { - const adapter = createAdapter(); - expect(adapter.channelType).toBe('signal'); - }); - - it('does not support threads', () => { - const adapter = createAdapter(); - expect(adapter.supportsThreads).toBe(false); - }); - }); -}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts deleted file mode 100644 index 20cba81..0000000 --- a/src/channels/signal.ts +++ /dev/null @@ -1,811 +0,0 @@ -/** - * Signal channel adapter for NanoClaw v2. - * - * Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging. - * Requires signal-cli (https://github.com/AsamK/signal-cli) installed - * and a linked account. - * - * Ported from v1 — see v1 source for commit history. - */ -import { execFileSync, spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { createConnection, type Socket } from 'node:net'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; -import { registerChannelAdapter } from './channel-registry.js'; -import { readEnvFile } from '../env.js'; -import { log } from '../log.js'; - -// --------------------------------------------------------------------------- -// Signal CLI daemon management -// --------------------------------------------------------------------------- - -interface DaemonHandle { - stop: () => void; - exited: Promise; - isExited: () => boolean; -} - -function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle { - const args: string[] = []; - if (account) args.push('-a', account); - args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout'); - args.push('--receive-mode', 'on-start'); - - const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let exited = false; - - const exitedPromise = new Promise((resolve) => { - child.once('exit', (code, signal) => { - exited = true; - if (code !== 0 && code !== null) { - const reason = signal ? `signal ${signal}` : `code ${code}`; - log.error('signal-cli daemon exited', { reason }); - } - resolve(); - }); - child.on('error', (err) => { - exited = true; - log.error('signal-cli spawn error', { err }); - resolve(); - }); - }); - - child.stdout?.on('data', (data: Buffer) => { - for (const line of data.toString().split(/\r?\n/)) { - if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() }); - } - }); - child.stderr?.on('data', (data: Buffer) => { - for (const line of data.toString().split(/\r?\n/)) { - if (!line.trim()) continue; - if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { - log.warn('signal-cli stderr', { line: line.trim() }); - } else { - log.debug('signal-cli stderr', { line: line.trim() }); - } - } - }); - - return { - stop: () => { - if (!child.killed && !exited) child.kill('SIGTERM'); - }, - exited: exitedPromise, - isExited: () => exited, - }; -} - -// --------------------------------------------------------------------------- -// TCP JSON-RPC client for signal-cli daemon (--tcp mode) -// -// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket. -// Requests are sent as JSON + newline; responses and push notifications -// (inbound messages) arrive the same way. -// --------------------------------------------------------------------------- - -const RPC_TIMEOUT_MS = 15_000; - -class SignalTcpClient { - private socket: Socket | null = null; - private buffer = ''; - private pending = new Map< - string, - { - resolve: (value: unknown) => void; - reject: (err: Error) => void; - timer: ReturnType; - } - >(); - private onNotification: ((method: string, params: unknown) => void) | null = null; - private onClose: (() => void) | null = null; - - constructor( - private host: string, - private port: number, - ) {} - - connect(handlers?: { - onNotification?: (method: string, params: unknown) => void; - onClose?: () => void; - }): Promise { - this.onNotification = handlers?.onNotification ?? null; - this.onClose = handlers?.onClose ?? null; - return new Promise((resolve, reject) => { - const sock = createConnection(this.port, this.host, () => { - this.socket = sock; - resolve(); - }); - sock.on('error', (err) => { - if (!this.socket) { - reject(err); - return; - } - log.warn('Signal TCP socket error', { err }); - }); - sock.on('data', (chunk) => this.onData(chunk)); - sock.on('close', () => { - const wasConnected = this.socket !== null; - this.socket = null; - for (const [, p] of this.pending) { - clearTimeout(p.timer); - p.reject(new Error('Signal TCP connection closed')); - } - this.pending.clear(); - if (wasConnected) this.onClose?.(); - }); - }); - } - - async rpc(method: string, params?: Record): Promise { - if (!this.socket) throw new Error('Signal TCP not connected'); - const id = Math.random().toString(36).slice(2); - const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n'; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Signal RPC timeout: ${method}`)); - }, RPC_TIMEOUT_MS); - - this.pending.set(id, { - resolve: resolve as (v: unknown) => void, - reject, - timer, - }); - this.socket!.write(msg); - }); - } - - close() { - this.socket?.destroy(); - this.socket = null; - } - - isConnected(): boolean { - return this.socket !== null && !this.socket.destroyed; - } - - private onData(chunk: Buffer) { - this.buffer += chunk.toString(); - let newlineIdx = this.buffer.indexOf('\n'); - while (newlineIdx !== -1) { - const line = this.buffer.slice(0, newlineIdx).trim(); - this.buffer = this.buffer.slice(newlineIdx + 1); - if (line) this.handleLine(line); - newlineIdx = this.buffer.indexOf('\n'); - } - } - - private handleLine(line: string) { - let parsed: any; - try { - parsed = JSON.parse(line); - } catch { - log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) }); - return; - } - - if (parsed.id && this.pending.has(parsed.id)) { - const p = this.pending.get(parsed.id)!; - this.pending.delete(parsed.id); - clearTimeout(p.timer); - if (parsed.error) { - p.reject(new Error(parsed.error.message ?? 'Signal RPC error')); - } else { - p.resolve(parsed.result); - } - return; - } - - if (parsed.method && this.onNotification) { - this.onNotification(parsed.method, parsed.params); - } - } -} - -async function signalTcpCheck(host: string, port: number): Promise { - return new Promise((resolve) => { - let settled = false; - const finish = (result: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timer); - sock.destroy(); - resolve(result); - }; - const sock = createConnection(port, host, () => finish(true)); - sock.on('error', () => finish(false)); - const timer = setTimeout(() => finish(false), 5000); - }); -} - -// --------------------------------------------------------------------------- -// Echo cache -// --------------------------------------------------------------------------- - -const ECHO_TTL_MS = 10_000; - -/** - * Per-recipient dedup for messages we sent ourselves. - * - * signal-cli echoes our own outbound back via syncMessage (and, for Note to - * Self, via sentMessage-with-self-destination). Without dedup, the agent sees - * its own replies as new inbound and loops. We remember `(platformId, text)` - * briefly after every send, and drop the first match within TTL. - * - * Keying on text alone is not enough: if we send "hi" to Alice and Bob then - * sends "hi" from a different chat, Bob's real message gets silently dropped. - */ -class EchoCache { - private entries = new Map(); - - private keyFor(platformId: string, text: string): string { - return `${platformId}\x00${text.trim()}`; - } - - remember(platformId: string, text: string): void { - const trimmed = text.trim(); - if (!trimmed) return; - this.entries.set(this.keyFor(platformId, trimmed), Date.now()); - this.cleanup(); - } - - isEcho(platformId: string, text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - const key = this.keyFor(platformId, trimmed); - const ts = this.entries.get(key); - if (!ts) return false; - if (Date.now() - ts > ECHO_TTL_MS) { - this.entries.delete(key); - return false; - } - this.entries.delete(key); - return true; - } - - private cleanup(): void { - const now = Date.now(); - for (const [key, ts] of this.entries) { - if (now - ts > ECHO_TTL_MS) this.entries.delete(key); - } - } -} - -// --------------------------------------------------------------------------- -// Signal envelope types -// --------------------------------------------------------------------------- - -interface SignalQuote { - id?: number; - authorNumber?: string; - authorUuid?: string; - text?: string; -} - -interface SignalDataMessage { - timestamp?: number; - message?: string; - groupInfo?: { groupId?: string; groupName?: string; type?: string }; - quote?: SignalQuote; - attachments?: Array<{ - id?: string; - contentType?: string; - filename?: string; - size?: number; - }>; -} - -interface SignalEnvelope { - source?: string; - sourceName?: string; - sourceNumber?: string; - sourceUuid?: string; - dataMessage?: SignalDataMessage; - syncMessage?: { - sentMessage?: SignalDataMessage & { - destination?: string; - destinationNumber?: string; - }; - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function chunkText(text: string, limit: number): string[] { - const chunks: string[] = []; - let remaining = text; - while (remaining.length > 0) { - if (remaining.length <= limit) { - chunks.push(remaining); - break; - } - let splitAt = remaining.lastIndexOf('\n', limit); - if (splitAt <= 0) splitAt = limit; - chunks.push(remaining.slice(0, splitAt)); - remaining = remaining.slice(splitAt).replace(/^\n/, ''); - } - return chunks; -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -// --------------------------------------------------------------------------- -// Signal text styles — convert Markdown to Signal's offset-based formatting -// --------------------------------------------------------------------------- - -interface SignalTextStyle { - style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER'; - start: number; - length: number; -} - -interface StyledText { - text: string; - textStyles: SignalTextStyle[]; -} - -/** - * Convert Markdown-ish input to Signal's offset-based style ranges. - * - * Walks the input recursively: at each level we find the leftmost matching - * pattern, descend into its captured inner text (so `**bold with \`code\` - * inside**` stays bold-plus-monospace rather than leaking stripped markers), - * then continue past the match. Style offsets are recorded against the - * *output* text length as it's built, so nested styles always point at the - * right span of the final plain text. - */ -function parseSignalStyles(input: string): StyledText { - const styles: SignalTextStyle[] = []; - - // Ordering matters: longer/greedier delimiters first so `` ``` `` beats - // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on - // whitespace so `*` isn't mistakenly opened on " * " in list-like text. - const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ - { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, - { regex: /`([^`]+)`/, style: 'MONOSPACE' }, - { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, - { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, - { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, - { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, - { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, - ]; - - function walk(segment: string, outputBase: number): string { - let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; - for (const { regex, style } of patterns) { - const m = regex.exec(segment); - if (!m) continue; - if (earliest === null || m.index < earliest.start) { - earliest = { start: m.index, match: m, style }; - } - } - if (!earliest) return segment; - - const before = segment.slice(0, earliest.start); - const fullMatch = earliest.match[0]; - const inner = earliest.match[1]; - const afterStart = earliest.start + fullMatch.length; - const after = segment.slice(afterStart); - - const innerOut = walk(inner, outputBase + before.length); - styles.push({ - style: earliest.style, - start: outputBase + before.length, - length: innerOut.length, - }); - const afterOut = walk(after, outputBase + before.length + innerOut.length); - - return before + innerOut + afterOut; - } - - const text = walk(input, 0); - return { text, textStyles: styles }; -} - -// --------------------------------------------------------------------------- -// SignalAdapter — v2 ChannelAdapter implementation -// --------------------------------------------------------------------------- - -/** - * Platform ID format: - * DM: phone number or UUID (e.g. "+15555550123") - * Group: "group:" (e.g. "group:abc123") - * - * channelType is always "signal". The router combines channelType + platformId - * to look up or create the messaging_group. - */ -export function createSignalAdapter(config: { - cliPath: string; - account: string; - tcpHost: string; - tcpPort: number; - manageDaemon: boolean; - signalDataDir: string; -}): ChannelAdapter { - let daemon: DaemonHandle | null = null; - let tcp: SignalTcpClient | null = null; - let connected = false; - const echoCache = new EchoCache(); - let setup: ChannelSetup | null = null; - - // -- inbound handling -- - - function handleNotification(method: string, params: unknown): void { - if (method === 'receive') { - const envelope = (params as any)?.envelope; - if (envelope) { - handleEnvelope(envelope).catch((err) => { - log.error('Signal: error handling envelope', { err }); - }); - } - } - } - - async function handleEnvelope(envelope: SignalEnvelope): Promise { - if (!setup) return; - - // Sync messages (sent from another device) - const syncSent = envelope.syncMessage?.sentMessage; - if (syncSent) { - const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim(); - // "Note to Self" — destination is our own account - if (dest === config.account) { - const text = (syncSent.message ?? '').trim(); - if (!text) return; - const platformId = config.account; - if (echoCache.isEcho(platformId, text)) return; - const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); - - setup.onMetadata(platformId, 'Note to Self', false); - - const msg: InboundMessage = { - id: String(syncSent.timestamp ?? Date.now()), - kind: 'chat', - content: { - text, - sender: config.account, - senderId: `signal:${config.account}`, - senderName: 'Me', - isFromMe: true, - ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), - }, - timestamp, - }; - await setup.onInbound(platformId, null, msg); - return; - } - // Other sync messages are our outbound — skip - return; - } - - const dataMessage = envelope.dataMessage; - if (!dataMessage) return; - - const text = (dataMessage.message ?? '').trim(); - - // Check for voice attachments - const hasVoice = !text && dataMessage.attachments?.some((a) => a.contentType?.startsWith('audio/')); - - if (!text && !hasVoice) return; - - const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); - if (!sender) return; - - const senderName = (envelope.sourceName?.trim() || sender).trim(); - const groupInfo = dataMessage.groupInfo; - const isGroup = Boolean(groupInfo?.groupId); - const groupId = groupInfo?.groupId; - - const platformId = isGroup ? `group:${groupId}` : sender; - - if (text && echoCache.isEcho(platformId, text)) { - log.debug('Signal: skipping echo', { platformId }); - return; - } - const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); - - const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); - - setup.onMetadata(platformId, chatName, isGroup); - - let content = text; - - // Voice attachment — log path, deliver placeholder text. - // v2 does not have built-in transcription; a future MCP tool could handle this. - if (hasVoice) { - const audio = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/')); - if (audio?.id) { - const attachmentPath = join(config.signalDataDir, 'attachments', audio.id); - if (existsSync(attachmentPath)) { - log.info('Signal: voice attachment received', { - platformId, - attachmentId: audio.id, - path: attachmentPath, - }); - content = '[Voice Message]'; - } else { - log.warn('Signal: voice attachment file not found', { - id: audio.id, - path: attachmentPath, - }); - content = '[Voice Message - file not found]'; - } - } else { - content = '[Voice Message]'; - } - } - - const msg: InboundMessage = { - id: String(dataMessage.timestamp ?? Date.now()), - kind: 'chat', - content: { - text: content, - sender, - senderId: `signal:${sender}`, - senderName, - ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), - }, - timestamp, - }; - await setup.onInbound(platformId, null, msg); - - log.info('Signal message received', { platformId, sender: senderName }); - } - - function quoteToContent(quote: SignalQuote): Record { - return { - replyToSenderName: quote.authorNumber ?? 'someone', - replyToMessageContent: quote.text || undefined, - replyToMessageId: quote.id ? String(quote.id) : undefined, - }; - } - - // -- send helpers -- - - async function sendText(platformId: string, text: string): Promise { - if (!connected || !tcp) return; - - echoCache.remember(platformId, text); - - const MAX_CHUNK = 4000; - const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); - - for (const chunk of chunks) { - try { - const { text: plainText, textStyles } = parseSignalStyles(chunk); - const params: Record = { message: plainText }; - if (config.account) params.account = config.account; - if (textStyles.length > 0) { - params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`); - } - - if (platformId.startsWith('group:')) { - params.groupId = platformId.slice('group:'.length); - } else { - params.recipient = [platformId]; - } - - try { - await tcp.rpc('send', params); - } catch (styledErr) { - if (textStyles.length > 0) { - log.debug('Signal: textStyle rejected, retrying with markup'); - delete params.textStyle; - params.message = chunk; - await tcp.rpc('send', params); - } else { - throw styledErr; - } - } - } catch (err) { - log.error('Signal: send failed', { platformId, err }); - } - } - - log.info('Signal message sent', { platformId, length: text.length }); - } - - async function waitForDaemon(): Promise { - const maxWait = 30_000; - const pollInterval = 1000; - const start = Date.now(); - - while (Date.now() - start < maxWait) { - if (daemon?.isExited()) return false; - const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); - if (ok) return true; - await sleep(pollInterval); - } - return false; - } - - // -- adapter -- - - const adapter: ChannelAdapter = { - name: 'signal', - channelType: 'signal', - supportsThreads: false, - - async setup(cfg: ChannelSetup): Promise { - setup = cfg; - - if (config.manageDaemon) { - daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort); - const ready = await waitForDaemon(); - if (!ready) { - daemon.stop(); - throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?'); - } - } else { - const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); - if (!ok) { - const err = new Error( - `Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`, - ); - (err as any).name = 'NetworkError'; - throw err; - } - } - - tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect({ - onNotification: handleNotification, - // Signal the adapter that the daemon dropped us. No auto-reconnect yet - // — subsequent deliver/setTyping calls short-circuit on `connected` - // and log rather than throw into the retry loop. Operators see this in - // logs/nanoclaw.log and can restart the service. - onClose: () => { - if (!connected) return; - connected = false; - log.warn('Signal channel lost TCP connection to signal-cli daemon', { - account: config.account, - host: config.tcpHost, - port: config.tcpPort, - }); - }, - }); - - try { - await tcp.rpc('updateProfile', { - name: 'NanoClaw', - account: config.account, - }); - } catch { - log.debug('Signal: could not set profile name'); - } - - try { - await tcp.rpc('updateConfiguration', { - typingIndicators: true, - account: config.account, - }); - } catch { - log.debug('Signal: could not enable typing indicators'); - } - - connected = true; - log.info('Signal channel connected', { - account: config.account, - host: config.tcpHost, - port: config.tcpPort, - }); - }, - - async teardown(): Promise { - connected = false; - tcp?.close(); - tcp = null; - if (daemon && config.manageDaemon) { - daemon.stop(); - await daemon.exited; - } - daemon = null; - log.info('Signal channel disconnected'); - }, - - isConnected(): boolean { - return connected; - }, - - async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { - if (message.files && message.files.length > 0) { - // Native adapter doesn't yet forward file uploads to signal-cli's - // `send --attachment`. Don't silently swallow — operators need to see - // that an attachment was requested but not sent. - log.warn('Signal: outbound files not supported, dropping', { - platformId, - count: message.files.length, - filenames: message.files.map((f) => f.filename), - }); - } - - const content = message.content as Record | string | undefined; - let text: string | null = null; - if (typeof content === 'string') { - text = content; - } else if (content && typeof content === 'object' && typeof content.text === 'string') { - text = content.text; - } - if (!text) return undefined; - - await sendText(platformId, text); - return undefined; - }, - - async setTyping(platformId: string, _threadId: string | null): Promise { - if (!connected || !tcp) return; - if (platformId.startsWith('group:')) return; - - try { - const params: Record = { recipient: [platformId] }; - if (config.account) params.account = config.account; - await tcp.rpc('sendTyping', params); - } catch (err) { - log.debug('Signal: typing indicator failed', { platformId, err }); - } - }, - }; - - return adapter; -} - -// --------------------------------------------------------------------------- -// Self-registration -// --------------------------------------------------------------------------- - -const DEFAULT_TCP_HOST = '127.0.0.1'; -const DEFAULT_TCP_PORT = 7583; - -registerChannelAdapter('signal', { - factory: () => { - const envVars = readEnvFile([ - 'SIGNAL_ACCOUNT', - 'SIGNAL_TCP_HOST', - 'SIGNAL_TCP_PORT', - 'SIGNAL_CLI_PATH', - 'SIGNAL_MANAGE_DAEMON', - 'SIGNAL_DATA_DIR', - ]); - - const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || ''; - if (!account) { - log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel'); - return null; - } - - const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; - const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; - const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10); - const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; - - const signalDataDir = - process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); - - // Only check for `signal-cli` on PATH when the operator left cliPath at - // the default AND asked us to manage the daemon. A custom absolute path - // is treated as an explicit promise and spawn will surface its own ENOENT. - if (manageDaemon && cliPath === 'signal-cli') { - try { - execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); - } catch { - log.debug('Signal: signal-cli binary not found, skipping channel'); - return null; - } - } - - return createSignalAdapter({ - cliPath, - account, - tcpHost, - tcpPort, - manageDaemon, - signalDataDir, - }); - }, -}); From 78b0ad68f6dfd8fea5f7ed1a4cc41052c51085dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 20:05:01 +0000 Subject: [PATCH 23/47] chore: bump version to 2.0.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 098e01f..20afddb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.9", + "version": "2.0.10", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 3fa001409edc7b4aac1a7abf6fd6021475c58185 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 23:19:30 +0300 Subject: [PATCH 24/47] feat(setup): wire Signal into the auto setup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bash nanoclaw.sh` can now offer Signal as a channel choice, scan the signal-cli link QR in the terminal, and wire up the first agent end to end — mirroring the WhatsApp and Telegram flows. Pieces: - setup/add-signal.sh — non-interactive installer. Fetches src/channels/signal.ts + signal.test.ts from the channels branch, appends the self-registration import, installs qrcode (for the setup-flow QR render), and builds. Idempotent and standalone-runnable. - setup/signal-auth.ts — step runner. Spawns `signal-cli link --name NanoClaw`, watches stdout for the `sgnl://linkdevice?…` (or legacy `tsdevice://`) URL, emits SIGNAL_AUTH_QR with it. On exit 0, runs `signal-cli -o json listAccounts` and reports the new account via SIGNAL_AUTH STATUS=success. Pre-check via listAccounts returns STATUS=skipped if an account is already linked. - setup/channels/signal.ts — interactive driver. Probes for signal-cli (offering `brew install signal-cli` on macOS or linking GitHub releases on Linux if missing), runs add-signal.sh, renders each SIGNAL_AUTH_QR block as a terminal QR inside a clack spinner, persists SIGNAL_ACCOUNT to .env + data/env/env, restarts the service, then wires the first agent via init-first-agent. - setup/index.ts: register `signal-auth` in the STEPS map. - setup/auto.ts: add 'signal' to ChannelChoice, import the driver, add it to the channel picker (after WhatsApp, hint "needs signal-cli installed"), branch the dispatch, and map channelDmLabel. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-signal.sh | 95 +++++++++++ setup/auto.ts | 11 ++ setup/channels/signal.ts | 357 +++++++++++++++++++++++++++++++++++++++ setup/index.ts | 1 + setup/signal-auth.ts | 182 ++++++++++++++++++++ 5 files changed, 646 insertions(+) create mode 100755 setup/add-signal.sh create mode 100644 setup/channels/signal.ts create mode 100644 setup/signal-auth.ts diff --git a/setup/add-signal.sh b/setup/add-signal.sh new file mode 100755 index 0000000..8ebf2b9 --- /dev/null +++ b/setup/add-signal.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Install the Signal adapter in an already-running NanoClaw checkout. +# Non-interactive — the operator-facing "install signal-cli" + QR scan +# live in setup/channels/signal.ts. This script only: +# +# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels +# branch. +# 2. Appends the self-registration import to src/channels/index.ts. +# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has +# no npm deps). +# 4. Builds. +# +# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli +# link has produced a number; that keeps this script idempotent and +# re-runnable without re-auth. +# +# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All +# chatty progress goes to stderr so setup:auto's raw-log capture sees +# the full story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-signal/SKILL.md. +QRCODE_VERSION="qrcode@1.5.4" +QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" + +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_SIGNAL ===" + echo "STATUS: ${status}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-signal] $*" >&2; } + +need_install() { + [ ! -f src/channels/signal.ts ] && return 0 + ! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter files from ${CHANNELS_BRANCH}…" + for f in \ + src/channels/signal.ts \ + src/channels/signal.test.ts + do + git show "${CHANNELS_BRANCH}:$f" > "$f" || { + emit_status failed "git show ${CHANNELS_BRANCH}:$f failed" + exit 1 + } + done + + if ! grep -q "^import './signal.js';" src/channels/index.ts; then + echo "import './signal.js';" >> src/channels/index.ts + fi +fi + +# qrcode is needed by setup/signal-auth.ts to render the linking URL as a +# terminal QR. Install idempotently — if it's already present (e.g. from a +# prior WhatsApp install) pnpm is a no-op. +if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then + log "Installing ${QRCODE_VERSION}…" + pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${QRCODE_VERSION} failed" + exit 1 + } +fi + +log "Building…" +pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 +} + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 4c20262..cff2f63 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -28,6 +28,7 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runIMessageChannel } from './channels/imessage.js'; +import { runSignalChannel } from './channels/signal.js'; import { runSlackChannel } from './channels/slack.js'; import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; @@ -54,6 +55,7 @@ type ChannelChoice = | 'telegram' | 'discord' | 'whatsapp' + | 'signal' | 'teams' | 'slack' | 'imessage' @@ -315,6 +317,8 @@ async function main(): Promise { await runDiscordChannel(displayName!); } else if (channelChoice === 'whatsapp') { await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { @@ -442,6 +446,8 @@ function channelDmLabel(choice: ChannelChoice): string | null { return 'Discord DMs'; case 'whatsapp': return 'WhatsApp'; + case 'signal': + return 'Signal'; case 'teams': return 'Teams'; case 'imessage': @@ -835,6 +841,11 @@ async function askChannelChoice(): Promise { { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { + value: 'signal', + label: 'Yes, connect Signal', + hint: 'needs signal-cli installed', + }, { value: 'imessage', label: 'Yes, connect iMessage (experimental)', diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts new file mode 100644 index 0000000..9e54cb9 --- /dev/null +++ b/setup/channels/signal.ts @@ -0,0 +1,357 @@ +/** + * Signal channel flow for setup:auto. + * + * `runSignalChannel(displayName)` owns the full branch from signal-cli + * presence check through the welcome DM: + * + * 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it, + * offer `brew install signal-cli` inline. On Linux, surface the + * GitHub releases URL and bail with an actionable error. + * 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent). + * 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as + * a terminal QR the operator scans from Signal → Linked Devices. + * 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env). + * 5. Kick the service so the adapter picks up the new credentials. + * 6. Ask operator role + agent name. + * 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome + * DM path delivers the greeting through the adapter. + * + * Signal's `link` flow creates a *secondary* device. The phone number + * comes from the primary (the phone that scanned the QR); this host then + * sends/receives as that primary number. No registration of new numbers. + * + * Output obeys the three-level contract: clack UI for the user, structured + * entries in logs/setup.log, full raw output in per-step files under + * logs/setup-steps/. See docs/setup-flow.md. + */ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +export async function runSignalChannel(displayName: string): Promise { + await ensureSignalCli(); + + const install = await runQuietChild( + 'signal-install', + 'bash', + ['setup/add-signal.sh'], + { + running: 'Installing the Signal adapter…', + done: 'Signal adapter installed.', + skipped: 'Signal adapter already installed.', + }, + ); + if (!install.ok) { + await fail( + 'signal-install', + "Couldn't install the Signal adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const auth = await runSignalAuth(); + if (!auth.ok) { + const reason = auth.terminal?.fields.ERROR ?? 'unknown'; + await fail( + 'signal-auth', + `Signal link failed (${reason}).`, + reason === 'qr_timeout' + ? 'The code expired. Re-run setup to get a fresh one.' + : 'Re-run setup to try again.', + ); + } + + const account = auth.terminal?.fields.ACCOUNT; + if (!account) { + await fail( + 'signal-auth', + 'Linked with Signal but couldn\'t read the phone number back.', + 'Run `signal-cli listAccounts` to confirm, then re-run setup.', + ); + } + + writeSignalAccount(account!); + await restartService(); + + const role = await askOperatorRole('Signal'); + setupLog.userInput('signal_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'signal', + '--user-id', account!, + '--platform-id', account!, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Connecting ${agentName} to Signal…`, + done: `${agentName} is ready. Check Signal for a welcome message.`, + }, + { + extraFields: { + CHANNEL: 'signal', + AGENT_NAME: agentName, + PLATFORM_ID: account!, + ROLE: role, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', + ); + } +} + +async function ensureSignalCli(): Promise { + const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli'; + const probe = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (!probe.error && probe.status === 0) return; + + if (process.platform === 'darwin') { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'The quickest way on macOS is Homebrew:', + '', + k.cyan(' brew install signal-cli'), + '', + "Install it in another terminal, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } else { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'Grab the latest release from GitHub:', + '', + k.cyan(' https://github.com/AsamK/signal-cli/releases'), + '', + "Install it, make sure `signal-cli --version` works, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } + await fail( + 'signal-install', + 'signal-cli is required but not installed.', + 'Install it and re-run setup.', + ); +} + +async function runSignalAuth(): Promise< + StepResult & { rawLog: string; durationMs: number } +> { + const rawLog = setupLog.stepRawLog('signal-auth'); + const start = Date.now(); + const s = p.spinner(); + s.start('Starting Signal link…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number): void => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + // Tracks how many lines the QR block occupies so we can wipe it in-place + // once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's, + // but we still want to erase the QR from screen once it's served). + let qrLinesPrinted = 0; + + const result = await spawnStep( + 'signal-auth', + [], + (block: Block) => { + if (block.type === 'SIGNAL_AUTH_QR') { + const qr = block.fields.QR ?? ''; + if (!qr) return; + void renderQr(qr).then((lines) => { + stopSpinner('Scan this QR from Signal → Settings → Linked Devices.'); + process.stdout.write(lines.join('\n') + '\n'); + qrLinesPrinted = lines.length; + s.start('Waiting for you to scan…'); + spinnerActive = true; + }); + } else if (block.type === 'SIGNAL_AUTH') { + const status = block.fields.STATUS; + // Wipe the QR block regardless of outcome — it's either scanned + // and useless, or expired and misleading. + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + const account = block.fields.ACCOUNT; + if (status === 'skipped') { + stopSpinner( + account + ? `Signal already linked as ${k.cyan(account)}.` + : 'Signal already linked.', + ); + } else if (status === 'success') { + stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`); + } else if (status === 'failed') { + const err = block.fields.ERROR ?? 'unknown'; + stopSpinner(`Signal link failed: ${err}`, 1); + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Signal link ended unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('signal-auth', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** + * Render the raw linking URL as a block-art QR, returned line-by-line so + * the caller can count lines for in-place cleanup. Uses small-mode so the + * code stays scannable on 24-row terminals. If qrcode isn't installed + * (add-signal.sh should have handled it, but we're defensive), fall back + * to the raw URL and ask the user to paste it into an external renderer. + */ +async function renderQr(url: string): Promise { + try { + const QRCode = await import('qrcode'); + const qrText = await QRCode.toString(url, { type: 'terminal', small: true }); + const caption = k.dim( + ' Signal → Settings → Linked Devices → Link New Device → scan.', + ); + return [...qrText.trimEnd().split('\n'), '', caption]; + } catch { + return [ + 'Linking URL (render at https://qr.io or similar):', + '', + url, + '', + k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'), + ]; + } +} + +/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */ +function writeSignalAccount(account: string): void { + const envPath = path.join(process.cwd(), '.env'); + let contents = ''; + try { + contents = fs.readFileSync(envPath, 'utf-8'); + } catch { + contents = ''; + } + if (/^SIGNAL_ACCOUNT=/m.test(contents)) { + contents = contents.replace( + /^SIGNAL_ACCOUNT=.*$/m, + `SIGNAL_ACCOUNT=${account}`, + ); + } else { + if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n'; + contents += `SIGNAL_ACCOUNT=${account}\n`; + } + fs.writeFileSync(envPath, contents); + + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(envPath, path.join(containerEnvDir, 'env')); + + setupLog.userInput('signal_account', account); +} + +async function restartService(): Promise { + const s = p.spinner(); + s.start('Restarting NanoClaw so it sees your Signal account…'); + const start = Date.now(); + const platform = process.platform; + try { + if (platform === 'darwin') { + spawnSync( + 'launchctl', + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`], + { stdio: 'ignore' }, + ); + } else if (platform === 'linux') { + const unit = getSystemdUnit(); + const user = spawnSync('systemctl', ['--user', 'restart', unit], { + stdio: 'ignore', + }); + if (user.status !== 0) { + spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' }); + } + } + // Give the adapter a moment to connect to signal-cli before + // init-first-agent's welcome DM hits the delivery path. + await new Promise((r) => setTimeout(r, 5000)); + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step('signal-restart', 'success', Date.now() - start, { + PLATFORM: platform, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + s.stop(`Restart may have failed: ${message}`, 1); + setupLog.step('signal-restart', 'failed', Date.now() - start, { + ERROR: message, + }); + // Non-fatal — the user can restart manually if init-first-agent fails. + } +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/index.ts b/setup/index.ts index 25d1934..200b9e2 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -16,6 +16,7 @@ const STEPS: Record< register: () => import('./register.js'), groups: () => import('./groups.js'), 'whatsapp-auth': () => import('./whatsapp-auth.js'), + 'signal-auth': () => import('./signal-auth.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/signal-auth.ts b/setup/signal-auth.ts new file mode 100644 index 0000000..ce289db --- /dev/null +++ b/setup/signal-auth.ts @@ -0,0 +1,182 @@ +/** + * Step: signal-auth — link this host to an existing Signal account via + * signal-cli's QR-code flow. + * + * signal-cli `link` opens a bi-directional handshake with the Signal + * servers: it prints one line containing a linking URL (`sgnl://linkdevice?…` + * or older `tsdevice://linkdevice?…`), then blocks until either the user + * scans it from an existing Signal install, or the code expires. On + * success, a secondary account is created under the user's signal-cli + * data directory, associated with the phone number of the scanner. + * + * Methods: + * (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR + * with the URL, wait for completion. + * + * Block schema (parent parses these): + * SIGNAL_AUTH_QR { QR: "" } — one-shot + * SIGNAL_AUTH { STATUS: success, ACCOUNT: + } — terminal + * { STATUS: skipped, ACCOUNT, REASON: already-authenticated } + * { STATUS: failed, ERROR: } + * + * STATUS values match the runner's vocabulary (success/skipped/failed) so + * spawnStep recognises them and sets `ok` correctly; Signal-specific UI + * lives in setup/channels/signal.ts. + * + * If one or more accounts are already linked (discovered via + * `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH + * STATUS=skipped with the first account so the driver can reuse it. + * Selecting a different existing account is a driver concern. + */ +import { spawn, spawnSync } from 'child_process'; + +import { emitStatus } from './status.js'; + +const LINK_TIMEOUT_MS = 180_000; +const DEFAULT_DEVICE_NAME = 'NanoClaw'; + +interface SignalAccount { + account?: string; + registered?: boolean; +} + +function cliPath(): string { + return process.env.SIGNAL_CLI_PATH || 'signal-cli'; +} + +/** + * Query signal-cli for currently linked accounts. Empty array if none + * configured, no binary, or the call fails for any other reason. + */ +function listAccounts(): string[] { + const cli = cliPath(); + try { + const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (res.status !== 0) return []; + const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[]; + return parsed + .filter((a) => a.registered !== false) + .map((a) => a.account ?? '') + .filter(Boolean); + } catch { + return []; + } +} + +export async function run(_args: string[]): Promise { + const cli = cliPath(); + + // Verify signal-cli exists before we commit to the long-running link. + // The driver checks too, but this keeps the step honest when run alone. + const probe = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (probe.error || probe.status !== 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'failed', + ERROR: 'signal-cli not found. Install signal-cli first.', + }); + return; + } + + const existing = listAccounts(); + if (existing.length > 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'skipped', + ACCOUNT: existing[0], + REASON: 'already-authenticated', + }); + return; + } + + await new Promise((resolve) => { + let settled = false; + let qrEmitted = false; + + const finish = (block: Record, code: number): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + emitStatus('SIGNAL_AUTH', block); + resolve(); + setTimeout(() => process.exit(code), 500); + }; + + const timer = setTimeout(() => { + try { + child.kill('SIGTERM'); + } catch { + /* ignore */ + } + finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1); + }, LINK_TIMEOUT_MS); + + const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // stdout carries the URL on the first line; subsequent lines may print + // status like "Associated with: +1555…". We don't strictly need to parse + // the number — listAccounts after exit is the source of truth — but the + // URL match drives the QR emit, which is the whole point. + let stdoutBuf = ''; + const handleStdout = (chunk: Buffer): void => { + stdoutBuf += chunk.toString('utf-8'); + let idx: number; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx).trim(); + stdoutBuf = stdoutBuf.slice(idx + 1); + if (!line) continue; + // Match both modern (sgnl://) and legacy (tsdevice://) schemes. + if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) { + qrEmitted = true; + emitStatus('SIGNAL_AUTH_QR', { QR: line }); + } + } + }; + child.stdout.on('data', handleStdout); + + // Capture stderr for the transcript / log — signal-cli writes warnings + // and errors there. We don't emit on partial stderr lines since a + // successful link can still produce noise. + let stderrBuf = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf-8'); + }); + + child.on('error', (err) => { + finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1); + }); + + child.on('close', (code) => { + // After a successful link, signal-cli exits 0 and the newly linked + // account shows up in listAccounts. Use that as the source of truth + // rather than scraping stdout — more robust across signal-cli versions. + if (code === 0) { + const post = listAccounts(); + if (post.length === 0) { + finish( + { STATUS: 'failed', ERROR: 'link exited 0 but no account registered' }, + 1, + ); + return; + } + finish({ STATUS: 'success', ACCOUNT: post[0] }, 0); + return; + } + + // Non-zero exit. Surface the last non-empty stderr line for context; + // signal-cli's own error messages are usually informative. + const lastErr = + stderrBuf + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .slice(-1)[0] ?? `signal-cli link exited with code ${code}`; + finish({ STATUS: 'failed', ERROR: lastErr }, 1); + }); + }); +} From ce28e7f5583959a8b827ee361af743b8266d0766 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 01:27:20 +0300 Subject: [PATCH 25/47] docs(add-codex): bump CODEX_VERSION to 0.124.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index 17910b7..3411bae 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -67,7 +67,7 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present): **(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`: ```dockerfile -ARG CODEX_VERSION=0.121.0 +ARG CODEX_VERSION=0.124.0 ``` **(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call: From 5845a5a98029c0a2d284e8607ead213a07eec499 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 22:47:10 +0000 Subject: [PATCH 26/47] fix(container-runner): honor agent_provider DB columns with session override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveProviderContribution read only containerConfig.provider (from each group's container.json) and ignored both agent_groups.agent_provider and sessions.agent_provider. The provider-install skills (opencode, codex) and CLAUDE.md document those DB columns as the source of truth with session-overrides-group precedence, but the code never consulted them — so setting `agent_provider = 'codex'` on a group had no effect, and the only way to route to a non-default provider was to edit the per-group JSON directly. Discovered while wiring up Codex: DB update landed but the spawned container kept running Claude. Extract a pure `resolveProviderName(session, group, containerConfig)` with the documented precedence: sessions.agent_provider → agent_groups.agent_provider → container.json `provider` → 'claude' `resolveProviderContribution` now calls it. The container.json fallback stays so existing installs that only set provider in JSON keep working. Empty strings treated as unset to avoid footguns when a DB-backed form writes '' for "no override." Added unit tests covering precedence, null-fallthrough, empty-string fallthrough, and case normalization. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.test.ts | 32 ++++++++++++++++++++++++++++++++ src/container-runner.ts | 21 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/container-runner.test.ts diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts new file mode 100644 index 0000000..cd18a72 --- /dev/null +++ b/src/container-runner.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProviderName } from './container-runner.js'; + +describe('resolveProviderName', () => { + it('prefers session over group and container.json', () => { + expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex'); + }); + + it('falls back to group when session is null', () => { + expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex'); + }); + + it('falls back to container.json when session and group are null', () => { + expect(resolveProviderName(null, null, 'opencode')).toBe('opencode'); + }); + + it('defaults to claude when nothing is set', () => { + expect(resolveProviderName(null, null, undefined)).toBe('claude'); + }); + + it('lowercases the resolved name', () => { + expect(resolveProviderName('CODEX', null, null)).toBe('codex'); + expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode'); + expect(resolveProviderName(null, null, 'Claude')).toBe('claude'); + }); + + it('treats empty string as unset (falls through)', () => { + expect(resolveProviderName('', 'codex', null)).toBe('codex'); + expect(resolveProviderName(null, '', 'opencode')).toBe('opencode'); + }); +}); diff --git a/src/container-runner.ts b/src/container-runner.ts index fca88c4..029b5fe 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -191,12 +191,31 @@ export function killContainer(sessionId: string, reason: string): void { } } +/** + * Resolve the provider name for a session using the precedence documented in + * the provider-install skills: + * + * sessions.agent_provider + * → agent_groups.agent_provider + * → container.json `provider` + * → 'claude' + * + * Pure so the precedence can be unit-tested without a DB or filesystem. + */ +export function resolveProviderName( + sessionProvider: string | null | undefined, + agentGroupProvider: string | null | undefined, + containerConfigProvider: string | null | undefined, +): string { + return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase(); +} + function resolveProviderContribution( session: Session, agentGroup: AgentGroup, containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = (containerConfig.provider || 'claude').toLowerCase(); + const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ From a4346f566c87a25418aa5e783fc2a54089e11e6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 22:54:40 +0000 Subject: [PATCH 27/47] =?UTF-8?q?docs:=20update=20token=20count=20to=20130?= =?UTF-8?q?k=20tokens=20=C2=B7=2065%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index fd25267..fd8a436 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 129k tokens, 64% of context window + + 130k tokens, 65% of context window @@ -15,8 +15,8 @@ tokens - - 129k + + 130k From d0c608c75114fb6ada970be3bc3f7212ad5bc47a Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 23 Apr 2026 19:44:47 -0400 Subject: [PATCH 28/47] fix(setup): register step uses engage_mode columns dropped by migration 010 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 010-engage-modes (replace trigger_rules + response_scope with engage_mode/engage_pattern/sender_scope/ignored_message_policy) updated the schema and the production code paths, but missed setup/register.ts. The step still constructed a payload with the dropped columns. On any fresh v2 install, attempting to register a channel via: pnpm exec tsx setup/index.ts --step register -- --platform-id ... fails with: `Missing named parameter "engage_mode"`. This affects every flow that calls the register step — the /add- skills, /manage-channels, and the setup auto driver. Map old → new: - trigger_rules.pattern (string) → engage_mode='pattern', engage_pattern= - requiresTrigger=false (no pattern) → engage_mode='pattern', engage_pattern='.' (the "always" sentinel from migration 010) - requiresTrigger=true (no pattern) → engage_mode='mention' - response_scope='all' → sender_scope='all', ignored_message_policy='drop' (conservative default matching the migration backfill rule) Tested by registering three Telegram channels (one DM, two groups) on a fresh v2 install — all succeeded. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/register.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index a308add..ff194fc 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -167,18 +167,16 @@ export async function run(args: string[]): Promise { if (!existing) { newlyWired = true; const mgaId = generateId('mga'); - const triggerRules = parsed.trigger - ? JSON.stringify({ - pattern: parsed.trigger, - requiresTrigger: parsed.requiresTrigger, - }) - : null; + const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention'; + const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null); createMessagingGroupAgent({ id: mgaId, messaging_group_id: messagingGroup.id, agent_group_id: agentGroup.id, - trigger_rules: triggerRules, - response_scope: 'all', + engage_mode: engageMode, + engage_pattern: engagePattern, + sender_scope: 'all', + ignored_message_policy: 'drop', session_mode: parsed.sessionMode, priority: 0, created_at: new Date().toISOString(), From 9e33274e2a81121fbf65531108f8239bb4e1e465 Mon Sep 17 00:00:00 2001 From: grtwrn Date: Thu, 23 Apr 2026 20:43:02 -0400 Subject: [PATCH 29/47] skill(add-gmail-tool): OneCLI-native Gmail MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /add-gmail-tool — a Utility skill that installs Gmail as an MCP tool in NanoClaw v2 using OneCLI for credential injection. No raw OAuth tokens ever reach the container; the gateway swaps the "onecli-managed" stub bearer for the real token at request time. Scope (3 files): - container/Dockerfile: pnpm global-install of @gongrzhe/server-gmail-autoauth-mcp@1.1.11, pinned behind GMAIL_MCP_VERSION. Also pins zod-to-json-schema@3.22.5 to avoid an ERR_PACKAGE_PATH_NOT_EXPORTED crash: the MCP server's loose zod range resolves zod@3.24.x while zod-to-json-schema@3.25.x imports the zod/v3 subpath that only exists in zod>=3.25. - container/agent-runner/src/providers/claude.ts: adds 'mcp__gmail__*' to TOOL_ALLOWLIST so the agent can invoke the server's tools. - .claude/skills/add-gmail-tool/SKILL.md: pre-flight checks (OneCLI Gmail app connected, stubs present, mount allowlist covers ~/.gmail-mcp, agent secret-mode), per-group wiring in container.json (mount + mcpServers), verification steps, troubleshooting, removal instructions. Credits to gongrzhe for the MCP server and the add-atomic-chat-tool / add-vercel skill patterns. Addresses #1500 (proxy Gmail OAuth through credential proxy) on the Gmail side. Overlaps in intent with #1810 but stays surgical — no bundled unrelated changes. Tested end-to-end on Linux/Docker: CLI and WhatsApp self-chat agents can list labels, search/read/send mail via OneCLI-injected tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-gmail-tool/SKILL.md | 229 ++++++++++++++++++ container/Dockerfile | 6 + .../agent-runner/src/providers/claude.ts | 1 + 3 files changed, 236 insertions(+) create mode 100644 .claude/skills/add-gmail-tool/SKILL.md diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md new file mode 100644 index 0000000..095c285 --- /dev/null +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -0,0 +1,229 @@ +--- +name: add-gmail-tool +description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form. +--- + +# Add Gmail Tool (OneCLI-native) + +This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault. + +Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`. + +**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight. + +## Phase 1: Pre-flight + +### Verify OneCLI has Gmail connected + +```bash +onecli apps get --provider gmail +``` + +Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`. + +If not connected, tell the user: + +> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as. + +### Verify stub credentials exist + +```bash +ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1 +``` + +If both exist and contain `"onecli-managed"`: + +```bash +grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json +``` + +...skip to Phase 2. + +If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong. + +If both files are absent, write them now: + +```bash +mkdir -p ~/.gmail-mcp +cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF' +{ + "installed": { + "client_id": "onecli-managed.apps.googleusercontent.com", + "client_secret": "onecli-managed", + "redirect_uris": ["http://localhost:3000/oauth2callback"] + } +} +EOF +cat > ~/.gmail-mcp/credentials.json <<'EOF' +{ + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "token_type": "Bearer", + "expiry_date": 99999999999999, + "scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send" +} +EOF +chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json +``` + +### Verify mount allowlist covers the path + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json +``` + +`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory. + +### Check agent secret-mode + +For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`: + +```bash +onecli agents list +``` + +If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets: + +```bash +onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app) +onecli agents set-secrets --id --secret-ids +``` + +## Phase 2: Apply Code Changes + +### Check if already applied + +```bash +grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \ +grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \ +echo "ALREADY APPLIED — skip to Phase 3" +``` + +### Add MCP server to Dockerfile + +Edit `container/Dockerfile`. Find the pinned-version ARG block: + +```dockerfile +ARG CLAUDE_CODE_VERSION=2.1.116 +ARG AGENT_BROWSER_VERSION=latest +ARG VERCEL_VERSION=latest +ARG BUN_VERSION=1.3.12 +``` + +Add a new line: + +```dockerfile +ARG GMAIL_MCP_VERSION=1.1.11 +``` + +Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" +``` + +Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image. + +**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`. + +### Add tools to allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it. + +### Rebuild the container image + +```bash +./container/build.sh +``` + +Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild). + +## Phase 3: Wire Per-Agent-Group + +For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups//container.json` to add the mount and MCP server. + +Merge these into the group's `container.json`: + +```jsonc +{ + "mcpServers": { + "gmail": { + "command": "gmail-mcp", + "args": [], + "env": { + "GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json", + "GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json" + } + } + }, + "additionalMounts": [ + { + "hostPath": "/home//.gmail-mcp", + "containerPath": ".gmail-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes). + +**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container. + +## Phase 4: Build and Restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +## Phase 5: Verify + +### Test from the wired agent + +Tell the user: + +> In your `` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**. +> +> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange. + +### Check logs if the tool isn't working + +```bash +tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp' +# Per-container logs — session-scoped: +ls data/v2-sessions/*/stderr.log | head +``` + +Common signals: +- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile). +- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`. +- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id `) and that the Gmail app is connected (`onecli apps get --provider gmail`). +- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious). + +## Removal + +1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`. +2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`. +3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`. +4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`. +5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs. +6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`. + +## Notes + +- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes. +- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set. +- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0. + +## Credits & references + +- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed. +- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`. +- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md). +- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side. +- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version. diff --git a/container/Dockerfile b/container/Dockerfile index 4b4cf22..8c296ea 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -23,6 +23,7 @@ ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest ARG BUN_VERSION=1.3.12 +ARG GMAIL_MCP_VERSION=1.1.11 # ---- System dependencies ----------------------------------------------------- # tini: correct PID 1 / signal forwarding so outbound.db writes finalize on @@ -104,6 +105,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" + # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..0ba0919 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,6 +55,7 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', + 'mcp__gmail__*', ]; interface SDKUserMessage { From 81ef193e692dcf76a2fcd72a3995dc7edd017aef Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 13:38:46 +1000 Subject: [PATCH 30/47] refactor(session-state): key continuations per provider to survive provider switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, every provider stored its opaque continuation id under the single outbound.db key `sdk_session_id`. Flipping a session's agent_provider (e.g. Codex → Claude) meant the new provider read the old provider's id at wake, handed it to its own SDK, and got a "No conversation found" error that cost the user one sacrificed message before the stale-session recovery path cleared the id. This reshapes session_state so continuations are keyed `continuation:` instead. Consequences: - Per-provider continuations coexist. Flipping Claude → Codex → Claude resumes the Claude thread exactly where it left off, with the intervening Codex thread also still on file. - No provider ever reads another provider's id. Switching costs no sacrificed message and emits no transient error. - Legacy installs are migrated forward on first startup: migrateLegacyContinuation() adopts any pre-existing `sdk_session_id` row into the current provider's slot (best guess — it was whichever provider ran last), then deletes the legacy row unconditionally so it can't poison a future provider's read. runPollLoop now takes providerName alongside the provider instance, and threads it through processQuery to setContinuation on init. Tests: 9 new tests covering set/get isolation across providers, clear-specificity, legacy-adoption, legacy-always-deleted, prefer-existing-slot-over-legacy, and idempotency of a second migration call. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-runner/src/db/session-state.test.ts | 100 ++++++++++++++++++ .../agent-runner/src/db/session-state.ts | 62 ++++++++--- container/agent-runner/src/index.ts | 1 + .../agent-runner/src/integration.test.ts | 1 + container/agent-runner/src/poll-loop.ts | 28 +++-- 5 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 container/agent-runner/src/db/session-state.test.ts diff --git a/container/agent-runner/src/db/session-state.test.ts b/container/agent-runner/src/db/session-state.test.ts new file mode 100644 index 0000000..b5aa269 --- /dev/null +++ b/container/agent-runner/src/db/session-state.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { getOutboundDb, initTestSessionDb } from './connection.js'; +import { + clearContinuation, + getContinuation, + migrateLegacyContinuation, + setContinuation, +} from './session-state.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +function seedLegacy(value: string): void { + getOutboundDb() + .prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') + .run('sdk_session_id', value, new Date().toISOString()); +} + +describe('session-state — per-provider continuations', () => { + test('set/get round-trip, case-insensitive provider key', () => { + setContinuation('claude', 'claude-conv-1'); + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('Claude')).toBe('claude-conv-1'); + expect(getContinuation('CLAUDE')).toBe('claude-conv-1'); + }); + + test('providers are isolated — switching reads the right slot', () => { + setContinuation('claude', 'claude-conv-1'); + setContinuation('codex', 'codex-thread-xyz'); + + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('codex')).toBe('codex-thread-xyz'); + }); + + test('clearContinuation only affects the specified provider', () => { + setContinuation('claude', 'keep-me'); + setContinuation('codex', 'drop-me'); + + clearContinuation('codex'); + + expect(getContinuation('claude')).toBe('keep-me'); + expect(getContinuation('codex')).toBeUndefined(); + }); + + test('unknown provider returns undefined', () => { + expect(getContinuation('never-used')).toBeUndefined(); + }); +}); + +describe('session-state — legacy migration', () => { + test('adopts legacy value into current provider when current is empty', () => { + seedLegacy('old-session-id'); + + const adopted = migrateLegacyContinuation('claude'); + + expect(adopted).toBe('old-session-id'); + expect(getContinuation('claude')).toBe('old-session-id'); + }); + + test('always deletes legacy row regardless of migration outcome', () => { + seedLegacy('old-session-id'); + setContinuation('claude', 'existing'); + + migrateLegacyContinuation('claude'); + + // After migration the legacy key must be gone, whether or not it was adopted. + // A subsequent migration for a different provider must not see it. + const resultAfterSecondCall = migrateLegacyContinuation('codex'); + expect(resultAfterSecondCall).toBeUndefined(); + }); + + test('prefers existing current-provider slot over legacy', () => { + seedLegacy('legacy-value'); + setContinuation('claude', 'claude-value'); + + const result = migrateLegacyContinuation('claude'); + + expect(result).toBe('claude-value'); + expect(getContinuation('claude')).toBe('claude-value'); + }); + + test('no legacy row — returns current provider value (possibly undefined)', () => { + expect(migrateLegacyContinuation('claude')).toBeUndefined(); + + setContinuation('codex', 'codex-value'); + expect(migrateLegacyContinuation('codex')).toBe('codex-value'); + }); + + test('migration is idempotent on a second call (legacy already gone)', () => { + seedLegacy('once'); + + const first = migrateLegacyContinuation('claude'); + expect(first).toBe('once'); + + const second = migrateLegacyContinuation('claude'); + expect(second).toBe('once'); + }); +}); diff --git a/container/agent-runner/src/db/session-state.ts b/container/agent-runner/src/db/session-state.ts index a199ae1..9e12309 100644 --- a/container/agent-runner/src/db/session-state.ts +++ b/container/agent-runner/src/db/session-state.ts @@ -2,12 +2,20 @@ * Persistent key/value state for the container. Lives in outbound.db * (container-owned, already scoped per channel/thread). * - * Primary use: remember the SDK session ID so the agent's conversation - * resumes across container restarts. Cleared by /clear. + * Primary use: remember each provider's opaque continuation id so the + * agent's conversation resumes across container restarts. Keyed per + * provider because continuations are provider-private — a Claude + * conversation id means nothing to Codex and vice versa. Switching + * providers is therefore lossless: each provider's last thread stays + * on file and resumes cleanly if the user flips back. */ import { getOutboundDb } from './connection.js'; -const SDK_SESSION_KEY = 'sdk_session_id'; +const LEGACY_KEY = 'sdk_session_id'; + +function continuationKey(providerName: string): string { + return `continuation:${providerName.toLowerCase()}`; +} function getValue(key: string): string | undefined { const row = getOutboundDb() @@ -18,9 +26,7 @@ function getValue(key: string): string | undefined { function setValue(key: string, value: string): void { getOutboundDb() - .prepare( - 'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)', - ) + .prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') .run(key, value, new Date().toISOString()); } @@ -28,14 +34,46 @@ function deleteValue(key: string): void { getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key); } -export function getStoredSessionId(): string | undefined { - return getValue(SDK_SESSION_KEY); +/** + * One-time migration of the pre-per-provider continuation row. + * + * Before this was keyed per provider, continuations lived under the + * single key `sdk_session_id`. On container start, if that legacy row + * exists and the current provider has no continuation of its own, adopt + * the legacy value into the current provider's slot (best-guess — the + * legacy row was written by whatever provider ran last). The legacy row + * is always deleted so future provider flips never re-read a stale id + * through the wrong lens. + * + * Returns the continuation the caller should use at startup (either the + * current provider's existing value, the adopted legacy value, or + * undefined). + */ +export function migrateLegacyContinuation(providerName: string): string | undefined { + const legacy = getValue(LEGACY_KEY); + const currentKey = continuationKey(providerName); + const current = getValue(currentKey); + + if (legacy === undefined) return current; + + // Always drop the legacy row so no future provider reads it. + deleteValue(LEGACY_KEY); + + // Prefer the current provider's own slot if one already exists. + if (current !== undefined) return current; + + setValue(currentKey, legacy); + return legacy; } -export function setStoredSessionId(sessionId: string): void { - setValue(SDK_SESSION_KEY, sessionId); +export function getContinuation(providerName: string): string | undefined { + return getValue(continuationKey(providerName)); } -export function clearStoredSessionId(): void { - deleteValue(SDK_SESSION_KEY); +export function setContinuation(providerName: string, id: string): void { + setValue(continuationKey(providerName), id); +} + +export function clearContinuation(providerName: string): void { + deleteValue(continuationKey(providerName)); } diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 236be4c..90c690f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -95,6 +95,7 @@ async function main(): Promise { await runPollLoop({ provider, + providerName, cwd: CWD, systemContext: { instructions }, }); diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 4a8b091..3447c38 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna return Promise.race([ runPollLoop({ provider, + providerName: 'mock', cwd: '/tmp', }), new Promise((_, reject) => { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index d93bdd3..bd48db2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -2,7 +2,11 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; -import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; +import { + clearContinuation, + migrateLegacyContinuation, + setContinuation, +} from './db/session-state.js'; import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; @@ -19,6 +23,12 @@ function generateId(): string { export interface PollLoopConfig { provider: AgentProvider; + /** + * Name of the provider (e.g. "claude", "codex", "opencode"). Used to key + * the stored continuation per-provider so flipping providers doesn't + * resurrect a stale id from a different backend. + */ + providerName: string; cwd: string; systemContext?: { instructions?: string; @@ -39,8 +49,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise { // Resume the agent's prior session from a previous container run if one // was persisted. The continuation is opaque to the poll-loop — the // provider decides how to use it (Claude resumes a .jsonl transcript, - // other providers may reload a thread ID, etc.). - let continuation: string | undefined = getStoredSessionId(); + // other providers may reload a thread ID, etc.). Keyed per-provider so + // a Codex thread id never gets handed to Claude or vice versa. + let continuation: string | undefined = migrateLegacyContinuation(config.providerName); if (continuation) { log(`Resuming agent session ${continuation}`); @@ -94,7 +105,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) { log('Clearing session (resetting continuation)'); continuation = undefined; - clearStoredSessionId(); + clearContinuation(config.providerName); writeMessageOut({ id: generateId(), kind: 'chat', @@ -160,10 +171,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds); + const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; - setStoredSessionId(continuation); + setContinuation(config.providerName, continuation); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -175,7 +186,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { if (continuation && config.provider.isSessionInvalid(err)) { log(`Stale session detected (${continuation}) — clearing for next retry`); continuation = undefined; - clearStoredSessionId(); + clearContinuation(config.providerName); } // Write error response so the user knows something went wrong @@ -238,6 +249,7 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], + providerName: string, ): Promise { let queryContinuation: string | undefined; let done = false; @@ -288,7 +300,7 @@ async function processQuery( // container died between `init` and `result`, the SDK session was // effectively orphaned and the next message started a blank // Claude session with no prior context. - setStoredSessionId(event.continuation); + setContinuation(providerName, event.continuation); } else if (event.type === 'result') { // A result — with or without text — means the turn is done. Mark // the initial batch completed now so the host sweep doesn't see From 672e228876aa84ecb5c1a7142ab3b991c5506cee Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 15:15:33 +1000 Subject: [PATCH 31/47] fix(agent-route): forward file attachments between agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: `send_file(to='parent')` from a sub-agent wrote the bytes to the sub-agent's own session outbox, but agent-to-agent routing copied only the content JSON — the target's inbound message referenced `files: ['x.png']` but the bytes lived in a session directory the target couldn't mount. Parent agents orchestrating sub-agents (e.g. Design Team delegating illustration work to an Illustrator sub-agent on Codex) received file-reference messages with nothing to forward. Fix: on route, if the source's content has `files`, copy each referenced file from `/outbox//` to `/inbox//`, and emit `attachments` (the existing formatter convention — see formatter.ts:223) with `localPath` relative to `/workspace/`. The target formatter already renders these as `[file: — saved to /workspace/inbox//]`, so the target agent sees the path and can call `send_file(path=…, to=…)` to forward onward. Convention matches what session-manager.ts:256 already does for base64-encoded channel-inbound attachments — same inbox layout, same content shape. Nothing on the formatter/agent side needed to change. ## Scope - `forwardAttachedFiles(source, target)` — pure-ish helper that copies files and returns the attachments array. - `forwardFileAttachments(msg, …)` — wraps the helper for the route path: parses content, copies files if present, merges into any existing `attachments`, re-serialises. - `routeAgentMessage` — uses the rewritten content when writing the target's inbound row. - Log line now includes `forwardedFileCount` for observability. Missing source files are skipped with a warning rather than killing the route — a bad filename in a batch shouldn't drop the accompanying text. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/agent-to-agent/agent-route.ts | 144 +++++++++++++++++++++- 1 file changed, 138 insertions(+), 6 deletions(-) diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 760356c..faec2b9 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -3,9 +3,13 @@ * * Outbound messages with `channel_type === 'agent'` target another agent * group rather than a channel. Permission is enforced via `agent_destinations` — - * the source agent must have a row for the target. Content is copied verbatim; - * the target's formatter looks up the source agent in its own local map to - * display a name. + * the source agent must have a row for the target. Content is copied into the + * target's inbound DB; if the source message had `files` (from `send_file`), + * the actual bytes are copied from the source's outbox into the target's + * `inbox//` directory and surfaced to the target agent as + * `attachments` (existing formatter convention — see formatter.ts:230). + * The target agent can then forward the file onward via its own `send_file` + * call using the absolute `/workspace/inbox//` path. * * Self-messages are always allowed (used for system notes injected back into * an agent's own session, e.g. post-approval follow-up prompts). @@ -14,14 +18,75 @@ * `channel_type === 'agent'` check. When the module is absent the check in * core throws with a "module not installed" message so retry → mark failed. */ +import fs from 'fs'; +import path from 'path'; + import { getAgentGroup } from '../../db/agent-groups.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; import { log } from '../../log.js'; -import { resolveSession, writeSessionMessage } from '../../session-manager.js'; +import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; +export interface ForwardedAttachment { + name: string; + filename: string; + type: 'file'; + localPath: string; +} + +/** + * Copy file attachments from the source agent's outbox into the target + * agent's inbox. Returns attachments using the formatter's existing + * `{name, type, localPath}` convention — target agent reads `localPath` + * as relative to `/workspace/`, matching how channel-inbound attachments + * are surfaced today. + * + * Missing source files are skipped with a warning rather than failing + * the whole route — a bad filename reference shouldn't kill the + * accompanying text. + */ +export function forwardAttachedFiles( + source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] }, + target: { agentGroupId: string; sessionId: string; messageId: string }, +): ForwardedAttachment[] { + if (source.filenames.length === 0) return []; + + const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId); + if (!fs.existsSync(sourceDir)) { + log.warn('agent-route: source outbox dir missing, no files forwarded', { + sourceMsgId: source.messageId, + sourceDir, + }); + return []; + } + + const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId); + fs.mkdirSync(targetInboxDir, { recursive: true }); + + const attachments: ForwardedAttachment[] = []; + for (const filename of source.filenames) { + const src = path.join(sourceDir, filename); + if (!fs.existsSync(src)) { + log.warn('agent-route: referenced file missing in source outbox, skipped', { + sourceMsgId: source.messageId, + filename, + }); + continue; + } + const dst = path.join(targetInboxDir, filename); + fs.copyFileSync(src, dst); + attachments.push({ + name: filename, + filename, + type: 'file', + localPath: `inbox/${target.messageId}/${filename}`, + }); + } + return attachments; +} + export interface RoutableAgentMessage { id: string; platform_id: string | null; @@ -45,20 +110,87 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`); } const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); + const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // If the source message references files (via `send_file`), forward the + // bytes from the source's outbox into the target's inbox so the target + // agent can actually see and re-send them. Without this, agent-to-agent + // file attachments look like they arrive but the target has no way to + // read the bytes — they live in a session dir it doesn't mount. + const forwardedContent = forwardFileAttachments(msg, a2aMsgId, session, targetAgentGroupId, targetSession.id); + writeSessionMessage(targetAgentGroupId, targetSession.id, { - id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: a2aMsgId, kind: 'chat', timestamp: new Date().toISOString(), platformId: session.agent_group_id, channelType: 'agent', threadId: null, - content: msg.content, + content: forwardedContent, }); log.info('Agent message routed', { from: session.agent_group_id, to: targetAgentGroupId, targetSession: targetSession.id, + a2aMsgId, + forwardedFileCount: countForwardedFiles(forwardedContent), }); const fresh = getSession(targetSession.id); if (fresh) await wakeContainer(fresh); } + +/** + * Parse source content, copy any referenced `files` from source outbox to + * target inbox, and return a JSON string with an `attachments` array added + * (formatter.ts:223 already knows how to render this shape). + * + * If the source content isn't JSON or has no files, returns the original + * content string unchanged — this is safe to call on every route. + */ +function forwardFileAttachments( + msg: RoutableAgentMessage, + a2aMsgId: string, + sourceSession: Session, + targetAgentGroupId: string, + targetSessionId: string, +): string { + let parsed: Record; + try { + parsed = JSON.parse(msg.content); + } catch { + return msg.content; + } + const files = parsed.files as unknown; + if (!Array.isArray(files) || files.length === 0) return msg.content; + const filenames = files.filter((f): f is string => typeof f === 'string'); + if (filenames.length === 0) return msg.content; + + const attachments = forwardAttachedFiles( + { + agentGroupId: sourceSession.agent_group_id, + sessionId: sourceSession.id, + messageId: msg.id, + filenames, + }, + { + agentGroupId: targetAgentGroupId, + sessionId: targetSessionId, + messageId: a2aMsgId, + }, + ); + + // Merge into any existing `attachments` (unlikely in a2a context but safe). + const existing = Array.isArray(parsed.attachments) ? (parsed.attachments as Record[]) : []; + parsed.attachments = [...existing, ...attachments]; + + return JSON.stringify(parsed); +} + +function countForwardedFiles(contentStr: string): number { + try { + const parsed = JSON.parse(contentStr); + return Array.isArray(parsed.attachments) ? parsed.attachments.length : 0; + } catch { + return 0; + } +} From fd03b893336397728a09fa850473d001432e4063 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 24 Apr 2026 15:44:19 +1000 Subject: [PATCH 32/47] fix(agent-route): reject unsafe attachment filenames to prevent path traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filenames in forwardAttachedFiles arrived from the source agent's messages_out content and were used directly in path.join on both source outbox read and target inbox write. A value like `../evil.sh` could escape `inbox//` on the target session (and similarly the source outbox on read), breaking session isolation — an adversarial or hallucinating sub-agent could overwrite files in a sibling session. Adds isSafeAttachmentName(name) — exported so it's unit-testable — which rejects empty, `.`, `..`, anything containing `/`, `\`, or NUL, and anything path.basename would strip. Guard runs before any I/O. Unsafe names are dropped with a warning log, same pattern as missing-source-file handling; a bad filename in one attachment doesn't kill the whole route's text delivery. Addresses Codex Review P1 on qwibitai/nanoclaw#1967. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-to-agent/agent-route.test.ts | 46 +++++++++++++++++++ src/modules/agent-to-agent/agent-route.ts | 33 +++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/modules/agent-to-agent/agent-route.test.ts diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts new file mode 100644 index 0000000..4d48f6f --- /dev/null +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { isSafeAttachmentName } from './agent-route.js'; + +/** + * `forwardAttachedFiles` has a filesystem side that's awkward to unit-test + * without mocking DATA_DIR. The guarantee worth pinning is that the + * filename validator rejects everything that could escape the inbox dir — + * `forwardAttachedFiles` runs this guard before any I/O, so traversal is + * impossible as long as this matrix holds. + */ +describe('isSafeAttachmentName', () => { + it('accepts plain filenames', () => { + expect(isSafeAttachmentName('baby-duck.png')).toBe(true); + expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true); + expect(isSafeAttachmentName('report.v2.docx')).toBe(true); + expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..` + }); + + it('rejects empty / sentinel values', () => { + expect(isSafeAttachmentName('')).toBe(false); + expect(isSafeAttachmentName('.')).toBe(false); + expect(isSafeAttachmentName('..')).toBe(false); + }); + + it('rejects path separators', () => { + expect(isSafeAttachmentName('../evil.png')).toBe(false); + expect(isSafeAttachmentName('/etc/passwd')).toBe(false); + expect(isSafeAttachmentName('nested/file.txt')).toBe(false); + expect(isSafeAttachmentName('windows\\path.exe')).toBe(false); + }); + + it('rejects NUL bytes', () => { + expect(isSafeAttachmentName('clean\0.png')).toBe(false); + }); + + it('rejects anything path.basename would strip', () => { + expect(isSafeAttachmentName('a/b')).toBe(false); + expect(isSafeAttachmentName('./thing')).toBe(false); + }); + + it('rejects non-string input', () => { + expect(isSafeAttachmentName(null as unknown as string)).toBe(false); + expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false); + }); +}); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index faec2b9..812cb8e 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -36,6 +36,26 @@ export interface ForwardedAttachment { localPath: string; } +/** + * Is `name` safe to use as the last segment of a path inside the target + * agent's inbox directory? Filenames arrive in messages_out content from + * the source agent — under a multi-agent setup with heterogenous providers + * (or a compromised / hallucinating sub-agent) they can't be trusted. + * + * Rejects: + * - empty string + * - `.` / `..` (traversal sentinels that path.basename returns as-is) + * - anything containing a path separator (`/` or `\`) or NUL + * - any value where `path.basename(name) !== name`, catching OS-specific + * separators and covering drives/prefixes on Windows runtimes + */ +export function isSafeAttachmentName(name: string): boolean { + if (typeof name !== 'string' || name.length === 0) return false; + if (name === '.' || name === '..') return false; + if (/[\\/\0]/.test(name)) return false; + return path.basename(name) === name; +} + /** * Copy file attachments from the source agent's outbox into the target * agent's inbox. Returns attachments using the formatter's existing @@ -43,9 +63,9 @@ export interface ForwardedAttachment { * as relative to `/workspace/`, matching how channel-inbound attachments * are surfaced today. * - * Missing source files are skipped with a warning rather than failing - * the whole route — a bad filename reference shouldn't kill the - * accompanying text. + * Missing source files and unsafe (path-traversal) filenames are skipped + * with a warning rather than failing the whole route — a bad filename + * reference shouldn't kill the accompanying text. */ export function forwardAttachedFiles( source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] }, @@ -67,6 +87,13 @@ export function forwardAttachedFiles( const attachments: ForwardedAttachment[] = []; for (const filename of source.filenames) { + if (!isSafeAttachmentName(filename)) { + log.warn('agent-route: rejecting unsafe attachment filename (path traversal attempt?)', { + sourceMsgId: source.messageId, + filename, + }); + continue; + } const src = path.join(sourceDir, filename); if (!fs.existsSync(src)) { log.warn('agent-route: referenced file missing in source outbox, skipped', { From f41c1620091185a65239e2169c1d402a731a916d Mon Sep 17 00:00:00 2001 From: Pankaj Garg Date: Fri, 24 Apr 2026 08:42:10 +0200 Subject: [PATCH 33/47] detect setup auth ping failures --- setup/lib/agent-ping.test.ts | 30 ++++++++++++++++++++++++++++++ setup/lib/agent-ping.ts | 24 ++++++++++++++++++++---- setup/verify.ts | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 setup/lib/agent-ping.test.ts diff --git a/setup/lib/agent-ping.test.ts b/setup/lib/agent-ping.test.ts new file mode 100644 index 0000000..5f2be2c --- /dev/null +++ b/setup/lib/agent-ping.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyPingResult } from './agent-ping.js'; + +describe('classifyPingResult', () => { + it('treats a normal text reply as ok', () => { + expect(classifyPingResult(0, 'pong\n')).toBe('ok'); + }); + + it('detects Anthropic auth errors printed as a chat reply', () => { + expect( + classifyPingResult( + 0, + 'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}', + ), + ).toBe('auth_error'); + }); + + it('detects auth errors on stderr too', () => { + expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error'); + }); + + it('preserves socket errors', () => { + expect(classifyPingResult(2, '')).toBe('socket_error'); + }); + + it('treats empty output as no reply', () => { + expect(classifyPingResult(0, '')).toBe('no_reply'); + }); +}); diff --git a/setup/lib/agent-ping.ts b/setup/lib/agent-ping.ts index 8c5127f..49c5fe2 100644 --- a/setup/lib/agent-ping.ts +++ b/setup/lib/agent-ping.ts @@ -13,7 +13,21 @@ */ import { spawn } from 'child_process'; -export type PingResult = 'ok' | 'no_reply' | 'socket_error'; +export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error'; + +export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult { + const output = `${stdout}\n${stderr}`; + if ( + /Invalid bearer token/i.test(output) || + /authentication[_ ]error/i.test(output) || + /Failed to authenticate/i.test(output) + ) { + return 'auth_error'; + } + if (exitCode === 2) return 'socket_error'; + if (exitCode === 0 && stdout.trim().length > 0) return 'ok'; + return 'no_reply'; +} export function pingCliAgent(timeoutMs = 30_000): Promise { return new Promise((resolve) => { @@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise { stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; + let stderr = ''; let settled = false; const timer = setTimeout(() => { if (settled) return; @@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise { child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8'); }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + }); child.on('close', (code) => { if (settled) return; settled = true; clearTimeout(timer); - if (code === 2) resolve('socket_error'); - else if (code === 0 && stdout.trim().length > 0) resolve('ok'); - else resolve('no_reply'); + resolve(classifyPingResult(code, stdout, stderr)); }); child.on('error', () => { if (settled) return; diff --git a/setup/verify.ts b/setup/verify.ts index 281b243..873af66 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -220,7 +220,7 @@ export async function run(_args: string[]): Promise { // 7. End-to-end: ping the CLI agent and confirm it replies. Only run if // everything upstream looks healthy, since a broken socket would just hang. - let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped'; + let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped'; if (service === 'running' && registeredGroups > 0) { log.info('Pinging CLI agent'); agentPing = await pingCliAgent(); From 1de5a0356bd5fddd6f36eb8470316883e297a238 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:44:35 +0200 Subject: [PATCH 34/47] fix(setup): accept CLI-only verify success --- setup/verify.ts | 69 ++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/setup/verify.ts b/setup/verify.ts index 281b243..4bfd3d0 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -14,14 +14,9 @@ import Database from 'better-sqlite3'; import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; -import { pingCliAgent } from './lib/agent-ping.js'; +import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; -import { - getPlatform, - getServiceManager, - hasSystemd, - isRoot, -} from './platform.js'; +import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { @@ -38,11 +33,7 @@ export async function run(_args: string[]): Promise { // a sibling checkout (common for developers with multiple clones), this // repo's `data/cli.sock` won't exist — AGENT_PING would return a // misleading `socket_error`. Surface the mismatch directly instead. - let service: - | 'not_found' - | 'stopped' - | 'running' - | 'running_other_checkout' = 'not_found'; + let service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout' = 'not_found'; let runningFromPath: string | null = null; const mgr = getServiceManager(); @@ -74,10 +65,7 @@ export async function run(_args: string[]): Promise { execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { - const pidStr = execSync( - `${prefix} show ${systemdUnit} -p MainPID --value`, - { encoding: 'utf-8' }, - ).trim(); + const pidStr = execSync(`${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }).trim(); const pid = Number(pidStr); if (Number.isInteger(pid) && pid > 0) { runningFromPath = resolveBinaryScript(pid); @@ -115,11 +103,7 @@ export async function run(_args: string[]): Promise { } } - if ( - service === 'running' && - runningFromPath && - !isPathInside(runningFromPath, projectRoot) - ) { + if (service === 'running' && runningFromPath && !isPathInside(runningFromPath, projectRoot)) { service = 'running_other_checkout'; } @@ -210,11 +194,7 @@ export async function run(_args: string[]): Promise { // 6. Check mount allowlist let mountAllowlist = 'missing'; - if ( - fs.existsSync( - path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), - ) - ) { + if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) { mountAllowlist = 'configured'; } @@ -227,15 +207,15 @@ export async function run(_args: string[]): Promise { log.info('Agent ping result', { agentPing }); } - // Determine overall status - const status = - service === 'running' && - credentials !== 'missing' && - anyChannelConfigured && - registeredGroups > 0 && - (agentPing === 'ok' || agentPing === 'skipped') - ? 'success' - : 'failed'; + // Determine overall status. A CLI-only install is valid when the local + // agent round-trip succeeds; messaging app credentials are optional. + const status = determineVerifyStatus({ + service, + credentials, + anyChannelConfigured, + registeredGroups, + agentPing, + }); log.info('Verification complete', { status, channelAuth }); @@ -255,6 +235,25 @@ export async function run(_args: string[]): Promise { if (status === 'failed') process.exit(1); } +export function determineVerifyStatus(input: { + service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout'; + credentials: string; + anyChannelConfigured: boolean; + registeredGroups: number; + agentPing: PingResult | 'skipped'; +}): 'success' | 'failed' { + const cliAgentResponds = input.agentPing === 'ok'; + const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds; + + return input.service === 'running' && + input.credentials !== 'missing' && + hasUsableChannel && + input.registeredGroups > 0 && + (cliAgentResponds || input.agentPing === 'skipped') + ? 'success' + : 'failed'; +} + /** * Given a PID, resolve the script path the process is executing (i.e. the * first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any From 4fc2c4275cc41be6abf2d2d7ad51e7911dad4b08 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:44:58 +0200 Subject: [PATCH 35/47] test(setup): cover CLI-only verify status --- setup/verify.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 setup/verify.test.ts diff --git a/setup/verify.test.ts b/setup/verify.test.ts new file mode 100644 index 0000000..1e09acd --- /dev/null +++ b/setup/verify.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { determineVerifyStatus } from './verify.js'; + +const healthyBase = { + service: 'running' as const, + credentials: 'configured', + anyChannelConfigured: false, + registeredGroups: 1, + agentPing: 'ok' as const, +}; + +describe('determineVerifyStatus', () => { + it('accepts a working CLI-only install', () => { + expect(determineVerifyStatus(healthyBase)).toBe('success'); + }); + + it('accepts a messaging-channel install when CLI ping is skipped', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + anyChannelConfigured: true, + agentPing: 'skipped', + }), + ).toBe('success'); + }); + + it('fails when neither CLI nor messaging channels are usable', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + agentPing: 'skipped', + }), + ).toBe('failed'); + }); + + it('fails when the CLI agent does not respond', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + anyChannelConfigured: true, + agentPing: 'no_reply', + }), + ).toBe('failed'); + }); + + it('fails when no agent groups are registered', () => { + expect( + determineVerifyStatus({ + ...healthyBase, + registeredGroups: 0, + }), + ).toBe('failed'); + }); +}); From 9fd694c763d086253717567d1f624e68abc803c7 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 11:49:04 +0200 Subject: [PATCH 36/47] chore(setup): minimize verify diff --- setup/verify.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/setup/verify.ts b/setup/verify.ts index 4bfd3d0..dbd37e5 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -16,7 +16,12 @@ import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; -import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js'; +import { + getPlatform, + getServiceManager, + hasSystemd, + isRoot, +} from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { @@ -33,7 +38,11 @@ export async function run(_args: string[]): Promise { // a sibling checkout (common for developers with multiple clones), this // repo's `data/cli.sock` won't exist — AGENT_PING would return a // misleading `socket_error`. Surface the mismatch directly instead. - let service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout' = 'not_found'; + let service: + | 'not_found' + | 'stopped' + | 'running' + | 'running_other_checkout' = 'not_found'; let runningFromPath: string | null = null; const mgr = getServiceManager(); @@ -65,7 +74,10 @@ export async function run(_args: string[]): Promise { execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { - const pidStr = execSync(`${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }).trim(); + const pidStr = execSync( + `${prefix} show ${systemdUnit} -p MainPID --value`, + { encoding: 'utf-8' }, + ).trim(); const pid = Number(pidStr); if (Number.isInteger(pid) && pid > 0) { runningFromPath = resolveBinaryScript(pid); @@ -103,7 +115,11 @@ export async function run(_args: string[]): Promise { } } - if (service === 'running' && runningFromPath && !isPathInside(runningFromPath, projectRoot)) { + if ( + service === 'running' && + runningFromPath && + !isPathInside(runningFromPath, projectRoot) + ) { service = 'running_other_checkout'; } @@ -194,7 +210,11 @@ export async function run(_args: string[]): Promise { // 6. Check mount allowlist let mountAllowlist = 'missing'; - if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) { + if ( + fs.existsSync( + path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), + ) + ) { mountAllowlist = 'configured'; } From 3d6837c411133227a4de7a5ae4b347c275d5fbcd Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 12:12:05 +0200 Subject: [PATCH 37/47] chore(format): apply prettier to chat-sdk-bridge.ts Two long-line violations introduced in d121cd1 (isGroup plumbing) exceed the printWidth limit. CI format:check fails on every PR opened against main until this is fixed; the fix is isolated here so no behavior change is mixed in. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index c8cf3cc..18ab2cb 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -125,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let setupConfig: ChannelSetup; let gatewayAbort: AbortController | null = null; - async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise { + async function messageToInbound( + message: ChatMessage, + isMention: boolean, + isGroup?: boolean, + ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -216,7 +220,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true)); + await setupConfig.onInbound( + channelId, + thread.id, + await messageToInbound(message, message.isMention === true, true), + ); }); // @mention in an unsubscribed thread — SDK-confirmed bot mention. From 2b51a4e7076d154b389499afb1df011cbe1e8123 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 24 Apr 2026 12:50:25 +0200 Subject: [PATCH 38/47] fix(workflows): label PRs from forks that follow the contributing template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fork PR, GITHUB_TOKEN is demoted to read-only regardless of the workflow's permissions: block, so issues.addLabels() returns 403. The label workflow silently works for PRs that skip the template (no checkboxes ticked → no API call) and fails for PRs that actually follow it — a hostile incentive against contributors who do the right thing. pull_request_target runs in the context of the base branch with full declared permissions, which is the documented fix for this case. Safe here because the workflow is metadata-only: it reads context.payload.pull_request.body and calls addLabels. No checkout, no PR-supplied code executes. A SECURITY comment is added above the trigger to keep it that way. Refs: - https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target - https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/label-pr.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index bec9d3e..ebfe3f3 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -1,7 +1,12 @@ name: Label PR +# SECURITY: this workflow runs with write access to the base repo on fork PRs, +# because `pull_request_target` executes in the context of the base branch. +# Keep it metadata-only — do NOT add actions/checkout or any step that +# executes PR-supplied content (install scripts, build commands, etc.). +# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ on: - pull_request: + pull_request_target: types: [opened, edited] jobs: From 5cbfccec05ef4fd078a8a0188e2d67c485d76c6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 12:25:45 +0000 Subject: [PATCH 39/47] chore: bump version to 2.0.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 20afddb..5454aa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.10", + "version": "2.0.11", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From f37e7753589b44dffe0aaf3f5d10e56f6cc091b3 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 16:30:14 +0300 Subject: [PATCH 40/47] Revert src changes; skill applies them at install time Phase 2 of the SKILL.md already contains the Dockerfile + TOOL_ALLOWLIST edit instructions with an "ALREADY APPLIED" short-circuit. Keeping those edits out of trunk means users who never run /add-gmail-tool don't carry the Gmail MCP package in their image. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/Dockerfile | 6 ------ container/agent-runner/src/providers/claude.ts | 1 - 2 files changed, 7 deletions(-) diff --git a/container/Dockerfile b/container/Dockerfile index 8c296ea..4b4cf22 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -23,7 +23,6 @@ ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=latest ARG BUN_VERSION=1.3.12 -ARG GMAIL_MCP_VERSION=1.1.11 # ---- System dependencies ----------------------------------------------------- # tini: correct PID 1 / signal forwarding so outbound.db writes finalize on @@ -105,11 +104,6 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -RUN --mount=type=cache,target=/root/.cache/pnpm \ - pnpm install -g \ - "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ - "zod-to-json-schema@3.22.5" - # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 0ba0919..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,7 +55,6 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', - 'mcp__gmail__*', ]; interface SDKUserMessage { From 52f8661f0cb172c953b6c361dc963c68d4d8c417 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 24 Apr 2026 13:35:49 +0000 Subject: [PATCH 41/47] docs(providers): note that container.json provider is what the runner reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream precedence fix (5845a5a) made agent_groups.agent_provider and sessions.agent_provider authoritative for host-side provider contribution (per-session mount, env passthrough), but those DB values don't propagate into the group's container.json — and the in-container runner reads `provider` from container.json, not from the DB. That caused a confusing failure mode: flipping the DB column to 'codex', rebuilding, and restarting still spawned a Claude runner because container.json had no provider field. The old skill wording ("container receives AGENT_PROVIDER from the resolved value") overstated the integration. Update add-codex and add-opencode "Per group / per session" sections to say: set `"provider": ""` in the group's container.json — that's the source the runner reads. Keep the DB columns documented for the host-side contribution they actually drive, and spell out the session → group → container.json → 'claude' fallback so the precedence is still discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 2 +- .claude/skills/add-opencode/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index 3411bae..14b3072 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -128,7 +128,7 @@ Codex also ships first-class local-runner flags — `codex --oss --local-provide ### Per group / per session -Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `codex` for groups or sessions that should use Codex. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). +Set `"provider": "codex"` in the group's **`container.json`** (`groups//container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`. `CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group. diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md index 08a558f..555f0fe 100644 --- a/.claude/skills/add-opencode/SKILL.md +++ b/.claude/skills/add-opencode/SKILL.md @@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \ ### Per group / per session -Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). +Set `"provider": "opencode"` in the group's **`container.json`** (`groups//container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`. Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers. From 6d35c8512997e5639af0d1f1ac1d95313226caa4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 16:49:40 +0300 Subject: [PATCH 42/47] skill(add-gcal-tool): OneCLI-native Google Calendar MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /add-gcal-tool — a sibling of /add-gmail-tool that installs @cocal/google-calendar-mcp with the same OneCLI stub-file pattern. Skill applies the Dockerfile + TOOL_ALLOWLIST changes at install time; trunk stays clean so users who never run the skill don't carry the calendar MCP in their image. Dropped the Phase 5 dry-run section since it hardcoded a per-install image tag slug and duplicated Phase 4's live agent test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-gcal-tool/SKILL.md | 210 ++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .claude/skills/add-gcal-tool/SKILL.md diff --git a/.claude/skills/add-gcal-tool/SKILL.md b/.claude/skills/add-gcal-tool/SKILL.md new file mode 100644 index 0000000..5751933 --- /dev/null +++ b/.claude/skills/add-gcal-tool/SKILL.md @@ -0,0 +1,210 @@ +--- +name: add-gcal-tool +description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time. +--- + +# Add Google Calendar Tool (OneCLI-native) + +This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault. + +**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained. + +Tools exposed (surfaced as `mcp__calendar__`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools. + +**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly. + +## Phase 1: Pre-flight + +### Verify OneCLI has Google Calendar connected + +```bash +onecli apps get --provider google-calendar +``` + +Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`. + +If not connected, tell the user: + +> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes. + +### Verify stub credentials exist + +The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead. + +```bash +ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1 +``` + +If both exist with `onecli-managed`: + +```bash +grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json +``` + +...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding. + +If absent, write them: + +```bash +mkdir -p ~/.calendar-mcp +cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF' +{ + "installed": { + "client_id": "onecli-managed.apps.googleusercontent.com", + "client_secret": "onecli-managed", + "redirect_uris": ["http://localhost:3000/oauth2callback"] + } +} +EOF +cat > ~/.calendar-mcp/credentials.json <<'EOF' +{ + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "token_type": "Bearer", + "expiry_date": 99999999999999, + "scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events" +} +EOF +chmod 600 ~/.calendar-mcp/*.json +``` + +### Verify mount allowlist covers the path + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json +``` + +`~/.calendar-mcp` must sit under an `allowedRoots` entry. + +### Check agent secret-mode + +For each target agent group, confirm OneCLI will inject the Google Calendar token: + +```bash +onecli agents list +``` + +`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret. + +## Phase 2: Apply Code Changes + +### Check if already applied + +```bash +grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \ +grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \ +echo "ALREADY APPLIED — skip to Phase 3" +``` + +### Add MCP server to Dockerfile + +Edit `container/Dockerfile`. Find the pinned-version ARG block and add: + +```dockerfile +ARG CALENDAR_MCP_VERSION=2.6.1 +``` + +If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" +``` + +If `/add-gmail-tool` hasn't been applied, install Calendar standalone: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" +``` + +### Add tools to allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present). + +### Rebuild the container image + +```bash +./container/build.sh +``` + +## Phase 3: Wire Per-Agent-Group + +For each agent group, merge into `groups//container.json`: + +```jsonc +{ + "mcpServers": { + "calendar": { + "command": "google-calendar-mcp", + "args": [], + "env": { + "GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json", + "GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json" + } + } + }, + "additionalMounts": [ + { + "hostPath": "/home//.calendar-mcp", + "containerPath": ".calendar-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/`). + +**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`. + +## Phase 4: Build and Restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +Kill any existing agent containers so they respawn with the new mcpServers config: + +```bash +docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill +``` + +## Phase 5: Verify + +### Test from a wired agent + +> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**. +> +> First call takes 2–3s while the MCP server starts and OneCLI does the token exchange. + +### Check logs if the tool isn't working + +```bash +tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp' +``` + +Common signals: +- `command not found: google-calendar-mcp` → image not rebuilt. +- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist. +- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected. +- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again). + +## Removal + +1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`. +2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`. +3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block. +4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`. +5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`. + +## Credits & references + +- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar. +- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it. +- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism. From fc375ca72b28ce2582e9ea5a0de492d1cef04a5a Mon Sep 17 00:00:00 2001 From: grtwrn Date: Thu, 23 Apr 2026 21:04:15 -0400 Subject: [PATCH 43/47] fix(register): wire channels with correct engage fields, skip prefix for native IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup/register.ts had two bugs that prevented new channels from being registered via `/manage-channels`: 1. createMessagingGroupAgent was called with the legacy field names `trigger_rules` and `response_scope`. The SQL INSERT expects `engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy` (migration 010). Every register call failed with `RangeError: Missing named parameter "engage_mode"` after the agent and messaging group were partially created — leaving an orphaned pair. Now mirrors scripts/init-first-agent.ts:wireIfMissing: - Groups (is_group=1) default to engage_mode='mention' (bot only responds when addressed). - DMs (is_group=0) default to engage_mode='pattern' with '.' (respond to every message). - An explicit --trigger overrides the pattern regex. 2. The "normalize platform_id" block unconditionally prefixed ":" even for native IDs like WhatsApp JIDs ("120363408974444974@g.us"), iMessage emails ("user@example.com"), or Signal phones ("+15551234567") / Signal groups ("group:abc"). But the router (src/router.ts:158) looks up messaging_groups by the raw event.platformId from the adapter, which for these native adapters never has a prefix. So the prefixed row was never matched — the message was silently dropped with no "Message routed" log. Extracted scripts/init-first-agent.ts:namespacedPlatformId into src/platform-id.ts so both setup paths use the same heuristic (skip the prefix for IDs containing '@', starting with '+', or starting with 'group:'). Prevents future drift between the two paths. Tested by: re-running `setup/index.ts --step register` for a WhatsApp group JID, confirming the row is created with correct engage fields and matching platform_id, then sending a test message and observing "Message routed" with the right agent group. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/init-first-agent.ts | 27 +-------------------------- setup/register.ts | 22 +++++++++++++--------- src/platform-id.ts | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 src/platform-id.ts diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index fc61b9c..61a17d6 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -48,6 +48,7 @@ import { addMember } from '../src/modules/permissions/db/agent-group-members.js' import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; +import { namespacedPlatformId } from '../src/platform-id.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; type Role = 'owner' | 'admin' | 'member'; @@ -137,32 +138,6 @@ function namespacedUserId(channel: string, raw: string): string { return raw.includes(':') ? raw : `${channel}:${raw}`; } -/** - * Determine whether a platform ID needs a channel-type prefix. - * - * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their - * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". - * The router stores `channel_type` and `platform_id` in separate columns, but - * Chat SDK adapters send the prefixed form as the platform_id, so this script - * must match that format. - * - * Native adapters (Signal, WhatsApp) use their own ID formats and send them - * as-is — no channel prefix. Signal sends raw phone numbers (+15551234567) - * for DMs and "group:" for group chats. WhatsApp sends JIDs containing - * '@' (@s.whatsapp.net, @g.us). Prefixing these would cause - * a mismatch between what the adapter sends and what the DB stores, breaking - * message routing. - */ -function namespacedPlatformId(channel: string, raw: string): string { - if (raw.startsWith(`${channel}:`)) return raw; - // Native WhatsApp JIDs contain '@' — no prefix needed. - if (raw.includes('@')) return raw; - // Native Signal IDs: phone numbers (+...) and group IDs (group:...). - if (raw.startsWith('+') || raw.startsWith('group:')) return raw; - // Chat SDK adapters — add the channel prefix. - return `${channel}:${raw}`; -} - function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } diff --git a/setup/register.ts b/setup/register.ts index ff194fc..7bd5ae3 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -20,6 +20,7 @@ import { import { isValidGroupFolder } from '../src/group-folder.js'; import { initGroupFilesystem } from '../src/group-init.js'; import { log } from '../src/log.js'; +import { namespacedPlatformId } from '../src/platform-id.js'; import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import { emitStatus } from './status.js'; @@ -112,12 +113,10 @@ export async function run(args: string[]): Promise { process.exit(4); } - // Chat SDK adapters prefix platform IDs with the channel type - // (e.g. "telegram:123", "discord:guild:channel"). Normalize here so - // the stored ID always matches what the adapter sends at runtime. - if (!parsed.platformId.startsWith(`${parsed.channel}:`)) { - parsed.platformId = `${parsed.channel}:${parsed.platformId}`; - } + // Normalize platform_id to the same shape the adapter will emit at runtime, + // so the router's (channel_type, platform_id) lookup matches what we store. + // Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't. + parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId); log.info('Registering channel', parsed); @@ -167,8 +166,13 @@ export async function run(args: string[]): Promise { if (!existing) { newlyWired = true; const mgaId = generateId('mga'); - const engageMode = parsed.trigger || !parsed.requiresTrigger ? 'pattern' : 'mention'; - const engagePattern = parsed.trigger ? parsed.trigger : (!parsed.requiresTrigger ? '.' : null); + // Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths + // create rows with the same shape. Groups default to 'mention' (bot only + // responds when addressed); DMs default to 'pattern'/'.' (respond to + // every message). An explicit --trigger overrides the pattern regex. + const isGroup = messagingGroup.is_group === 1; + const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern'; + const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null; createMessagingGroupAgent({ id: mgaId, messaging_group_id: messagingGroup.id, @@ -177,7 +181,7 @@ export async function run(args: string[]): Promise { engage_pattern: engagePattern, sender_scope: 'all', ignored_message_policy: 'drop', - session_mode: parsed.sessionMode, + session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared', priority: 0, created_at: new Date().toISOString(), }); diff --git a/src/platform-id.ts b/src/platform-id.ts new file mode 100644 index 0000000..1c49325 --- /dev/null +++ b/src/platform-id.ts @@ -0,0 +1,23 @@ +/** + * Determine whether a platform ID needs a channel-type prefix. + * + * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their + * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". + * The router stores channel_type and platform_id in separate columns, but + * Chat SDK adapters send the prefixed form as the platform_id — so any code + * that writes messaging_groups rows must produce the same shape the adapter + * will later emit as event.platformId, or router lookups miss and messages + * get silently dropped. + * + * Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and + * send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails + * containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs + * and 'group:' for group chats. Prefixing any of these would cause a + * mismatch with what the adapter later emits. + */ +export function namespacedPlatformId(channel: string, raw: string): string { + if (raw.startsWith(`${channel}:`)) return raw; + if (raw.includes('@')) return raw; + if (raw.startsWith('+') || raw.startsWith('group:')) return raw; + return `${channel}:${raw}`; +} From 226fc9379595b97eb1746200bffc9ed396ca0ade Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:13:32 +0000 Subject: [PATCH 44/47] =?UTF-8?q?docs:=20update=20token=20count=20to=20132?= =?UTF-8?q?k=20tokens=20=C2=B7=2066%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index fd8a436..0dfb9a2 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 130k tokens, 65% of context window + + 132k tokens, 66% of context window @@ -15,8 +15,8 @@ tokens - - 130k + + 132k From 15a6950b5b74f65afe3f86c85882323204369d8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:13:34 +0000 Subject: [PATCH 45/47] chore: bump version to 2.0.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5454aa4..c3a3d85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.11", + "version": "2.0.12", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 8d8522202a0604d187f9da132c59f386e3c489a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 14:20:58 +0000 Subject: [PATCH 46/47] chore: bump version to 2.0.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c3a3d85..6029e0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.12", + "version": "2.0.13", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From b6be3b9bf458e4710c0a8537002a60b6486447cb Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Sat, 25 Apr 2026 16:52:20 +0300 Subject: [PATCH 47/47] docs(skills): merge add-signal-v2 lessons into add-signal, drop v2 Absorbs battle-tested knowledge from the v2 skill into the upstream add-signal: registration paths (new number + linked device), CAPTCHA flow, VoIP SMS-first timing, Java prereq, config-lock warning, wiring SQL for groups, not_member silent-drop fix, GroupV2 groupId extraction note, and UUID-based platform ID format. Corrects a factual error in the upstream: DM platform IDs are signal:{UUID} (ACI), not phone numbers. Removes the now-redundant add-signal-v2 skill. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-signal-v2/SKILL.md | 311 -------------------------- .claude/skills/add-signal/SKILL.md | 198 ++++++++++++++-- 2 files changed, 184 insertions(+), 325 deletions(-) delete mode 100644 .claude/skills/add-signal-v2/SKILL.md diff --git a/.claude/skills/add-signal-v2/SKILL.md b/.claude/skills/add-signal-v2/SKILL.md deleted file mode 100644 index 06edd17..0000000 --- a/.claude/skills/add-signal-v2/SKILL.md +++ /dev/null @@ -1,311 +0,0 @@ -# Add Signal Channel (v2) - -Adds Signal messaging support to NanoClaw v2 using `signal-sdk` (a TypeScript -wrapper around `signal-cli`). Unlike Telegram/Discord, Signal has no bot API — -NanoClaw registers as a full Signal account on a dedicated phone number. - -**Two registration paths:** -- **New number (recommended):** Register a dedicated SIM or VoIP number as a - standalone Signal account. NanoClaw owns the number entirely. -- **Linked device:** Join an existing Signal account as a secondary device via - QR code. Simpler, but NanoClaw shares your personal number. - -Both paths are documented below. The new-number path is battle-tested. - ---- - -## Pre-flight - -Check if `src/channels/signal.ts` exists and the import is uncommented in -`src/channels/index.ts`. If both are in place, skip to Registration. - -## Install - -### 1. Check Java - -Java 17+ is required. Check: - -```bash -java -version -``` - -If missing: -- **RHEL/CentOS/Fedora:** `sudo dnf install -y java-17-openjdk` -- **Debian/Ubuntu:** `sudo apt-get install -y default-jre` -- **macOS:** `brew install --cask temurin@17` - -Java 17–25 all work. Java 25 (RHEL9 default) is confirmed working. - -### 2. Install signal-cli - -The `signal-sdk` npm package bundles signal-cli, but the bundled version is -often outdated. Install the latest standalone binary: - -```bash -SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])") -curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ - | tar -xz -C ~/.local -ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli -signal-cli --version -``` - -> **Note:** The Linux native tarball extracts a single binary directly to -> `~/.local/signal-cli` (not into a subdirectory). The symlink above handles this. - -### 3. Install signal-sdk - -```bash -npm install signal-sdk -``` - -### 4. Enable the adapter - -Uncomment the Signal import in `src/channels/index.ts`: - -```typescript -import './signal.js'; -``` - -### 5. Build - -Always build with Node 22 (nvm): - -```bash -source ~/.nvm/nvm.sh && nvm use 22 && npm run build -``` - ---- - -## Path A: Register a new Signal number - -Use this if you have a dedicated SIM or VoIP number for NanoClaw. - -> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP -> providers are blocked even for voice calls. If registration fails with an auth -> error, try a different provider or a physical SIM. - -### Step 1: Request SMS verification - -Signal requires a CAPTCHA on first registration: - -1. Open `https://signalcaptchas.org/registration/generate.html` in a browser -2. Solve the captcha -3. Right-click the **"Open Signal"** button → **Copy Link** -4. The link starts with `signalcaptcha://...` - -```bash -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER register \ - --captcha "PASTE_CAPTCHA_TOKEN_HERE" -``` - -The captcha token is everything after `signalcaptcha://` in the copied link. - -### Step 2: Voice call fallback (VoIP numbers without SMS) - -If your number cannot receive SMS, wait ~60 seconds after the SMS request then -request a voice call: - -```bash -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER register --voice \ - --captcha "SAME_CAPTCHA_TOKEN" -``` - -Signal will call your number and read a 6-digit code. - -> The captcha token from Step 1 is reusable for the voice retry — no need to -> solve a new one. - -### Step 3: Verify - -```bash -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER verify CODE -``` - -No output = success. - -### Step 4: Set profile name (optional) - -> ⚠ signal-sdk holds an exclusive lock on `data/signal/` while nanoclaw is -> running. Stop the service before running signal-cli commands, then restart. - -```bash -systemctl --user stop nanoclaw -SIGNAL_CLI_CONFIG_PATH=/path/to/nanoclaw/data/signal \ - signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" -systemctl --user start nanoclaw -``` - -To set an avatar too: -```bash -signal-cli -u +YOURNUMBER updateProfile --name "YourBotName" --avatar /path/to/avatar.jpg -``` - ---- - -## Path B: Link as secondary device - -Use this to join an existing Signal account as a secondary device. - -```bash -mkdir -p data/signal -export SIGNAL_CLI_CONFIG_PATH=$(pwd)/data/signal -npx signal-sdk link -n "NanoClaw" -a +YOURNUMBER -``` - -This prints a QR code. On your phone: **Settings → Linked Devices → Link New Device**. -Scan the code within ~30 seconds. - ---- - -## Configure environment - -Add to `.env`: - -```bash -SIGNAL_PHONE_NUMBER=+YOURNUMBER -``` - ---- - -## Wire to an agent - -### DMs - -After the service starts, **send any message** to the Signal number from your -personal Signal app. The router auto-creates a `messaging_groups` row. Then: - -```bash -sqlite3 data/v2.db \ - "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" -``` - -Pass the `id` and `platform_id` to `/init-first-agent` or wire manually. - -**Important:** DM `platform_id` is UUID-based, not phone-based: -- DM: `signal:3de71d7f-ffa3-437e-b4db-097534bccd46` (UUID of sender) -- Group: `signal:UwaIz6bc09Olg1pBr/XeBuQf6z3fyCZoNj/Y3Tpe3hI=` (base64 group ID) - -### Groups - -Add the Signal number to a group from your phone. Send any message — the router -auto-creates the group's `messaging_groups` row. Wire it: - -```bash -sqlite3 data/v2.db \ - "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" -``` - -Then insert the wiring (replace IDs): - -```bash -NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " -INSERT OR IGNORE INTO messaging_group_agents - (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) -VALUES - ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); -" -``` - -Use `session_mode='isolated'` for groups so each group has its own session. - -### Grant user access - -Users who message via Signal need to be granted membership. Without this, -messages are silently dropped with `not_member`. After first contact: - -```bash -NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " -INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) - VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); -INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) - VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); -" -``` - -Find the UUID from `messaging_groups.platform_id` or the `users` table. - ---- - -## Voice Transcription (optional) - -Inbound voice messages are automatically transcribed using a local whisper -binary. The feature degrades gracefully — if whisper or ffmpeg is missing, -voice messages are still delivered as attachments with no transcript. - -See `/add-voice-transcription-free-whisper` for full setup instructions -(ffmpeg, whisper backends, model download, environment variables, troubleshooting). - ---- - -## Channel Info - -- **type**: `signal` -- **terminology**: "Chats" (1:1) and "groups" (multi-member). No threads. -- **supports-threads**: no -- **inbound**: text, reactions (forwarded to agent with emoji + targetTimestamp), images, files, voice (transcribed via whisper-cli if installed) -- **outbound**: text, reactions (sendReaction), file attachments -- **platform-id-format**: - - DM: `signal:{UUID}` — sender's Signal UUID, **not** their phone number - - Group: `signal:{base64GroupId}` -- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` - as shown above. -- **config-lock**: signal-sdk holds an exclusive lock on `data/signal/` while - nanoclaw is running. Stop the service before running any `signal-cli` commands. -- **attachment storage**: signal-sdk launches signal-cli **without** a `--config` - flag, so signal-cli stores attachments at the XDG default - (`~/.local/share/signal-cli/attachments/`), not under `data/signal/`. The - adapter checks both locations. Verify with: - `ps aux | grep signal-cli` — if there is no `-c` argument, XDG default is in use. - ---- - -## Troubleshooting - -**`Config file is in use by another instance`** — nanoclaw is running and -signal-sdk has the lock. Stop the service, run the command, restart. - -**Messages dropped with `not_member`** — the Signal user hasn't been granted -membership. See "Grant user access" above. This affects every new Signal user, -including the owner's Signal identity (which is separate from their Telegram -identity even if it's the same person). - -**Captcha required** — Signal requires captcha for new registrations. Go to -`https://signalcaptchas.org/registration/generate.html`, solve it, right-click -"Open Signal", copy the link, extract the token after `signalcaptcha://`. - -**`Invalid verification method: Before requesting voice verification…`** — -You must request SMS first, wait ~60 seconds, then request voice. Both can use -the same captcha token. - -**`The provided model identifier is invalid` (Bedrock)** — unrelated to Signal; -this is a LiteLLM model ID issue. - -**Group replies going to DM instead of group** — modern Signal groups use -GroupV2. The adapter must extract the group ID from -`envelope?.dataMessage?.groupV2?.id` (not just `groupInfo?.groupId`, which is -GroupV1/legacy). Check `src/channels/signal.ts` and confirm the groupId -extraction falls through to `groupV2.id`. - -**Voice messages / attachments silently skipped, no transcript** — signal-sdk -launches signal-cli without a `--config` flag, so attachments land at the XDG -default (`~/.local/share/signal-cli/attachments/`) rather than under -`data/signal/`. Confirm with `ps aux | grep signal-cli` — if there is no `-c` -argument in the process line, the XDG default is in use. The adapter falls back -to that location automatically. If you still see no "Signal attachment saved" -log lines, add a debug log around the `if (!storedPath) continue` guard in -`src/channels/signal.ts` to inspect `att.storedFilename` and `att.id`. - -**Java not found** — install Java 17+ (see Install step 1). - -**QR code expired (Path B)** — QR codes expire in ~30 seconds. Re-run the -link command to generate a new one. - -**signal-cli binary location** — The native Linux tarball extracts directly to -`~/.local/signal-cli` (a single file, not a directory). The system `aws` or -other tools named `signal-cli` won't be in PATH by default; check -`~/.local/bin/signal-cli`. diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index e6d41aa..7dcc8ad 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -5,20 +5,116 @@ description: Add Signal channel integration via signal-cli TCP daemon. Native ad # Add Signal Channel -Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge, no npm deps — only Node.js builtins. +Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). + +Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number. ## Prerequisites -`signal-cli` installed and a Signal account linked: +### Java -- macOS: `brew install signal-cli` -- Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) -- Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) +signal-cli requires Java 17+: + +```bash +java -version +``` + +If missing: +- **macOS:** `brew install --cask temurin@17` +- **Debian/Ubuntu:** `sudo apt-get install -y default-jre` +- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk` + +Java 17–25 all work. + +### signal-cli + +- **macOS:** `brew install signal-cli` +- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases): + +```bash +SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])") +curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ + | tar -xz -C ~/.local +ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli +signal-cli --version +``` + +> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH. + +## Registration + +Two paths. The new-number path is recommended and battle-tested. + +### Path A: Register a new number (recommended) + +Use a dedicated SIM or VoIP number. NanoClaw owns it entirely. + +> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM. + +**Step 1: Solve the CAPTCHA** + +Signal requires a CAPTCHA on first registration: + +1. Open `https://signalcaptchas.org/registration/generate.html` in a browser +2. Solve the captcha +3. Right-click the **"Open Signal"** button → **Copy Link** +4. The link starts with `signalcaptcha://` — the token is everything after that prefix + +**Step 2: Request SMS verification** + +```bash +signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE" +``` + +**Step 3: Voice call fallback (if your number can't receive SMS)** + +Wait ~60 seconds after the SMS request, then: + +```bash +signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN" +``` + +Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one. + +> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…` + +**Step 4: Verify** + +```bash +signal-cli -a +1YOURNUMBER verify CODE +``` + +No output = success. + +**Step 5: Set profile name (optional)** + +> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running. + +```bash +# macOS +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" +# optionally: --avatar /path/to/avatar.jpg +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist + +# Linux +systemctl --user stop nanoclaw +signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" +systemctl --user start nanoclaw +``` + +### Path B: Link as secondary device + +Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number. + +```bash +signal-cli -a +1YOURNUMBER link --name "NanoClaw" +``` + +This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires. ## Install -NanoClaw doesn't ship channels in trunk. This skill copies the Signal adapter and its tests in from the `channels` branch. - ### Pre-flight (idempotent) Skip to **Credentials** if all of these are already in place: @@ -55,7 +151,7 @@ import './signal.js'; pnpm run build ``` -No npm packages to install — the adapter uses only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). +No npm packages to install — the adapter uses only Node.js builtins. ## Credentials @@ -97,27 +193,73 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw systemctl --user restart nanoclaw ``` +## Wiring + +### DMs + +After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then: + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" +``` + +Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group. + +### Groups + +Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR IGNORE INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) +VALUES + ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); +" +``` + +### Grant user access + +New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); +INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); +" +``` + +Find the UUID from `messaging_groups.platform_id` or the `users` table. + ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. -Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. Signal is direct-addressable — your phone number is the platform ID. +Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. ## Channel Info - **type**: `signal` -- **terminology**: Signal has "chats" (1:1 DMs) and "groups." -- **how-to-find-id**: DMs use your phone number (e.g. `+15555550123`). Groups use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups`. +- **terminology**: Signal has "chats" (1:1 DMs) and "groups" - **supports-threads**: no +- **platform-id-format**: + - DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number + - Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID +- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above - **typical-use**: Personal assistant via Signal DMs or small group chats -- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically be separate. +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode ### Features - Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles) - Quoted replies — `replyTo*` fields populated from Signal quotes - Typing indicators — DMs only (Signal doesn't support group typing) -- Echo suppression — outbound messages are matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops +- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops - Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true` - Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx @@ -145,4 +287,32 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA ### Lost connection mid-session -If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped us. There's no auto-reconnect yet — restart the service to re-establish. +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish. + +### Messages dropped with `not_member` + +The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person. + +### Captcha required + +Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`. + +### `Invalid verification method: Before requesting voice verification…` + +You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token. + +### Config file in use / daemon lock + +signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward. + +### Group replies going to DM instead of group + +Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`. + +### Java not found + +Install Java 17+ — see the Prerequisites section above. + +### QR code expired (Path B) + +QR codes expire in ~30 seconds. Re-run the link command to generate a new one.