Merge branch 'main' into feat/migrate-from-v1
This commit is contained in:
161
.claude/skills/add-codex/SKILL.md
Normal file
161
.claude/skills/add-codex/SKILL.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
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.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:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/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.
|
||||||
|
|
||||||
|
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.
|
||||||
210
.claude/skills/add-gcal-tool/SKILL.md
Normal file
210
.claude/skills/add-gcal-tool/SKILL.md
Normal file
@@ -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__<name>`, 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/<folder>/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/<user>/.calendar-mcp",
|
||||||
|
"containerPath": ".calendar-mcp",
|
||||||
|
"readonly": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||||
|
|
||||||
|
**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.
|
||||||
229
.claude/skills/add-gmail-tool/SKILL.md
Normal file
229
.claude/skills/add-gmail-tool/SKILL.md
Normal file
@@ -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__<name>`): `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/<user>`). 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 <agent-id> --secret-ids <gmail-secret-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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/<folder>/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/<user>/.gmail-mcp",
|
||||||
|
"containerPath": ".gmail-mcp",
|
||||||
|
"readonly": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute `<user>` 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 `<agent-name>` 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 <agent-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.
|
||||||
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
|||||||
|
|
||||||
### Per group / per session
|
### 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/<folder>/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.
|
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.
|
||||||
|
|
||||||
|
|||||||
13
.claude/skills/add-signal/REMOVE.md
Normal file
13
.claude/skills/add-signal/REMOVE.md
Normal file
@@ -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 <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
|
||||||
318
.claude/skills/add-signal/SKILL.md
Normal file
318
.claude/skills/add-signal/SKILL.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
---
|
||||||
|
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 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
|
||||||
|
|
||||||
|
### Java
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Pre-flight (idempotent)
|
||||||
|
|
||||||
|
Skip to **Credentials** if all of these are already in place:
|
||||||
|
|
||||||
|
- `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 channels branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin channels
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Copy the adapter and tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SIGNAL_ACCOUNT=+1YOURNUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TCP daemon host and port (default: 127.0.0.1:7583)
|
||||||
|
SIGNAL_TCP_HOST=127.0.0.1
|
||||||
|
SIGNAL_TCP_PORT=7583
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
Sync to container: `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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Channel Info
|
||||||
|
|
||||||
|
- **type**: `signal`
|
||||||
|
- **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 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 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 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.
|
||||||
5
.claude/skills/add-signal/VERIFY.md
Normal file
5
.claude/skills/add-signal/VERIFY.md
Normal file
@@ -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`.
|
||||||
7
.github/workflows/label-pr.yml
vendored
7
.github/workflows/label-pr.yml
vendored
@@ -1,7 +1,12 @@
|
|||||||
name: Label PR
|
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:
|
on:
|
||||||
pull_request:
|
pull_request_target:
|
||||||
types: [opened, edited]
|
types: [opened, edited]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
100
container/agent-runner/src/db/session-state.test.ts
Normal file
100
container/agent-runner/src/db/session-state.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,20 @@
|
|||||||
* Persistent key/value state for the container. Lives in outbound.db
|
* Persistent key/value state for the container. Lives in outbound.db
|
||||||
* (container-owned, already scoped per channel/thread).
|
* (container-owned, already scoped per channel/thread).
|
||||||
*
|
*
|
||||||
* Primary use: remember the SDK session ID so the agent's conversation
|
* Primary use: remember each provider's opaque continuation id so the
|
||||||
* resumes across container restarts. Cleared by /clear.
|
* 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';
|
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 {
|
function getValue(key: string): string | undefined {
|
||||||
const row = getOutboundDb()
|
const row = getOutboundDb()
|
||||||
@@ -18,9 +26,7 @@ function getValue(key: string): string | undefined {
|
|||||||
|
|
||||||
function setValue(key: string, value: string): void {
|
function setValue(key: string, value: string): void {
|
||||||
getOutboundDb()
|
getOutboundDb()
|
||||||
.prepare(
|
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||||
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
|
|
||||||
)
|
|
||||||
.run(key, value, new Date().toISOString());
|
.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);
|
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 {
|
export function getContinuation(providerName: string): string | undefined {
|
||||||
setValue(SDK_SESSION_KEY, sessionId);
|
return getValue(continuationKey(providerName));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStoredSessionId(): void {
|
export function setContinuation(providerName: string, id: string): void {
|
||||||
deleteValue(SDK_SESSION_KEY);
|
setValue(continuationKey(providerName), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearContinuation(providerName: string): void {
|
||||||
|
deleteValue(continuationKey(providerName));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
await runPollLoop({
|
await runPollLoop({
|
||||||
provider,
|
provider,
|
||||||
|
providerName,
|
||||||
cwd: CWD,
|
cwd: CWD,
|
||||||
systemContext: { instructions },
|
systemContext: { instructions },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
|||||||
return Promise.race([
|
return Promise.race([
|
||||||
runPollLoop({
|
runPollLoop({
|
||||||
provider,
|
provider,
|
||||||
|
providerName: 'mock',
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
}),
|
}),
|
||||||
new Promise<void>((_, reject) => {
|
new Promise<void>((_, reject) => {
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
|||||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||||
import { writeMessageOut } from './db/messages-out.js';
|
import { writeMessageOut } from './db/messages-out.js';
|
||||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.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 { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||||
|
|
||||||
@@ -19,6 +23,12 @@ function generateId(): string {
|
|||||||
|
|
||||||
export interface PollLoopConfig {
|
export interface PollLoopConfig {
|
||||||
provider: AgentProvider;
|
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;
|
cwd: string;
|
||||||
systemContext?: {
|
systemContext?: {
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
@@ -39,8 +49,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
// Resume the agent's prior session from a previous container run if one
|
// Resume the agent's prior session from a previous container run if one
|
||||||
// was persisted. The continuation is opaque to the poll-loop — the
|
// was persisted. The continuation is opaque to the poll-loop — the
|
||||||
// provider decides how to use it (Claude resumes a .jsonl transcript,
|
// provider decides how to use it (Claude resumes a .jsonl transcript,
|
||||||
// other providers may reload a thread ID, etc.).
|
// other providers may reload a thread ID, etc.). Keyed per-provider so
|
||||||
let continuation: string | undefined = getStoredSessionId();
|
// a Codex thread id never gets handed to Claude or vice versa.
|
||||||
|
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
|
||||||
|
|
||||||
if (continuation) {
|
if (continuation) {
|
||||||
log(`Resuming agent session ${continuation}`);
|
log(`Resuming agent session ${continuation}`);
|
||||||
@@ -94,7 +105,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
||||||
log('Clearing session (resetting continuation)');
|
log('Clearing session (resetting continuation)');
|
||||||
continuation = undefined;
|
continuation = undefined;
|
||||||
clearStoredSessionId();
|
clearContinuation(config.providerName);
|
||||||
writeMessageOut({
|
writeMessageOut({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
kind: 'chat',
|
kind: 'chat',
|
||||||
@@ -160,10 +171,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
const skippedSet = new Set(skipped);
|
const skippedSet = new Set(skipped);
|
||||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||||
try {
|
try {
|
||||||
const result = await processQuery(query, routing, processingIds);
|
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||||
if (result.continuation && result.continuation !== continuation) {
|
if (result.continuation && result.continuation !== continuation) {
|
||||||
continuation = result.continuation;
|
continuation = result.continuation;
|
||||||
setStoredSessionId(continuation);
|
setContinuation(config.providerName, continuation);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -175,7 +186,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
if (continuation && config.provider.isSessionInvalid(err)) {
|
if (continuation && config.provider.isSessionInvalid(err)) {
|
||||||
log(`Stale session detected (${continuation}) — clearing for next retry`);
|
log(`Stale session detected (${continuation}) — clearing for next retry`);
|
||||||
continuation = undefined;
|
continuation = undefined;
|
||||||
clearStoredSessionId();
|
clearContinuation(config.providerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write error response so the user knows something went wrong
|
// Write error response so the user knows something went wrong
|
||||||
@@ -238,6 +249,7 @@ async function processQuery(
|
|||||||
query: AgentQuery,
|
query: AgentQuery,
|
||||||
routing: RoutingContext,
|
routing: RoutingContext,
|
||||||
initialBatchIds: string[],
|
initialBatchIds: string[],
|
||||||
|
providerName: string,
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
let queryContinuation: string | undefined;
|
let queryContinuation: string | undefined;
|
||||||
let done = false;
|
let done = false;
|
||||||
@@ -288,7 +300,7 @@ async function processQuery(
|
|||||||
// container died between `init` and `result`, the SDK session was
|
// container died between `init` and `result`, the SDK session was
|
||||||
// effectively orphaned and the next message started a blank
|
// effectively orphaned and the next message started a blank
|
||||||
// Claude session with no prior context.
|
// Claude session with no prior context.
|
||||||
setStoredSessionId(event.continuation);
|
setContinuation(providerName, event.continuation);
|
||||||
} else if (event.type === 'result') {
|
} else if (event.type === 'result') {
|
||||||
// A result — with or without text — means the turn is done. Mark
|
// A result — with or without text — means the turn is done. Mark
|
||||||
// the initial batch completed now so the host sweep doesn't see
|
// the initial batch completed now so the host sweep doesn't see
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.0.7",
|
"version": "2.0.13",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="132k tokens, 66% of context window">
|
||||||
<title>128k tokens, 64% of context window</title>
|
<title>132k tokens, 66% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
|
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">132k</text>
|
||||||
<text x="71" y="14">128k</text>
|
<text x="71" y="14">132k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -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 { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||||
import { initGroupFilesystem } from '../src/group-init.js';
|
import { initGroupFilesystem } from '../src/group-init.js';
|
||||||
|
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||||
|
|
||||||
type Role = 'owner' | 'admin' | 'member';
|
type Role = 'owner' | 'admin' | 'member';
|
||||||
@@ -137,16 +138,6 @@ function namespacedUserId(channel: string, raw: string): string {
|
|||||||
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function namespacedPlatformId(channel: string, raw: string): string {
|
|
||||||
if (raw.startsWith(`${channel}:`)) return raw;
|
|
||||||
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
|
|
||||||
// <groupId>@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.
|
|
||||||
if (raw.includes('@')) return raw;
|
|
||||||
return `${channel}:${raw}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateId(prefix: string): string {
|
function generateId(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
95
setup/add-signal.sh
Executable file
95
setup/add-signal.sh
Executable file
@@ -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
|
||||||
@@ -30,6 +30,7 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import { runDiscordChannel } from './channels/discord.js';
|
import { runDiscordChannel } from './channels/discord.js';
|
||||||
import { runIMessageChannel } from './channels/imessage.js';
|
import { runIMessageChannel } from './channels/imessage.js';
|
||||||
|
import { runSignalChannel } from './channels/signal.js';
|
||||||
import { runSlackChannel } from './channels/slack.js';
|
import { runSlackChannel } from './channels/slack.js';
|
||||||
import { runTeamsChannel } from './channels/teams.js';
|
import { runTeamsChannel } from './channels/teams.js';
|
||||||
import { runTelegramChannel } from './channels/telegram.js';
|
import { runTelegramChannel } from './channels/telegram.js';
|
||||||
@@ -57,6 +58,7 @@ type ChannelChoice =
|
|||||||
| 'telegram'
|
| 'telegram'
|
||||||
| 'discord'
|
| 'discord'
|
||||||
| 'whatsapp'
|
| 'whatsapp'
|
||||||
|
| 'signal'
|
||||||
| 'teams'
|
| 'teams'
|
||||||
| 'slack'
|
| 'slack'
|
||||||
| 'imessage'
|
| 'imessage'
|
||||||
@@ -327,6 +329,8 @@ async function main(): Promise<void> {
|
|||||||
await runDiscordChannel(displayName!);
|
await runDiscordChannel(displayName!);
|
||||||
} else if (channelChoice === 'whatsapp') {
|
} else if (channelChoice === 'whatsapp') {
|
||||||
await runWhatsAppChannel(displayName!);
|
await runWhatsAppChannel(displayName!);
|
||||||
|
} else if (channelChoice === 'signal') {
|
||||||
|
await runSignalChannel(displayName!);
|
||||||
} else if (channelChoice === 'teams') {
|
} else if (channelChoice === 'teams') {
|
||||||
await runTeamsChannel(displayName!);
|
await runTeamsChannel(displayName!);
|
||||||
} else if (channelChoice === 'slack') {
|
} else if (channelChoice === 'slack') {
|
||||||
@@ -454,6 +458,8 @@ function channelDmLabel(choice: ChannelChoice): string | null {
|
|||||||
return 'Discord DMs';
|
return 'Discord DMs';
|
||||||
case 'whatsapp':
|
case 'whatsapp':
|
||||||
return 'WhatsApp';
|
return 'WhatsApp';
|
||||||
|
case 'signal':
|
||||||
|
return 'Signal';
|
||||||
case 'teams':
|
case 'teams':
|
||||||
return 'Teams';
|
return 'Teams';
|
||||||
case 'imessage':
|
case 'imessage':
|
||||||
@@ -847,6 +853,11 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
|||||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||||
|
{
|
||||||
|
value: 'signal',
|
||||||
|
label: 'Yes, connect Signal',
|
||||||
|
hint: 'needs signal-cli installed',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'imessage',
|
value: 'imessage',
|
||||||
label: 'Yes, connect iMessage (experimental)',
|
label: 'Yes, connect iMessage (experimental)',
|
||||||
|
|||||||
357
setup/channels/signal.ts
Normal file
357
setup/channels/signal.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
@@ -17,58 +19,63 @@ describe('environment detection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registered groups DB query', () => {
|
describe('detectRegisteredGroups', () => {
|
||||||
let db: Database.Database;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
db = new Database(':memory:');
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||||
jid TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
folder TEXT NOT NULL UNIQUE,
|
|
||||||
trigger_pattern TEXT NOT NULL,
|
|
||||||
added_at TEXT NOT NULL,
|
|
||||||
container_config TEXT,
|
|
||||||
requires_trigger INTEGER DEFAULT 1
|
|
||||||
)`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 0 for empty table', () => {
|
afterEach(() => {
|
||||||
const row = db
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
|
||||||
.get() as { count: number };
|
|
||||||
expect(row.count).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct count after inserts', () => {
|
it('returns false when no registration state exists', async () => {
|
||||||
db.prepare(
|
const { detectRegisteredGroups } = await import('./environment.js');
|
||||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
});
|
||||||
).run(
|
|
||||||
'123@g.us',
|
|
||||||
'Group 1',
|
|
||||||
'group-1',
|
|
||||||
'@Andy',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
db.prepare(
|
it('detects pre-migration registered_groups.json', async () => {
|
||||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
const { detectRegisteredGroups } = await import('./environment.js');
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
|
||||||
).run(
|
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||||
'456@g.us',
|
});
|
||||||
'Group 2',
|
|
||||||
'group-2',
|
|
||||||
'@Andy',
|
|
||||||
'2024-01-01T00:00:00.000Z',
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = db
|
it('returns false for an empty v2 central DB', async () => {
|
||||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
const { detectRegisteredGroups } = await import('./environment.js');
|
||||||
.get() as { count: number };
|
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||||
expect(row.count).toBe(2);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,35 @@ import path from 'path';
|
|||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
import { STORE_DIR } from '../src/config.js';
|
|
||||||
import { log } from '../src/log.js';
|
import { log } from '../src/log.js';
|
||||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||||
import { emitStatus } from './status.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<void> {
|
export async function run(_args: string[]): Promise<void> {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
|
|
||||||
@@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||||
|
|
||||||
let hasRegisteredGroups = false;
|
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing OpenClaw installation
|
// Check for existing OpenClaw installation
|
||||||
const homedir = (await import('os')).homedir();
|
const homedir = (await import('os')).homedir();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const STEPS: Record<
|
|||||||
register: () => import('./register.js'),
|
register: () => import('./register.js'),
|
||||||
groups: () => import('./groups.js'),
|
groups: () => import('./groups.js'),
|
||||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||||
|
'signal-auth': () => import('./signal-auth.js'),
|
||||||
mounts: () => import('./mounts.js'),
|
mounts: () => import('./mounts.js'),
|
||||||
service: () => import('./service.js'),
|
service: () => import('./service.js'),
|
||||||
verify: () => import('./verify.js'),
|
verify: () => import('./verify.js'),
|
||||||
|
|||||||
30
setup/lib/agent-ping.test.ts
Normal file
30
setup/lib/agent-ping.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,21 @@
|
|||||||
*/
|
*/
|
||||||
import { spawn } from 'child_process';
|
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<PingResult> {
|
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
|||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
|||||||
child.stdout.on('data', (chunk: Buffer) => {
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
stdout += chunk.toString('utf-8');
|
stdout += chunk.toString('utf-8');
|
||||||
});
|
});
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString('utf-8');
|
||||||
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (code === 2) resolve('socket_error');
|
resolve(classifyPingResult(code, stdout, stderr));
|
||||||
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
|
|
||||||
else resolve('no_reply');
|
|
||||||
});
|
});
|
||||||
child.on('error', () => {
|
child.on('error', () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||||
import { initGroupFilesystem } from '../src/group-init.js';
|
import { initGroupFilesystem } from '../src/group-init.js';
|
||||||
import { log } from '../src/log.js';
|
import { log } from '../src/log.js';
|
||||||
|
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
@@ -112,12 +113,10 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
process.exit(4);
|
process.exit(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat SDK adapters prefix platform IDs with the channel type
|
// Normalize platform_id to the same shape the adapter will emit at runtime,
|
||||||
// (e.g. "telegram:123", "discord:guild:channel"). Normalize here so
|
// so the router's (channel_type, platform_id) lookup matches what we store.
|
||||||
// the stored ID always matches what the adapter sends at runtime.
|
// Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't.
|
||||||
if (!parsed.platformId.startsWith(`${parsed.channel}:`)) {
|
parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId);
|
||||||
parsed.platformId = `${parsed.channel}:${parsed.platformId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('Registering channel', parsed);
|
log.info('Registering channel', parsed);
|
||||||
|
|
||||||
@@ -167,19 +166,22 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
newlyWired = true;
|
newlyWired = true;
|
||||||
const mgaId = generateId('mga');
|
const mgaId = generateId('mga');
|
||||||
const triggerRules = parsed.trigger
|
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
|
||||||
? JSON.stringify({
|
// create rows with the same shape. Groups default to 'mention' (bot only
|
||||||
pattern: parsed.trigger,
|
// responds when addressed); DMs default to 'pattern'/'.' (respond to
|
||||||
requiresTrigger: parsed.requiresTrigger,
|
// every message). An explicit --trigger overrides the pattern regex.
|
||||||
})
|
const isGroup = messagingGroup.is_group === 1;
|
||||||
: null;
|
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
|
||||||
|
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
|
||||||
createMessagingGroupAgent({
|
createMessagingGroupAgent({
|
||||||
id: mgaId,
|
id: mgaId,
|
||||||
messaging_group_id: messagingGroup.id,
|
messaging_group_id: messagingGroup.id,
|
||||||
agent_group_id: agentGroup.id,
|
agent_group_id: agentGroup.id,
|
||||||
trigger_rules: triggerRules,
|
engage_mode: engageMode,
|
||||||
response_scope: 'all',
|
engage_pattern: engagePattern,
|
||||||
session_mode: parsed.sessionMode,
|
sender_scope: 'all',
|
||||||
|
ignored_message_policy: 'drop',
|
||||||
|
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
182
setup/signal-auth.ts
Normal file
182
setup/signal-auth.ts
Normal file
@@ -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: "<sgnl:// or tsdevice:// url>" } — one-shot
|
||||||
|
* SIGNAL_AUTH { STATUS: success, ACCOUNT: +<digits> } — terminal
|
||||||
|
* { STATUS: skipped, ACCOUNT, REASON: already-authenticated }
|
||||||
|
* { STATUS: failed, ERROR: <reason> }
|
||||||
|
*
|
||||||
|
* 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<void> {
|
||||||
|
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<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let qrEmitted = false;
|
||||||
|
|
||||||
|
const finish = (block: Record<string, string | number | boolean>, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
55
setup/verify.test.ts
Normal file
55
setup/verify.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { DATA_DIR } from '../src/config.js';
|
import { DATA_DIR } from '../src/config.js';
|
||||||
import { readEnvFile } from '../src/env.js';
|
import { readEnvFile } from '../src/env.js';
|
||||||
import { log } from '../src/log.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 { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||||
import {
|
import {
|
||||||
getPlatform,
|
getPlatform,
|
||||||
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
|
|
||||||
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
|
// 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.
|
// 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) {
|
if (service === 'running' && registeredGroups > 0) {
|
||||||
log.info('Pinging CLI agent');
|
log.info('Pinging CLI agent');
|
||||||
agentPing = await pingCliAgent();
|
agentPing = await pingCliAgent();
|
||||||
log.info('Agent ping result', { agentPing });
|
log.info('Agent ping result', { agentPing });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine overall status
|
// Determine overall status. A CLI-only install is valid when the local
|
||||||
const status =
|
// agent round-trip succeeds; messaging app credentials are optional.
|
||||||
service === 'running' &&
|
const status = determineVerifyStatus({
|
||||||
credentials !== 'missing' &&
|
service,
|
||||||
anyChannelConfigured &&
|
credentials,
|
||||||
registeredGroups > 0 &&
|
anyChannelConfigured,
|
||||||
(agentPing === 'ok' || agentPing === 'skipped')
|
registeredGroups,
|
||||||
? 'success'
|
agentPing,
|
||||||
: 'failed';
|
});
|
||||||
|
|
||||||
log.info('Verification complete', { status, channelAuth });
|
log.info('Verification complete', { status, channelAuth });
|
||||||
|
|
||||||
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
if (status === 'failed') process.exit(1);
|
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
|
* 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
|
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface InboundEvent {
|
|||||||
* See InboundMessage.isMention for the full explanation.
|
* See InboundMessage.isMention for the full explanation.
|
||||||
*/
|
*/
|
||||||
isMention?: boolean;
|
isMention?: boolean;
|
||||||
|
/** True when the source is a group/channel thread, false for DMs. */
|
||||||
|
isGroup?: boolean;
|
||||||
};
|
};
|
||||||
replyTo?: DeliveryAddress;
|
replyTo?: DeliveryAddress;
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,8 @@ export interface InboundMessage {
|
|||||||
* router falls back to text-match against agent_group_name.
|
* router falls back to text-match against agent_group_name.
|
||||||
*/
|
*/
|
||||||
isMention?: boolean;
|
isMention?: boolean;
|
||||||
|
/** True when the source is a group/channel thread, false for DMs. */
|
||||||
|
isGroup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A file attachment to deliver alongside a message. */
|
/** A file attachment to deliver alongside a message. */
|
||||||
|
|||||||
@@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig {
|
|||||||
* chunk boundary will render as two independent blocks on the receiving
|
* chunk boundary will render as two independent blocks on the receiving
|
||||||
* platform, which is the same behavior as manually re-opening a fence.
|
* 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[] {
|
export function splitForLimit(text: string, limit: number): string[] {
|
||||||
if (text.length <= limit) return [text];
|
if (text.length <= limit) return [text];
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
@@ -105,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
let setupConfig: ChannelSetup;
|
let setupConfig: ChannelSetup;
|
||||||
let gatewayAbort: AbortController | null = null;
|
let gatewayAbort: AbortController | null = null;
|
||||||
|
|
||||||
async function messageToInbound(message: ChatMessage, isMention: boolean): Promise<InboundMessage> {
|
async function messageToInbound(
|
||||||
|
message: ChatMessage,
|
||||||
|
isMention: boolean,
|
||||||
|
isGroup?: boolean,
|
||||||
|
): Promise<InboundMessage> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const serialized = message.toJSON() as Record<string, any>;
|
const serialized = message.toJSON() as Record<string, any>;
|
||||||
|
|
||||||
@@ -162,6 +186,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
content: serialized,
|
content: serialized,
|
||||||
timestamp: message.metadata.dateSent.toISOString(),
|
timestamp: message.metadata.dateSent.toISOString(),
|
||||||
isMention,
|
isMention,
|
||||||
|
isGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +220,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
// wirings still fire on in-thread mentions.
|
// wirings still fire on in-thread mentions.
|
||||||
chat.onSubscribedMessage(async (thread, message) => {
|
chat.onSubscribedMessage(async (thread, message) => {
|
||||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
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.
|
// @mention in an unsubscribed thread — SDK-confirmed bot mention.
|
||||||
chat.onNewMention(async (thread, message) => {
|
chat.onNewMention(async (thread, message) => {
|
||||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
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
|
// DMs — by definition addressed to the bot. Thread id flows through
|
||||||
@@ -216,7 +245,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
|
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
|
||||||
threadId: thread.id,
|
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.
|
// Plain messages in unsubscribed threads.
|
||||||
@@ -231,7 +260,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
// flood gate.
|
// flood gate.
|
||||||
chat.onNewMessage(/./, async (thread, message) => {
|
chat.onNewMessage(/./, async (thread, message) => {
|
||||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
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)
|
// Handle button clicks (ask_user_question)
|
||||||
@@ -240,11 +269,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
const parts = event.actionId.split(':');
|
const parts = event.actionId.split(':');
|
||||||
if (parts.length < 3) return;
|
if (parts.length < 3) return;
|
||||||
const questionId = parts[1];
|
const questionId = parts[1];
|
||||||
const selectedOption = event.value || '';
|
const tail = parts.slice(2).join(':');
|
||||||
const userId = event.user?.userId || '';
|
const userId = event.user?.userId || '';
|
||||||
|
|
||||||
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
|
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
|
||||||
const render = getAskQuestionRender(questionId);
|
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 title = render?.title ?? '❓ Question';
|
||||||
const matched = render?.options.find((o) => o.value === selectedOption);
|
const matched = render?.options.find((o) => o.value === selectedOption);
|
||||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||||
@@ -348,8 +381,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
children: [
|
children: [
|
||||||
CardText(question),
|
CardText(question),
|
||||||
Actions(
|
Actions(
|
||||||
options.map((opt) =>
|
// Encode button id/value with the option index rather than the
|
||||||
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
|
// 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) }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -501,18 +539,21 @@ async function handleForwardedEvent(
|
|||||||
// type 3 = MessageComponent (button/select)
|
// type 3 = MessageComponent (button/select)
|
||||||
if (interaction.type === 3) {
|
if (interaction.type === 3) {
|
||||||
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
|
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
|
||||||
const user = (interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined;
|
// In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly.
|
||||||
|
const user =
|
||||||
|
((interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined) ??
|
||||||
|
(interaction.user as Record<string, string> | undefined);
|
||||||
const interactionId = interaction.id as string;
|
const interactionId = interaction.id as string;
|
||||||
const interactionToken = interaction.token as string;
|
const interactionToken = interaction.token as string;
|
||||||
|
|
||||||
// Parse the selected option from custom_id
|
// Parse the selected option from custom_id
|
||||||
let questionId: string | undefined;
|
let questionId: string | undefined;
|
||||||
let selectedOption: string | undefined;
|
let tail: string | undefined;
|
||||||
if (customId?.startsWith('ncq:')) {
|
if (customId?.startsWith('ncq:')) {
|
||||||
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
|
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
|
||||||
if (colonIdx !== -1) {
|
if (colonIdx !== -1) {
|
||||||
questionId = customId.slice(4, colonIdx);
|
questionId = customId.slice(4, colonIdx);
|
||||||
selectedOption = customId.slice(colonIdx + 1);
|
tail = customId.slice(colonIdx + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +562,9 @@ async function handleForwardedEvent(
|
|||||||
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
||||||
const originalDescription = (originalEmbeds[0]?.description as string) || '';
|
const originalDescription = (originalEmbeds[0]?.description as string) || '';
|
||||||
const render = questionId ? getAskQuestionRender(questionId) : undefined;
|
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 cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
|
||||||
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
|
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
|
||||||
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
||||||
|
|||||||
32
src/container-runner.test.ts
Normal file
32
src/container-runner.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,7 +36,13 @@ import {
|
|||||||
type ProviderContainerContribution,
|
type ProviderContainerContribution,
|
||||||
type VolumeMount,
|
type VolumeMount,
|
||||||
} from './providers/provider-container-registry.js';
|
} 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';
|
import type { AgentGroup, Session } from './types.js';
|
||||||
|
|
||||||
const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY });
|
const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY });
|
||||||
@@ -131,6 +137,12 @@ async function spawnContainer(session: Session): Promise<void> {
|
|||||||
|
|
||||||
log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName });
|
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'] });
|
const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
activeContainers.set(session.id, { process: container, containerName });
|
activeContainers.set(session.id, { process: container, containerName });
|
||||||
@@ -179,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(
|
function resolveProviderContribution(
|
||||||
session: Session,
|
session: Session,
|
||||||
agentGroup: AgentGroup,
|
agentGroup: AgentGroup,
|
||||||
containerConfig: import('./container-config.js').ContainerConfig,
|
containerConfig: import('./container-config.js').ContainerConfig,
|
||||||
): { provider: string; contribution: ProviderContainerContribution } {
|
): { 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 fn = getProviderContainerConfig(provider);
|
||||||
const contribution = fn
|
const contribution = fn
|
||||||
? fn({
|
? fn({
|
||||||
|
|||||||
27
src/db/migrations/013-approval-render-metadata.ts
Normal file
27
src/db/migrations/013-approval-render-metadata.ts
Normal file
@@ -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 '[]'`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { migration009 } from './009-drop-pending-credentials.js';
|
|||||||
import { migration010 } from './010-engage-modes.js';
|
import { migration010 } from './010-engage-modes.js';
|
||||||
import { migration011 } from './011-pending-sender-approvals.js';
|
import { migration011 } from './011-pending-sender-approvals.js';
|
||||||
import { migration012 } from './012-channel-registration.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 { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const migrations: Migration[] = [
|
|||||||
migration010,
|
migration010,
|
||||||
migration011,
|
migration011,
|
||||||
migration012,
|
migration012,
|
||||||
|
migration013,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: Database.Database): void {
|
export function runMigrations(db: Database.Database): void {
|
||||||
|
|||||||
@@ -139,10 +139,10 @@ export function getMessageForRetry(
|
|||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
status: string,
|
status: string,
|
||||||
): { id: string; tries: number } | undefined {
|
): { id: string; tries: number; processAfter: string | null } | undefined {
|
||||||
return db.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?').get(messageId, status) as
|
return db
|
||||||
| { id: string; tries: number }
|
.prepare('SELECT id, tries, process_after as processAfter FROM messages_in WHERE id = ? AND status = ?')
|
||||||
| undefined;
|
.get(messageId, status) as { id: string; tries: number; processAfter: string | null } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
|
export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PendingApproval, PendingQuestion, Session } from '../types.js';
|
import type { PendingApproval, PendingQuestion, Session } from '../types.js';
|
||||||
import { getDb } from './connection.js';
|
import { getDb, hasTable } from './connection.js';
|
||||||
|
|
||||||
// ── Sessions ──
|
// ── Sessions ──
|
||||||
|
|
||||||
@@ -97,10 +97,16 @@ export function deleteSession(id: string): void {
|
|||||||
|
|
||||||
// ── Pending Questions ──
|
// ── 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(
|
.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)`,
|
VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @title, @options_json, @created_at)`,
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
@@ -114,6 +120,7 @@ export function createPendingQuestion(pq: PendingQuestion): void {
|
|||||||
options_json: JSON.stringify(pq.options),
|
options_json: JSON.stringify(pq.options),
|
||||||
created_at: pq.created_at,
|
created_at: pq.created_at,
|
||||||
});
|
});
|
||||||
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPendingQuestion(questionId: string): PendingQuestion | undefined {
|
export function getPendingQuestion(questionId: string): PendingQuestion | undefined {
|
||||||
@@ -131,16 +138,21 @@ export function deletePendingQuestion(questionId: string): void {
|
|||||||
|
|
||||||
// ── Pending Approvals ──
|
// ── 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(
|
export function createPendingApproval(
|
||||||
pa: Partial<PendingApproval> &
|
pa: Partial<PendingApproval> &
|
||||||
Pick<
|
Pick<
|
||||||
PendingApproval,
|
PendingApproval,
|
||||||
'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json'
|
'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json'
|
||||||
>,
|
>,
|
||||||
): void {
|
): boolean {
|
||||||
getDb()
|
const result = getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO pending_approvals
|
`INSERT OR IGNORE INTO pending_approvals
|
||||||
(approval_id, session_id, request_id, action, payload, created_at,
|
(approval_id, session_id, request_id, action, payload, created_at,
|
||||||
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
|
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
|
||||||
title, options_json)
|
title, options_json)
|
||||||
@@ -159,6 +171,7 @@ export function createPendingApproval(
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
...pa,
|
...pa,
|
||||||
});
|
});
|
||||||
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPendingApproval(approvalId: string): PendingApproval | undefined {
|
export function getPendingApproval(approvalId: string): PendingApproval | undefined {
|
||||||
@@ -192,6 +205,23 @@ export function getAskQuestionRender(
|
|||||||
const a = getDb().prepare('SELECT title, options_json FROM pending_approvals WHERE approval_id = ?').get(id) as
|
const a = getDb().prepare('SELECT title, options_json FROM pending_approvals WHERE approval_id = ?').get(id) as
|
||||||
| { title: string; options_json: string }
|
| { title: string; options_json: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!a || !a.title) return undefined;
|
if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) };
|
||||||
return { title: a.title, options: JSON.parse(a.options_json) };
|
|
||||||
|
// 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 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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTable(getDb(), 'pending_sender_approvals')) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ async function deliverMessage(
|
|||||||
questionId: content.questionId,
|
questionId: content.questionId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
createPendingQuestion({
|
const inserted = createPendingQuestion({
|
||||||
question_id: content.questionId,
|
question_id: content.questionId,
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
message_out_id: msg.id,
|
message_out_id: msg.id,
|
||||||
@@ -332,7 +332,9 @@ async function deliverMessage(
|
|||||||
options: normalizeOptions(rawOptions as never),
|
options: normalizeOptions(rawOptions as never),
|
||||||
created_at: new Date().toISOString(),
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,23 +159,31 @@ async function sweepSession(session: Session): Promise<void> {
|
|||||||
syncProcessingAcks(inDb, outDb);
|
syncProcessingAcks(inDb, outDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
const alive = isContainerRunning(session.id);
|
// 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
|
||||||
// 2. Crashed-container cleanup: processing rows left behind get retried.
|
// to clean its own orphan processing_ack rows on startup (see
|
||||||
if (!alive && outDb) {
|
// container/agent-runner/src/db/connection.ts). Otherwise the reset path
|
||||||
resetStuckProcessingRows(inDb, outDb, session, 'container not running');
|
// 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.
|
// 3. Running-container SLA: absolute ceiling + per-claim stuck rules.
|
||||||
if (alive && outDb) {
|
if (alive && outDb) {
|
||||||
enforceRunningContainerSla(inDb, outDb, session, agentGroup.id);
|
enforceRunningContainerSla(inDb, outDb, session, agentGroup.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Wake a container if new work is due and nothing is running.
|
// 4. Crashed-container cleanup: processing rows left behind get retried.
|
||||||
const dueCount = countDueMessages(inDb);
|
// Only fires when wake in step 2 didn't pick up the work (no due messages,
|
||||||
if (dueCount > 0 && !isContainerRunning(session.id)) {
|
// or wake failed). resetStuckProcessingRows itself is idempotent — it
|
||||||
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
|
// skips messages already scheduled for a future retry.
|
||||||
await wakeContainer(session);
|
if (!alive && outDb) {
|
||||||
|
resetStuckProcessingRows(inDb, outDb, session, 'container not running');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Recurrence fanout for completed recurring tasks.
|
// 5. Recurrence fanout for completed recurring tasks.
|
||||||
@@ -246,10 +254,16 @@ function resetStuckProcessingRows(
|
|||||||
reason: string,
|
reason: string,
|
||||||
): void {
|
): void {
|
||||||
const claims = getProcessingClaims(outDb);
|
const claims = getProcessingClaims(outDb);
|
||||||
|
const now = Date.now();
|
||||||
for (const { message_id } of claims) {
|
for (const { message_id } of claims) {
|
||||||
const msg = getMessageForRetry(inDb, message_id, 'pending');
|
const msg = getMessageForRetry(inDb, message_id, 'pending');
|
||||||
if (!msg) continue;
|
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) {
|
if (msg.tries >= MAX_TRIES) {
|
||||||
markMessageFailed(inDb, msg.id);
|
markMessageFailed(inDb, msg.id);
|
||||||
log.warn('Message marked as failed after max retries', {
|
log.warn('Message marked as failed after max retries', {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async function main(): Promise<void> {
|
|||||||
content: JSON.stringify(message.content),
|
content: JSON.stringify(message.content),
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
isMention: message.isMention,
|
isMention: message.isMention,
|
||||||
|
isGroup: message.isGroup,
|
||||||
},
|
},
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
log.error('Failed to route inbound message', { channelType: adapter.channelType, err });
|
log.error('Failed to route inbound message', { channelType: adapter.channelType, err });
|
||||||
|
|||||||
46
src/modules/agent-to-agent/agent-route.test.ts
Normal file
46
src/modules/agent-to-agent/agent-route.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
*
|
*
|
||||||
* Outbound messages with `channel_type === 'agent'` target another agent
|
* Outbound messages with `channel_type === 'agent'` target another agent
|
||||||
* group rather than a channel. Permission is enforced via `agent_destinations` —
|
* 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 source agent must have a row for the target. Content is copied into the
|
||||||
* the target's formatter looks up the source agent in its own local map to
|
* target's inbound DB; if the source message had `files` (from `send_file`),
|
||||||
* display a name.
|
* the actual bytes are copied from the source's outbox into the target's
|
||||||
|
* `inbox/<a2a-msg-id>/` 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/<a2a-msg-id>/<filename>` path.
|
||||||
*
|
*
|
||||||
* Self-messages are always allowed (used for system notes injected back into
|
* Self-messages are always allowed (used for system notes injected back into
|
||||||
* an agent's own session, e.g. post-approval follow-up prompts).
|
* an agent's own session, e.g. post-approval follow-up prompts).
|
||||||
@@ -14,14 +18,102 @@
|
|||||||
* `channel_type === 'agent'` check. When the module is absent the check in
|
* `channel_type === 'agent'` check. When the module is absent the check in
|
||||||
* core throws with a "module not installed" message so retry → mark failed.
|
* 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 { getAgentGroup } from '../../db/agent-groups.js';
|
||||||
import { getSession } from '../../db/sessions.js';
|
import { getSession } from '../../db/sessions.js';
|
||||||
import { wakeContainer } from '../../container-runner.js';
|
import { wakeContainer } from '../../container-runner.js';
|
||||||
import { log } from '../../log.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 type { Session } from '../../types.js';
|
||||||
import { hasDestination } from './db/agent-destinations.js';
|
import { hasDestination } from './db/agent-destinations.js';
|
||||||
|
|
||||||
|
export interface ForwardedAttachment {
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
type: 'file';
|
||||||
|
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
|
||||||
|
* `{name, type, localPath}` convention — target agent reads `localPath`
|
||||||
|
* as relative to `/workspace/`, matching how channel-inbound attachments
|
||||||
|
* are surfaced today.
|
||||||
|
*
|
||||||
|
* 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[] },
|
||||||
|
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) {
|
||||||
|
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', {
|
||||||
|
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 {
|
export interface RoutableAgentMessage {
|
||||||
id: string;
|
id: string;
|
||||||
platform_id: string | null;
|
platform_id: string | null;
|
||||||
@@ -45,20 +137,87 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
|
|||||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||||
}
|
}
|
||||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
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, {
|
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||||
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: a2aMsgId,
|
||||||
kind: 'chat',
|
kind: 'chat',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
platformId: session.agent_group_id,
|
platformId: session.agent_group_id,
|
||||||
channelType: 'agent',
|
channelType: 'agent',
|
||||||
threadId: null,
|
threadId: null,
|
||||||
content: msg.content,
|
content: forwardedContent,
|
||||||
});
|
});
|
||||||
log.info('Agent message routed', {
|
log.info('Agent message routed', {
|
||||||
from: session.agent_group_id,
|
from: session.agent_group_id,
|
||||||
to: targetAgentGroupId,
|
to: targetAgentGroupId,
|
||||||
targetSession: targetSession.id,
|
targetSession: targetSession.id,
|
||||||
|
a2aMsgId,
|
||||||
|
forwardedFileCount: countForwardedFiles(forwardedContent),
|
||||||
});
|
});
|
||||||
const fresh = getSession(targetSession.id);
|
const fresh = getSession(targetSession.id);
|
||||||
if (fresh) await wakeContainer(fresh);
|
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<string, unknown>;
|
||||||
|
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<string, unknown>[]) : [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,13 +101,26 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat';
|
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
|
||||||
const 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<string, unknown>;
|
||||||
|
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 title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
|
||||||
const question = isGroup
|
const question = isGroup
|
||||||
? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?`
|
? senderName
|
||||||
: `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`;
|
? `${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?`;
|
||||||
|
const options = normalizeOptions(APPROVAL_OPTIONS);
|
||||||
|
|
||||||
createPendingChannelApproval({
|
createPendingChannelApproval({
|
||||||
messaging_group_id: messagingGroupId,
|
messaging_group_id: messagingGroupId,
|
||||||
@@ -115,6 +128,8 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
original_message: JSON.stringify(event),
|
original_message: JSON.stringify(event),
|
||||||
approver_user_id: delivery.userId,
|
approver_user_id: delivery.userId,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
title,
|
||||||
|
options_json: JSON.stringify(options),
|
||||||
});
|
});
|
||||||
|
|
||||||
const adapter = getDeliveryAdapter();
|
const adapter = getDeliveryAdapter();
|
||||||
@@ -139,7 +154,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
|||||||
questionId: messagingGroupId,
|
questionId: messagingGroupId,
|
||||||
title,
|
title,
|
||||||
question,
|
question,
|
||||||
options: normalizeOptions(APPROVAL_OPTIONS),
|
options,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
log.info('Channel registration card delivered', {
|
log.info('Channel registration card delivered', {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface PendingChannelApproval {
|
|||||||
original_message: string;
|
original_message: string;
|
||||||
approver_user_id: string;
|
approver_user_id: string;
|
||||||
created_at: 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 {
|
export function createPendingChannelApproval(row: PendingChannelApproval): void {
|
||||||
@@ -24,11 +28,11 @@ export function createPendingChannelApproval(row: PendingChannelApproval): void
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO pending_channel_approvals (
|
`INSERT INTO pending_channel_approvals (
|
||||||
messaging_group_id, agent_group_id, original_message,
|
messaging_group_id, agent_group_id, original_message,
|
||||||
approver_user_id, created_at
|
approver_user_id, created_at, title, options_json
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
@messaging_group_id, @agent_group_id, @original_message,
|
@messaging_group_id, @agent_group_id, @original_message,
|
||||||
@approver_user_id, @created_at
|
@approver_user_id, @created_at, @title, @options_json
|
||||||
)`,
|
)`,
|
||||||
)
|
)
|
||||||
.run(row);
|
.run(row);
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface PendingSenderApproval {
|
|||||||
original_message: string;
|
original_message: string;
|
||||||
approver_user_id: string;
|
approver_user_id: string;
|
||||||
created_at: 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 {
|
export function createPendingSenderApproval(row: PendingSenderApproval): void {
|
||||||
@@ -26,11 +30,13 @@ export function createPendingSenderApproval(row: PendingSenderApproval): void {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO pending_sender_approvals (
|
`INSERT INTO pending_sender_approvals (
|
||||||
id, messaging_group_id, agent_group_id, sender_identity,
|
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 (
|
VALUES (
|
||||||
@id, @messaging_group_id, @agent_group_id, @sender_identity,
|
@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);
|
.run(row);
|
||||||
|
|||||||
@@ -88,10 +88,11 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
|
|||||||
|
|
||||||
const approvalId = generateId();
|
const approvalId = generateId();
|
||||||
const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity;
|
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 title = '👤 New sender';
|
||||||
const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`;
|
const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`;
|
||||||
|
const options = normalizeOptions(APPROVAL_OPTIONS);
|
||||||
|
|
||||||
createPendingSenderApproval({
|
createPendingSenderApproval({
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
@@ -102,6 +103,8 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
|
|||||||
original_message: JSON.stringify(event),
|
original_message: JSON.stringify(event),
|
||||||
approver_user_id: target.userId,
|
approver_user_id: target.userId,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
title,
|
||||||
|
options_json: JSON.stringify(options),
|
||||||
});
|
});
|
||||||
|
|
||||||
const adapter = getDeliveryAdapter();
|
const adapter = getDeliveryAdapter();
|
||||||
@@ -126,7 +129,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
|
|||||||
questionId: approvalId,
|
questionId: approvalId,
|
||||||
title,
|
title,
|
||||||
question,
|
question,
|
||||||
options: APPROVAL_OPTIONS,
|
options,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
log.info('Unknown-sender approval card delivered', {
|
log.info('Unknown-sender approval card delivered', {
|
||||||
|
|||||||
23
src/platform-id.ts
Normal file
23
src/platform-id.ts
Normal file
@@ -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:<id>' 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}`;
|
||||||
|
}
|
||||||
@@ -170,7 +170,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
|||||||
channel_type: event.channelType,
|
channel_type: event.channelType,
|
||||||
platform_id: event.platformId,
|
platform_id: event.platformId,
|
||||||
name: null,
|
name: null,
|
||||||
is_group: 0,
|
is_group: event.message.isGroup ? 1 : 0,
|
||||||
unknown_sender_policy: 'request_approval',
|
unknown_sender_policy: 'request_approval',
|
||||||
denied_at: null,
|
denied_at: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
|||||||
Reference in New Issue
Block a user