mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cfa86e570 | |||
| 36cbf17e10 | |||
| 4459ab2e54 | |||
| 9e6238d28f | |||
| d1bda5d15b | |||
| 7eddc7d8c9 | |||
| 991ef986f8 | |||
| 0f2557e2bc | |||
| 4e6552ed55 | |||
| 978b998ee6 | |||
| 83951d7c01 | |||
| 76ef097521 | |||
| 1c85fd6e50 | |||
| 42275ede1f | |||
| 53e1989529 | |||
| 6f2142d7c7 | |||
| 79a0226962 | |||
| 0b31695e92 | |||
| 421f8707d2 | |||
| 67ccd9e74c | |||
| f69af07c57 | |||
| 93a302b5db | |||
| eef285ba3b | |||
| a806534199 | |||
| 0ac8073e34 | |||
| 539a2b3c63 | |||
| fccaadf24c | |||
| 3329270c67 | |||
| f16ea0c783 | |||
| 1c024bc976 | |||
| 6c26f3ef08 | |||
| ab6ab6936c | |||
| 501afb4beb | |||
| 9040dbb86e | |||
| d8748e3a45 | |||
| 41a720dd59 | |||
| 6ae83f48ac | |||
| dc34ceb83d | |||
| ad3dfad3f5 | |||
| 0bdc6d2bb2 | |||
| 820cd8ece6 | |||
| e44d497cdf | |||
| ac37ecbfd6 | |||
| c6627d32e2 | |||
| 51bf403b22 | |||
| 265953ffec | |||
| 6227bd1a5b | |||
| 28032bc0ec | |||
| 3e3a2945a5 | |||
| f3fc18e56e | |||
| d85efea229 | |||
| c5b22cb308 | |||
| 1592369201 | |||
| aef8d38b36 | |||
| 6d6f813deb | |||
| f9c86d0af2 | |||
| 728c6a641b | |||
| 8385236c30 |
@@ -111,8 +111,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
|
||||
@@ -119,8 +119,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
|
||||
@@ -39,3 +39,10 @@ groups/*
|
||||
.nanoclaw/
|
||||
|
||||
agents-sdk-docs
|
||||
.agents
|
||||
AGENTS.md
|
||||
|
||||
# Internal working docs, never committed
|
||||
docs/maintainer-guide.md
|
||||
docs/drafts/
|
||||
forks.md
|
||||
|
||||
@@ -33,7 +33,7 @@ user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
|
||||
|
||||
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
|
||||
messaging_groups (one chat/channel on one platform; unknown_sender_policy)
|
||||
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
|
||||
|
||||
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
|
||||
```
|
||||
@@ -83,6 +83,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
|
||||
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
@@ -274,6 +275,9 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
|
||||
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
|
||||
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
|
||||
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
|
||||
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
|
||||
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
|
||||
+17
-12
@@ -29,26 +29,27 @@ Every user should have clean and minimal code that does exactly what they need.
|
||||
|
||||
### Skill types
|
||||
|
||||
#### 1. Feature skills (branch-based)
|
||||
#### 1. Channel and provider skills (registry branches)
|
||||
|
||||
Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setup instructions; the actual code lives on a `skill/*` branch.
|
||||
Add a messaging channel or an agent provider. The SKILL.md contains the install steps; the actual code lives on a long-lived registry branch (`channels` or `providers`) that we keep in sync with `main`.
|
||||
|
||||
**Location:** `.claude/skills/` on `main` (instructions only), code on `skill/*` branch
|
||||
**Location:** `.claude/skills/` on `main` (instructions only), code on the `channels` or `providers` branch
|
||||
|
||||
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`
|
||||
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-opencode`
|
||||
|
||||
**How they work:**
|
||||
1. User runs `/add-telegram`
|
||||
2. Claude follows the SKILL.md: fetches and merges the `skill/telegram` branch
|
||||
3. Claude walks through interactive setup (env vars, bot creation, etc.)
|
||||
2. Claude follows the SKILL.md: `git fetch origin channels`, then copies each file in with `git show origin/channels:<path> > <path>`. Install is an additive fetch, never a `git merge`.
|
||||
3. The adapter's registration test is fetched the same way and run as verification
|
||||
4. Claude walks through interactive setup (tokens, bot creation, etc.)
|
||||
|
||||
**Contributing a feature skill:**
|
||||
**Contributing a channel or provider skill:**
|
||||
1. Fork `nanocoai/nanoclaw` and branch from `main`
|
||||
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
|
||||
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
|
||||
4. Open a PR. We'll create the `skill/<name>` branch from your work
|
||||
2. Build the adapter following [docs/skill-guidelines.md](docs/skill-guidelines.md): a self-registering module, one appended barrel import, and a registration test that imports the real barrel
|
||||
3. Add a SKILL.md in `.claude/skills/<name>/` with the fetch-and-copy steps, and a REMOVE.md that reverses every change
|
||||
4. Open a PR. We'll land the code on the registry branch from your work
|
||||
|
||||
See `/add-telegram` for a good example. See [docs/skills-as-branches.md](docs/skills-as-branches.md) for the full system design.
|
||||
See `/add-slack` for a good example. See [docs/skills-model.md](docs/skills-model.md) for why install is a fetch, never a merge.
|
||||
|
||||
#### 2. Utility skills (with code files)
|
||||
|
||||
@@ -58,7 +59,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
|
||||
|
||||
**Examples:** a self-contained CLI or helper shipped in a `scripts/` subfolder of the skill.
|
||||
|
||||
**Key difference from feature skills:** No branch merge needed. The code is self-contained in the skill directory and gets copied into place during installation.
|
||||
**Key difference from channel/provider skills:** the code is self-contained in the skill directory and gets copied into place during installation; nothing is fetched from a registry branch.
|
||||
|
||||
**Guidelines:**
|
||||
- Put code in separate files, not inline in the SKILL.md
|
||||
@@ -93,6 +94,10 @@ Skills that run inside the agent container, not on the host. These teach the con
|
||||
- Use `allowed-tools` frontmatter to scope tool permissions
|
||||
- Keep them focused — the agent's context window is shared across all container skills
|
||||
|
||||
### Writing a good skill
|
||||
|
||||
The authoring bar is [docs/skill-guidelines.md](docs/skill-guidelines.md): mostly adds, minimal reach-ins into existing code, a test for every functional integration point, and a REMOVE.md whenever apply leaves anything behind. [docs/skills-model.md](docs/skills-model.md) explains the model behind it.
|
||||
|
||||
### SKILL.md format
|
||||
|
||||
All skills use the [Claude Code skills standard](https://code.claude.com/docs/en/skills):
|
||||
|
||||
@@ -196,11 +196,19 @@ Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?"
|
||||
|
||||
If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill.
|
||||
|
||||
**How do I uninstall NanoClaw?**
|
||||
|
||||
```bash
|
||||
bash nanoclaw.sh --uninstall
|
||||
```
|
||||
|
||||
Every install is tagged with a per-checkout id, so the uninstaller removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself.
|
||||
|
||||
**What changes will be accepted into the codebase?**
|
||||
|
||||
Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all.
|
||||
|
||||
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills on the `channels` or `providers` branch.
|
||||
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills: channel and provider code on the `channels`/`providers` registry branches, everything else as a self-contained skill. See [docs/customizing.md](docs/customizing.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.154
|
||||
ARG CLAUDE_CODE_VERSION=2.1.170
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
@@ -19,23 +19,23 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.154", "", { "os": "darwin", "cpu": "x64" }, "sha512-5BgWEueP+cqoctWjZYhCbyltuaV/N2DmKDXD3/69cKaVmJp8XL9OCzlq/HEirA/+Ssjskx6hDUBaOcpuZ3iwQA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRkW4SBL3W7zQvKscCIfIGlmoeuTbMV6dXFbPdmpRGvmYZIs79RpzO6xrGBnnhmm+B7znQ9oHAnffi/2FBgJbA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-o2bCQN4Xn3UqCLErC5m4T7u0yYArJYmgFCUFnA6K96DdW2RERvx+gTKXxWuHEBkDO+eMoHLHLxk0u2jGES00Ng=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-GpiFF8Ez6PbM3m0gqtCo/FKM346qyRdP7VhbmJzdnbNKTiiUZ66vDQyEUPZPCG24ZkrG4m96KpRIUwY08rHiNg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.154", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDW1YFbU/PJFlrGXhlAGcbkXt80sEO6WtnH8nN8YHXLn5NWduy2q7o/qC6i8XozgvRGf6t/eMoH7IasGIEDhDw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.154", "", { "os": "win32", "cpu": "x64" }, "sha512-tSKaIIpL72OPg3WfzZTCIl8OJgcbq4qieu8/fDWjsdeQuari9gQMIuEflFphk9HqNsxpSmDqKi8Sm5mW2V566Q=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
import { loadConfig } from './config.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
// Providers barrel — each enabled provider self-registers on import.
|
||||
// Provider skills append imports to providers/index.ts.
|
||||
import './providers/index.js';
|
||||
@@ -95,6 +96,12 @@ async function main(): Promise<void> {
|
||||
effort: config.effort,
|
||||
});
|
||||
|
||||
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
|
||||
// the runner creates a persistent memory/ tree in its host-backed workspace at
|
||||
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
|
||||
// and keeps its native memory untouched.
|
||||
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
* send_message(to="agent-name") since agents and channels share the
|
||||
* unified destinations namespace.
|
||||
*
|
||||
* create_agent is admin-only. Non-admin containers never see this tool
|
||||
* (see mcp-tools/index.ts). The host re-checks permission on receive.
|
||||
* create_agent writes central-DB state. The host authorizes it by CLI scope:
|
||||
* trusted owner agent groups (scope 'global') create directly; confined groups
|
||||
* require admin approval (see src/modules/agent-to-agent/create-agent.ts). This
|
||||
* tool just writes the outbound request; authorization is enforced host-side,
|
||||
* not here — the container is untrusted and cannot be relied on to gate itself.
|
||||
*/
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { registerTools } from './server.js';
|
||||
@@ -32,7 +35,7 @@ export const createAgent: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'create_agent',
|
||||
description:
|
||||
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. Admin-only. Fire-and-forget.',
|
||||
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. May require admin approval before the agent is created. Fire-and-forget.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
|
||||
describe('ensureMemoryScaffold', () => {
|
||||
it('deterministically creates the memory tree', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent and never clobbers the agent edits', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
const indexFile = path.join(base, 'memory', 'index.md');
|
||||
fs.writeFileSync(indexFile, '# my own index\n');
|
||||
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Create the agent's persistent memory scaffold, container-side, at boot.
|
||||
*
|
||||
* The runner owns its own workspace: it writes the memory tree straight into
|
||||
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
|
||||
* ephemeral container). No host-side step, nothing mounted in.
|
||||
*
|
||||
* The default `definition.md` / `index.md` live as real markdown templates next
|
||||
* to this module (under `memory-templates/`) — not as strings in code — so the
|
||||
* doctrine is editable as markdown and the agent receives an unescaped copy.
|
||||
* They ship in the mounted `/app/src` tree, so no image change is needed.
|
||||
*
|
||||
* Idempotent — only writes what's missing, so the agent's own edits and
|
||||
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
|
||||
* the runner makes no assumption about which harness is running — a provider
|
||||
* opts in via `usesMemoryScaffold`.
|
||||
*/
|
||||
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
|
||||
|
||||
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
|
||||
const memoryDir = path.join(baseDir, 'memory');
|
||||
const systemDir = path.join(memoryDir, 'system');
|
||||
|
||||
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
|
||||
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
|
||||
}
|
||||
|
||||
function copyTemplateIfMissing(template: string, dest: string): void {
|
||||
if (fs.existsSync(dest)) return;
|
||||
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
|
||||
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
|
||||
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
|
||||
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
|
||||
// test goes red. main() can't be driven in-process (it reads
|
||||
// /workspace/agent/container.json and enters the poll loop), so the guard is
|
||||
// structural: gate + import must both be present in the real entry point.
|
||||
describe('memory scaffold boot wiring', () => {
|
||||
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
|
||||
|
||||
it('gates the scaffold on the provider capability in main()', () => {
|
||||
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
|
||||
});
|
||||
|
||||
it('imports ensureMemoryScaffold from the seam module', () => {
|
||||
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Memory System
|
||||
|
||||
This editable file defines how your persistent memory works. It is a starting
|
||||
point, not a contract — reorganize it as the work demands. If the user or another
|
||||
memory system replaces this definition, follow the replacement.
|
||||
|
||||
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
|
||||
Treat indexes as core data: keep them accurate and concise.
|
||||
Every folder of durable memory has its own `index.md` describing its contents.
|
||||
When an index grows past roughly 20 entries, group related items into subfolders,
|
||||
and give each new subfolder its own `index.md` linked from the parent.
|
||||
|
||||
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
|
||||
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
|
||||
Use entity folders for things that matter: projects, people, places, organizations, decisions.
|
||||
|
||||
When the user shares something that should survive future turns, store it in the
|
||||
smallest useful file; prefer updating an existing file over creating duplicates.
|
||||
Write concise, source-aware notes; include dates when timing matters.
|
||||
If a fact is corrected, update the memory and keep only useful history.
|
||||
When you add, move, or remove memory, update the nearest index.
|
||||
Before answering from memory, read the relevant index or file instead of guessing;
|
||||
if memory is missing or uncertain, say so and verify when it matters.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Memory Index
|
||||
|
||||
- [Memory system definition](system/definition.md)
|
||||
- [Memories](memories/) - durable facts, people, projects, decisions
|
||||
- [Data](data/) - structured reference data
|
||||
@@ -6,6 +6,14 @@ export interface AgentProvider {
|
||||
*/
|
||||
readonly supportsNativeSlashCommands: boolean;
|
||||
|
||||
/**
|
||||
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
|
||||
* agent's workspace at boot. Providers with their own native memory (e.g.
|
||||
* Claude's `CLAUDE.local.md`) omit this and get nothing — memory is opt-in per
|
||||
* provider, never gated on a provider name.
|
||||
*/
|
||||
readonly usesMemoryScaffold?: boolean;
|
||||
|
||||
/** Start a new query. Returns a handle for streaming input and output. */
|
||||
query(input: QueryInput): AgentQuery;
|
||||
|
||||
|
||||
@@ -9,6 +9,5 @@ The files in this directory are original design documents and developer referenc
|
||||
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||
| [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
|
||||
@@ -668,15 +668,19 @@ CREATE TABLE agent_groups (
|
||||
);
|
||||
|
||||
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
|
||||
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
|
||||
-- (the "default instance"), so single-instance installs never see it.
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL, -- 'whatsapp', 'slack', 'discord', 'telegram', 'email'
|
||||
platform_id TEXT NOT NULL, -- platform-specific ID (JID, channel ID, etc.)
|
||||
instance TEXT NOT NULL, -- adapter-instance name; default = channel_type
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public'
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
|
||||
-- Users (messaging platform identities, namespaced "<channel_type>:<handle>")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Customizing NanoClaw
|
||||
|
||||
NanoClaw is made to be forked and changed. The catch with most projects is that once you edit the code, every upstream update turns into a merge fight, and the more you customized, the worse it gets.
|
||||
|
||||
NanoClaw avoids that with one simple idea: **every change you make is a skill.**
|
||||
|
||||
## The idea in a minute
|
||||
|
||||
- A **skill** is a small, self-contained add-on. It brings its own code and knows how to install itself.
|
||||
- Your **fork is just a list of skills**, plus one "recipe" that says which skills you have and how they fit together.
|
||||
- Because your changes live beside the core instead of tangled into it, **pulling in updates stays easy**.
|
||||
|
||||
## What makes it work
|
||||
|
||||
A good skill mostly **adds** things: new files, a line appended to an existing file, a dependency. It avoids rewriting existing code in place.
|
||||
|
||||
And it ships a test for each spot where it touches the rest of the system. When an update moves something your skill depends on, that test fails and points at the fix, instead of you finding out when things break in production.
|
||||
|
||||
## How you actually work
|
||||
|
||||
You don't have to think in skills while you're building. **Edit the code directly, get it working, then turn your changes into skills afterward.** A coding agent does the conversion for you, following [skill-guidelines.md](skill-guidelines.md).
|
||||
|
||||
The only rule worth remembering: **a change isn't really part of your fork until it's a skill**, because that's the form that survives an upgrade.
|
||||
|
||||
## Upgrading
|
||||
|
||||
Always upgrade by running `/update-nanoclaw`. **Don't just `git pull`.** The command sets a rollback point, pulls the upstream changes, runs your tests, and walks you through anything that needs fixing, usually a small, local fix in one skill.
|
||||
|
||||
## The deal
|
||||
|
||||
We keep the core small and stable, and every breaking change ships with its migration. You keep your changes as skills, with tests. Do that, and upgrades won't break you. Changes edited directly into the core are the one thing the model can't protect.
|
||||
|
||||
## Go deeper
|
||||
|
||||
- **[The skills model in full](skills-model.md)**: how skills, recipes, tests, and upgrades work under the hood.
|
||||
- **[Skill guidelines](skill-guidelines.md)**: the authoritative checklist for writing one.
|
||||
+6
-3
@@ -27,21 +27,24 @@ CREATE TABLE agent_groups (
|
||||
|
||||
### 1.2 `messaging_groups`
|
||||
|
||||
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.).
|
||||
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.) per adapter instance.
|
||||
|
||||
```sql
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
```
|
||||
|
||||
- `instance`: adapter-instance name — N adapters of one platform (e.g. three Slack apps in one workspace) each own their rows. The default instance IS the channel type: migration 016 backfills `instance = channel_type` and `createMessagingGroup` stamps the same default, so single-instance installs never see the dimension. Inbound lookups are exact-on-instance (an unknown named instance auto-creates its own row); outbound lookups resolve default-instance-first.
|
||||
- `unknown_sender_policy`: `strict` (drop), `request_approval` (ask admin), `public` (allow).
|
||||
- **Readers:** `src/router.ts`, `src/delivery.ts`, `src/session-manager.ts`
|
||||
- **Writers:** `src/db/messaging-groups.ts`, channel setup flows
|
||||
@@ -134,7 +137,7 @@ CREATE TABLE user_dms (
|
||||
);
|
||||
```
|
||||
|
||||
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`.
|
||||
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`. Cold DMs resolve via the channel's default adapter instance — `PRIMARY KEY (user_id, channel_type)` is per-platform, not per-instance.
|
||||
|
||||
### 1.8 `sessions`
|
||||
|
||||
|
||||
@@ -53,6 +53,80 @@ Model selection considerations for Apple Silicon:
|
||||
|
||||
The agent uses tool calls extensively (read/write files, shell commands). Models that support tool use reliably work best. Gemma 4 and Qwen 3 Coder both handle structured tool calls well.
|
||||
|
||||
## Allowing Prompt Caching (filter the cache-busting hash)
|
||||
|
||||
Out of the box this path is slow — every reply re-reads the whole multi-thousand-token system prompt from scratch, even for a one-word answer. Ollama has a prompt cache that should skip that repeated work, but on this path it never kicks in.
|
||||
|
||||
**Cause.** The Claude Agent SDK adds a per-request hash to the front of every prompt — `x-anthropic-billing-header: ...; cch=<hash>;`. It changes on every request, and Ollama's cache only reuses a prompt whose start is unchanged. So that one shifting value at the front makes Ollama treat every prompt as new and re-read all of it. (Ollama ignores the hash itself, so filtering it has no effect on output.)
|
||||
|
||||
**Fix.** Run a tiny proxy between the container and Ollama that filters the hash out (pins `cch=<hash>` to a constant). The start of the prompt is now stable, so the cache kicks in and only the new message gets processed. In our setup — a 31B model on Apple Silicon — follow-up replies dropped from ~80s to ~4s; your numbers will vary with model size and hardware. Output is unchanged, since Ollama ignores the value anyway.
|
||||
|
||||
Point the agent group's `ANTHROPIC_BASE_URL` at the proxy instead of Ollama directly (everything else from the sections above is unchanged):
|
||||
|
||||
```
|
||||
ANTHROPIC_BASE_URL=http://host.docker.internal:11999 # the proxy
|
||||
# proxy forwards to http://127.0.0.1:11434 (Ollama)
|
||||
```
|
||||
|
||||
The proxy is ~40 lines of dependency-free Node:
|
||||
|
||||
```js
|
||||
// ollama-cch-proxy.mjs — normalize the SDK's per-request cch nonce so Ollama's
|
||||
// prefix cache survives across turns. Listens on :11999, forwards to Ollama.
|
||||
import http from 'node:http';
|
||||
|
||||
const TARGET_HOST = process.env.OLLAMA_HOST || '127.0.0.1';
|
||||
const TARGET_PORT = Number(process.env.OLLAMA_PORT || 11434);
|
||||
const LISTEN_PORT = Number(process.env.PROXY_PORT || 11999);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
let body = Buffer.concat(chunks);
|
||||
if (req.method === 'POST' && body.length) {
|
||||
body = Buffer.from(body.toString('utf8').replace(/cch=[0-9a-f]+;/g, 'cch=00000;'), 'utf8');
|
||||
}
|
||||
const headers = { ...req.headers, host: `${TARGET_HOST}:${TARGET_PORT}`, 'content-length': String(body.length) };
|
||||
const proxyReq = http.request(
|
||||
{ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers },
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
},
|
||||
);
|
||||
proxyReq.on('error', (e) => { res.writeHead(502); res.end(String(e)); });
|
||||
proxyReq.end(body);
|
||||
});
|
||||
});
|
||||
server.listen(LISTEN_PORT, '0.0.0.0', () => console.log(`cch-proxy :${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}`));
|
||||
```
|
||||
|
||||
Run it durably so it survives reboots. On Linux, a systemd user service:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/ollama-cch-proxy.service
|
||||
[Unit]
|
||||
Description=Ollama cch-normalizing proxy for NanoClaw
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/node %h/.config/nanoclaw/ollama-cch-proxy.mjs
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now ollama-cch-proxy
|
||||
loginctl enable-linger "$USER" # so it runs without an active login session
|
||||
```
|
||||
|
||||
On macOS use a `launchd` user agent (`~/Library/LaunchAgents/`) running the same script.
|
||||
|
||||
**Scope.** This only affects the Claude-Code-CLI → Ollama path described here. Codex and OpenCode don't use the Claude Agent SDK, so they never emit the `cch` hash and get prompt caching for free.
|
||||
|
||||
## What Changes at the Code Level
|
||||
|
||||
Three files need to support this feature. See `/add-ollama-provider` for the exact changes.
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ leaking the token to disk outweighs the debugging value.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. |
|
||||
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. `--uninstall` bypasses bootstrap entirely — it execs setup:auto directly (the flow lives in `setup/uninstall/`), or prints manual-cleanup guidance and exits 1 when the TS toolchain is missing. |
|
||||
| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). |
|
||||
| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. |
|
||||
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Skill guidelines
|
||||
|
||||
The authoritative checklist for writing a NanoClaw skill: the bar that conformance tooling and registry review will hold every skill to. [customizing.md](customizing.md) is the short introduction; [skills-model.md](skills-model.md) explains why the model works this way. This document evolves with the system; when a rule here proves wrong, fix the rule.
|
||||
|
||||
---
|
||||
|
||||
## Principles
|
||||
|
||||
Every customization is an additive **skill**: not an edit buried in core, but a skill that carries its own code and knows how to install and remove itself. Two principles make a skill *maintainable*; everything else in this document follows from them.
|
||||
|
||||
### 1. Minimal integration surface
|
||||
|
||||
A skill adds files and makes the **smallest possible reach-ins** into existing code. Adding a file or a dependency never breaks on upgrade; reaching into existing code is the only thing that does, so the integration surface *is* the upgrade risk. Keep reach-ins few, tiny, and ideally a single line that *calls* into the skill's own code.
|
||||
|
||||
Follows from this:
|
||||
|
||||
- **Mostly add.** See the change shapes below, in safety order.
|
||||
- **Push logic into skill-owned files** so the core edit is one call, not an inlined block. This shrinks the surface *and* makes the point testable.
|
||||
- **Colocated, self-contained** edits over edits in two places.
|
||||
- **Use an existing registry or hook when there is one**: appending to a registry is a smaller surface than reaching into code. When none exists, a true code-level edit is fine and first-class. (Whether to *add* a hook because a spot has become a hotspot is the maintainer's call, not the skill's.)
|
||||
|
||||
### 2. A test for every functional integration point
|
||||
|
||||
Every reach-in with a **functional consequence** gets a test that goes **red if the wiring is deleted or drifts**. That's what protects the fork from upstream changes. The tests are also the verification: there is no separate "verify" step.
|
||||
|
||||
Follows from this:
|
||||
|
||||
- **Tests target integration with core, not internal correctness.** Unit tests of a skill's own logic, or its behavior against an external service, are the creator's call: fine, just not required.
|
||||
- **A direct unit test doesn't count**: calling the skill's own function bypasses the wiring and stays green when the reach-in is deleted. Drive the real entry, or assert the wiring structurally.
|
||||
- **Build / typecheck is an always-on leg**: drift (moved imports, renamed fields) is the main enemy and slips past runtime tests.
|
||||
- **The test lives where the point runs**: host code uses vitest under `src/`; container code uses `bun:test` under `container/agent-runner/`.
|
||||
- **"Functional" is the filter**: weigh a reach-in by what breaks if it's gone. A cosmetic one (raising a log line's level) gets no test.
|
||||
|
||||
The two interlock: a minimal surface keeps the integration points few and testable; a test per point keeps the surface safe. *Maintainable = small surface, every functional point guarded.*
|
||||
|
||||
---
|
||||
|
||||
## Skill anatomy
|
||||
|
||||
A skill carries everything it needs:
|
||||
|
||||
- **Code**: the files it adds. They live in the skill's own folder, or, for large registry-backed skills like channels and providers, on a registry branch the skill fetches from. Apply copies them in.
|
||||
- **Apply**: the steps in `SKILL.md`, written as prose an agent can run. Apply must be safe to re-run: upgrades re-run it, and a skill that half-applies twice is a bug.
|
||||
- **Remove**: a separate `REMOVE.md` that reverses *every* change apply made: barrel lines deleted (not commented out), every copied file removed including tests, dependencies uninstalled, Dockerfile edits reverted, env lines removed. **REMOVE.md is required exactly when apply leaves anything behind.** A pure instruction-only skill that copies nothing needs none, and an empty one is noise.
|
||||
- **Tests**: files that ship with the skill and are copied into the project's test tree on apply, so they run against the *composed* system.
|
||||
- **Recipe entry**: how it composes with the fork's other skills (ordering, dependencies).
|
||||
|
||||
---
|
||||
|
||||
## Change shapes
|
||||
|
||||
In rough order of safety:
|
||||
|
||||
- **Add a file**: safest. New code in the skill's own files, or fetched from a registry branch (`git show origin/<branch>:path > path`).
|
||||
- **Append to a file**: an import in a barrel, a line in `.env`, an entry at the end of a list.
|
||||
- **Edit a value in JSON**: e.g. a `package.json` field.
|
||||
- **Add a dependency**, pinned to an exact version.
|
||||
- **Insert into existing code (an "integration point")**: the one risky move. Keep it to a line or two that *calls* code living in the skill's own files, never an inlined block of logic. A skill full of these is a smell.
|
||||
|
||||
Fetching from a registry branch is **additive, never a merge**. `git fetch origin <branch>` then `git show origin/<branch>:path > path` per file. Never `git merge` a registry branch into an install.
|
||||
|
||||
---
|
||||
|
||||
## Integration points
|
||||
|
||||
The integration point is wherever the skill reaches into existing code. Make it **minimal, colocated, and self-contained**:
|
||||
|
||||
- All real logic lives in the skill's own file behind a single entry function; the edit to core is just the call.
|
||||
- **Prefer one colocated block** over edits in two places. For an inserted call, a dynamic import at the call site keeps the import and call together and avoids touching the top-of-file import block (itself a merge hotspot):
|
||||
|
||||
```typescript
|
||||
const { startDashboard } = await import('./dashboard-pusher.js');
|
||||
await startDashboard();
|
||||
```
|
||||
|
||||
A static import + call is acceptable too; this is a recommendation, not a mandate.
|
||||
- Keep any gating (feature flags, env checks) *inside* the skill's function, so the core edit stays a single call.
|
||||
- When the reach-in lands inside an entangled function, extract a tiny skill-owned helper so the core touch is one line, like `args.push(...mySkillEnvArgs())`, rather than exporting the whole function or inlining the logic.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
**What the standard requires: integration with the NanoClaw system.**
|
||||
|
||||
- **Required:** a test for every functional integration point, and, where an added file consumes core (core APIs, data shapes, registries), a test that exercises that consumption against the real core. That's the leg that catches core drift.
|
||||
- **Optional, the creator's call:** unit tests of the skill's own internal logic, or its behavior against an external service. Often good practice; not what defines a maintainable skill, because they don't protect against upstream changes.
|
||||
|
||||
### Choosing the test type
|
||||
|
||||
For a code-edit integration point, how you test the wiring depends on whether you can invoke the function the edit lives in. **Prefer behavior; fall back to structure.**
|
||||
|
||||
- **If the edit lives in an invocable function, test that function's behavior.** Calling it exercises the edit; remove or break the edit and the test goes red. This is the strongest option, and usually available, because a minimal integration point pushes the logic into the skill's own exported function anyway.
|
||||
- **If the edit lives in a non-invocable entry point** (e.g. `main()` or boot), **use a structural / AST test.** Use the TypeScript compiler API and assert not just that the symbol exists but its **placement**: awaited, a direct statement of the right function, importing the right module path, correctly ordered. A present-but-misplaced call must go red.
|
||||
|
||||
Two more legs apply when relevant:
|
||||
|
||||
- **Build / typecheck** always applies: it catches a renamed symbol, a moved module, a bad signature.
|
||||
- **A behavior test of how added code consumes core**, required when the added file reaches into core APIs or data at runtime. When the consumption is a *typed* call into a core API (a Chat SDK adapter calling `createChatSdkBridge`), the build leg already guards it and no separate behavior test is required. The behavior-test requirement targets runtime consumption: core DB state, data shapes, registries.
|
||||
|
||||
Together these cover deletion, misplacement, drift, and core consumption. Only true runtime-reachability (a call stranded behind a dead branch) needs the heavy option of booting the real entry point, a rare "real run" reserved for critical wiring.
|
||||
|
||||
### Registration reach-ins: behavior, not structural
|
||||
|
||||
A registry queryable at runtime gets a **behavior** test: import the real barrel, assert the registry contains the entry. A structural parse only proves the *source line* exists. It stays green when the barrel can't evaluate or the package isn't installed, which is exactly when the thing is actually broken. The behavior test goes red on a deleted barrel line, a barrel that won't evaluate, *and* an uninstalled package (the unmocked import throws), so it covers the dependency integration point for free.
|
||||
|
||||
Two consequences. First, **don't mock the adapter's package in the shipped test**: that would defeat the dependency check, and the test runs in the composed install where the package is present. Second, the only reason to fall back to a structural parse is an adapter with real import-time side effects (spawns a process, opens a socket, needs creds at load), which is an adapter smell to fix, not a reason to weaken the test. Conformant adapters do all side-effectful work in the factory or `setup()`, never at import.
|
||||
|
||||
### Test archetypes
|
||||
|
||||
The test matches the kind of integration point:
|
||||
|
||||
- **In-process seam with core** (a channel into the router, a pusher into the central DB): drive the real added component against the **real core collaborators** (DB, registry, router), faking only the external edge. The highest-value archetype: it exercises the added file's consumption of core, which is what catches core drift.
|
||||
- **Wiring / registration** (a barrel import, a `main()` call, an entry in an `mcpServers` map): behavior test via the registry where queryable (see above); structural / AST test where not.
|
||||
- **Config / container probe** (mounts, Dockerfile, a tool installed in the image): run the change where you can. Spin up a container to confirm a mount or binary. Checking that a line exists in a file is the last resort.
|
||||
- **Agentic run** (operational, instruction-only skills): run the workflow with a small model; did it complete?
|
||||
- **Patch behavior** (a patch skill that changes core logic): a behavior test of the changed behavior.
|
||||
- **Provider (multi-point)**: a non-default agent backend reaches into *two* barrels (host `src/providers/index.ts`; container `container/agent-runner/src/providers/index.ts`), plus Dockerfile edits and a CLI or SDK dependency. Each is a separate way to break, and each needs its own guard. Ship a **barrel-driven registration test per tree** that imports *only* the real barrel and asserts the registry contains the provider. **The trap:** a `*.factory.test.ts` that imports the provider module directly self-registers it and stays green when the barrel line is deleted; that's a unit test, not a registration guard. REMOVE.md must reverse both barrel lines, all copied files in both trees, the dependency, and the Dockerfile edits.
|
||||
- **Content / instruction-only** (a reference wiki, a pure workflow): makes no functional reach-in, so it owes no integration test. Conformance is anatomy: idempotent apply, plus REMOVE.md iff apply leaves anything behind.
|
||||
|
||||
### Dependencies are integration points
|
||||
|
||||
A skill that installs a package has made a reach-in: the code now assumes it's there. Guard it so a missing package goes red, in order of preference:
|
||||
|
||||
1. **An unmocked import in a behavior test**: the test imports real code that imports the package, so a missing package throws. Covers presence *and* exercises the real dependency.
|
||||
2. **The build leg**: a typed import of a missing module fails typecheck. The fallback when the package genuinely can't be imported in a test (e.g. it binds a port on import). Only works if the validate step runs the build before or alongside the tests, so verify the order.
|
||||
3. **A Dockerfile-installed CLI binary** is the case most often left unguarded: it isn't importable, so neither guard above sees it. Use a **structural test** asserting the Dockerfile `ARG <X>_VERSION=` and install line are present, optionally backed by a `<bin> --version` container probe. Pin the version; reject `latest`.
|
||||
|
||||
You do *not* need to test the dependency's own API contract; that's optional external-service coverage.
|
||||
|
||||
### When there is genuinely nothing to test in-tree
|
||||
|
||||
Some skills' only functional integration is a runtime operator action with no source footprint: registering an MCP server through `ncl`, or a mount through the sanctioned query wrapper (until the `ncl` add-mount verb lands). There's no line in the tree whose deletion a test could catch, so a registration test is structurally inapplicable. **State this explicitly in SKILL.md** rather than inventing a hollow test; conformance is then anatomy plus the dependency guard. This is a conformant outcome, valid only when the reach-in has no in-tree representation. (A raw-SQL write into core's schema to achieve the same thing is a smell, not a workaround.)
|
||||
|
||||
### Test rules
|
||||
|
||||
- **Hermetic at the external edge.** Mock genuinely external services (a fake HTTP server, stubbed creds), never the package under guard (see "Registration reach-ins").
|
||||
- **Exercise the real entry, or assert it structurally.** A test that imports the skill's function directly does not test the integration.
|
||||
- **Tests travel with the skill** and are copied in on apply; an integration test only means anything against the composed project.
|
||||
- **Robustness check.** Apply the skill with a small, cheap model. If a small model fumbles the instructions, they're too vague. Fix the instructions, don't blame the model. (Small models also keep applying skills cheap.)
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
Each with its fix. These are patterns to remove, not to test around: a drift-prone, untestable reach-in is usually a symptom of a bad pattern, not a missing test. Reviewers reject them; the conformance linter will flag them automatically.
|
||||
|
||||
1. **A separate VERIFY.md.** Delete it; tests are the verification. Fold any genuinely useful manual smoke check into SKILL.md's next steps.
|
||||
2. **REMOVE.md soft-disable** (comments out an import; leaves copied files behind). DELETE the import line and `rm` every file the skill copied.
|
||||
3. **REMOVE.md incomplete** (misses env vars, the package uninstall, copied tests). Reverse *every* change; read the env vars from the skill's own credentials section, don't guess.
|
||||
4. **Raw SQL against a core DB** (read or write). Use a core helper or an `ncl` verb; the in-tree query wrapper is the sanctioned last resort. Never the `sqlite3` binary.
|
||||
5. **Credential threading** (`-e KEY=…` or a stdin secrets payload into the container). OneCLI gateway only; it injects credentials per request.
|
||||
6. **Branch-merge install** (`git merge` of a registry branch or any code branch). Install by additive fetch: `git fetch origin <branch>`, then `git show origin/<branch>:path > path` per file. For an update/reapply workflow, re-run each installed skill's additive apply, never merge.
|
||||
7. **Diff-against-past framing** ("earlier versions…", "this is now redundant") and **documenting non-steps** ("no X needed"). Write present-tense DO steps only. A skill reads as a standalone artifact with no memory of its own edits.
|
||||
8. **Stale reach-in targets** (an edit aimed at code that no longer exists; a reach-in already shipped in trunk). Verify the target exists *before* instructing the edit; reconcile already-in-trunk ones to a no-op. Before appending to an allowlist or list, check how it's consumed; the entry may already be derived from a registry, making the edit dead.
|
||||
9. **Hand-maintained duplicate copies** (a mirror directory kept in sync by hand or sed). Generate the mirror from a single canonical source.
|
||||
|
||||
---
|
||||
|
||||
## Worked examples
|
||||
|
||||
In-tree exemplars for the code archetypes. (Two carry known smells, kept deliberately pending architectural fixes; they demonstrate the test shapes, not perfection.)
|
||||
|
||||
- `add-dashboard`: in-process seam with core (the pusher against the central DB), plus an AST wiring test for its `main()` call.
|
||||
- `add-slack`: Chat SDK channel registration; the template for the whole channel family.
|
||||
- `add-deltachat`: native channel registration.
|
||||
- `add-atomic-chat-tool`: MCP-tool wiring across both runtimes (container registration and host env-helper call).
|
||||
- `add-opencode` / `add-codex`: the provider multi-point archetype, with two barrels, Dockerfile pins, and per-tree registration tests.
|
||||
@@ -1,677 +0,0 @@
|
||||
# Skills as Branches
|
||||
|
||||
## Overview
|
||||
|
||||
This document covers **feature skills** — skills that add capabilities via git branch merges. This is the most complex skill type and the primary way NanoClaw is extended.
|
||||
|
||||
NanoClaw has four types of skills overall. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full taxonomy:
|
||||
|
||||
| Type | Location | How it works |
|
||||
|------|----------|-------------|
|
||||
| **Feature** (this doc) | `.claude/skills/` + `skill/*` branch | SKILL.md has instructions; code lives on a branch, applied via `git merge` |
|
||||
| **Utility** | `.claude/skills/<name>/` with code files | Self-contained tools; code in skill directory, copied into place on install |
|
||||
| **Operational** | `.claude/skills/` on `main` | Instruction-only workflows (setup, debug, update) |
|
||||
| **Container** | `container/skills/` | Loaded inside agent containers at runtime |
|
||||
|
||||
---
|
||||
|
||||
Feature skills are distributed as git branches on the upstream repository. Applying a skill is a `git merge`. Updating core is a `git merge`. Everything is standard git.
|
||||
|
||||
This replaces the previous `skills-engine/` system (three-way file merging, `.nanoclaw/` state, manifest files, replay, backup/restore) with plain git operations and Claude for conflict resolution.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Repository structure
|
||||
|
||||
The upstream repo (`nanocoai/nanoclaw`) maintains:
|
||||
|
||||
- `main` — core NanoClaw (no skill code)
|
||||
- `skill/discord` — main + Discord integration
|
||||
- `skill/telegram` — main + Telegram integration
|
||||
- `skill/slack` — main + Slack integration
|
||||
- `skill/gmail` — main + Gmail integration
|
||||
- etc.
|
||||
|
||||
Each skill branch contains all the code changes for that skill: new files, modified source files, updated `package.json` dependencies, `.env.example` additions — everything. No manifest, no structured operations, no separate `add/` and `modify/` directories.
|
||||
|
||||
### Skill discovery and installation
|
||||
|
||||
Skills are split into two categories:
|
||||
|
||||
**Operational skills** (on `main`, always available):
|
||||
- `/setup`, `/debug`, `/update-nanoclaw`, `/customize`, `/update-skills`
|
||||
- These are instruction-only SKILL.md files — no code changes, just workflows
|
||||
- Live in `.claude/skills/` on `main`, immediately available to every user
|
||||
|
||||
**Feature skills** (in marketplace, installed on demand):
|
||||
- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc.
|
||||
- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code
|
||||
- Live in the marketplace repo (`nanocoai/nanoclaw-skills`)
|
||||
|
||||
Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently:
|
||||
|
||||
```bash
|
||||
# Claude runs this behind the scenes — users don't see it
|
||||
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
|
||||
```
|
||||
|
||||
Skills are hot-loaded after `claude plugin install` — no restart needed. This means `/setup` can install the marketplace plugin, then immediately run any feature skill, all in one session.
|
||||
|
||||
### Selective skill installation
|
||||
|
||||
`/setup` asks users what channels they want, then only offers relevant skills:
|
||||
|
||||
1. "Which messaging channels do you want to use?" → Discord, Telegram, Slack, WhatsApp
|
||||
2. User picks Telegram → Claude installs the plugin and runs `/add-telegram`
|
||||
3. After Telegram is set up: "Want to add Agent Swarm support for Telegram?" → offers `/add-telegram-swarm`
|
||||
4. "Want to enable community skills?" → installs community marketplace plugins
|
||||
|
||||
Dependent skills (e.g., `telegram-swarm` depends on `telegram`) are only offered after their parent is installed. `/customize` follows the same pattern for post-setup additions.
|
||||
|
||||
### Marketplace configuration
|
||||
|
||||
NanoClaw's `.claude/settings.json` registers the official marketplace:
|
||||
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The marketplace repo uses Claude Code's plugin structure:
|
||||
|
||||
```
|
||||
nanocoai/nanoclaw-skills/
|
||||
.claude-plugin/
|
||||
marketplace.json # Plugin catalog
|
||||
plugins/
|
||||
nanoclaw-skills/ # Single plugin bundling all official skills
|
||||
.claude-plugin/
|
||||
plugin.json # Plugin manifest
|
||||
skills/
|
||||
add-discord/
|
||||
SKILL.md # Setup instructions; step 1 is "merge the branch"
|
||||
add-telegram/
|
||||
SKILL.md
|
||||
add-slack/
|
||||
SKILL.md
|
||||
...
|
||||
```
|
||||
|
||||
Multiple skills are bundled in one plugin — installing `nanoclaw-skills` makes all feature skills available at once. Individual skills don't need separate installation.
|
||||
|
||||
Each SKILL.md tells Claude to merge the corresponding skill branch as step 1, then walks through interactive setup (env vars, bot creation, etc.).
|
||||
|
||||
### Applying a skill
|
||||
|
||||
User runs `/add-discord` (discovered via marketplace). Claude follows the SKILL.md:
|
||||
|
||||
1. `git fetch upstream skill/discord`
|
||||
2. `git merge upstream/skill/discord`
|
||||
3. Interactive setup (create bot, get token, configure env vars, etc.)
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/discord
|
||||
git merge upstream/skill/discord
|
||||
```
|
||||
|
||||
### Applying multiple skills
|
||||
|
||||
```bash
|
||||
git merge upstream/skill/discord
|
||||
git merge upstream/skill/telegram
|
||||
```
|
||||
|
||||
Git handles the composition. If both skills modify the same lines, it's a real conflict and Claude resolves it.
|
||||
|
||||
### Updating core
|
||||
|
||||
```bash
|
||||
git fetch upstream main
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
Since skill branches are kept merged-forward with main (see CI section), the user's merged-in skill changes and upstream changes have proper common ancestors.
|
||||
|
||||
### Checking for skill updates
|
||||
|
||||
Users who previously merged a skill branch can check for updates. For each `upstream/skill/*` branch, check whether the branch has commits that aren't in the user's HEAD:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
for branch in $(git branch -r | grep 'upstream/skill/'); do
|
||||
# Check if user has merged this skill at some point
|
||||
merge_base=$(git merge-base HEAD "$branch" 2>/dev/null) || continue
|
||||
# Check if the skill branch has new commits beyond what the user has
|
||||
if ! git merge-base --is-ancestor "$branch" HEAD 2>/dev/null; then
|
||||
echo "$branch has updates available"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
This requires no state — it uses git history to determine which skills were previously merged and whether they have new commits.
|
||||
|
||||
This logic is available in two ways:
|
||||
- Built into `/update-nanoclaw` — after merging main, optionally check for skill updates
|
||||
- Standalone `/update-skills` — check and merge skill updates independently
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
At any merge step, conflicts may arise. Claude resolves them — reading the conflicted files, understanding the intent of both sides, and producing the correct result. This is what makes the branch approach viable at scale: conflict resolution that previously required human judgment is now automated.
|
||||
|
||||
### Skill dependencies
|
||||
|
||||
Some skills depend on other skills. E.g., `skill/telegram-swarm` requires `skill/telegram`. Dependent skill branches are branched from their parent skill branch, not from `main`.
|
||||
|
||||
This means `skill/telegram-swarm` includes all of telegram's changes plus its own additions. When a user merges `skill/telegram-swarm`, they get both — no need to merge telegram separately.
|
||||
|
||||
Dependencies are implicit in git history — `git merge-base --is-ancestor` determines whether one skill branch is an ancestor of another. No separate dependency file is needed.
|
||||
|
||||
### Uninstalling a skill
|
||||
|
||||
```bash
|
||||
# Find the merge commit
|
||||
git log --merges --oneline | grep discord
|
||||
|
||||
# Revert it
|
||||
git revert -m 1 <merge-commit>
|
||||
```
|
||||
|
||||
This creates a new commit that undoes the skill's changes. Claude can handle the whole flow.
|
||||
|
||||
If the user has modified the skill's code since merging (custom changes on top), the revert might conflict — Claude resolves it.
|
||||
|
||||
If the user later wants to re-apply the skill, they need to revert the revert first (git treats reverted changes as "already applied and undone"). Claude handles this too.
|
||||
|
||||
## CI: Keeping Skill Branches Current
|
||||
|
||||
A GitHub Action runs on every push to `main`:
|
||||
|
||||
1. List all `skill/*` branches
|
||||
2. For each skill branch, merge `main` into it (merge-forward, not rebase)
|
||||
3. Run build and tests on the merged result
|
||||
4. If tests pass, push the updated skill branch
|
||||
5. If a skill fails (conflict, build error, test failure), open a GitHub issue for manual resolution
|
||||
|
||||
**Why merge-forward instead of rebase:**
|
||||
- No force-push — preserves history for users who already merged the skill
|
||||
- Users can re-merge a skill branch to pick up skill updates (bug fixes, improvements)
|
||||
- Git has proper common ancestors throughout the merge graph
|
||||
|
||||
**Why this scales:** With a few hundred skills and a few commits to main per day, the CI cost is trivial. Haiku is fast and cheap. The approach that wouldn't have been feasible a year or two ago is now practical because Claude can resolve conflicts at scale.
|
||||
|
||||
## Installation Flow
|
||||
|
||||
### New users (recommended)
|
||||
|
||||
1. Fork `nanocoai/nanoclaw` on GitHub (click the Fork button)
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/<you>/nanoclaw.git
|
||||
cd nanoclaw
|
||||
```
|
||||
3. Run Claude Code:
|
||||
```bash
|
||||
claude
|
||||
```
|
||||
4. Run `/setup` — Claude handles dependencies, authentication, container setup, service configuration, and adds `upstream` remote if not present
|
||||
|
||||
Forking is recommended because it gives users a remote to push their customizations to. Clone-only works for trying things out but provides no remote backup.
|
||||
|
||||
### Existing users migrating from clone
|
||||
|
||||
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
|
||||
|
||||
1. Fork `nanocoai/nanoclaw` on GitHub
|
||||
2. Reroute remotes:
|
||||
```bash
|
||||
git remote rename origin upstream
|
||||
git remote add origin https://github.com/<you>/nanoclaw.git
|
||||
git push --force origin main
|
||||
```
|
||||
The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose.
|
||||
3. From this point, `origin` = their fork, `upstream` = nanocoai/nanoclaw
|
||||
|
||||
### Existing users migrating from the old skills engine
|
||||
|
||||
Users who previously applied skills via the `skills-engine/` system have skill code in their tree but no merge commits linking to skill branches. Git doesn't know these changes came from a skill, so merging a skill branch on top would conflict or duplicate.
|
||||
|
||||
**For new skills going forward:** just merge skill branches as normal. No issue.
|
||||
|
||||
**For existing old-engine skills**, two migration paths:
|
||||
|
||||
**Option A: Per-skill reapply (keep your fork)**
|
||||
1. For each old-engine skill: identify and revert the old changes, then merge the skill branch fresh
|
||||
2. Claude assists with identifying what to revert and resolving any conflicts
|
||||
3. Custom modifications (non-skill changes) are preserved
|
||||
|
||||
**Option B: Fresh start (cleanest)**
|
||||
1. Create a new fork from upstream
|
||||
2. Merge the skill branches you want
|
||||
3. Manually re-apply your custom (non-skill) changes
|
||||
4. Claude assists by diffing your old fork against the new one to identify custom changes
|
||||
|
||||
In both cases:
|
||||
- Delete the `.nanoclaw/` directory (no longer needed)
|
||||
- The `skills-engine/` code will be removed from upstream once all skills are migrated
|
||||
- `/update-skills` only tracks skills applied via branch merge — old-engine skills won't appear in update checks
|
||||
|
||||
## User Workflows
|
||||
|
||||
### Custom changes
|
||||
|
||||
Users make custom changes directly on their main branch. This is the standard fork workflow — their `main` IS their customized version.
|
||||
|
||||
```bash
|
||||
# Make changes
|
||||
vim src/config.ts
|
||||
git commit -am "change trigger word to @Bob"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Custom changes, skills, and core updates all coexist on their main branch. Git handles the three-way merging at each merge step because it can trace common ancestors through the merge history.
|
||||
|
||||
### Applying a skill
|
||||
|
||||
Run `/add-discord` in Claude Code (discovered via the marketplace plugin), or manually:
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/discord
|
||||
git merge upstream/skill/discord
|
||||
# Follow setup instructions for configuration
|
||||
git push origin main
|
||||
```
|
||||
|
||||
If the user is behind upstream's main when they merge a skill branch, the merge might bring in some core changes too (since skill branches are merged-forward with main). This is generally fine — they get a compatible version of everything.
|
||||
|
||||
### Updating core
|
||||
|
||||
```bash
|
||||
git fetch upstream main
|
||||
git merge upstream/main
|
||||
git push origin main
|
||||
```
|
||||
|
||||
This is the same as the existing `/update-nanoclaw` skill's merge path.
|
||||
|
||||
### Updating skills
|
||||
|
||||
Run `/update-skills` or let `/update-nanoclaw` check after a core update. For each previously-merged skill branch that has new commits, Claude offers to merge the updates.
|
||||
|
||||
### Contributing back to upstream
|
||||
|
||||
Users who want to submit a PR to upstream:
|
||||
|
||||
```bash
|
||||
git fetch upstream main
|
||||
git checkout -b my-fix upstream/main
|
||||
# Make changes
|
||||
git push origin my-fix
|
||||
# Create PR from my-fix to nanocoai/nanoclaw:main
|
||||
```
|
||||
|
||||
Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR.
|
||||
|
||||
## Contributing a Skill
|
||||
|
||||
The flow below is for **feature skills** (branch-based). For utility skills (self-contained tools) and container skills, the contributor opens a PR that adds files directly to `.claude/skills/<name>/` or `container/skills/<name>/` — no branch extraction needed. See [CONTRIBUTING.md](../CONTRIBUTING.md) for all skill types.
|
||||
|
||||
### Contributor flow (feature skills)
|
||||
|
||||
1. Fork `nanocoai/nanoclaw`
|
||||
2. Branch from `main`
|
||||
3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.)
|
||||
4. Open a PR to `main`
|
||||
|
||||
The contributor opens a normal PR — they don't need to know about skill branches or marketplace repos. They just make code changes and submit.
|
||||
|
||||
### Maintainer flow
|
||||
|
||||
When a skill PR is reviewed and approved:
|
||||
|
||||
1. Create a `skill/<name>` branch from the PR's commits:
|
||||
```bash
|
||||
git fetch origin pull/<PR_NUMBER>/head:skill/<name>
|
||||
git push origin skill/<name>
|
||||
```
|
||||
2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes)
|
||||
3. Merge the slimmed PR into `main` (just the contributor addition)
|
||||
4. Add the skill's SKILL.md to the marketplace repo (`nanocoai/nanoclaw-skills`)
|
||||
|
||||
This way:
|
||||
- The contributor gets merge credit (their PR is merged)
|
||||
- They're added to CONTRIBUTORS.md automatically by the maintainer
|
||||
- The skill branch is created from their work
|
||||
- `main` stays clean (no skill code)
|
||||
- The contributor only had to do one thing: open a PR with code changes
|
||||
|
||||
**Note:** GitHub PRs from forks have "Allow edits from maintainers" checked by default, so the maintainer can push to the contributor's PR branch.
|
||||
|
||||
### Skill SKILL.md
|
||||
|
||||
The contributor can optionally provide a SKILL.md (either in the PR or separately). This goes into the marketplace repo and contains:
|
||||
|
||||
1. Frontmatter (name, description, triggers)
|
||||
2. Step 1: Merge the skill branch
|
||||
3. Steps 2-N: Interactive setup (create bot, get token, configure env vars, verify)
|
||||
|
||||
If the contributor doesn't provide a SKILL.md, the maintainer writes one based on the PR.
|
||||
|
||||
## Community Marketplaces
|
||||
|
||||
Anyone can maintain their own fork with skill branches and their own marketplace repo. This enables a community-driven skill ecosystem without requiring write access to the upstream repo.
|
||||
|
||||
### How it works
|
||||
|
||||
A community contributor:
|
||||
|
||||
1. Maintains a fork of NanoClaw (e.g., `alice/nanoclaw`)
|
||||
2. Creates `skill/*` branches on their fork with their custom skills
|
||||
3. Creates a marketplace repo (e.g., `alice/nanoclaw-skills`) with a `.claude-plugin/marketplace.json` and plugin structure
|
||||
|
||||
### Adding a community marketplace
|
||||
|
||||
If the community contributor is trusted, they can open a PR to add their marketplace to NanoClaw's `.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
},
|
||||
"alice-nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "alice/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Once merged, all NanoClaw users automatically discover the community marketplace alongside the official one.
|
||||
|
||||
### Installing community skills
|
||||
|
||||
`/setup` and `/customize` ask users whether they want to enable community skills. If yes, Claude installs community marketplace plugins via `claude plugin install`:
|
||||
|
||||
```bash
|
||||
claude plugin install alice-skills@alice-nanoclaw-skills --scope project
|
||||
```
|
||||
|
||||
Community skills are hot-loaded and immediately available — no restart needed. Dependent skills are only offered after their prerequisites are met (e.g., community Telegram add-ons only after Telegram is installed).
|
||||
|
||||
Users can also browse and install community plugins manually via `/plugin`.
|
||||
|
||||
### Properties of this system
|
||||
|
||||
- **No gatekeeping required.** Anyone can create skills on their fork without permission. They only need approval to be listed in the auto-discovered marketplaces.
|
||||
- **Multiple marketplaces coexist.** Users see skills from all trusted marketplaces in `/plugin`.
|
||||
- **Community skills use the same merge pattern.** The SKILL.md just points to a different remote:
|
||||
```bash
|
||||
git remote add alice https://github.com/alice/nanoclaw.git
|
||||
git fetch alice skill/my-cool-feature
|
||||
git merge alice/skill/my-cool-feature
|
||||
```
|
||||
- **Users can also add marketplaces manually.** Even without being listed in settings.json, users can run `/plugin marketplace add alice/nanoclaw-skills` to discover skills from any source.
|
||||
- **CI is per-fork.** Each community maintainer runs their own CI to keep their skill branches merged-forward. They can use the same GitHub Action as the upstream repo.
|
||||
|
||||
## Flavors
|
||||
|
||||
A flavor is a curated fork of NanoClaw — a combination of skills, custom changes, and configuration tailored for a specific use case (e.g., "NanoClaw for Sales," "NanoClaw Minimal," "NanoClaw for Developers").
|
||||
|
||||
### Creating a flavor
|
||||
|
||||
1. Fork `nanocoai/nanoclaw`
|
||||
2. Merge in the skills you want
|
||||
3. Make custom changes (trigger word, prompts, integrations, etc.)
|
||||
4. Your fork's `main` IS the flavor
|
||||
|
||||
### Installing a flavor
|
||||
|
||||
During `/setup`, users are offered a choice of flavors before any configuration happens. The setup skill reads `flavors.yaml` from the repo (shipped with upstream, always up to date) and presents options:
|
||||
|
||||
AskUserQuestion: "Start with a flavor or default NanoClaw?"
|
||||
- Default NanoClaw
|
||||
- NanoClaw for Sales — Gmail + Slack + CRM (maintained by alice)
|
||||
- NanoClaw Minimal — Telegram-only, lightweight (maintained by bob)
|
||||
|
||||
If a flavor is chosen:
|
||||
|
||||
```bash
|
||||
git remote add <flavor-name> https://github.com/alice/nanoclaw.git
|
||||
git fetch <flavor-name> main
|
||||
git merge <flavor-name>/main
|
||||
```
|
||||
|
||||
Then setup continues normally (dependencies, auth, container, service).
|
||||
|
||||
**This choice is only offered on a fresh fork** — when the user's main matches or is close to upstream's main with no local commits. If `/setup` detects significant local changes (re-running setup on an existing install), it skips the flavor selection and goes straight to configuration.
|
||||
|
||||
After installation, the user's fork has three remotes:
|
||||
- `origin` — their fork (push customizations here)
|
||||
- `upstream` — `nanocoai/nanoclaw` (core updates)
|
||||
- `<flavor-name>` — the flavor fork (flavor updates)
|
||||
|
||||
### Updating a flavor
|
||||
|
||||
```bash
|
||||
git fetch <flavor-name> main
|
||||
git merge <flavor-name>/main
|
||||
```
|
||||
|
||||
The flavor maintainer keeps their fork updated (merging upstream, updating skills). Users pull flavor updates the same way they pull core updates.
|
||||
|
||||
### Flavors registry
|
||||
|
||||
`flavors.yaml` lives in the upstream repo:
|
||||
|
||||
```yaml
|
||||
flavors:
|
||||
- name: NanoClaw for Sales
|
||||
repo: alice/nanoclaw
|
||||
description: Gmail + Slack + CRM integration, daily pipeline summaries
|
||||
maintainer: alice
|
||||
|
||||
- name: NanoClaw Minimal
|
||||
repo: bob/nanoclaw
|
||||
description: Telegram-only, no container overhead
|
||||
maintainer: bob
|
||||
```
|
||||
|
||||
Anyone can PR to add their flavor. The file is available locally when `/setup` runs since it's part of the cloned repo.
|
||||
|
||||
### Discoverability
|
||||
|
||||
- **During setup** — flavor selection is offered as part of the initial setup flow
|
||||
- **`/browse-flavors` skill** — reads `flavors.yaml` and presents options at any time
|
||||
- **GitHub topics** — flavor forks can tag themselves with `nanoclaw-flavor` for searchability
|
||||
- **Discord / website** — community-curated lists
|
||||
|
||||
## Migration
|
||||
|
||||
Migration from the old skills engine to branches is complete. All feature skills now live on `skill/*` branches, and the skills engine has been removed.
|
||||
|
||||
### Skill branches
|
||||
|
||||
| Branch | Base | Description |
|
||||
|--------|------|-------------|
|
||||
| `skill/whatsapp` | `main` | WhatsApp channel |
|
||||
| `skill/telegram` | `main` | Telegram channel |
|
||||
| `skill/slack` | `main` | Slack channel |
|
||||
| `skill/discord` | `main` | Discord channel |
|
||||
| `skill/gmail` | `main` | Gmail channel |
|
||||
| `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper voice transcription |
|
||||
| `skill/image-vision` | `skill/whatsapp` | Image attachment processing |
|
||||
| `skill/pdf-reader` | `skill/whatsapp` | PDF attachment reading |
|
||||
| `skill/local-whisper` | `skill/voice-transcription` | Local whisper.cpp transcription |
|
||||
| `skill/ollama-tool` | `main` | Ollama MCP server for local models |
|
||||
| `skill/apple-container` | `main` | Apple Container runtime |
|
||||
| `skill/reactions` | `main` | WhatsApp emoji reactions |
|
||||
|
||||
### What was removed
|
||||
|
||||
- `skills-engine/` directory (entire engine)
|
||||
- `scripts/apply-skill.ts`, `scripts/uninstall-skill.ts`, `scripts/rebase.ts`
|
||||
- `scripts/fix-skill-drift.ts`, `scripts/validate-all-skills.ts`
|
||||
- `.github/workflows/skill-drift.yml`, `.github/workflows/skill-pr.yml`
|
||||
- All `add/`, `modify/`, `tests/`, and `manifest.yaml` from skill directories
|
||||
- `.nanoclaw/` state directory
|
||||
|
||||
Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`.
|
||||
|
||||
## What Changes
|
||||
|
||||
### README Quick Start
|
||||
|
||||
Before:
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/NanoClaw.git
|
||||
cd NanoClaw
|
||||
claude
|
||||
```
|
||||
|
||||
After:
|
||||
```
|
||||
1. Fork nanocoai/nanoclaw on GitHub
|
||||
2. git clone https://github.com/<you>/nanoclaw.git
|
||||
3. cd nanoclaw
|
||||
4. claude
|
||||
5. /setup
|
||||
```
|
||||
|
||||
### Setup skill (`/setup`)
|
||||
|
||||
Updates to the setup flow:
|
||||
|
||||
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/nanocoai/nanoclaw.git`
|
||||
- Check if `origin` points to the user's fork (not nanocoai). If it points to nanocoai, guide them through the fork migration.
|
||||
- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart)
|
||||
- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels
|
||||
- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp)
|
||||
- **Optionally enable community marketplaces:** ask if the user wants community skills, install those marketplace plugins too
|
||||
|
||||
### `.claude/settings.json`
|
||||
|
||||
Marketplace configuration so the official marketplace is auto-registered:
|
||||
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Skills directory on main
|
||||
|
||||
The `.claude/skills/` directory on `main` retains only operational skills (setup, debug, update-nanoclaw, customize, update-skills). Feature skills (add-discord, add-telegram, etc.) live in the marketplace repo, installed via `claude plugin install` during `/setup` or `/customize`.
|
||||
|
||||
### Skills engine removal
|
||||
|
||||
The following can be removed:
|
||||
|
||||
- `skills-engine/` — entire directory (apply, merge, replay, state, backup, etc.)
|
||||
- `scripts/apply-skill.ts`
|
||||
- `scripts/uninstall-skill.ts`
|
||||
- `scripts/fix-skill-drift.ts`
|
||||
- `scripts/validate-all-skills.ts`
|
||||
- `.nanoclaw/` — state directory
|
||||
- `add/` and `modify/` subdirectories from all skill directories
|
||||
- Feature skill SKILL.md files from `.claude/skills/` on main (they now live in the marketplace)
|
||||
|
||||
Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`.
|
||||
|
||||
### New infrastructure
|
||||
|
||||
- **Marketplace repo** (`nanocoai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
|
||||
- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution
|
||||
- **`/update-skills` skill** — checks for and applies skill branch updates using git history
|
||||
- **`CONTRIBUTORS.md`** — tracks skill contributors
|
||||
|
||||
### Update skill (`/update-nanoclaw`)
|
||||
|
||||
The update skill gets simpler with the branch-based approach. The old skills engine required replaying all applied skills after merging core updates — that entire step disappears. Skill changes are already in the user's git history, so `git merge upstream/main` just works.
|
||||
|
||||
**What stays the same:**
|
||||
- Preflight (clean working tree, upstream remote)
|
||||
- Backup branch + tag
|
||||
- Preview (git log, git diff, file buckets)
|
||||
- Merge/cherry-pick/rebase options
|
||||
- Conflict preview (dry-run merge)
|
||||
- Conflict resolution
|
||||
- Build + test validation
|
||||
- Rollback instructions
|
||||
|
||||
**What's removed:**
|
||||
- Skill replay step (was needed by the old skills engine to re-apply skills after core update)
|
||||
- Re-running structured operations (npm deps, env vars — these are part of git history now)
|
||||
|
||||
**What's added:**
|
||||
- Optional step at the end: "Check for skill updates?" which runs the `/update-skills` logic
|
||||
- This checks whether any previously-merged skill branches have new commits (bug fixes, improvements to the skill itself — not just merge-forwards from main)
|
||||
|
||||
**Why users don't need to re-merge skills after a core update:**
|
||||
When the user merged a skill branch, those changes became part of their git history. When they later merge `upstream/main`, git performs a normal three-way merge — the skill changes in their tree are untouched, and only core changes are brought in. The merge-forward CI ensures skill branches stay compatible with latest main, but that's for new users applying the skill fresh. Existing users who already merged the skill don't need to do anything.
|
||||
|
||||
Users only need to re-merge a skill branch if the skill itself was updated (not just merged-forward with main). The `/update-skills` check detects this.
|
||||
|
||||
## Discord Announcement
|
||||
|
||||
### For existing users
|
||||
|
||||
> **Skills are now git branches**
|
||||
>
|
||||
> We've simplified how skills work in NanoClaw. Instead of a custom skills engine, skills are now git branches that you merge in.
|
||||
>
|
||||
> **What this means for you:**
|
||||
> - Applying a skill: `git fetch upstream skill/discord && git merge upstream/skill/discord`
|
||||
> - Updating core: `git fetch upstream main && git merge upstream/main`
|
||||
> - Checking for skill updates: `/update-skills`
|
||||
> - No more `.nanoclaw/` state directory or skills engine
|
||||
>
|
||||
> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to.
|
||||
>
|
||||
> **If you currently have a clone with local changes**, migrate to a fork:
|
||||
> 1. Fork `nanocoai/nanoclaw` on GitHub
|
||||
> 2. Run:
|
||||
> ```
|
||||
> git remote rename origin upstream
|
||||
> git remote add origin https://github.com/<you>/nanoclaw.git
|
||||
> git push --force origin main
|
||||
> ```
|
||||
> This works even if you're way behind — just push your current state.
|
||||
>
|
||||
> **If you previously applied skills via the old system**, your code changes are already in your working tree — nothing to redo. You can delete the `.nanoclaw/` directory. Future skills and updates use the branch-based approach.
|
||||
>
|
||||
> **Discovering skills:** Skills are now available through Claude Code's plugin marketplace. Run `/plugin` in Claude Code to browse and install available skills.
|
||||
|
||||
### For skill contributors
|
||||
|
||||
> **Contributing skills**
|
||||
>
|
||||
> To contribute a skill:
|
||||
> 1. Fork `nanocoai/nanoclaw`
|
||||
> 2. Branch from `main` and make your code changes
|
||||
> 3. Open a regular PR
|
||||
>
|
||||
> That's it. We'll create a `skill/<name>` branch from your PR, add you to CONTRIBUTORS.md, and add the SKILL.md to the marketplace. CI automatically keeps skill branches merged-forward with `main` using Claude to resolve any conflicts.
|
||||
>
|
||||
> **Want to run your own skill marketplace?** Maintain skill branches on your fork and create a marketplace repo. Open a PR to add it to NanoClaw's auto-discovered marketplaces — or users can add it manually via `/plugin marketplace add`.
|
||||
@@ -0,0 +1,150 @@
|
||||
# The skills model
|
||||
|
||||
How NanoClaw stays customizable without breaking its forks. This is the full version; [customizing.md](customizing.md) is the short one, and [skill-guidelines.md](skill-guidelines.md) is the authoritative checklist for writing a skill.
|
||||
|
||||
## The problem
|
||||
|
||||
People fork NanoClaw and change the code. When we ship updates, their changes collide with ours and `git merge` turns into a fight. The more someone customized, the worse it gets. We can't grow the core without breaking everyone downstream.
|
||||
|
||||
## The bet
|
||||
|
||||
Every customization is a skill: not an edit buried in the core, but a skill that adds the change on top.
|
||||
|
||||
The core stays small and stable. Everything else composes on top as skills. Adding your 1st skill and your 500th skill is the same amount of work.
|
||||
|
||||
This works for any fork: a personal install with three tweaks, a company build with fifty.
|
||||
|
||||
## A fork is a recipe of skills
|
||||
|
||||
You don't track your changes as a pile of edits. You track them as skills.
|
||||
|
||||
- Each customization = one small skill.
|
||||
- One "recipe" skill lists all your skills and how they fit together: the order, and any dependencies between them.
|
||||
|
||||
So a fork is defined by its recipe. Most upgrades don't need to run it (see "Upgrading"), but it's what lets you rebuild the fork from scratch on clean upstream, and it's how you hand your whole fork to someone else. It replaces every "what did I change" artifact you'd otherwise keep (a migration guide, a manifest, a pile of notes) with one runnable thing.
|
||||
|
||||
The recipe is the one fork-specific thing. It lives in your fork, never upstream. (A recipe is itself a skill: a SKILL.md listing the fork's skills in apply order.)
|
||||
|
||||
## What's in a skill
|
||||
|
||||
A skill carries everything it needs:
|
||||
|
||||
- **Its code**: the files it adds (see "Where a skill's files live").
|
||||
- **Apply and remove.** Apply installs it; remove uninstalls it. Uninstall isn't a separate problem; it ships with the skill. (Remove is required exactly when apply leaves anything behind. A pure instruction-only skill that changes nothing needs none.)
|
||||
- **Its tests**: see "A test for every integration point." The tests *are* the verification. If they pass against the composed project, the skill applied correctly and works; there is no separate "verify" step.
|
||||
- **Its recipe entry**: how it composes with the others.
|
||||
|
||||
Apply must be safe to re-run. Upgrades re-run skills, so a skill that half-applies twice is a bug.
|
||||
|
||||
## Two kinds of skills
|
||||
|
||||
- **Capability skills** add something new: a channel, a provider, a tool, a dashboard.
|
||||
- **Patch skills** make small tweaks or bug fixes to existing behavior, instead of adding a capability.
|
||||
|
||||
Patch skills follow the same rules: a test for every edit, and code pushed into independent files wherever possible instead of inline. To keep the overhead down, bundle several small patches into a single patch skill rather than making one skill per one-line fix.
|
||||
|
||||
One honest exception: a bug fix that genuinely changes an existing line can't always be moved into a new file. That single line is the one place an upgrade can still hard-conflict. If upstream touched the same line, the fix has to be re-derived against the new code. That's fine when it's small and tested; just don't pretend it's free.
|
||||
|
||||
(Packaging is a separate axis: some skills fetch code from a registry branch, some ship files in their own folder, some are pure instructions.)
|
||||
|
||||
## What makes a good skill
|
||||
|
||||
A good skill mostly just *adds* things:
|
||||
|
||||
- Adds new files.
|
||||
- Adds a line to an existing file (an import, an entry, a line in `.env`).
|
||||
- Adds a dependency.
|
||||
- Changes a value in a JSON file like `package.json`.
|
||||
|
||||
These never really break.
|
||||
|
||||
The one risky move is when a skill has to *reach into* existing code and wire something in at a specific spot. That's the only part that breaks when we change the code later. Keep these rare, and keep them to a line or two that just *calls* code living in the skill's own files, not big chunks of logic inline.
|
||||
|
||||
Rule of thumb: aim for skills that are almost all "adds." Not 100%; some reach-ins are fine. But a skill full of reach-ins is a smell, and a sign that spot in the core should become a proper hook.
|
||||
|
||||
## Where a skill's files live
|
||||
|
||||
The files a skill adds live in the skill's own folder, and the skill copies them into the project when it runs. The skill is self-contained.
|
||||
|
||||
The exception is skills that plug into a registry: channels and providers. Their code is larger, multi-file, and has to stay in sync with the core as it changes over time. That code lives on a long-lived **registry branch** (`channels`, `providers`) that we forward-merge against main, and the skill fetches it from there (`git show origin/channels:path > path`). A frozen copy in a skill folder would go stale.
|
||||
|
||||
This fetch is **additive, never a merge**. The skill copies in the files it needs; it does *not* `git merge` the branch. Merging a registry branch into a customized install is exactly the conflict fight this model exists to avoid. A skill's **tests live on the branch alongside its code** and are fetched the same way; a channel's adapter travels with its registration test. A provider is the multi-point case: its code spans the host *and* container trees plus a Dockerfile edit, so it fetches files into both trees and ships a registration test per tree. See the provider archetype in [skill-guidelines.md](skill-guidelines.md).
|
||||
|
||||
Either way the skill brings its own code, from its folder or from its branch.
|
||||
|
||||
## A test for every integration point
|
||||
|
||||
The tests a skill *must* ship are the ones that prove it integrates with the core and keeps working as the core changes. That's the whole point. Tests of a skill's own internal logic, or of its behavior against an external service, are fine but optional: the creator's call, because they don't guard against upstream changes. A pure-add skill that touches nothing existing needs no required integration test at all.
|
||||
|
||||
The places that break on upgrade are the **integration points**: wherever a skill reaches into the existing system. That's not just the obvious code edit. An appended import, a config entry, a Dockerfile change, a mount, an installed dependency, and a direct read of the core's data all count. Each gets a guard that goes **red if it breaks or goes missing**:
|
||||
|
||||
- **A behavior or structural test of the wiring.** Prefer behavior when the seam is queryable at runtime: a channel's registration test imports the real barrel and asserts the registry contains it. Fall back to a structural test only for wiring with no invocable seam.
|
||||
- **The build / typecheck.** Always on. It catches the drift a runtime test can't: a renamed symbol, a moved module, a changed signature.
|
||||
- **Coverage of how an added file consumes the core.** When a skill's own file reaches into core APIs or data, a test must exercise that consumption against the *real* core. That's the leg that catches core drift.
|
||||
|
||||
Why points and not whole skills: a skill can have several, and each is a separate way to break. The count is honest signal: a skill's integration points are exactly its upgrade risk. Pure-add skills have zero and stay cheap.
|
||||
|
||||
This is what makes upgrades cheap to fix: when we move something in the core, the integration-point tests are exactly what fail, and that failing list *is* the set of skills to update.
|
||||
|
||||
**Tests travel with the skill.** They're files kept with the skill, in its folder or on its branch, and applying the skill copies them into the project's test tree. An integration-point test has to run against the *composed* system, so it only means anything once the skill is applied.
|
||||
|
||||
**The recipe tests the stack.** A single skill's tests prove that skill works alone. The recipe carries tests that run the skills *together*, in order. That's where you catch two skills that collide.
|
||||
|
||||
The full testing doctrine (how to pick the test type per point, the archetypes, the dependency cases) is in [skill-guidelines.md](skill-guidelines.md).
|
||||
|
||||
## How you actually work
|
||||
|
||||
You don't have to write a skill before you touch anything. Edit the code directly, get it working, then turn those edits into skills afterward; a coding agent does that conversion. Good authoring guidelines and a good recipe make skillifying-after-the-fact close to trivial.
|
||||
|
||||
The point isn't to slow you down at edit time. It's that nothing counts as part of your fork until it's a skill, because that's the only form that survives an upgrade.
|
||||
|
||||
## Upgrading
|
||||
|
||||
**Every update goes through `/update-nanoclaw`, never a raw `git pull`.** You don't know what an update contains until it lands; it might carry a breaking change with a migration. So the command inspects what's coming and runs the proper process: back up, pull the changes in, apply migrations, run tests, fix what broke, and flag when a fresh rebuild is needed instead.
|
||||
|
||||
Two different moves, two different rules. Your **fork pulls trunk**: that's a normal pull, run by the update command, and it's safe precisely because your changes live beside the core as skills rather than inside it. A **skill never merges**: it installs by fetching files and copying them in. If a skill's instructions say `git merge`, it isn't built to this model.
|
||||
|
||||
The update takes one of two paths:
|
||||
|
||||
**Normal upgrade: pull and fix what breaks.** Most of the time it pulls the latest upstream, resolves the occasional small conflict, runs the tests, and fixes whatever they flag. This stays cheap *because* the changes are small self-contained skills with tests: conflicts are rare, and when something does break, the failing test points at the exact skill and the fix is local.
|
||||
|
||||
**Rebuild from the recipe: the rare path.** Take fresh upstream and apply every skill from scratch. The command flags this when you've fallen far behind across many breaking changes (a clean rebuild beats catching up step by step). It's also how you hand your entire fork to someone else.
|
||||
|
||||
Around both:
|
||||
|
||||
- **The update skill updates itself first.** The first thing it does is fetch the latest version of the upgrade process. Otherwise you're upgrading with stale instructions.
|
||||
- **Snapshot first, restore on failure.** The upgrade sets a rollback point before it starts: today a git backup branch and tag; the model calls for a full project snapshot (code, database, data, files) so anything that fails rolls back and retries. Until that snapshot lands, a migration that touches data makes its own data backup. Nothing in the upgrade needs its own undo logic.
|
||||
- **Broken skills don't block you.** If a core change broke a skill, its test tells you, but the skill is usually still usable, and an agent fixes it at apply time. Skills are fixed lazily, when applied, not ahead of time for every core version.
|
||||
|
||||
## Migrations
|
||||
|
||||
Migrations are core, not an afterthought. Every breaking change ships with its migration, packaged together. A "migration" is broad: upgrading dependencies, a database change, a data backfill, moving files to new locations, whatever the change requires.
|
||||
|
||||
Migrations are **forward-only**. They don't need reverse scripts; the rollback point in front of the upgrade is the undo. If one fails, restore and retry.
|
||||
|
||||
A **startup tripwire** keeps installs on the supported path. Every sanctioned update path (install, update, migrate) stamps a marker with the version it reached; at startup the host checks that marker against the running code. If it's missing or doesn't match, because someone pulled by hand, the host stops, loudly, with the exact command to fix it instead of silently breaking.
|
||||
|
||||
The tripwire doesn't reason about *which* changes are breaking; it just enforces that the path was used. (DB schema migrations already run automatically at startup, so they aren't its concern; it guards everything else a raw `git pull` leaves undone.) To override, you stamp the marker yourself: an explicit "I know what I'm doing," not a deletion. If you have your **own** upgrade flow (a deploy script, a CI job), make stamping the last step after it succeeds: `pnpm exec tsx scripts/upgrade-state.ts set`. See [upgrade-recovery.md](upgrade-recovery.md).
|
||||
|
||||
## The maintainer's side of the deal
|
||||
|
||||
This is a two-sided contract. Users keep their changes as skills. In return, the maintainer keeps the core stable and owns the breakage.
|
||||
|
||||
As maintainer:
|
||||
|
||||
1. **Keep the core small and stable.** Resist hardwiring features into the core. Push them to skills too.
|
||||
2. **Before shipping a core change, run the skills against it.** That tells you what you broke before users find out.
|
||||
3. **When you break a skill, you fix it, not the users.** If a refactor moves something, update the affected skills or ship a migration. Don't make every user rediscover the same fix.
|
||||
4. **Ship the migration with the breaking change.** Packaged together: code, DB, files. Not a separate "good luck" note.
|
||||
5. **Watch for hotspots.** When lots of skills reach into the same spot in the core, that's the signal to add a proper hook there, so those reach-ins become clean adds.
|
||||
6. **Test against real forks.** Every core change and migration runs against a fleet of real, skill-built forks before shipping. Real proof on real installs.
|
||||
|
||||
## The public registry
|
||||
|
||||
Skills will be shared and composed; that's the whole point. A skill runs real code when it applies (copies files, installs dependencies, edits the Dockerfile). So a public registry of skills is a trust surface.
|
||||
|
||||
The rule: **every skill is reviewed and approved before it goes into the public registry, and every new version is re-reviewed.** Approving once and trusting forever is how supply chains get poisoned. Automated checks (linting against the guidelines, plus a harness that applies the skill on fresh upstream, runs its tests, removes it, and applies it twice) will clear the mechanical part so human review can focus on intent and safety. First-party skills are trusted by where they come from; the gate is for the public registry.
|
||||
|
||||
## The promise
|
||||
|
||||
Build your changes as skills following this, and we won't break you. It's a promise we can only make for skills: changes edited directly into the core are beyond what we can protect.
|
||||
+38
@@ -25,6 +25,44 @@ set -euo pipefail
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# ─── --uninstall: short-circuit before any setup work ──────────────────
|
||||
# Never install dependencies just to uninstall. With the TS toolchain
|
||||
# present, hand straight off to setup:auto (the flow lives in
|
||||
# setup/uninstall/); without it, print manual cleanup guidance. Runs
|
||||
# before diagnostics.sh is sourced so a pure uninstall doesn't emit
|
||||
# setup_launched, and before all pre-flights/bootstrap.
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--uninstall" ]; then
|
||||
# exec tsx directly rather than `pnpm run -- …`: pnpm passes the `--`
|
||||
# separator through to the script, where the flag parser treats
|
||||
# everything after it as positional args and the flags get dropped.
|
||||
# Gate on node (tsx's shebang interpreter) — pnpm isn't used here.
|
||||
if command -v node >/dev/null 2>&1 && [ -x "$PROJECT_ROOT/node_modules/.bin/tsx" ]; then
|
||||
exec "$PROJECT_ROOT/node_modules/.bin/tsx" "$PROJECT_ROOT/setup/auto.ts" "$@"
|
||||
fi
|
||||
export NANOCLAW_PROJECT_ROOT="$PROJECT_ROOT"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
UNINSTALL_RUNTIME="${CONTAINER_RUNTIME:-docker}"
|
||||
echo "Can't run the uninstaller: dependencies are missing (node_modules/)."
|
||||
echo "Either re-run 'bash nanoclaw.sh' once to restore them, or clean up manually:"
|
||||
echo ""
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
echo " launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist"
|
||||
echo " rm -f ~/Library/LaunchAgents/$(launchd_label).plist"
|
||||
else
|
||||
echo " systemctl --user disable --now $(systemd_unit).service"
|
||||
echo " rm -f ~/.config/systemd/user/$(systemd_unit).service && systemctl --user daemon-reload"
|
||||
fi
|
||||
echo " $UNINSTALL_RUNTIME ps -aq --filter label=nanoclaw-install=$(_nanoclaw_install_slug) | xargs -r $UNINSTALL_RUNTIME rm -f"
|
||||
echo " $UNINSTALL_RUNTIME rmi $(container_image_base):latest"
|
||||
echo " rm -f ~/.local/bin/ncl # only if it points at this folder"
|
||||
echo ""
|
||||
echo "Then back up $PROJECT_ROOT/.env if you need the keys, and delete the folder."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
LOGS_DIR="$PROJECT_ROOT/logs"
|
||||
STEPS_DIR="$LOGS_DIR/setup-steps"
|
||||
PROGRESS_LOG="$LOGS_DIR/setup.log"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.11",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"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="182k tokens, 91% of context window">
|
||||
<title>182k tokens, 91% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="190k tokens, 95% of context window">
|
||||
<title>190k tokens, 95% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">182k</text>
|
||||
<text x="71" y="14">182k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">190k</text>
|
||||
<text x="71" y="14">190k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -48,6 +48,8 @@ import {
|
||||
} from './lib/setup-config-parse.js';
|
||||
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { runUninstallFlow } from './uninstall/flow.js';
|
||||
import { detectExistingInstall } from './uninstall/scan.js';
|
||||
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
|
||||
import { pollHealth } from './onecli.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
@@ -88,6 +90,17 @@ async function main(): Promise<void> {
|
||||
let configValues = { ...readFromEnv(), ...flagResult.values };
|
||||
applyToEnv(configValues);
|
||||
|
||||
// --uninstall routes to the uninstall flow before any setup side effects —
|
||||
// in particular before initProgressionLog(), so an uninstall never resets
|
||||
// logs/setup.log on its way to (possibly) deleting logs/ entirely.
|
||||
if (configValues.uninstall === true) {
|
||||
await runUninstallFlow({
|
||||
dryRun: configValues.dryRun === true,
|
||||
yes: configValues.yes === true,
|
||||
invokedFrom: 'flag',
|
||||
});
|
||||
}
|
||||
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
phEmit('auto_started');
|
||||
@@ -121,6 +134,37 @@ async function main(): Promise<void> {
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// Offer removal when setup lands on an existing install. Skipped on every
|
||||
// resume path — both the fail() retry and the sg-docker re-exec pass
|
||||
// NANOCLAW_SKIP (and the latter sets NANOCLAW_REEXEC_SG) — so the prompt
|
||||
// appears at most once per fresh run.
|
||||
const isResume = process.env.NANOCLAW_REEXEC_SG === '1' || skip.size > 0;
|
||||
if (!isResume && detectExistingInstall(process.cwd())) {
|
||||
const action = ensureAnswer(
|
||||
await brightSelect<'keep' | 'uninstall'>({
|
||||
message: 'NanoClaw is already installed in this folder. What would you like to do?',
|
||||
options: [
|
||||
{
|
||||
value: 'keep',
|
||||
label: 'Keep it & continue setup',
|
||||
hint: 'recommended — re-running setup is safe',
|
||||
},
|
||||
{
|
||||
value: 'uninstall',
|
||||
label: 'Uninstall NanoClaw & exit',
|
||||
hint: 'removes service, data, and agent files — asks before each step',
|
||||
},
|
||||
],
|
||||
initialValue: 'keep',
|
||||
}),
|
||||
) as 'keep' | 'uninstall';
|
||||
setupLog.userInput('existing_install', action);
|
||||
phEmit('existing_install_detected', { action });
|
||||
if (action === 'uninstall') {
|
||||
await runUninstallFlow({ dryRun: false, yes: false, invokedFrom: 'setup-detection' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('environment')) {
|
||||
const res = await runQuietStep('environment', {
|
||||
running: 'Checking your system…',
|
||||
|
||||
+54
-47
@@ -11,9 +11,17 @@
|
||||
* 1. Build a handoff prompt from the caller's context: channel, current
|
||||
* step, completed steps, collected values (secrets redacted), relevant
|
||||
* files to read.
|
||||
* 2. Spawn `claude --append-system-prompt "<context>"
|
||||
* --permission-mode acceptEdits` with `stdio: 'inherit'` so Claude owns
|
||||
* the terminal.
|
||||
* 2. Spawn `claude "<prompt>" --permission-mode auto` with
|
||||
* `stdio: 'inherit'` so Claude owns the terminal. The positional prompt
|
||||
* is auto-submitted as the first user message, so Claude starts
|
||||
* orienting immediately instead of sitting at an empty prompt — and the
|
||||
* context stays visible in the transcript and survives `--resume`,
|
||||
* which an --append-system-prompt would not.
|
||||
* 2a. All handoffs in one setup run share a single session: the first
|
||||
* spawn pins a generated UUID via `--session-id`, later spawns pass
|
||||
* `--resume <uuid>` so Claude keeps the context of earlier handoffs.
|
||||
* (stdio is inherited, so we can't *read* the session id Claude picks —
|
||||
* pinning our own is the only way to find the session again.)
|
||||
* 3. When Claude exits (user types /exit, Ctrl-D, or closes the session),
|
||||
* control returns to the setup driver. The driver can then re-offer the
|
||||
* same step (e.g., "How did that go?" select).
|
||||
@@ -23,6 +31,7 @@
|
||||
* attempting to parse it as a real answer.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
@@ -61,8 +70,8 @@ export interface HandoffContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn interactive Claude with context pre-loaded as a system-prompt
|
||||
* append. Returns when Claude exits.
|
||||
* Spawn interactive Claude with the handoff context as an auto-submitted
|
||||
* first prompt. Returns when Claude exits.
|
||||
*
|
||||
* Silently no-ops (returns `false`) if `claude` isn't on PATH — setup runs
|
||||
* where the binary is guaranteed to exist (we install it in the auth step),
|
||||
@@ -78,8 +87,6 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
|
||||
return false;
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(ctx);
|
||||
|
||||
note(
|
||||
[
|
||||
"I'm handing you off to Claude in interactive mode.",
|
||||
@@ -90,18 +97,39 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
|
||||
'Handing off to Claude',
|
||||
);
|
||||
|
||||
return spawnInteractiveClaude(buildHandoffPrompt(ctx));
|
||||
}
|
||||
|
||||
// One session shared by every interactive handoff in this setup-driver
|
||||
// process. We pin the id ourselves (--session-id) on the first spawn because
|
||||
// stdio is inherited and Claude's own id is never visible to us; subsequent
|
||||
// spawns --resume it so Claude remembers earlier handoffs. Separate from
|
||||
// claude-assist's non-interactive session — the two formats don't mix.
|
||||
const handoffSessionId = randomUUID();
|
||||
let handoffSessionStarted = false;
|
||||
|
||||
/**
|
||||
* Spawn interactive Claude with the handoff context auto-submitted as the
|
||||
* first user message. Resolves when Claude exits and control returns to
|
||||
* the setup driver.
|
||||
*/
|
||||
function spawnInteractiveClaude(prompt: string): Promise<boolean> {
|
||||
const sessionArgs = handoffSessionStarted
|
||||
? ['--resume', handoffSessionId]
|
||||
: ['--session-id', handoffSessionId];
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'--append-system-prompt',
|
||||
systemPrompt,
|
||||
prompt,
|
||||
'--permission-mode',
|
||||
'acceptEdits',
|
||||
'auto',
|
||||
...sessionArgs,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
handoffSessionStarted = true;
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
@@ -164,20 +192,20 @@ function isClaudeUsable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemPrompt(ctx: HandoffContext): string {
|
||||
function buildHandoffPrompt(ctx: HandoffContext): string {
|
||||
const lines: string[] = [
|
||||
`The user is running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel.`,
|
||||
`They got stuck at the step: "${ctx.step}" (${ctx.stepDescription}) and asked for help.`,
|
||||
`I'm running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel`,
|
||||
`and got stuck at the step: "${ctx.step}" (${ctx.stepDescription}).`,
|
||||
'',
|
||||
"Your job: help them complete this specific step and get back to setup.",
|
||||
"You can read files, run commands (with acceptEdits permissions), search the web,",
|
||||
"and explain concepts. Be concise. When they're ready to resume, tell them to type",
|
||||
"/exit and they'll return to the setup flow at the same step.",
|
||||
'Help me complete this specific step and get back to setup.',
|
||||
'You can read files, run commands, search the web,',
|
||||
"and explain concepts. Be concise. When I'm ready to resume, remind me to type",
|
||||
"/exit and I'll return to the setup flow at the same step.",
|
||||
'',
|
||||
];
|
||||
|
||||
if (ctx.completedSteps && ctx.completedSteps.length > 0) {
|
||||
lines.push('Steps they have already completed:');
|
||||
lines.push("Steps I've already completed:");
|
||||
for (const s of ctx.completedSteps) lines.push(` ✓ ${s}`);
|
||||
lines.push('');
|
||||
}
|
||||
@@ -243,8 +271,6 @@ async function offerFailureHandoff(
|
||||
);
|
||||
if (!want) return false;
|
||||
|
||||
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
|
||||
|
||||
note(
|
||||
[
|
||||
"Launching Claude to help debug this failure.",
|
||||
@@ -255,29 +281,10 @@ async function offerFailureHandoff(
|
||||
'Handing off to Claude',
|
||||
);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'--append-system-prompt',
|
||||
systemPrompt,
|
||||
'--permission-mode',
|
||||
'acceptEdits',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
p.log.error("Couldn't launch Claude. Continuing without handoff.");
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
return spawnInteractiveClaude(buildFailurePrompt(ctx, projectRoot));
|
||||
}
|
||||
|
||||
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
function buildFailurePrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
||||
const references = [
|
||||
...BIG_PICTURE_FILES,
|
||||
@@ -289,20 +296,20 @@ function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): stri
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
const lines: string[] = [
|
||||
"The user is running NanoClaw's interactive setup flow and hit a failure.",
|
||||
"I'm running NanoClaw's interactive setup flow and hit a failure.",
|
||||
'',
|
||||
`Failed step: ${ctx.stepName}`,
|
||||
`Error: ${ctx.msg}`,
|
||||
];
|
||||
|
||||
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
|
||||
if (ctx.hint) lines.push(`Hint shown to me: ${ctx.hint}`);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'Your job: help them diagnose and fix this issue. Read the referenced files',
|
||||
'and logs to understand what went wrong, then help them fix it. You can read',
|
||||
'files, run commands, check logs, and explain what happened. Be concise.',
|
||||
"When they're ready to resume setup, tell them to type /exit.",
|
||||
'Help me diagnose and fix this issue. Read the referenced files and logs',
|
||||
'to understand what went wrong, then help me fix it. You can read files,',
|
||||
'run commands, check logs, and explain what happened. Be concise.',
|
||||
"When I'm ready to resume setup, remind me to type /exit.",
|
||||
'',
|
||||
'Relevant files (read as needed with the Read tool):',
|
||||
);
|
||||
|
||||
@@ -16,7 +16,13 @@ const INSTALL_ID_PATH = path.join('data', 'install-id');
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
export function installId(): string {
|
||||
/**
|
||||
* `persist: false` reads an existing id but never creates `data/install-id`
|
||||
* — required by the uninstall path, which must not mutate the filesystem
|
||||
* before (or instead of) removing it. Events in one process still join:
|
||||
* the generated id is cached.
|
||||
*/
|
||||
export function installId(persist = true): string {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const existing = fs.readFileSync(INSTALL_ID_PATH, 'utf-8').trim();
|
||||
@@ -28,11 +34,13 @@ export function installId(): string {
|
||||
// fall through to create
|
||||
}
|
||||
const id = randomUUID().toLowerCase();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true });
|
||||
fs.writeFileSync(INSTALL_ID_PATH, id);
|
||||
} catch {
|
||||
// best-effort; still return the id so the event fires
|
||||
if (persist) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true });
|
||||
fs.writeFileSync(INSTALL_ID_PATH, id);
|
||||
} catch {
|
||||
// best-effort; still return the id so the event fires
|
||||
}
|
||||
}
|
||||
cached = id;
|
||||
return id;
|
||||
@@ -41,6 +49,7 @@ export function installId(): string {
|
||||
export function emit(
|
||||
event: string,
|
||||
props: Record<string, string | number | boolean | undefined> = {},
|
||||
opts: { persistId?: boolean } = {},
|
||||
): void {
|
||||
if (process.env.NANOCLAW_NO_DIAGNOSTICS === '1') return;
|
||||
|
||||
@@ -53,7 +62,7 @@ export function emit(
|
||||
const body = JSON.stringify({
|
||||
api_key: POSTHOG_KEY,
|
||||
event,
|
||||
distinct_id: installId(),
|
||||
distinct_id: installId(opts.persistId !== false),
|
||||
properties: cleaned,
|
||||
});
|
||||
|
||||
|
||||
@@ -132,6 +132,32 @@ export const CONFIG: Entry[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
|
||||
// Uninstall route — handled in auto.ts before any setup work begins.
|
||||
{
|
||||
key: 'uninstall',
|
||||
label: 'Uninstall',
|
||||
help: 'Remove this NanoClaw copy (service, containers, data, vault agents). Asks per group.',
|
||||
surface: 'flag',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: 'dryRun',
|
||||
label: 'Uninstall dry run',
|
||||
help: 'With --uninstall: preview what would be removed without changing anything.',
|
||||
surface: 'flag',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: 'yes',
|
||||
label: 'Uninstall without prompts',
|
||||
help: 'With --uninstall: delete everything found without asking (orphan vault agents are still kept).',
|
||||
surface: 'flag',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── name derivation ───────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Uninstall flow — clack UI orchestration over scan/plan/remove.
|
||||
*
|
||||
* Self-deletion constraint: this flow runs on tsx out of the node_modules
|
||||
* it deletes. All imports are static (loaded before any deletion), dist/
|
||||
* and node_modules/ are removed last (the runtime tail), and once execution
|
||||
* starts nothing here writes to logs/ (which would recreate it) or does a
|
||||
* dynamic import. After the runtime tail, the only output is console.log.
|
||||
*
|
||||
* Removes ONLY what belongs to this checkout (per-checkout install slug).
|
||||
* Each non-empty group shows a WHAT/WHERE table and asks a default-No
|
||||
* confirm. Nothing is deleted until every decision has been made, so
|
||||
* Ctrl-C anywhere in the confirm phase leaves the install untouched.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { emit as phEmit } from '../lib/diagnostics.js';
|
||||
import { note } from '../lib/theme.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import {
|
||||
resolveOnecliDeletions,
|
||||
type RunCommand,
|
||||
type VaultAgent,
|
||||
} from './onecli-agents.js';
|
||||
import { buildRemovalPlan, type Decisions } from './plan.js';
|
||||
import { executePlan, type ExecDeps } from './remove.js';
|
||||
import { scanInstall, tilde, type Inventory } from './scan.js';
|
||||
|
||||
const GROUPS = {
|
||||
service: {
|
||||
title: '1) App & background service',
|
||||
desc: 'Runs NanoClaw in the background. Removing this stops the assistant. None of your data lives here.',
|
||||
prompt: 'Delete the app & background service shown above?',
|
||||
},
|
||||
data: {
|
||||
title: '2) App data, logs & secrets',
|
||||
desc: 'Message database, conversation history, logs, build files, and your .env (API keys / tokens). Removing this erases stored conversations and saved credentials.',
|
||||
prompt: 'Delete app data, logs & secrets shown above? (erases conversations + API keys)',
|
||||
},
|
||||
user: {
|
||||
title: "3) Your agents' memory & files",
|
||||
desc: 'Notes and memory your agents created (groups/) and any migrated data (store/). Content you made — it cannot be recovered after deletion.',
|
||||
prompt: "Delete your agents' memory & files shown above? (cannot be undone)",
|
||||
},
|
||||
onecli: {
|
||||
title: '4) OneCLI credential agents',
|
||||
desc: 'Per-agent entries this copy registered in the OneCLI vault. The OneCLI app, your credentials, and the gateway are NOT touched.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const runCommand: RunCommand = (cmd, args) => {
|
||||
const res = spawnSync(cmd, args, { encoding: 'utf-8' });
|
||||
return { status: res.status, stdout: res.stdout ?? '' };
|
||||
};
|
||||
|
||||
export async function runUninstallFlow(opts: {
|
||||
dryRun: boolean;
|
||||
yes: boolean;
|
||||
invokedFrom: 'flag' | 'setup-detection';
|
||||
}): Promise<never> {
|
||||
const { dryRun, yes } = opts;
|
||||
|
||||
if (!process.stdin.isTTY && !yes && !dryRun) {
|
||||
console.error(
|
||||
'Uninstall needs an interactive terminal. Re-run with --yes to delete everything found without prompts, or --dry-run to preview.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const home = os.homedir();
|
||||
|
||||
p.intro(k.bold(`Uninstall NanoClaw`));
|
||||
// persistId: false — the emit must not create data/install-id, which would
|
||||
// both break --dry-run's "changes nothing" promise and resurrect a data/
|
||||
// row in the very inventory we are about to scan.
|
||||
phEmit('uninstall_started', { invokedFrom: opts.invokedFrom, dryRun, yes }, { persistId: false });
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Checking what exists for this copy…');
|
||||
const inv = scanInstall({
|
||||
projectRoot,
|
||||
home,
|
||||
platform: process.platform,
|
||||
runCommand,
|
||||
});
|
||||
spinner.stop(`Scanned copy ${inv.slug} at ${tilde(projectRoot, home)}.`);
|
||||
|
||||
const svcRows = serviceRows(inv, home);
|
||||
const dataRows = [...inv.data, ...inv.runtime].map(({ what, where }) => ({ what, where }));
|
||||
const userRows = inv.user.map(({ what, where }) => ({ what, where }));
|
||||
const totalFound =
|
||||
svcRows.length +
|
||||
dataRows.length +
|
||||
userRows.length +
|
||||
inv.onecli.mine.length +
|
||||
inv.onecli.orphans.length;
|
||||
|
||||
if (totalFound === 0) {
|
||||
p.outro(
|
||||
`✓ Nothing to uninstall — this copy (${inv.slug}) is already clean.\n` +
|
||||
k.dim(' (No service, containers, image, data, or OneCLI agents found for this folder.)'),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
p.log.message(
|
||||
k.cyan('PREVIEW ONLY — this shows what would be deleted and changes nothing.'),
|
||||
);
|
||||
if (svcRows.length > 0) note(groupBody(GROUPS.service.desc, svcRows), GROUPS.service.title);
|
||||
if (dataRows.length > 0) note(groupBody(GROUPS.data.desc, dataRows), GROUPS.data.title);
|
||||
if (userRows.length > 0) note(groupBody(GROUPS.user.desc, userRows), GROUPS.user.title);
|
||||
if (inv.onecli.mine.length > 0 || inv.onecli.orphans.length > 0) {
|
||||
const lines = [GROUPS.onecli.desc, ''];
|
||||
lines.push('Would be deleted (after confirmation):');
|
||||
for (const a of inv.onecli.mine) lines.push(` ● ${a.name} — ${a.identifier}`);
|
||||
if (inv.onecli.mine.length === 0) lines.push(' (none)');
|
||||
lines.push('Left in place — may belong to another copy:');
|
||||
for (const a of inv.onecli.orphans) lines.push(` ○ ${a.name} — ${a.identifier}`);
|
||||
if (inv.onecli.orphans.length === 0) lines.push(' (none)');
|
||||
note(lines.join('\n'), GROUPS.onecli.title);
|
||||
}
|
||||
const empty = emptyGroupTitles(svcRows.length, dataRows.length, userRows.length, inv);
|
||||
if (empty.length > 0) p.log.message(k.dim(`Nothing found for: ${empty.join(', ')}`));
|
||||
for (const n of inv.notes) p.log.message(k.dim(`• ${n}`));
|
||||
p.outro('Preview complete. Nothing was changed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (yes) {
|
||||
p.log.warn('--yes given: deleting everything found below without asking.');
|
||||
} else {
|
||||
p.log.message(
|
||||
k.dim(
|
||||
'You will be asked about each group that has something. Default is to keep\n(just press Enter). Type "y" to delete a group.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── confirm phase — nothing is deleted until every decision is made ──
|
||||
|
||||
let serviceYes = false;
|
||||
if (svcRows.length > 0) {
|
||||
note(groupBody(GROUPS.service.desc, svcRows), GROUPS.service.title);
|
||||
serviceYes = await confirmGroup(GROUPS.service.prompt, yes);
|
||||
}
|
||||
|
||||
let dataYes = false;
|
||||
if (dataRows.length > 0) {
|
||||
note(groupBody(GROUPS.data.desc, dataRows), GROUPS.data.title);
|
||||
dataYes = await confirmGroup(GROUPS.data.prompt, yes);
|
||||
}
|
||||
|
||||
let userYes = false;
|
||||
if (userRows.length > 0) {
|
||||
note(groupBody(GROUPS.user.desc, userRows), GROUPS.user.title);
|
||||
userYes = await confirmGroup(GROUPS.user.prompt, yes);
|
||||
}
|
||||
|
||||
const keptNotes: string[] = [];
|
||||
if (!serviceYes && svcRows.length > 0) keptNotes.push(`${GROUPS.service.title}: kept by your choice.`);
|
||||
if (!dataYes && dataRows.length > 0) keptNotes.push(`${GROUPS.data.title}: kept by your choice.`);
|
||||
if (!userYes && userRows.length > 0) keptNotes.push(`${GROUPS.user.title}: kept by your choice.`);
|
||||
|
||||
const onecliDelete = await decideOnecli(inv, yes, keptNotes);
|
||||
|
||||
// Record the decisions before execution can delete logs/ — but only into
|
||||
// an existing logs/ (userInput would otherwise mkdir it back into
|
||||
// existence, leaving a fresh logs/setup.log behind after the uninstall).
|
||||
if (fs.existsSync(path.join(projectRoot, 'logs'))) {
|
||||
setupLog.userInput(
|
||||
'uninstall_decisions',
|
||||
JSON.stringify({
|
||||
service: serviceYes,
|
||||
data: dataYes,
|
||||
user: userYes,
|
||||
onecliAgentsDeleted: onecliDelete.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const decisions: Decisions = {
|
||||
service: serviceYes,
|
||||
data: dataYes,
|
||||
user: userYes,
|
||||
onecliDelete,
|
||||
};
|
||||
const actions = buildRemovalPlan(inv, decisions);
|
||||
|
||||
if (actions.length === 0) {
|
||||
printLeftAlone([...inv.notes, ...keptNotes]);
|
||||
p.outro('Nothing selected — nothing was changed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
phEmit(
|
||||
'uninstall_executed',
|
||||
{
|
||||
invokedFrom: opts.invokedFrom,
|
||||
service: serviceYes,
|
||||
data: dataYes,
|
||||
user: userYes,
|
||||
onecliAgentsDeleted: onecliDelete.length,
|
||||
},
|
||||
{ persistId: false },
|
||||
);
|
||||
|
||||
// The runtime tail (dist/, node_modules/) runs after every other action
|
||||
// AND after the summary — nothing but console.log may happen once the
|
||||
// modules we're running from are gone.
|
||||
const head = actions.filter((a) => a.kind !== 'delete-runtime-path');
|
||||
const tail = actions.filter((a) => a.kind === 'delete-runtime-path');
|
||||
|
||||
const deps: ExecDeps = {
|
||||
runCommand,
|
||||
log: (line) => p.log.message(line),
|
||||
isRoot: process.getuid?.() === 0,
|
||||
};
|
||||
const { notes: execNotes } = executePlan(head, deps);
|
||||
|
||||
printLeftAlone([...inv.notes, ...keptNotes, ...execNotes]);
|
||||
|
||||
const { notes: tailNotes } = executePlan(tail, {
|
||||
...deps,
|
||||
log: (line) => console.log(` ${line}`),
|
||||
});
|
||||
for (const n of tailNotes) console.log(` • ${n}`);
|
||||
console.log(`\n✓ Done. NanoClaw copy ${inv.slug} has been uninstalled.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/** Unwrap a confirm result; Ctrl-C / Esc cancels the whole uninstall — nothing deleted. */
|
||||
function answered<T>(value: T | symbol): T {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel('Uninstall cancelled. Nothing was deleted.');
|
||||
process.exit(0);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
async function confirmGroup(prompt: string, yes: boolean): Promise<boolean> {
|
||||
if (yes) return true;
|
||||
return answered(await p.confirm({ message: prompt, initialValue: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group 4 has two sub-decisions the single-prompt loop can't express:
|
||||
* MINE is one yes/no; ORPHANS get a separate default-No prompt with an
|
||||
* explicit cross-copy warning. --yes deletes MINE but never ORPHANS
|
||||
* (enforced in resolveOnecliDeletions); anything kept is reported with
|
||||
* the exact manual delete command (by vault uuid).
|
||||
*/
|
||||
async function decideOnecli(
|
||||
inv: Inventory,
|
||||
yes: boolean,
|
||||
keptNotes: string[],
|
||||
): Promise<VaultAgent[]> {
|
||||
const { mine, orphans } = inv.onecli;
|
||||
if (mine.length === 0 && orphans.length === 0) return [];
|
||||
|
||||
const rows = [
|
||||
...mine.map((a) => ({ what: 'OneCLI agent', where: `${a.name} — ${a.identifier}` })),
|
||||
...orphans.map((a) => ({ what: 'OneCLI agent (orphan)', where: `${a.name} — ${a.identifier}` })),
|
||||
];
|
||||
note(groupBody(GROUPS.onecli.desc, rows), GROUPS.onecli.title);
|
||||
|
||||
let deleteMine = false;
|
||||
if (mine.length > 0 && !yes) {
|
||||
deleteMine = answered(
|
||||
await p.confirm({
|
||||
message: `Delete this copy's ${mine.length} OneCLI agent(s)?`,
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!deleteMine) keptNotes.push('OneCLI agents (this copy): kept by your choice.');
|
||||
}
|
||||
|
||||
let deleteOrphans = false;
|
||||
if (orphans.length > 0) {
|
||||
if (yes) {
|
||||
p.log.warn(
|
||||
`${orphans.length} other NanoClaw-style agent(s) in the vault are not linked to this copy;\n--yes does NOT delete them (they may belong to another copy).`,
|
||||
);
|
||||
} else {
|
||||
p.log.warn(
|
||||
`Found ${orphans.length} other NanoClaw-style agent(s) in the vault not linked to this copy —\nthey may belong to ANOTHER NanoClaw copy on this machine.`,
|
||||
);
|
||||
deleteOrphans = answered(
|
||||
await p.confirm({ message: 'Delete them too?', initialValue: false }),
|
||||
);
|
||||
}
|
||||
if (yes || !deleteOrphans) {
|
||||
keptNotes.push(
|
||||
`OneCLI orphan agents (${orphans.length}): left in place — remove manually if they're yours:`,
|
||||
);
|
||||
for (const a of orphans) {
|
||||
keptNotes.push(` onecli agents delete --id ${a.uuid} # ${a.name} — ${a.identifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: yes,
|
||||
deleteMine,
|
||||
deleteOrphans,
|
||||
});
|
||||
}
|
||||
|
||||
function serviceRows(inv: Inventory, home: string): { what: string; where: string }[] {
|
||||
const s = inv.service;
|
||||
const rows: { what: string; where: string }[] = [];
|
||||
if (s.launchdPlist) rows.push({ what: 'Background service', where: tilde(s.launchdPlist, home) });
|
||||
if (s.systemdUserUnit) rows.push({ what: 'Background service', where: tilde(s.systemdUserUnit, home) });
|
||||
if (s.systemdSystemUnit) rows.push({ what: 'Background service (system)', where: s.systemdSystemUnit });
|
||||
if (s.pidFile) rows.push({ what: 'Running process', where: 'nanoclaw.pid' });
|
||||
if (s.containerIds.length > 0) {
|
||||
rows.push({ what: 'Running containers', where: `${s.containerIds.length} container(s)` });
|
||||
}
|
||||
if (s.image) rows.push({ what: 'Container image', where: s.image });
|
||||
if (s.nclSymlink) rows.push({ what: 'Command-line tool (ncl)', where: tilde(s.nclSymlink, home) });
|
||||
return rows;
|
||||
}
|
||||
|
||||
function groupBody(desc: string, rows: { what: string; where: string }[]): string {
|
||||
const width = Math.max(...rows.map((r) => r.what.length), 'WHAT'.length);
|
||||
const lines = [desc, '', `${'WHAT'.padEnd(width + 2)}WHERE`];
|
||||
for (const r of rows) lines.push(`${r.what.padEnd(width + 2)}${r.where}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function emptyGroupTitles(
|
||||
svcCount: number,
|
||||
dataCount: number,
|
||||
userCount: number,
|
||||
inv: Inventory,
|
||||
): string[] {
|
||||
const empty: string[] = [];
|
||||
if (svcCount === 0) empty.push(GROUPS.service.title);
|
||||
if (dataCount === 0) empty.push(GROUPS.data.title);
|
||||
if (userCount === 0) empty.push(GROUPS.user.title);
|
||||
if (inv.onecli.mine.length === 0 && inv.onecli.orphans.length === 0) {
|
||||
empty.push(GROUPS.onecli.title);
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
function printLeftAlone(notes: string[]): void {
|
||||
const lines = [
|
||||
'• OneCLI app, vault & credentials: ~/.local/share/onecli, ~/.local/bin/onecli',
|
||||
'• Host-wide config: ~/.config/nanoclaw/ (mount/sender allowlists)',
|
||||
'• PATH line in ~/.bashrc and ~/.zshrc',
|
||||
'• Other NanoClaw copies on this machine',
|
||||
...notes.map((n) => `• ${n}`),
|
||||
];
|
||||
note(lines.join('\n'), 'Left alone (shared / not ours)');
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import {
|
||||
listVaultAgents,
|
||||
readAgentGroupIds,
|
||||
resolveOnecliDeletions,
|
||||
splitVaultAgents,
|
||||
type VaultAgent,
|
||||
} from './onecli-agents.js';
|
||||
|
||||
const agent = (uuid: string, identifier: string, name = identifier): VaultAgent => ({
|
||||
uuid,
|
||||
identifier,
|
||||
name,
|
||||
});
|
||||
|
||||
describe('listVaultAgents', () => {
|
||||
it('parses non-default agents from onecli JSON output', () => {
|
||||
const payload = JSON.stringify({
|
||||
data: [
|
||||
{ id: 'u-1', identifier: 'ag-main', name: 'Main', isDefault: false },
|
||||
{ id: 'u-2', identifier: 'default', name: 'Default', isDefault: false },
|
||||
{ id: 'u-3', identifier: 'ag-dev', name: 'Dev', isDefault: true },
|
||||
],
|
||||
});
|
||||
const result = listVaultAgents(() => ({ status: 0, stdout: payload }));
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.agents).toEqual([agent('u-1', 'ag-main', 'Main')]);
|
||||
});
|
||||
|
||||
it('reports unavailable when the command fails', () => {
|
||||
expect(listVaultAgents(() => ({ status: 1, stdout: '' })).available).toBe(false);
|
||||
});
|
||||
|
||||
it('reports unavailable when the command cannot be spawned', () => {
|
||||
const result = listVaultAgents(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.agents).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports unavailable on unparseable output', () => {
|
||||
expect(listVaultAgents(() => ({ status: 0, stdout: 'not json' })).available).toBe(false);
|
||||
expect(listVaultAgents(() => ({ status: 0, stdout: '{"nope":1}' })).available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAgentGroupIds', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-uninstall-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads ids from a real DB', () => {
|
||||
const dbPath = path.join(tempDir, 'v2.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec('CREATE TABLE agent_groups (id TEXT PRIMARY KEY)');
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-one');
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-two');
|
||||
db.close();
|
||||
|
||||
const result = readAgentGroupIds(dbPath);
|
||||
expect(result.known).toBe(true);
|
||||
expect(result.ids).toEqual(new Set(['ag-one', 'ag-two']));
|
||||
});
|
||||
|
||||
it('returns known:false for a missing file', () => {
|
||||
const result = readAgentGroupIds(path.join(tempDir, 'missing.db'));
|
||||
expect(result.known).toBe(false);
|
||||
expect(result.ids.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns known:false for a corrupt file', () => {
|
||||
const dbPath = path.join(tempDir, 'corrupt.db');
|
||||
fs.writeFileSync(dbPath, 'this is not a sqlite database at all');
|
||||
const result = readAgentGroupIds(dbPath);
|
||||
expect(result.known).toBe(false);
|
||||
expect(result.ids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitVaultAgents', () => {
|
||||
it('splits mine vs ag-* orphans and ignores foreign identifiers', () => {
|
||||
const agents = [
|
||||
agent('u-1', 'ag-mine'),
|
||||
agent('u-2', 'ag-other'),
|
||||
agent('u-3', 'some-tool'),
|
||||
];
|
||||
const { mine, orphans } = splitVaultAgents(agents, new Set(['ag-mine']), true);
|
||||
expect(mine).toEqual([agent('u-1', 'ag-mine')]);
|
||||
expect(orphans).toEqual([agent('u-2', 'ag-other')]);
|
||||
});
|
||||
|
||||
it('forces all ag-* agents into orphans when ids are unknown', () => {
|
||||
const agents = [agent('u-1', 'ag-mine'), agent('u-2', 'ag-other')];
|
||||
// ids set even contains ag-mine — known:false must override.
|
||||
const { mine, orphans } = splitVaultAgents(agents, new Set(['ag-mine']), false);
|
||||
expect(mine).toEqual([]);
|
||||
expect(orphans).toEqual(agents);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOnecliDeletions', () => {
|
||||
const mine = [agent('u-1', 'ag-mine')];
|
||||
const orphans = [agent('u-2', 'ag-other')];
|
||||
|
||||
it('never deletes orphans under --yes, even if asked to', () => {
|
||||
const deletions = resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: true,
|
||||
deleteMine: false,
|
||||
deleteOrphans: true,
|
||||
});
|
||||
expect(deletions).toEqual(mine);
|
||||
});
|
||||
|
||||
it('deletes orphans only on explicit interactive consent', () => {
|
||||
expect(
|
||||
resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: false,
|
||||
deleteMine: true,
|
||||
deleteOrphans: true,
|
||||
}),
|
||||
).toEqual([...mine, ...orphans]);
|
||||
|
||||
expect(
|
||||
resolveOnecliDeletions({
|
||||
mine,
|
||||
orphans,
|
||||
assumeYes: false,
|
||||
deleteMine: false,
|
||||
deleteOrphans: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* OneCLI vault-agent inventory for the uninstaller.
|
||||
*
|
||||
* Vault agents split into two sets: MINE (identifier matches an agent-group
|
||||
* id in this copy's data/v2.db) and ORPHANS (NanoClaw-style `ag-*`
|
||||
* identifiers not in our DB — possibly another copy's). Deletion is always
|
||||
* by the vault's internal uuid: the agent-group id is NOT a valid
|
||||
* `onecli agents delete --id` value (see src/container-runner.ts).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export interface VaultAgent {
|
||||
/** Internal vault uuid — the only valid `onecli agents delete --id` value. */
|
||||
uuid: string;
|
||||
/** What the agent was registered under, e.g. a NanoClaw agent-group id (`ag-*`). */
|
||||
identifier: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type RunCommand = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
) => { status: number | null; stdout: string };
|
||||
|
||||
/**
|
||||
* List non-default vault agents via `onecli agents list`. `available: false`
|
||||
* means the vault couldn't be read at all (binary missing, command failed,
|
||||
* or unparseable output) — distinct from an empty vault.
|
||||
*/
|
||||
export function listVaultAgents(run: RunCommand): {
|
||||
available: boolean;
|
||||
agents: VaultAgent[];
|
||||
} {
|
||||
let result: { status: number | null; stdout: string };
|
||||
try {
|
||||
result = run('onecli', ['agents', 'list']);
|
||||
} catch {
|
||||
return { available: false, agents: [] };
|
||||
}
|
||||
if (result.status !== 0) return { available: false, agents: [] };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
return { available: false, agents: [] };
|
||||
}
|
||||
|
||||
const data =
|
||||
parsed !== null && typeof parsed === 'object' && 'data' in parsed
|
||||
? (parsed as { data: unknown }).data
|
||||
: null;
|
||||
if (!Array.isArray(data)) return { available: false, agents: [] };
|
||||
|
||||
const agents: VaultAgent[] = [];
|
||||
for (const entry of data) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
const a = entry as Record<string, unknown>;
|
||||
if (a.isDefault === true) continue;
|
||||
const identifier = typeof a.identifier === 'string' ? a.identifier : '';
|
||||
const uuid = typeof a.id === 'string' ? a.id : '';
|
||||
if (!identifier || identifier === 'default' || !uuid) continue;
|
||||
agents.push({
|
||||
uuid,
|
||||
identifier,
|
||||
name: typeof a.name === 'string' ? a.name : '',
|
||||
});
|
||||
}
|
||||
return { available: true, agents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this copy's agent-group ids from data/v2.db (readonly).
|
||||
*
|
||||
* `known: false` distinguishes "we couldn't read the DB at all" from "this
|
||||
* copy has zero agent groups" — without it every ag-* vault agent would be
|
||||
* mislabeled an orphan and --yes would silently leave this copy's agents
|
||||
* behind.
|
||||
*/
|
||||
export function readAgentGroupIds(dbPath: string): {
|
||||
ids: Set<string>;
|
||||
known: boolean;
|
||||
} {
|
||||
if (!fs.existsSync(dbPath)) return { ids: new Set(), known: false };
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare('SELECT id FROM agent_groups').all() as {
|
||||
id: string;
|
||||
}[];
|
||||
return { ids: new Set(rows.map((r) => r.id)), known: true };
|
||||
} catch {
|
||||
return { ids: new Set(), known: false };
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split vault agents into MINE (identifier ∈ ids) and ORPHANS (ag-* not in
|
||||
* ids). Non-NanoClaw identifiers are ignored entirely. With `known: false`
|
||||
* nothing can be MINE, so every ag-* agent lands in ORPHANS — the caller is
|
||||
* responsible for warning that the labels are unreliable.
|
||||
*/
|
||||
export function splitVaultAgents(
|
||||
agents: VaultAgent[],
|
||||
ids: Set<string>,
|
||||
known: boolean,
|
||||
): { mine: VaultAgent[]; orphans: VaultAgent[] } {
|
||||
const mine: VaultAgent[] = [];
|
||||
const orphans: VaultAgent[] = [];
|
||||
for (const agent of agents) {
|
||||
if (known && ids.has(agent.identifier)) {
|
||||
mine.push(agent);
|
||||
} else if (agent.identifier.startsWith('ag-')) {
|
||||
orphans.push(agent);
|
||||
}
|
||||
}
|
||||
return { mine, orphans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the vault-agent delete set from the user's answers. Under --yes
|
||||
* (`assumeYes`) MINE is always deleted but ORPHANS never are — deleting
|
||||
* what may be another copy's agents requires explicit human intent.
|
||||
*/
|
||||
export function resolveOnecliDeletions(input: {
|
||||
mine: VaultAgent[];
|
||||
orphans: VaultAgent[];
|
||||
assumeYes: boolean;
|
||||
deleteMine: boolean;
|
||||
deleteOrphans: boolean;
|
||||
}): VaultAgent[] {
|
||||
const out: VaultAgent[] = [];
|
||||
if (input.assumeYes || input.deleteMine) out.push(...input.mine);
|
||||
if (!input.assumeYes && input.deleteOrphans) out.push(...input.orphans);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { VaultAgent } from './onecli-agents.js';
|
||||
import { buildRemovalPlan, type Decisions, type RemovalAction } from './plan.js';
|
||||
import type { Inventory, PathItem } from './scan.js';
|
||||
|
||||
const item = (p: string, what: string): PathItem => ({ what, where: p, path: p });
|
||||
|
||||
const agent = (uuid: string, identifier: string): VaultAgent => ({
|
||||
uuid,
|
||||
identifier,
|
||||
name: identifier,
|
||||
});
|
||||
|
||||
function inventory(overrides: Partial<Inventory> = {}): Inventory {
|
||||
return {
|
||||
slug: 'abcd1234',
|
||||
projectRoot: '/proj',
|
||||
containerRuntime: 'docker',
|
||||
service: {
|
||||
launchdPlist: '/home/u/Library/LaunchAgents/com.nanoclaw-v2-abcd1234.plist',
|
||||
containerIds: ['c1', 'c2'],
|
||||
image: 'nanoclaw-agent-v2-abcd1234:latest',
|
||||
nclSymlink: '/home/u/.local/bin/ncl',
|
||||
},
|
||||
data: [
|
||||
item('/proj/data', 'Database & conversations'),
|
||||
item('/proj/logs', 'Logs'),
|
||||
item('/proj/.env', 'Secrets / API keys (.env)'),
|
||||
item('/proj/start-nanoclaw.sh', 'Start script'),
|
||||
],
|
||||
runtime: [
|
||||
// node_modules deliberately FIRST — the planner must still order it last.
|
||||
item('/proj/node_modules', 'Installed dependencies'),
|
||||
item('/proj/dist', 'Build output'),
|
||||
],
|
||||
user: [item('/proj/groups', 'Agent memory & files'), item('/proj/store', 'Migrated data store')],
|
||||
onecli: { mine: [], orphans: [], idsKnown: true },
|
||||
notes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const allYes = (onecliDelete: VaultAgent[] = []): Decisions => ({
|
||||
service: true,
|
||||
data: true,
|
||||
user: true,
|
||||
onecliDelete,
|
||||
});
|
||||
|
||||
const kinds = (actions: RemovalAction[]) => actions.map((a) => a.kind);
|
||||
|
||||
describe('buildRemovalPlan ordering invariants', () => {
|
||||
it('removes .env only via the atomic backup action, never a bare delete', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes());
|
||||
expect(actions.filter((a) => a.kind === 'backup-env')).toHaveLength(1);
|
||||
expect(
|
||||
actions.some((a) => a.kind === 'delete-path' && a.item.path === '/proj/.env'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('puts the runtime tail strictly last, with node_modules final', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes([agent('u-1', 'ag-mine')]));
|
||||
const tail = actions.slice(-2);
|
||||
expect(tail.map((a) => a.kind)).toEqual(['delete-runtime-path', 'delete-runtime-path']);
|
||||
expect(tail.map((a) => (a.kind === 'delete-runtime-path' ? a.item.path : ''))).toEqual([
|
||||
'/proj/dist',
|
||||
'/proj/node_modules',
|
||||
]);
|
||||
// No non-tail action after the first runtime delete.
|
||||
const firstTailIdx = actions.findIndex((a) => a.kind === 'delete-runtime-path');
|
||||
expect(
|
||||
actions.slice(firstTailIdx).every((a) => a.kind === 'delete-runtime-path'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes OneCLI agents before the data group (which removes data/v2.db)', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes([agent('u-1', 'ag-mine')]));
|
||||
const onecliIdx = actions.findIndex((a) => a.kind === 'delete-onecli-agent');
|
||||
const dataIdx = actions.findIndex(
|
||||
(a) => a.kind === 'delete-path' && a.item.path === '/proj/data',
|
||||
);
|
||||
expect(onecliIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(dataIdx).toBeGreaterThan(onecliIdx);
|
||||
});
|
||||
|
||||
it('runs service teardown before container removal so the host cannot respawn them', () => {
|
||||
const actions = buildRemovalPlan(inventory(), allYes());
|
||||
const unloadIdx = actions.findIndex((a) => a.kind === 'unload-service');
|
||||
const pkillIdx = actions.findIndex((a) => a.kind === 'pkill-host');
|
||||
const rmContainersIdx = actions.findIndex((a) => a.kind === 'rm-containers');
|
||||
expect(unloadIdx).toBeLessThan(rmContainersIdx);
|
||||
expect(pkillIdx).toBeLessThan(rmContainersIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemovalPlan declined groups', () => {
|
||||
it('declined data yields no data deletes and no runtime tail', () => {
|
||||
const actions = buildRemovalPlan(inventory(), {
|
||||
service: true,
|
||||
data: false,
|
||||
user: true,
|
||||
onecliDelete: [],
|
||||
});
|
||||
expect(kinds(actions)).not.toContain('backup-env');
|
||||
expect(kinds(actions)).not.toContain('delete-runtime-path');
|
||||
expect(
|
||||
actions.some((a) => a.kind === 'delete-path' && a.item.path.startsWith('/proj/data')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('all declined yields an empty plan', () => {
|
||||
const actions = buildRemovalPlan(inventory(), {
|
||||
service: false,
|
||||
data: false,
|
||||
user: false,
|
||||
onecliDelete: [],
|
||||
});
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('declined service yields no service actions', () => {
|
||||
const actions = buildRemovalPlan(inventory(), {
|
||||
service: false,
|
||||
data: true,
|
||||
user: false,
|
||||
onecliDelete: [],
|
||||
});
|
||||
for (const kind of ['unload-service', 'pkill-host', 'rm-containers', 'rmi', 'rm-ncl-symlink']) {
|
||||
expect(kinds(actions)).not.toContain(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemovalPlan conditional actions', () => {
|
||||
it('skips backup-env when there is no .env', () => {
|
||||
const inv = inventory({ data: [item('/proj/data', 'Database & conversations')] });
|
||||
expect(kinds(buildRemovalPlan(inv, allYes()))).not.toContain('backup-env');
|
||||
});
|
||||
|
||||
it('always re-sweeps containers and processes with a confirmed service group', () => {
|
||||
const inv = inventory({ service: { containerIds: [] } });
|
||||
const actions = buildRemovalPlan(inv, allYes());
|
||||
const actionKinds = kinds(actions);
|
||||
expect(actionKinds).not.toContain('rmi');
|
||||
expect(actionKinds).not.toContain('unload-service');
|
||||
// pkill and rm-containers run unconditionally — a manually started host
|
||||
// has no plist/unit, and the live host may have spawned containers the
|
||||
// scan never saw. Removal re-lists by install label, not scan-time ids.
|
||||
expect(actionKinds).toContain('pkill-host');
|
||||
const rm = actions.find((a) => a.kind === 'rm-containers');
|
||||
expect(rm && rm.kind === 'rm-containers' ? rm.labelFilter : '').toBe(
|
||||
'nanoclaw-install=abcd1234',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Pure removal planner: inventory + per-group decisions → ordered actions.
|
||||
*
|
||||
* The order is load-bearing:
|
||||
* 1. Service / processes / containers / image / symlink — stop the host
|
||||
* first so it can't respawn containers mid-removal.
|
||||
* 2. OneCLI agent deletions — before the data group, which removes the
|
||||
* data/v2.db the mine/orphan split was computed from.
|
||||
* 3. Data group, with the .env backup strictly before its deletion.
|
||||
* 4. User group (groups/, store/).
|
||||
* 5. Runtime tail: dist/ then node_modules/ — ALWAYS last. The uninstaller
|
||||
* runs on tsx out of node_modules; nothing may load after this.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import type { VaultAgent } from './onecli-agents.js';
|
||||
import type { Inventory, PathItem } from './scan.js';
|
||||
|
||||
export interface Decisions {
|
||||
service: boolean;
|
||||
data: boolean;
|
||||
user: boolean;
|
||||
onecliDelete: VaultAgent[];
|
||||
}
|
||||
|
||||
export type RemovalAction =
|
||||
| {
|
||||
kind: 'unload-service';
|
||||
flavor: 'launchd' | 'systemd-user' | 'systemd-system';
|
||||
unitPath: string;
|
||||
/** systemd unit name without .service (unused for launchd). */
|
||||
unitName: string;
|
||||
}
|
||||
| { kind: 'kill-pid'; pidFile: string }
|
||||
| { kind: 'pkill-host'; pattern: string }
|
||||
/**
|
||||
* Containers are re-listed by label at removal time, not removed from
|
||||
* scan-time ids — the host stays alive through the whole confirm phase
|
||||
* and can spawn new containers after the scan.
|
||||
*/
|
||||
| { kind: 'rm-containers'; runtime: string; labelFilter: string }
|
||||
| { kind: 'rmi'; runtime: string; image: string }
|
||||
| { kind: 'rm-ncl-symlink'; linkPath: string }
|
||||
| { kind: 'delete-onecli-agent'; agent: VaultAgent }
|
||||
/**
|
||||
* Backs up AND removes .env as one atomic action: a failed backup must
|
||||
* never be followed by the deletion (the backup is the user's only copy
|
||||
* of their API keys). .env is deliberately excluded from `delete-path`.
|
||||
*/
|
||||
| { kind: 'backup-env'; envPath: string }
|
||||
| { kind: 'delete-path'; item: PathItem }
|
||||
| { kind: 'delete-runtime-path'; item: PathItem };
|
||||
|
||||
export function buildRemovalPlan(inv: Inventory, d: Decisions): RemovalAction[] {
|
||||
const actions: RemovalAction[] = [];
|
||||
|
||||
if (d.service) {
|
||||
const s = inv.service;
|
||||
if (s.launchdPlist) {
|
||||
actions.push({
|
||||
kind: 'unload-service',
|
||||
flavor: 'launchd',
|
||||
unitPath: s.launchdPlist,
|
||||
unitName: path.basename(s.launchdPlist, '.plist'),
|
||||
});
|
||||
}
|
||||
if (s.systemdUserUnit) {
|
||||
actions.push({
|
||||
kind: 'unload-service',
|
||||
flavor: 'systemd-user',
|
||||
unitPath: s.systemdUserUnit,
|
||||
unitName: path.basename(s.systemdUserUnit, '.service'),
|
||||
});
|
||||
}
|
||||
if (s.systemdSystemUnit) {
|
||||
actions.push({
|
||||
kind: 'unload-service',
|
||||
flavor: 'systemd-system',
|
||||
unitPath: s.systemdSystemUnit,
|
||||
unitName: path.basename(s.systemdSystemUnit, '.service'),
|
||||
});
|
||||
}
|
||||
if (s.pidFile) actions.push({ kind: 'kill-pid', pidFile: s.pidFile });
|
||||
actions.push({
|
||||
kind: 'pkill-host',
|
||||
pattern: `${inv.projectRoot}/dist/index.js`,
|
||||
});
|
||||
// Unconditional (like pkill): the scan may have found zero containers
|
||||
// while the still-running host spawned one since.
|
||||
actions.push({
|
||||
kind: 'rm-containers',
|
||||
runtime: inv.containerRuntime,
|
||||
labelFilter: `nanoclaw-install=${inv.slug}`,
|
||||
});
|
||||
if (s.image) {
|
||||
actions.push({ kind: 'rmi', runtime: inv.containerRuntime, image: s.image });
|
||||
}
|
||||
if (s.nclSymlink) {
|
||||
actions.push({ kind: 'rm-ncl-symlink', linkPath: s.nclSymlink });
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of d.onecliDelete) {
|
||||
actions.push({ kind: 'delete-onecli-agent', agent });
|
||||
}
|
||||
|
||||
if (d.data) {
|
||||
const env = inv.data.find((i) => path.basename(i.path) === '.env');
|
||||
if (env) actions.push({ kind: 'backup-env', envPath: env.path });
|
||||
for (const item of inv.data) {
|
||||
if (item === env) continue; // removed by backup-env, never a bare delete
|
||||
actions.push({ kind: 'delete-path', item });
|
||||
}
|
||||
}
|
||||
|
||||
if (d.user) {
|
||||
for (const item of inv.user) actions.push({ kind: 'delete-path', item });
|
||||
}
|
||||
|
||||
if (d.data) {
|
||||
const tail = [...inv.runtime].sort(
|
||||
(a, b) =>
|
||||
Number(path.basename(a.path) === 'node_modules') -
|
||||
Number(path.basename(b.path) === 'node_modules'),
|
||||
);
|
||||
for (const item of tail) actions.push({ kind: 'delete-runtime-path', item });
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { RunCommand } from './onecli-agents.js';
|
||||
import type { RemovalAction } from './plan.js';
|
||||
import { backupEnv, executePlan, type ExecDeps } from './remove.js';
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-remove-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function deps(overrides: Partial<ExecDeps> = {}): ExecDeps {
|
||||
return {
|
||||
runCommand: () => ({ status: 0, stdout: '' }),
|
||||
log: () => {},
|
||||
isRoot: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('backupEnv', () => {
|
||||
it('backs up to .env.bak', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=secret');
|
||||
|
||||
const backup = backupEnv(envPath);
|
||||
|
||||
expect(backup).toBe(path.join(tempDir, '.env.bak'));
|
||||
expect(fs.readFileSync(backup, 'utf-8')).toBe('KEY=secret');
|
||||
});
|
||||
|
||||
it('falls back to a timestamped name when .env.bak exists', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=new');
|
||||
fs.writeFileSync(path.join(tempDir, '.env.bak'), 'KEY=old');
|
||||
|
||||
const backup = backupEnv(envPath);
|
||||
|
||||
expect(path.basename(backup)).toMatch(/^\.env\.bak\.\d{8}-\d{6}$/);
|
||||
expect(fs.readFileSync(backup, 'utf-8')).toBe('KEY=new');
|
||||
// The earlier backup is never clobbered.
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env.bak'), 'utf-8')).toBe('KEY=old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executePlan', () => {
|
||||
it('deletes paths recursively', () => {
|
||||
const dir = path.join(tempDir, 'data');
|
||||
fs.mkdirSync(path.join(dir, 'nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'nested', 'f.txt'), 'x');
|
||||
|
||||
const { notes } = executePlan(
|
||||
[{ kind: 'delete-path', item: { what: 'Data', where: dir, path: dir } }],
|
||||
deps(),
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('continues past a failing action and records a note', () => {
|
||||
const dir = path.join(tempDir, 'logs');
|
||||
fs.mkdirSync(dir);
|
||||
const actions: RemovalAction[] = [
|
||||
{
|
||||
kind: 'unload-service',
|
||||
flavor: 'launchd',
|
||||
unitPath: path.join(tempDir, 'svc.plist'),
|
||||
unitName: 'com.nanoclaw-v2-test',
|
||||
},
|
||||
{ kind: 'delete-path', item: { what: 'Logs', where: dir, path: dir } },
|
||||
];
|
||||
const failing: RunCommand = () => {
|
||||
throw new Error('launchctl exploded');
|
||||
};
|
||||
|
||||
const { notes } = executePlan(actions, deps({ runCommand: failing }));
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toContain('unload-service');
|
||||
expect(notes[0]).toContain('launchctl exploded');
|
||||
// Later actions still ran.
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a system unit in place without root and notes the sudo command', () => {
|
||||
const unitPath = path.join(tempDir, 'nanoclaw-v2-test.service');
|
||||
fs.writeFileSync(unitPath, '[Unit]');
|
||||
const calls: string[] = [];
|
||||
const recorder: RunCommand = (cmd) => {
|
||||
calls.push(cmd);
|
||||
return { status: 0, stdout: '' };
|
||||
};
|
||||
|
||||
const { notes } = executePlan(
|
||||
[
|
||||
{
|
||||
kind: 'unload-service',
|
||||
flavor: 'systemd-system',
|
||||
unitPath,
|
||||
unitName: 'nanoclaw-v2-test',
|
||||
},
|
||||
],
|
||||
deps({ runCommand: recorder, isRoot: false }),
|
||||
);
|
||||
|
||||
expect(fs.existsSync(unitPath)).toBe(true);
|
||||
expect(calls).toEqual([]);
|
||||
expect(notes.some((n) => n.includes('re-run with sudo'))).toBe(true);
|
||||
});
|
||||
|
||||
it('notes a failed image removal with the retry command', () => {
|
||||
const { notes } = executePlan(
|
||||
[{ kind: 'rmi', runtime: 'docker', image: 'img:latest' }],
|
||||
deps({ runCommand: () => ({ status: 1, stdout: '' }) }),
|
||||
);
|
||||
expect(notes.some((n) => n.includes('docker rmi img:latest'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes .env only after a successful backup', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=secret');
|
||||
|
||||
const { notes } = executePlan([{ kind: 'backup-env', envPath }], deps());
|
||||
|
||||
expect(fs.existsSync(envPath)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env.bak'), 'utf-8')).toBe('KEY=secret');
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps .env when the backup fails', () => {
|
||||
const envPath = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envPath, 'KEY=secret');
|
||||
fs.chmodSync(tempDir, 0o555); // backup destination unwritable
|
||||
|
||||
try {
|
||||
const { notes } = executePlan([{ kind: 'backup-env', envPath }], deps());
|
||||
expect(fs.existsSync(envPath)).toBe(true);
|
||||
expect(notes.some((n) => n.includes('backup-env'))).toBe(true);
|
||||
} finally {
|
||||
fs.chmodSync(tempDir, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-lists containers by label at removal time instead of using scan-time ids', () => {
|
||||
const calls: string[][] = [];
|
||||
const docker: RunCommand = (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
if (args[0] === 'ps') return { status: 0, stdout: 'fresh1\nfresh2\n' };
|
||||
return { status: 0, stdout: '' };
|
||||
};
|
||||
|
||||
executePlan(
|
||||
[{ kind: 'rm-containers', runtime: 'docker', labelFilter: 'nanoclaw-install=abcd1234' }],
|
||||
deps({ runCommand: docker }),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
['docker', 'ps', '-aq', '--filter', 'label=nanoclaw-install=abcd1234'],
|
||||
['docker', 'rm', '-f', 'fresh1', 'fresh2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('notes a manual command when the container runtime is unavailable', () => {
|
||||
const { notes } = executePlan(
|
||||
[{ kind: 'rm-containers', runtime: 'docker', labelFilter: 'nanoclaw-install=x' }],
|
||||
deps({ runCommand: () => ({ status: null, stdout: '' }) }),
|
||||
);
|
||||
expect(notes.some((n) => n.includes('xargs -r docker rm -f'))).toBe(true);
|
||||
});
|
||||
|
||||
it('notes a manual delete when onecli itself cannot be run', () => {
|
||||
const { notes } = executePlan(
|
||||
[
|
||||
{
|
||||
kind: 'delete-onecli-agent',
|
||||
agent: { uuid: 'u-123', identifier: 'ag-mine', name: 'Mine' },
|
||||
},
|
||||
],
|
||||
deps({ runCommand: () => ({ status: null, stdout: '' }) }),
|
||||
);
|
||||
expect(notes.some((n) => n.includes('onecli agents delete --id u-123'))).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes OneCLI agents by vault uuid, never by identifier', () => {
|
||||
const calls: string[][] = [];
|
||||
const recorder: RunCommand = (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
return { status: 0, stdout: '' };
|
||||
};
|
||||
|
||||
executePlan(
|
||||
[
|
||||
{
|
||||
kind: 'delete-onecli-agent',
|
||||
agent: { uuid: 'u-123', identifier: 'ag-mine', name: 'Mine' },
|
||||
},
|
||||
],
|
||||
deps({ runCommand: recorder }),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([['onecli', 'agents', 'delete', '--id', 'u-123']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Removal-plan executor. Each action runs in its own try/catch: a failure
|
||||
* becomes a summary note and execution continues (re-running the
|
||||
* uninstaller is idempotent — the next scan only finds what's left).
|
||||
*
|
||||
* Must stay safe to run after logs/ and node_modules/ are gone: only static
|
||||
* imports, no dynamic import(), no setup-log writes. Output goes through
|
||||
* the injected `log` callback.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import type { RunCommand } from './onecli-agents.js';
|
||||
import type { RemovalAction } from './plan.js';
|
||||
|
||||
export interface ExecDeps {
|
||||
runCommand: RunCommand;
|
||||
log: (line: string) => void;
|
||||
/** True when running as root — required to remove a system-level unit. */
|
||||
isRoot: boolean;
|
||||
}
|
||||
|
||||
export function executePlan(
|
||||
actions: RemovalAction[],
|
||||
deps: ExecDeps,
|
||||
): { notes: string[] } {
|
||||
const notes: string[] = [];
|
||||
for (const action of actions) {
|
||||
try {
|
||||
runAction(action, deps, notes);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
notes.push(
|
||||
`${action.kind}: failed (${msg}) — re-run the uninstaller to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy .env aside before deletion. Never clobbers an existing backup —
|
||||
* falls back to a timestamped name on collision. Returns the backup path.
|
||||
*/
|
||||
export function backupEnv(envPath: string): string {
|
||||
const dir = path.dirname(envPath);
|
||||
let backup = path.join(dir, '.env.bak');
|
||||
if (fs.existsSync(backup)) {
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace('T', '-')
|
||||
.slice(0, 15);
|
||||
backup = path.join(dir, `.env.bak.${stamp}`);
|
||||
}
|
||||
fs.copyFileSync(envPath, backup);
|
||||
return backup;
|
||||
}
|
||||
|
||||
function runAction(action: RemovalAction, deps: ExecDeps, notes: string[]): void {
|
||||
const { runCommand, log } = deps;
|
||||
switch (action.kind) {
|
||||
case 'unload-service':
|
||||
switch (action.flavor) {
|
||||
case 'launchd':
|
||||
runCommand('launchctl', ['unload', action.unitPath]);
|
||||
fs.rmSync(action.unitPath, { force: true });
|
||||
log('✓ background service removed');
|
||||
break;
|
||||
case 'systemd-user':
|
||||
runCommand('systemctl', [
|
||||
'--user',
|
||||
'disable',
|
||||
'--now',
|
||||
`${action.unitName}.service`,
|
||||
]);
|
||||
fs.rmSync(action.unitPath, { force: true });
|
||||
runCommand('systemctl', ['--user', 'daemon-reload']);
|
||||
log('✓ background service removed');
|
||||
break;
|
||||
case 'systemd-system':
|
||||
if (!deps.isRoot) {
|
||||
log('! system service needs root — left in place');
|
||||
notes.push(
|
||||
`System service ${action.unitPath} — re-run with sudo to remove.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
runCommand('systemctl', ['disable', '--now', `${action.unitName}.service`]);
|
||||
fs.rmSync(action.unitPath, { force: true });
|
||||
runCommand('systemctl', ['daemon-reload']);
|
||||
log('✓ system service removed');
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'kill-pid': {
|
||||
let pid = NaN;
|
||||
try {
|
||||
pid = Number(fs.readFileSync(action.pidFile, 'utf-8').trim());
|
||||
} catch {
|
||||
// pidfile already gone
|
||||
}
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
try {
|
||||
process.kill(pid);
|
||||
log('✓ stopped host process');
|
||||
} catch {
|
||||
// not running
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pkill-host':
|
||||
// Exit 1 = no matching process — not a failure.
|
||||
runCommand('pkill', ['-f', action.pattern]);
|
||||
break;
|
||||
case 'rm-containers': {
|
||||
// Re-list at removal time: the host was alive during the confirm
|
||||
// phase and may have spawned containers the scan never saw.
|
||||
const ps = runCommand(action.runtime, [
|
||||
'ps',
|
||||
'-aq',
|
||||
'--filter',
|
||||
`label=${action.labelFilter}`,
|
||||
]);
|
||||
if (ps.status !== 0) {
|
||||
notes.push(
|
||||
`Containers: '${action.runtime}' unavailable — remove later with: ` +
|
||||
`${action.runtime} ps -aq --filter label=${action.labelFilter} | xargs -r ${action.runtime} rm -f`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const ids = ps.stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (ids.length === 0) break;
|
||||
runCommand(action.runtime, ['rm', '-f', ...ids]);
|
||||
log(`✓ removed ${ids.length} container(s)`);
|
||||
break;
|
||||
}
|
||||
case 'rmi': {
|
||||
const res = runCommand(action.runtime, ['rmi', action.image]);
|
||||
if (res.status === 0) {
|
||||
log('✓ removed container image');
|
||||
} else {
|
||||
log('! could not remove image (in use?)');
|
||||
notes.push(
|
||||
`Image ${action.image}: not removed — retry with: ${action.runtime} rmi ${action.image}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rm-ncl-symlink':
|
||||
fs.rmSync(action.linkPath, { force: true });
|
||||
log('✓ removed ncl command');
|
||||
break;
|
||||
case 'delete-onecli-agent': {
|
||||
const res = runCommand('onecli', [
|
||||
'agents',
|
||||
'delete',
|
||||
'--id',
|
||||
action.agent.uuid,
|
||||
]);
|
||||
if (res.status === 0) {
|
||||
log(`✓ deleted OneCLI agent ${action.agent.name} (${action.agent.identifier})`);
|
||||
} else if (res.status === null) {
|
||||
// spawn failure (binary gone since the scan), not a missing agent
|
||||
log(`! couldn't run onecli for ${action.agent.identifier}`);
|
||||
notes.push(
|
||||
`OneCLI agent ${action.agent.name} (${action.agent.identifier}): couldn't run onecli — ` +
|
||||
`delete manually with: onecli agents delete --id ${action.agent.uuid}`,
|
||||
);
|
||||
} else {
|
||||
log(`! OneCLI agent ${action.agent.identifier} already gone`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'backup-env': {
|
||||
// Backup and removal are one action so a failed backup (which throws
|
||||
// into executePlan's catch) can never be followed by the deletion.
|
||||
const backup = backupEnv(action.envPath);
|
||||
fs.rmSync(action.envPath, { force: true });
|
||||
log(`✓ removed .env (backup at ${backup})`);
|
||||
break;
|
||||
}
|
||||
case 'delete-path':
|
||||
case 'delete-runtime-path':
|
||||
fs.rmSync(action.item.path, { recursive: true, force: true });
|
||||
log(`✓ removed ${action.item.what}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import type { RunCommand } from './onecli-agents.js';
|
||||
import { detectExistingInstall, scanInstall, type ScanDeps } from './scan.js';
|
||||
|
||||
let root: string;
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-scan-root-'));
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-scan-home-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Fake runCommand: unhandled commands fail (binary missing / daemon down). */
|
||||
function fakeRun(
|
||||
handlers: Record<string, (args: string[]) => { status: number | null; stdout: string }>,
|
||||
): RunCommand {
|
||||
return (cmd, args) => (handlers[cmd] ?? (() => ({ status: 1, stdout: '' })))(args);
|
||||
}
|
||||
|
||||
function deps(overrides: Partial<ScanDeps> = {}): ScanDeps {
|
||||
return {
|
||||
projectRoot: root,
|
||||
home,
|
||||
platform: 'darwin',
|
||||
runCommand: fakeRun({}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const dockerUp = (containerIds: string[], hasImage: boolean) =>
|
||||
fakeRun({
|
||||
docker: (args) => {
|
||||
if (args[0] === 'ps') return { status: 0, stdout: containerIds.join('\n') + '\n' };
|
||||
if (args[0] === 'image') return { status: hasImage ? 0 : 1, stdout: '' };
|
||||
return { status: 1, stdout: '' };
|
||||
},
|
||||
});
|
||||
|
||||
describe('scanInstall path groups', () => {
|
||||
it('puts dist and node_modules in runtime, not data', () => {
|
||||
for (const dir of ['data', 'logs', 'dist', 'node_modules', 'groups', 'store']) {
|
||||
fs.mkdirSync(path.join(root, dir));
|
||||
}
|
||||
fs.writeFileSync(path.join(root, '.env'), 'KEY=v');
|
||||
fs.writeFileSync(path.join(root, 'start-nanoclaw.sh'), '#!/bin/bash');
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
|
||||
expect(inv.data.map((i) => path.basename(i.path))).toEqual([
|
||||
'data',
|
||||
'logs',
|
||||
'.env',
|
||||
'start-nanoclaw.sh',
|
||||
]);
|
||||
expect(inv.runtime.map((i) => path.basename(i.path))).toEqual([
|
||||
'dist',
|
||||
'node_modules',
|
||||
]);
|
||||
expect(inv.user.map((i) => path.basename(i.path))).toEqual(['groups', 'store']);
|
||||
});
|
||||
|
||||
it('finds nothing in an empty checkout', () => {
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.data).toEqual([]);
|
||||
expect(inv.runtime).toEqual([]);
|
||||
expect(inv.user).toEqual([]);
|
||||
expect(inv.service.containerIds).toEqual([]);
|
||||
expect(inv.service.image).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanInstall service artifacts', () => {
|
||||
it('detects the launchd plist on macOS', () => {
|
||||
const plist = path.join(
|
||||
home,
|
||||
'Library',
|
||||
'LaunchAgents',
|
||||
`${getLaunchdLabel(root)}.plist`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
||||
fs.writeFileSync(plist, '<plist/>');
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.launchdPlist).toBe(plist);
|
||||
expect(inv.service.systemdUserUnit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects systemd user unit and pidfile on Linux', () => {
|
||||
const unit = path.join(
|
||||
home,
|
||||
'.config',
|
||||
'systemd',
|
||||
'user',
|
||||
`${getSystemdUnit(root)}.service`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(unit), { recursive: true });
|
||||
fs.writeFileSync(unit, '[Unit]');
|
||||
fs.writeFileSync(path.join(root, 'nanoclaw.pid'), '12345');
|
||||
|
||||
const inv = scanInstall(deps({ platform: 'linux' }));
|
||||
expect(inv.service.systemdUserUnit).toBe(unit);
|
||||
expect(inv.service.pidFile).toBe(path.join(root, 'nanoclaw.pid'));
|
||||
expect(inv.service.launchdPlist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('captures container ids and image when docker is up', () => {
|
||||
const inv = scanInstall(deps({ runCommand: dockerUp(['abc123', 'def456'], true) }));
|
||||
expect(inv.service.containerIds).toEqual(['abc123', 'def456']);
|
||||
expect(inv.service.image).toMatch(/^nanoclaw-agent-v2-[0-9a-f]{8}:latest$/);
|
||||
expect(inv.notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('degrades with a manual-cleanup note when docker is unavailable', () => {
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.containerIds).toEqual([]);
|
||||
expect(inv.service.image).toBeUndefined();
|
||||
expect(inv.notes.some((n) => n.includes("'docker' unavailable"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanInstall ncl symlink', () => {
|
||||
const link = () => path.join(home, '.local', 'bin', 'ncl');
|
||||
|
||||
it('includes the symlink only when it targets this checkout', () => {
|
||||
fs.mkdirSync(path.dirname(link()), { recursive: true });
|
||||
fs.symlinkSync(path.join(root, 'bin', 'ncl'), link());
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.nclSymlink).toBe(link());
|
||||
});
|
||||
|
||||
it('leaves a symlink pointing at another copy, with a note', () => {
|
||||
fs.mkdirSync(path.dirname(link()), { recursive: true });
|
||||
fs.symlinkSync('/some/other/copy/bin/ncl', link());
|
||||
|
||||
const inv = scanInstall(deps());
|
||||
expect(inv.service.nclSymlink).toBeUndefined();
|
||||
expect(inv.notes.some((n) => n.includes('points to another NanoClaw copy'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanInstall OneCLI agents', () => {
|
||||
const vault = JSON.stringify({
|
||||
data: [
|
||||
{ id: 'u-1', identifier: 'ag-mine', name: 'Mine', isDefault: false },
|
||||
{ id: 'u-2', identifier: 'ag-other', name: 'Other', isDefault: false },
|
||||
],
|
||||
});
|
||||
const onecliUp = fakeRun({ onecli: () => ({ status: 0, stdout: vault }) });
|
||||
|
||||
it('splits mine vs orphans against the central DB', () => {
|
||||
fs.mkdirSync(path.join(root, 'data'));
|
||||
const db = new Database(path.join(root, 'data', 'v2.db'));
|
||||
db.exec('CREATE TABLE agent_groups (id TEXT PRIMARY KEY)');
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-mine');
|
||||
db.close();
|
||||
|
||||
const inv = scanInstall(deps({ runCommand: onecliUp }));
|
||||
expect(inv.onecli.idsKnown).toBe(true);
|
||||
expect(inv.onecli.mine.map((a) => a.identifier)).toEqual(['ag-mine']);
|
||||
expect(inv.onecli.orphans.map((a) => a.identifier)).toEqual(['ag-other']);
|
||||
});
|
||||
|
||||
it('flags orphan labels as unreliable when the DB is unreadable', () => {
|
||||
const inv = scanInstall(deps({ runCommand: onecliUp }));
|
||||
expect(inv.onecli.idsKnown).toBe(false);
|
||||
expect(inv.onecli.mine).toEqual([]);
|
||||
expect(inv.onecli.orphans.map((a) => a.identifier)).toEqual(['ag-mine', 'ag-other']);
|
||||
expect(inv.notes.some((n) => n.includes("Couldn't read agent_groups"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectExistingInstall', () => {
|
||||
it('is false for an empty checkout', () => {
|
||||
expect(detectExistingInstall(root)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when the central DB exists', () => {
|
||||
fs.mkdirSync(path.join(root, 'data'));
|
||||
const db = new Database(path.join(root, 'data', 'v2.db'));
|
||||
db.close();
|
||||
expect(detectExistingInstall(root)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Uninstall inventory scan — find every artifact this checkout created.
|
||||
*
|
||||
* Everything NanoClaw creates is tagged with the per-checkout install slug
|
||||
* (sha1(projectRoot)[:8]), so several copies can coexist on one machine.
|
||||
* The scan reports ONLY things belonging to the given project root; shared
|
||||
* tools (the OneCLI app/vault, shell PATH lines, host-wide config) are
|
||||
* never inventoried.
|
||||
*
|
||||
* External commands (docker, onecli) go through the injected `runCommand`
|
||||
* so tests can fake them; filesystem checks are real — tests use temp dirs.
|
||||
* A missing/down docker daemon degrades to an empty result plus a note with
|
||||
* manual cleanup commands; it never throws.
|
||||
*
|
||||
* Deliberately does NOT import src/config.ts (import-time side effects).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
getContainerImageBase,
|
||||
getInstallSlug,
|
||||
getLaunchdLabel,
|
||||
getSystemdUnit,
|
||||
} from '../../src/install-slug.js';
|
||||
import {
|
||||
listVaultAgents,
|
||||
readAgentGroupIds,
|
||||
splitVaultAgents,
|
||||
type RunCommand,
|
||||
type VaultAgent,
|
||||
} from './onecli-agents.js';
|
||||
|
||||
export interface PathItem {
|
||||
/** Human label, e.g. "Database & conversations". */
|
||||
what: string;
|
||||
/** Display location (tilde-abbreviated). */
|
||||
where: string;
|
||||
/** Absolute path to remove. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ServiceInventory {
|
||||
launchdPlist?: string;
|
||||
systemdUserUnit?: string;
|
||||
systemdSystemUnit?: string;
|
||||
pidFile?: string;
|
||||
containerIds: string[];
|
||||
image?: string;
|
||||
nclSymlink?: string;
|
||||
}
|
||||
|
||||
export interface OnecliInventory {
|
||||
mine: VaultAgent[];
|
||||
orphans: VaultAgent[];
|
||||
/** False when agent_groups couldn't be read — orphan labels are then unreliable. */
|
||||
idsKnown: boolean;
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
slug: string;
|
||||
projectRoot: string;
|
||||
containerRuntime: string;
|
||||
service: ServiceInventory;
|
||||
/** Group 2: app data, logs & secrets. */
|
||||
data: PathItem[];
|
||||
/**
|
||||
* dist/ + node_modules/ — displayed with the data group but removed dead
|
||||
* last: the uninstaller itself runs on tsx out of node_modules.
|
||||
*/
|
||||
runtime: PathItem[];
|
||||
/** Group 3: groups/ and store/ — user content, unrecoverable. */
|
||||
user: PathItem[];
|
||||
onecli: OnecliInventory;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface ScanDeps {
|
||||
projectRoot: string;
|
||||
home: string;
|
||||
platform: NodeJS.Platform;
|
||||
runCommand: RunCommand;
|
||||
}
|
||||
|
||||
export function tilde(p: string, home: string): string {
|
||||
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
||||
}
|
||||
|
||||
export function scanInstall(deps: ScanDeps): Inventory {
|
||||
const { projectRoot, home, runCommand } = deps;
|
||||
const slug = getInstallSlug(projectRoot);
|
||||
const containerRuntime = process.env.CONTAINER_RUNTIME ?? 'docker';
|
||||
const notes: string[] = [];
|
||||
|
||||
const service = scanService(deps, slug, containerRuntime, notes);
|
||||
|
||||
const data = existingItems(projectRoot, home, [
|
||||
{ rel: 'data', what: 'Database & conversations' },
|
||||
{ rel: 'logs', what: 'Logs' },
|
||||
{ rel: '.env', what: 'Secrets / API keys (.env)', where: 'backed up before removal' },
|
||||
{ rel: 'start-nanoclaw.sh', what: 'Start script', where: 'start-nanoclaw.sh' },
|
||||
{ rel: 'nanoclaw.pid', what: 'PID file', where: 'nanoclaw.pid' },
|
||||
]);
|
||||
|
||||
const runtime = existingItems(projectRoot, home, [
|
||||
{ rel: 'dist', what: 'Build output' },
|
||||
{ rel: 'node_modules', what: 'Installed dependencies' },
|
||||
]);
|
||||
|
||||
const user = existingItems(projectRoot, home, [
|
||||
{ rel: 'groups', what: 'Agent memory & files' },
|
||||
{ rel: 'store', what: 'Migrated data store' },
|
||||
]);
|
||||
|
||||
const onecli = scanOnecli(projectRoot, runCommand, notes);
|
||||
|
||||
return {
|
||||
slug,
|
||||
projectRoot,
|
||||
containerRuntime,
|
||||
service,
|
||||
data,
|
||||
runtime,
|
||||
user,
|
||||
onecli,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap existing-install probe for mid-setup detection: service registration
|
||||
* (per-platform) or a central DB. No docker or onecli calls.
|
||||
*/
|
||||
export function detectExistingInstall(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'v2.db'))) return true;
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'darwin') {
|
||||
return fs.existsSync(
|
||||
path.join(home, 'Library', 'LaunchAgents', `${getLaunchdLabel(projectRoot)}.plist`),
|
||||
);
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const unit = getSystemdUnit(projectRoot);
|
||||
return (
|
||||
fs.existsSync(path.join(home, '.config', 'systemd', 'user', `${unit}.service`)) ||
|
||||
fs.existsSync(`/etc/systemd/system/${unit}.service`)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanService(
|
||||
deps: ScanDeps,
|
||||
slug: string,
|
||||
containerRuntime: string,
|
||||
notes: string[],
|
||||
): ServiceInventory {
|
||||
const { projectRoot, home, platform, runCommand } = deps;
|
||||
const service: ServiceInventory = { containerIds: [] };
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const plist = path.join(
|
||||
home,
|
||||
'Library',
|
||||
'LaunchAgents',
|
||||
`${getLaunchdLabel(projectRoot)}.plist`,
|
||||
);
|
||||
if (fs.existsSync(plist)) service.launchdPlist = plist;
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit(projectRoot);
|
||||
const userUnit = path.join(home, '.config', 'systemd', 'user', `${unit}.service`);
|
||||
const systemUnit = `/etc/systemd/system/${unit}.service`;
|
||||
if (fs.existsSync(userUnit)) service.systemdUserUnit = userUnit;
|
||||
if (fs.existsSync(systemUnit)) service.systemdSystemUnit = systemUnit;
|
||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
||||
if (fs.existsSync(pidFile)) service.pidFile = pidFile;
|
||||
}
|
||||
|
||||
// Container label matches what container-runner.ts stamps at spawn time.
|
||||
const installLabel = `nanoclaw-install=${slug}`;
|
||||
const image = `${getContainerImageBase(projectRoot)}:latest`;
|
||||
let runtimeOk = true;
|
||||
try {
|
||||
const ps = runCommand(containerRuntime, [
|
||||
'ps',
|
||||
'-aq',
|
||||
'--filter',
|
||||
`label=${installLabel}`,
|
||||
]);
|
||||
if (ps.status === 0) {
|
||||
service.containerIds = ps.stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
runtimeOk = false;
|
||||
}
|
||||
} catch {
|
||||
runtimeOk = false;
|
||||
}
|
||||
if (runtimeOk) {
|
||||
try {
|
||||
const inspect = runCommand(containerRuntime, ['image', 'inspect', image]);
|
||||
if (inspect.status === 0) service.image = image;
|
||||
} catch {
|
||||
runtimeOk = false;
|
||||
}
|
||||
}
|
||||
if (!runtimeOk) {
|
||||
notes.push(
|
||||
`Containers/image: '${containerRuntime}' unavailable; remove later with: ` +
|
||||
`${containerRuntime} ps -aq --filter label=${installLabel} | xargs -r ${containerRuntime} rm -f; ` +
|
||||
`${containerRuntime} rmi ${image}`,
|
||||
);
|
||||
}
|
||||
|
||||
const link = path.join(home, '.local', 'bin', 'ncl');
|
||||
let linkStat: fs.Stats | null = null;
|
||||
try {
|
||||
linkStat = fs.lstatSync(link);
|
||||
} catch {
|
||||
linkStat = null;
|
||||
}
|
||||
if (linkStat?.isSymbolicLink()) {
|
||||
let target = fs.readlinkSync(link);
|
||||
if (!path.isAbsolute(target)) {
|
||||
target = path.resolve(path.dirname(link), target);
|
||||
}
|
||||
if (path.resolve(target) === path.join(projectRoot, 'bin', 'ncl')) {
|
||||
service.nclSymlink = link;
|
||||
} else {
|
||||
notes.push(
|
||||
`ncl command ${tilde(link, home)} points to another NanoClaw copy; left untouched.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
function scanOnecli(
|
||||
projectRoot: string,
|
||||
runCommand: RunCommand,
|
||||
notes: string[],
|
||||
): OnecliInventory {
|
||||
const vault = listVaultAgents(runCommand);
|
||||
if (!vault.available || vault.agents.length === 0) {
|
||||
return { mine: [], orphans: [], idsKnown: false };
|
||||
}
|
||||
|
||||
const { ids, known } = readAgentGroupIds(path.join(projectRoot, 'data', 'v2.db'));
|
||||
const { mine, orphans } = splitVaultAgents(vault.agents, ids, known);
|
||||
if (!known && orphans.length > 0) {
|
||||
notes.push(
|
||||
"Couldn't read agent_groups from data/v2.db; OneCLI agents shown as 'orphan' may actually belong to this copy.",
|
||||
);
|
||||
}
|
||||
return { mine, orphans, idsKnown: known };
|
||||
}
|
||||
|
||||
function existingItems(
|
||||
projectRoot: string,
|
||||
home: string,
|
||||
specs: { rel: string; what: string; where?: string }[],
|
||||
): PathItem[] {
|
||||
const items: PathItem[] = [];
|
||||
for (const spec of specs) {
|
||||
const p = path.join(projectRoot, spec.rel);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
items.push({
|
||||
what: spec.what,
|
||||
where: spec.where ?? `${tilde(p, home)}/`,
|
||||
path: p,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -44,6 +44,9 @@ export interface DeliveryAddress {
|
||||
*/
|
||||
export interface InboundEvent {
|
||||
channelType: string;
|
||||
/** Receiving adapter instance; stamped host-side (src/index.ts onInbound).
|
||||
* Absent (e.g. CLI onInboundEvent) means the default instance (= channelType). */
|
||||
instance?: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
message: {
|
||||
@@ -112,6 +115,15 @@ export interface ChannelAdapter {
|
||||
name: string;
|
||||
channelType: string;
|
||||
|
||||
/**
|
||||
* Adapter-instance name — distinguishes N adapters of one platform
|
||||
* (e.g. three Slack apps in one workspace). Defaults to channelType.
|
||||
* channelType stays the SEMANTIC platform key (user ids '<channelType>:<handle>',
|
||||
* formatting, container config); instance is a host-side routing key only.
|
||||
* Must be unique across active adapters and URL-safe (no '/', '?', ':').
|
||||
*/
|
||||
instance?: string;
|
||||
|
||||
/**
|
||||
* Whether this adapter models conversations as threads.
|
||||
*
|
||||
|
||||
@@ -30,19 +30,24 @@ function now() {
|
||||
/** Create a mock ChannelAdapter for testing. */
|
||||
function createMockAdapter(
|
||||
channelType: string,
|
||||
): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[] } {
|
||||
instance?: string,
|
||||
): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[]; setupTimes: number[] } {
|
||||
const delivered: OutboundMessage[] = [];
|
||||
const inbound: InboundMessage[] = [];
|
||||
const setupTimes: number[] = [];
|
||||
let setupConfig: ChannelSetup | null = null;
|
||||
|
||||
return {
|
||||
name: channelType,
|
||||
name: instance ?? channelType,
|
||||
channelType,
|
||||
instance,
|
||||
supportsThreads: false,
|
||||
delivered,
|
||||
inbound,
|
||||
setupTimes,
|
||||
|
||||
async setup(config: ChannelSetup) {
|
||||
setupTimes.push(Date.now());
|
||||
setupConfig = config;
|
||||
},
|
||||
|
||||
@@ -117,6 +122,117 @@ describe('channel registry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel registry — instance keying', () => {
|
||||
// Fresh module per test: the registry and activeAdapters maps are
|
||||
// module-level, and these arms register conflicting same-channelType
|
||||
// adapters that must not leak across tests.
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
// Drop this test's registrations so later describe blocks (which import
|
||||
// the registry without resetting) start from an empty registry instead
|
||||
// of inheriting same-channelType pairs.
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const mockSetup = () => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
});
|
||||
|
||||
it('keys two same-channelType adapters by instance — both resolvable', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const worker = createMockAdapter('slack', 'slack-worker');
|
||||
const tester = createMockAdapter('slack', 'slack-tester');
|
||||
reg.registerChannelAdapter('slack-worker', { factory: () => worker });
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
|
||||
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelAdapter('slack-worker')).toBe(worker);
|
||||
expect(reg.getChannelAdapter('slack-tester')).toBe(tester);
|
||||
expect(reg.getActiveAdapters()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('resolves channelType to the default-instance adapter when one exists, else first-registered', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const named = createMockAdapter('slack', 'slack-tester');
|
||||
const unnamed = createMockAdapter('slack');
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => named });
|
||||
reg.registerChannelAdapter('slack', { factory: () => unnamed });
|
||||
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
// Exact key (default instance keyed by channelType) beats the fallback
|
||||
// scan, even though the named sibling registered first.
|
||||
expect(reg.getChannelAdapter('slack')).toBe(unnamed);
|
||||
|
||||
// With ONLY named instances active, channelType still resolves —
|
||||
// deterministic first-registered fallback.
|
||||
await reg.teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
const reg2 = await import('./channel-registry.js');
|
||||
const first = createMockAdapter('slack', 'slack-tester');
|
||||
const second = createMockAdapter('slack', 'slack-worker');
|
||||
reg2.registerChannelAdapter('slack-tester', { factory: () => first });
|
||||
reg2.registerChannelAdapter('slack-worker', { factory: () => second });
|
||||
await reg2.initChannelAdapters(mockSetup);
|
||||
expect(reg2.getChannelAdapter('slack')).toBe(first);
|
||||
});
|
||||
|
||||
it('does NOT reroute default-instance outbound through a named sibling when the default adapter is missing', async () => {
|
||||
// The default Slack app is offline (token rotated, factory returned
|
||||
// null, …) while a named sibling boots fine. Outbound for the default
|
||||
// instance must get the offline-adapter handling (drop into the retry
|
||||
// path) — NEVER a cross-identity send through the sibling bot.
|
||||
const reg = await import('./channel-registry.js');
|
||||
const tester = createMockAdapter('slack', 'slack-tester');
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
|
||||
reg.registerChannelAdapter('slack', { factory: () => null });
|
||||
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
// Exact lookup (delivery/typing path): the default key resolves nothing.
|
||||
expect(reg.getChannelAdapterExact('slack')).toBeUndefined();
|
||||
// Fallback-capable lookup (channelType-only callers) still resolves.
|
||||
expect(reg.getChannelAdapter('slack')).toBe(tester);
|
||||
|
||||
// The delivery bridge dispatches by exact key: a default-instance
|
||||
// message (instance === channelType after backfill) is dropped, not
|
||||
// delivered through the sibling's identity.
|
||||
const bridge = reg.createChannelDeliveryAdapter();
|
||||
const result = await bridge.deliver(
|
||||
'slack',
|
||||
'slack:C1',
|
||||
null,
|
||||
'chat',
|
||||
JSON.stringify({ text: 'to the default bot' }),
|
||||
undefined,
|
||||
'slack',
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(tester.delivered).toHaveLength(0);
|
||||
|
||||
// Sanity: the same bridge DOES deliver when the exact instance is live.
|
||||
await bridge.deliver(
|
||||
'slack',
|
||||
'slack:C1',
|
||||
null,
|
||||
'chat',
|
||||
JSON.stringify({ text: 'to the tester bot' }),
|
||||
undefined,
|
||||
'slack-tester',
|
||||
);
|
||||
expect(tester.delivered).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel + router integration', () => {
|
||||
beforeEach(async () => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* Channels self-register on import. The host calls initChannelAdapters() at startup
|
||||
* to instantiate and set up all registered adapters.
|
||||
*/
|
||||
import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
|
||||
import type { ChannelDeliveryAdapter } from '../delivery.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000];
|
||||
@@ -26,9 +27,79 @@ export function registerChannelAdapter(name: string, registration: ChannelRegist
|
||||
registry.set(name, registration);
|
||||
}
|
||||
|
||||
/** Get a live adapter by channel type. */
|
||||
export function getChannelAdapter(channelType: string): ChannelAdapter | undefined {
|
||||
return activeAdapters.get(channelType);
|
||||
/** Get a live adapter by its EXACT registry key (instance name; default
|
||||
* instances are keyed by channelType itself). No channelType fallback —
|
||||
* callers that address a specific instance (outbound delivery, typing)
|
||||
* must never be rerouted through a sibling instance: that would send
|
||||
* through the wrong bot identity with the wrong token. A missing key
|
||||
* means the owning adapter is offline; callers apply their normal
|
||||
* offline-adapter handling. */
|
||||
export function getChannelAdapterExact(key: string): ChannelAdapter | undefined {
|
||||
return activeAdapters.get(key);
|
||||
}
|
||||
|
||||
/** Get a live adapter by instance name, falling back to any adapter of the
|
||||
* given channel type. The fallback exists ONLY for channelType-only callers
|
||||
* (user-id prefix resolution and cold DMs in user-dm.ts, approval delivery
|
||||
* in channel-approval.ts, the router's thread-policy probe when an event
|
||||
* carries no instance) — they must still resolve when every instance of a
|
||||
* platform is named. First registered wins (Map insertion order,
|
||||
* deterministic). Default instances are keyed by channelType itself, so
|
||||
* single-instance installs always hit the exact-key path. Instance-addressed
|
||||
* dispatch (delivery, typing) must use getChannelAdapterExact instead. */
|
||||
export function getChannelAdapter(key: string): ChannelAdapter | undefined {
|
||||
const exact = activeAdapters.get(key);
|
||||
if (exact) return exact;
|
||||
for (const [registryKey, adapter] of activeAdapters) {
|
||||
if (adapter.channelType === key) {
|
||||
log.warn('Channel adapter fallback: requested key resolved through a differently-keyed instance', {
|
||||
requested: key,
|
||||
resolvedKey: registryKey,
|
||||
});
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the host's outbound delivery bridge: dispatches delivery-poll and
|
||||
* typing traffic into the adapter registry. Resolution is EXACT-key only —
|
||||
* `instance ?? channelType`. For default-instance messaging_groups rows the
|
||||
* stored instance IS the channelType, which matches default-registered
|
||||
* adapters, so single-instance behavior is unchanged. A named instance whose
|
||||
* adapter is offline gets the normal offline-adapter handling (warn + drop
|
||||
* into the delivery retry path) — never a cross-identity send through a
|
||||
* sibling bot of the same platform.
|
||||
*/
|
||||
export function createChannelDeliveryAdapter(): ChannelDeliveryAdapter {
|
||||
return {
|
||||
async deliver(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
kind: string,
|
||||
content: string,
|
||||
files?: OutboundFile[],
|
||||
instance?: string,
|
||||
): Promise<string | undefined> {
|
||||
const adapter = getChannelAdapterExact(instance ?? channelType);
|
||||
if (!adapter) {
|
||||
log.warn('No adapter for channel type', { channelType, instance });
|
||||
return;
|
||||
}
|
||||
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
|
||||
},
|
||||
async setTyping(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
instance?: string,
|
||||
): Promise<void> {
|
||||
const adapter = getChannelAdapterExact(instance ?? channelType);
|
||||
await adapter?.setTyping?.(platformId, threadId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all active adapters. */
|
||||
@@ -85,8 +156,16 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) =>
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
activeAdapters.set(adapter.channelType, adapter);
|
||||
log.info('Channel adapter started', { channel: name, type: adapter.channelType });
|
||||
// Adapters key by instance (default instance = channelType), so N
|
||||
// instances of one platform coexist. Duplicate keys warn instead of
|
||||
// throwing — boot stays resilient, matching the historical silent
|
||||
// last-write-wins, but now visibly.
|
||||
const key = adapter.instance ?? adapter.channelType;
|
||||
if (activeAdapters.has(key)) {
|
||||
log.warn('Duplicate adapter instance key — overwriting previous adapter', { key, channel: name });
|
||||
}
|
||||
activeAdapters.set(key, adapter);
|
||||
log.info('Channel adapter started', { channel: name, type: adapter.channelType, instance: key });
|
||||
} catch (err) {
|
||||
log.error('Failed to start channel adapter', { channel: name, err });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Approval-card actor byline in the Chat SDK bridge.
|
||||
*
|
||||
* Drives the bridge's real onAction handler through the real Chat SDK
|
||||
* dispatch (`chat.processAction`): `bridge.setup()` registers the handler on
|
||||
* a real Chat instance, which the test captures from the webhook-server
|
||||
* registration (mocked so no HTTP server binds a port). After a button click
|
||||
* the bridge edits the card; the edit must append " — <actor>" so shared
|
||||
* channels see who resolved an approval. Goes red if the byLine concatenation
|
||||
* is removed from the edited markdown.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Adapter, Chat } from 'chat';
|
||||
|
||||
const captured = vi.hoisted(() => ({ chat: null as unknown }));
|
||||
|
||||
vi.mock('../webhook-server.js', () => ({
|
||||
registerWebhookAdapter: vi.fn((chat: unknown) => {
|
||||
captured.chat = chat;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { closeDb, initTestDb, runMigrations } from '../db/index.js';
|
||||
import type { ChannelSetup } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
|
||||
interface CapturedEdit {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function makeAdapter(edits: CapturedEdit[]): Adapter {
|
||||
return {
|
||||
name: 'stub',
|
||||
initialize: async () => {},
|
||||
channelIdFromThreadId: (threadId: string) => `stub:${threadId}`,
|
||||
editMessage: async (threadId: string, messageId: string, content: { markdown: string }) => {
|
||||
edits.push({ threadId, messageId, markdown: content.markdown });
|
||||
},
|
||||
} as unknown as Adapter;
|
||||
}
|
||||
|
||||
async function fireAction(user: Record<string, unknown>): Promise<{ edits: CapturedEdit[]; actions: string[] }> {
|
||||
const edits: CapturedEdit[] = [];
|
||||
const actions: string[] = [];
|
||||
const adapter = makeAdapter(edits);
|
||||
const bridge = createChatSdkBridge({ adapter, supportsThreads: false });
|
||||
|
||||
await bridge.setup({
|
||||
onInbound: async () => {},
|
||||
onInboundEvent: async () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: (questionId: string, selectedOption: string, userId: string) => {
|
||||
actions.push(`${questionId}:${selectedOption}:${userId}`);
|
||||
},
|
||||
} as ChannelSetup);
|
||||
|
||||
const chat = captured.chat as Chat;
|
||||
expect(chat).toBeTruthy();
|
||||
await chat.processAction(
|
||||
{
|
||||
actionId: 'ncq:q-1:approve',
|
||||
adapter,
|
||||
messageId: 'msg-1',
|
||||
raw: {},
|
||||
threadId: 'T-1',
|
||||
user: user as never,
|
||||
value: 'approve',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
return { edits, actions };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured.chat = null;
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('chat-sdk-bridge approval-card byline', () => {
|
||||
it('appends the acting user to the edited card markdown', async () => {
|
||||
const { edits, actions } = await fireAction({ userId: 'U1', userName: 'gavriel', fullName: 'Gavriel C' });
|
||||
|
||||
expect(edits).toHaveLength(1);
|
||||
expect(edits[0].threadId).toBe('T-1');
|
||||
expect(edits[0].messageId).toBe('msg-1');
|
||||
expect(edits[0].markdown).toContain('approve — gavriel');
|
||||
expect(actions).toEqual(['q-1:approve:U1']);
|
||||
});
|
||||
|
||||
it('falls back to fullName when userName is missing', async () => {
|
||||
const { edits } = await fireAction({ userId: 'U2', fullName: 'Gavriel C' });
|
||||
|
||||
expect(edits).toHaveLength(1);
|
||||
expect(edits[0].markdown).toContain('— Gavriel C');
|
||||
});
|
||||
|
||||
it('omits the byline when the actor has no name', async () => {
|
||||
const { edits } = await fireAction({ userId: 'U3' });
|
||||
|
||||
expect(edits).toHaveLength(1);
|
||||
expect(edits[0].markdown).not.toContain('—');
|
||||
expect(edits[0].markdown).toContain('approve');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Adapter, AdapterPostableMessage, RawMessage } from 'chat';
|
||||
|
||||
import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js';
|
||||
|
||||
vi.mock('../webhook-server.js', () => ({
|
||||
registerWebhookAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
function stubAdapter(partial: Partial<Adapter>): Adapter {
|
||||
return { name: 'stub', ...partial } as unknown as Adapter;
|
||||
}
|
||||
@@ -93,6 +97,147 @@ describe('createChatSdkBridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge — instance identity', () => {
|
||||
it('default: name === channelType === adapter.name, instance undefined', () => {
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: stubAdapter({ name: 'slack' }),
|
||||
supportsThreads: true,
|
||||
});
|
||||
expect(bridge.name).toBe('slack');
|
||||
expect(bridge.channelType).toBe('slack');
|
||||
expect(bridge.instance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('named instance: name follows the instance, channelType stays the platform', () => {
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: stubAdapter({ name: 'slack' }),
|
||||
instance: 'slack-tester',
|
||||
supportsThreads: true,
|
||||
});
|
||||
expect(bridge.name).toBe('slack-tester');
|
||||
expect(bridge.channelType).toBe('slack');
|
||||
expect(bridge.instance).toBe('slack-tester');
|
||||
});
|
||||
|
||||
it('rejects instance names that would break the webhook route or state delimiter', () => {
|
||||
for (const bad of ['a/b', 'a:b', 'a?b', 'a b']) {
|
||||
expect(() =>
|
||||
createChatSdkBridge({ adapter: stubAdapter({ name: 'slack' }), instance: bad, supportsThreads: true }),
|
||||
).toThrow(/URL-safe/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty and whitespace-only instance names (config bug — fail loud)', () => {
|
||||
// '' is falsy: a truthiness guard would skip it, dead-ending the
|
||||
// webhook route ('/webhook/' + '') and collapsing the state namespace
|
||||
// into the default instance's unprefixed keyspace — the exact
|
||||
// cross-bot dedupe/lock collisions the namespace exists to prevent.
|
||||
for (const bad of ['', ' ', ' ', '\t']) {
|
||||
expect(() =>
|
||||
createChatSdkBridge({ adapter: stubAdapter({ name: 'slack' }), instance: bad, supportsThreads: true }),
|
||||
).toThrow(/URL-safe/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge.setup — webhook route and state namespace', () => {
|
||||
// Real setup() over a stub adapter: Chat.initialize() needs a working
|
||||
// StateAdapter (chat_sdk_* tables) and an adapter.initialize — nothing
|
||||
// platform-side. registerWebhookAdapter is mocked at module level so we
|
||||
// can assert the (chat, adapterName, routingPath) triple.
|
||||
function setupStubAdapter(): Adapter {
|
||||
return stubAdapter({
|
||||
name: 'slack',
|
||||
initialize: async () => {},
|
||||
} as unknown as Partial<Adapter>);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { initTestDb } = await import('../db/connection.js');
|
||||
const { runMigrations } = await import('../db/migrations/index.js');
|
||||
runMigrations(initTestDb());
|
||||
const { registerWebhookAdapter } = await import('../webhook-server.js');
|
||||
vi.mocked(registerWebhookAdapter).mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { closeDb } = await import('../db/connection.js');
|
||||
closeDb();
|
||||
});
|
||||
|
||||
const hostConfig = {
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
};
|
||||
|
||||
it('named instance registers the webhook with adapterName as handler key and instance as route', async () => {
|
||||
const { registerWebhookAdapter } = await import('../webhook-server.js');
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: setupStubAdapter(),
|
||||
instance: 'slack-tester',
|
||||
supportsThreads: true,
|
||||
});
|
||||
await bridge.setup(hostConfig);
|
||||
expect(registerWebhookAdapter).toHaveBeenCalledTimes(1);
|
||||
const [, adapterName, routingPath] = vi.mocked(registerWebhookAdapter).mock.calls[0];
|
||||
expect(adapterName).toBe('slack');
|
||||
expect(routingPath).toBe('slack-tester');
|
||||
await bridge.teardown();
|
||||
});
|
||||
|
||||
it('default instance registers the historical route', async () => {
|
||||
const { registerWebhookAdapter } = await import('../webhook-server.js');
|
||||
const bridge = createChatSdkBridge({ adapter: setupStubAdapter(), supportsThreads: true });
|
||||
await bridge.setup(hostConfig);
|
||||
const [, adapterName, routingPath] = vi.mocked(registerWebhookAdapter).mock.calls[0];
|
||||
expect(adapterName).toBe('slack');
|
||||
expect(routingPath ?? adapterName).toBe('slack');
|
||||
await bridge.teardown();
|
||||
});
|
||||
|
||||
it('named instance namespaces Chat SDK state; default stays unprefixed (live-install constraint)', async () => {
|
||||
const { getDb } = await import('../db/connection.js');
|
||||
|
||||
const named = createChatSdkBridge({
|
||||
adapter: setupStubAdapter(),
|
||||
instance: 'slack-tester',
|
||||
supportsThreads: true,
|
||||
});
|
||||
await named.setup(hostConfig);
|
||||
await named.subscribe!('slack:C1', 'slack:T1');
|
||||
|
||||
const def = createChatSdkBridge({ adapter: setupStubAdapter(), supportsThreads: true });
|
||||
await def.setup(hostConfig);
|
||||
await def.subscribe!('slack:C1', 'slack:T1');
|
||||
|
||||
const rows = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions ORDER BY thread_id').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(rows.map((r) => r.thread_id)).toEqual(['slack-tester:slack:T1', 'slack:T1']);
|
||||
|
||||
await named.teardown();
|
||||
await def.teardown();
|
||||
});
|
||||
|
||||
it('explicitly naming the primary instance after the platform stays on the unprefixed keyspace', async () => {
|
||||
const { getDb } = await import('../db/connection.js');
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: setupStubAdapter(),
|
||||
instance: 'slack', // explicit, but equal to adapter.name ⇒ default keyspace
|
||||
supportsThreads: true,
|
||||
});
|
||||
await bridge.setup(hostConfig);
|
||||
await bridge.subscribe!('slack:C1', 'slack:T9');
|
||||
const rows = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(rows.map((r) => r.thread_id)).toEqual(['slack:T9']);
|
||||
await bridge.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge.deliver — display cards (send_card)', () => {
|
||||
// The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`.
|
||||
// Before this branch existed the bridge silently dropped them: cards have no
|
||||
|
||||
@@ -47,6 +47,15 @@ export type ReplyContextExtractor = (raw: Record<string, any>) => ReplyContext |
|
||||
|
||||
export interface ChatSdkBridgeConfig {
|
||||
adapter: Adapter;
|
||||
/**
|
||||
* Adapter-instance name for running multiple bridges of one platform
|
||||
* (e.g. several Slack apps in one workspace). Defaults to the platform
|
||||
* name. Drives the registry key, the webhook route (/webhook/<instance>),
|
||||
* and the Chat SDK state namespace. channelType is NOT affected — user
|
||||
* identity, formatting, and container config stay keyed on the platform.
|
||||
* Must be URL-safe: non-empty, only letters, digits, '.', '_' or '-'.
|
||||
*/
|
||||
instance?: string;
|
||||
concurrency?: ConcurrencyStrategy;
|
||||
/** Bot token for authenticating forwarded Gateway events (required for interaction handling). */
|
||||
botToken?: string;
|
||||
@@ -121,6 +130,19 @@ export function splitForLimit(text: string, limit: number): string[] {
|
||||
|
||||
export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter {
|
||||
const { adapter } = config;
|
||||
// The instance name becomes a webhook route segment (the route regex is
|
||||
// [^/?]+) and ':' is the state-namespace delimiter — reject anything that
|
||||
// would break either, at construction time rather than at first webhook.
|
||||
// Positive allow-list (not a deny-list): also rejects '' and
|
||||
// whitespace-only names, which are config bugs — '' is falsy, so it
|
||||
// would skip a truthiness guard, dead-end the webhook route, and
|
||||
// collapse the state namespace into the default instance's keyspace.
|
||||
if (config.instance !== undefined && !/^[A-Za-z0-9._-]+$/.test(config.instance)) {
|
||||
throw new Error(
|
||||
`chat-sdk bridge instance ${JSON.stringify(config.instance)} must be URL-safe: ` +
|
||||
`non-empty, only letters, digits, '.', '_' or '-'`,
|
||||
);
|
||||
}
|
||||
const transformText = (t: string): string => (config.transformOutboundText ? config.transformOutboundText(t) : t);
|
||||
let chat: Chat;
|
||||
let state: SqliteStateAdapter;
|
||||
@@ -193,14 +215,21 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
}
|
||||
|
||||
const bridge: ChannelAdapter = {
|
||||
name: adapter.name,
|
||||
channelType: adapter.name,
|
||||
name: config.instance ?? adapter.name,
|
||||
channelType: adapter.name, // unchanged — semantic platform key
|
||||
instance: config.instance, // undefined ⇒ default instance
|
||||
|
||||
supportsThreads: config.supportsThreads,
|
||||
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
setupConfig = hostConfig;
|
||||
|
||||
state = new SqliteStateAdapter();
|
||||
// State namespace: ONLY for a named non-default instance. A skill
|
||||
// that explicitly names the primary instance after the platform
|
||||
// (instance === adapter.name) still lands on the legacy UNPREFIXED
|
||||
// keyspace — prefixing the default would orphan every live install's
|
||||
// chat_sdk_subscriptions/kv/locks/lists rows.
|
||||
state = new SqliteStateAdapter(config.instance && config.instance !== adapter.name ? config.instance : undefined);
|
||||
|
||||
chat = new Chat({
|
||||
adapters: { [adapter.name]: adapter },
|
||||
@@ -284,11 +313,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
const matched = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||
|
||||
// Update the card to show the selected answer and remove buttons
|
||||
// Update the card to show the selected answer, who acted, and remove buttons
|
||||
const actorName = event.user?.userName || event.user?.fullName || '';
|
||||
const byLine = actorName ? ` — ${actorName}` : '';
|
||||
try {
|
||||
const tid = event.threadId;
|
||||
await adapter.editMessage(tid, event.messageId, {
|
||||
markdown: `${title}\n\n${selectedLabel}`,
|
||||
markdown: `${title}\n\n${selectedLabel}${byLine}`,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('Failed to update card after action', { err });
|
||||
@@ -358,8 +389,12 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
startGateway();
|
||||
log.info('Gateway listener started', { adapter: adapter.name });
|
||||
} else {
|
||||
// Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the shared webhook server
|
||||
registerWebhookAdapter(chat, adapter.name);
|
||||
// Non-gateway adapters (Slack, Teams, GitHub, etc.) — register on the
|
||||
// shared webhook server. The handler key stays adapter.name (the
|
||||
// Chat instance's webhooks map is keyed by it); the route segment is
|
||||
// the instance, so each same-platform bridge gets its own URL (and
|
||||
// its own signing secret — platforms sign per-app).
|
||||
registerWebhookAdapter(chat, adapter.name, config.instance ?? adapter.name);
|
||||
}
|
||||
|
||||
log.info('Chat SDK bridge initialized', { adapter: adapter.name });
|
||||
|
||||
@@ -90,8 +90,8 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
now(),
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (?, 'telegram', 'tg-1', 'chat', 1, 'strict', ?)`,
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (?, 'telegram', 'tg-1', 'telegram', 'chat', 1, 'strict', ?)`,
|
||||
).run(MGID, now());
|
||||
|
||||
db.prepare(
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Channel-instance dimension tests (migration 016 + messaging-groups queries).
|
||||
*
|
||||
* Covers the three load-bearing rules:
|
||||
* 1. Backfill/default — instance = channel_type everywhere it isn't set,
|
||||
* so single-instance installs behave byte-identically.
|
||||
* 2. UNIQUE(channel_type, platform_id, instance) — siblings coexist,
|
||||
* single-bot pair-uniqueness is preserved via the default value.
|
||||
* 3. Lookup asymmetry — inbound (getMessagingGroupWithAgentCount) is
|
||||
* exact-on-instance with NO fallback (unknown named instance ⇒ null ⇒
|
||||
* router auto-creates instead of hijacking a sibling's row); outbound
|
||||
* (getMessagingGroupByPlatform) is default-instance-first.
|
||||
*
|
||||
* The wired-DB arm reproduces the failure mode that bit migration 011: a
|
||||
* table recreate on a live DB with FK children. It must pass with
|
||||
* disableForeignKeys: true and fail without it.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, getDb } from './connection.js';
|
||||
import { runMigrations, migrations, type Migration } from './migrations/index.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
getMessagingGroupByPlatform,
|
||||
getMessagingGroupWithAgentCount,
|
||||
} from './messaging-groups.js';
|
||||
import type { MessagingGroup } from '../types.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function mg(overrides: Partial<MessagingGroup> & { id: string }): MessagingGroup {
|
||||
return {
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
name: null,
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('migration 016 — fresh DB', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
it('adds a NOT NULL instance column', () => {
|
||||
const cols = getDb().prepare("PRAGMA table_info('messaging_groups')").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
const instance = cols.find((c) => c.name === 'instance');
|
||||
expect(instance).toBeDefined();
|
||||
expect(instance!.notnull).toBe(1);
|
||||
});
|
||||
|
||||
it('createMessagingGroup without instance stamps instance = channel_type', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-default' }));
|
||||
const row = getDb().prepare("SELECT instance FROM messaging_groups WHERE id = 'mg-default'").get() as {
|
||||
instance: string;
|
||||
};
|
||||
expect(row.instance).toBe('slack');
|
||||
});
|
||||
|
||||
it('allows sibling instances on the same (channel_type, platform_id)', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-default' }));
|
||||
createMessagingGroup(mg({ id: 'mg-tester', instance: 'slack-tester' }));
|
||||
const count = getDb().prepare('SELECT COUNT(*) AS c FROM messaging_groups').get() as { c: number };
|
||||
expect(count.c).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects a duplicate (channel_type, platform_id, instance) triple', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-a', instance: 'slack-tester' }));
|
||||
expect(() => createMessagingGroup(mg({ id: 'mg-b', instance: 'slack-tester' }))).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a duplicate default pair (single-bot uniqueness preserved)', () => {
|
||||
createMessagingGroup(mg({ id: 'mg-a' }));
|
||||
expect(() => createMessagingGroup(mg({ id: 'mg-b' }))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration 016 — wired legacy DB upgrade (the FK recreate arm)', () => {
|
||||
it('recreates messaging_groups under FK children without violations and backfills instance', () => {
|
||||
const db = initTestDb();
|
||||
// Bring the DB to the pre-016 schema.
|
||||
runMigrations(
|
||||
db,
|
||||
migrations.filter((m) => m.name !== 'messaging-group-instance'),
|
||||
);
|
||||
const preCols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
|
||||
expect(preCols.some((c) => c.name === 'instance')).toBe(false);
|
||||
|
||||
// Seed a wired install: messaging_groups with live FK children
|
||||
// (messaging_group_agents + sessions reference messaging_groups.id).
|
||||
// Raw SQL — the new createMessagingGroup expects the instance column.
|
||||
db.prepare("INSERT INTO agent_groups (id, name, folder, created_at) VALUES ('ag-1', 'A', 'a', ?)").run(now());
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-1', 'telegram', 'telegram:123', 'Chat', 0, 'public', ?)`,
|
||||
).run(now());
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, engage_mode, sender_scope, ignored_message_policy, created_at)
|
||||
VALUES ('mga-1', 'mg-1', 'ag-1', 'pattern', 'all', 'drop', ?)`,
|
||||
).run(now());
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, agent_group_id, messaging_group_id, created_at)
|
||||
VALUES ('sess-1', 'ag-1', 'mg-1', ?)`,
|
||||
).run(now());
|
||||
|
||||
// Upgrade: only 016 is pending now. Without disableForeignKeys this
|
||||
// throws 'FOREIGN KEY constraint failed' at DROP TABLE.
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
|
||||
// Backfill: existing row got instance = channel_type.
|
||||
const row = db.prepare("SELECT instance FROM messaging_groups WHERE id = 'mg-1'").get() as { instance: string };
|
||||
expect(row.instance).toBe('telegram');
|
||||
|
||||
// Children intact and pointing at the recreated parent.
|
||||
expect(
|
||||
db.prepare("SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = 'mg-1'").get(),
|
||||
).toEqual({ c: 1 });
|
||||
expect(db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE messaging_group_id = 'mg-1'").get()).toEqual({ c: 1 });
|
||||
|
||||
// Full-DB FK integrity (FK enforcement was restored by the runner).
|
||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||
expect(db.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
});
|
||||
|
||||
it('tolerates pre-existing FK orphans: the migration still applies (no boot crash-loop)', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(
|
||||
db,
|
||||
migrations.filter((m) => m.name !== 'messaging-group-instance'),
|
||||
);
|
||||
|
||||
// Seed the orphan class that demonstrably exists on live installs
|
||||
// (ensureUserDm tolerates it at runtime): a user_dms row whose
|
||||
// messaging_group was deleted through a FK-OFF connection — the
|
||||
// sqlite3 CLI ships with foreign_keys OFF, and operators are told to
|
||||
// poke v2.db when troubleshooting.
|
||||
db.prepare("INSERT INTO users (id, kind, created_at) VALUES ('slack:U1', 'slack', ?)").run(now());
|
||||
db.pragma('foreign_keys = OFF');
|
||||
db.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES ('slack:U1', 'slack', 'mg-deleted-via-cli', ?)`,
|
||||
).run(now());
|
||||
db.pragma('foreign_keys = ON');
|
||||
expect(db.pragma('foreign_key_check')).toHaveLength(1);
|
||||
|
||||
// 016 did not create this violation — it must still apply (the runner
|
||||
// diffs post-up violations against a pre-up snapshot and only throws
|
||||
// on NEW ones; pre-existing ones are warned about and carried through).
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
|
||||
expect(cols.some((c) => c.name === 'instance')).toBe(true);
|
||||
|
||||
// The orphan is untouched: still present, still the only violation.
|
||||
expect(db.pragma('foreign_key_check')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still rejects a migration that ITSELF introduces FK violations', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
const rogue: Migration = {
|
||||
version: 999,
|
||||
name: 'test-rogue-fk-violation',
|
||||
disableForeignKeys: true,
|
||||
up: (d) => {
|
||||
d.prepare("INSERT INTO users (id, kind, created_at) VALUES ('slack:U-rogue', 'slack', datetime('now'))").run();
|
||||
d.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES ('slack:U-rogue', 'slack', 'mg-never-existed', datetime('now'))`,
|
||||
).run();
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => runMigrations(db, [...migrations, rogue])).toThrow(/left FK violations/);
|
||||
|
||||
// Rolled back atomically: not recorded as applied, nothing committed.
|
||||
expect(db.prepare("SELECT 1 FROM schema_version WHERE name = 'test-rogue-fk-violation'").get()).toBeUndefined();
|
||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('is idempotent — re-running the full barrel is a no-op', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createMessagingGroup(mg({ id: 'mg-keep', instance: 'slack-tester' }));
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
const row = db.prepare("SELECT instance FROM messaging_groups WHERE id = 'mg-keep'").get() as {
|
||||
instance: string;
|
||||
};
|
||||
expect(row.instance).toBe('slack-tester');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookup asymmetry — inbound exact-only vs outbound default-first', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
// The named instance ('alpha-tester') sorts lexically BEFORE the
|
||||
// channel type ('slack') and is inserted first — so both rowid order
|
||||
// and the triple-autoindex order put it ahead of the default row.
|
||||
// A query missing the `(instance = channel_type) DESC` ORDER BY would
|
||||
// return it; only the deterministic default-first ordering picks
|
||||
// mg-default.
|
||||
createMessagingGroup(mg({ id: 'mg-tester', instance: 'alpha-tester' }));
|
||||
createMessagingGroup(mg({ id: 'mg-default' }));
|
||||
});
|
||||
|
||||
it('getMessagingGroupWithAgentCount without instance resolves the default-instance row', () => {
|
||||
const found = getMessagingGroupWithAgentCount('slack', 'slack:C1');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.mg.id).toBe('mg-default');
|
||||
});
|
||||
|
||||
it('getMessagingGroupWithAgentCount with a named instance resolves exactly that row', () => {
|
||||
const found = getMessagingGroupWithAgentCount('slack', 'slack:C1', 'alpha-tester');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.mg.id).toBe('mg-tester');
|
||||
});
|
||||
|
||||
it('getMessagingGroupWithAgentCount with an unknown instance returns null (no-hijack rule)', () => {
|
||||
expect(getMessagingGroupWithAgentCount('slack', 'slack:C1', 'slack-unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('getMessagingGroupByPlatform without instance prefers the default-instance row', () => {
|
||||
const found = getMessagingGroupByPlatform('slack', 'slack:C1');
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe('mg-default');
|
||||
});
|
||||
|
||||
it('getMessagingGroupByPlatform with explicit instance is exact', () => {
|
||||
expect(getMessagingGroupByPlatform('slack', 'slack:C1', 'alpha-tester')!.id).toBe('mg-tester');
|
||||
expect(getMessagingGroupByPlatform('slack', 'slack:C1', 'slack-unknown')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getMessagingGroupByPlatform falls back deterministically when only named instances exist', () => {
|
||||
const db = getDb();
|
||||
db.prepare("DELETE FROM messaging_groups WHERE id = 'mg-default'").run();
|
||||
createMessagingGroup(mg({ id: 'mg-zeta', instance: 'zeta' }));
|
||||
const found = getMessagingGroupByPlatform('slack', 'slack:C1');
|
||||
// Lexically-first named instance: 'alpha-tester' < 'zeta'.
|
||||
expect(found!.id).toBe('mg-tester');
|
||||
});
|
||||
});
|
||||
@@ -21,19 +21,43 @@ import { getDb, hasTable } from './connection.js';
|
||||
export function createMessagingGroup(group: MessagingGroup): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (@id, @channel_type, @platform_id, @name, @is_group, @unknown_sender_policy, @created_at)`,
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (@id, @channel_type, @platform_id, @instance, @name, @is_group, @unknown_sender_policy, @created_at)`,
|
||||
)
|
||||
.run(group);
|
||||
.run({ ...group, instance: group.instance ?? group.channel_type });
|
||||
}
|
||||
|
||||
export function getMessagingGroup(id: string): MessagingGroup | undefined {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups WHERE id = ?').get(id) as MessagingGroup | undefined;
|
||||
}
|
||||
|
||||
export function getMessagingGroupByPlatform(channelType: string, platformId: string): MessagingGroup | undefined {
|
||||
/**
|
||||
* Outbound / cold-DM / setup lookup by platform address.
|
||||
*
|
||||
* Instance semantics are deliberately ASYMMETRIC with the router's
|
||||
* `getMessagingGroupWithAgentCount` (exact-only): outbound callers usually
|
||||
* don't know (or care) which adapter instance owns a chat, so an unset
|
||||
* `instance` resolves the default instance first (instance = channel_type),
|
||||
* falling back deterministically to the lexically-first named instance.
|
||||
* A set `instance` is exact-only — unknown instance returns undefined.
|
||||
*/
|
||||
export function getMessagingGroupByPlatform(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
instance?: string,
|
||||
): MessagingGroup | undefined {
|
||||
if (instance !== undefined) {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM messaging_groups WHERE channel_type = ? AND platform_id = ? AND instance = ?')
|
||||
.get(channelType, platformId, instance) as MessagingGroup | undefined;
|
||||
}
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM messaging_groups WHERE channel_type = ? AND platform_id = ?')
|
||||
.prepare(
|
||||
`SELECT * FROM messaging_groups
|
||||
WHERE channel_type = ? AND platform_id = ?
|
||||
ORDER BY (instance = channel_type) DESC, instance ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(channelType, platformId) as MessagingGroup | undefined;
|
||||
}
|
||||
|
||||
@@ -46,23 +70,31 @@ export function getMessagingGroupByPlatform(channelType: string, platformId: str
|
||||
*
|
||||
* Returns `null` when no messaging_groups row exists for this channel.
|
||||
* Returns `{ mg, agentCount: 0 }` when the row exists but has no wired
|
||||
* agents. Uses the `UNIQUE(channel_type, platform_id)` index plus the
|
||||
* `UNIQUE(messaging_group_id, agent_group_id)` index for the JOIN — both
|
||||
* agents. Uses the `UNIQUE(channel_type, platform_id, instance)` index plus
|
||||
* the `UNIQUE(messaging_group_id, agent_group_id)` index for the JOIN — both
|
||||
* covered by existing SQLite auto-indexes from the UNIQUE constraints.
|
||||
*
|
||||
* `instance` is EXACT-ONLY, with no fallback — deliberately asymmetric with
|
||||
* `getMessagingGroupByPlatform`'s default-instance-first resolution. An
|
||||
* unknown named instance must return null so the router auto-creates a
|
||||
* per-instance group instead of hijacking a sibling instance's row. The
|
||||
* default param (= channelType) keeps instance-less callers resolving the
|
||||
* default instance, identical to pre-instance behavior.
|
||||
*/
|
||||
export function getMessagingGroupWithAgentCount(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
instance: string = channelType,
|
||||
): { mg: MessagingGroup; agentCount: number } | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT mg.*, COUNT(mga.id) AS agent_count
|
||||
FROM messaging_groups mg
|
||||
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
|
||||
WHERE mg.channel_type = ? AND mg.platform_id = ?
|
||||
WHERE mg.channel_type = ? AND mg.platform_id = ? AND mg.instance = ?
|
||||
GROUP BY mg.id`,
|
||||
)
|
||||
.get(channelType, platformId) as (MessagingGroup & { agent_count: number }) | undefined;
|
||||
.get(channelType, platformId, instance) as (MessagingGroup & { agent_count: number }) | undefined;
|
||||
if (!row) return null;
|
||||
const { agent_count, ...mg } = row;
|
||||
return { mg: mg as MessagingGroup, agentCount: agent_count };
|
||||
@@ -72,6 +104,12 @@ export function getAllMessagingGroups(): MessagingGroup[] {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups ORDER BY name').all() as MessagingGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* All messaging groups on a platform, across every adapter instance.
|
||||
* Semantics intentionally unchanged by the instance dimension — channel_type
|
||||
* stays the semantic platform key. No live caller today; if a caller needs
|
||||
* a single instance's rows, filter on `mg.instance`.
|
||||
*/
|
||||
export function getMessagingGroupsByChannel(channelType: string): MessagingGroup[] {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups WHERE channel_type = ?').all(channelType) as MessagingGroup[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Channel-instance dimension on messaging_groups.
|
||||
*
|
||||
* `instance` names the adapter instance that owns a chat — N adapters of one
|
||||
* platform (e.g. three Slack apps in one workspace) each get their own
|
||||
* messaging_groups rows. The default instance IS the channel type: every
|
||||
* existing row is backfilled with `instance = channel_type`, so all existing
|
||||
* lookups keep resolving the same rows with zero operator action. NOT NULL
|
||||
* (instead of nullable + partial unique index) keeps every lookup two-state:
|
||||
* "default instance" is just the literal value `channel_type`.
|
||||
*
|
||||
* Uniqueness relaxes from UNIQUE(channel_type, platform_id) to
|
||||
* UNIQUE(channel_type, platform_id, instance). SQLite cannot relax a
|
||||
* table-level UNIQUE in place — this requires the documented 12-step
|
||||
* recreate (new table → copy → DROP → RENAME, sqlite.org/lang_altertable.html).
|
||||
* DROP TABLE fails `FOREIGN KEY constraint failed` on live DBs because five
|
||||
* child tables REFERENCE messaging_groups(id) (messaging_group_agents,
|
||||
* user_dms, sessions, pending_sender_approvals, pending_channel_approvals) —
|
||||
* the exact failure that forced migration 011 to abandon its rebuild (see
|
||||
* its header). Hence `disableForeignKeys: true`: the runner toggles
|
||||
* foreign_keys=OFF around the transaction (the pragma is a no-op inside one)
|
||||
* and runs PRAGMA foreign_key_check inside it so violations roll back.
|
||||
*
|
||||
* Column list mirrors the live tip schema exactly (001 columns + 012's
|
||||
* denied_at) — verified against PRAGMA table_info on a freshly-migrated DB.
|
||||
* A recreate with a stale column list silently drops data.
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
export const migration016: Migration = {
|
||||
version: 16,
|
||||
name: 'messaging-group-instance',
|
||||
disableForeignKeys: true,
|
||||
up: (db: Database.Database) => {
|
||||
// Idempotency guard per the 012 pattern.
|
||||
const cols = db.prepare("PRAGMA table_info('messaging_groups')").all() as Array<{ name: string }>;
|
||||
if (cols.some((c) => c.name === 'instance')) return;
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE messaging_groups_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
created_at TEXT NOT NULL,
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
INSERT INTO messaging_groups_new
|
||||
(id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at, denied_at)
|
||||
SELECT id, channel_type, platform_id, channel_type, name, is_group, unknown_sender_policy, created_at, denied_at
|
||||
FROM messaging_groups;
|
||||
DROP TABLE messaging_groups;
|
||||
ALTER TABLE messaging_groups_new RENAME TO messaging_groups;
|
||||
`);
|
||||
},
|
||||
};
|
||||
+70
-13
@@ -12,6 +12,7 @@ import { migration012 } from './012-channel-registration.js';
|
||||
import { migration013 } from './013-approval-render-metadata.js';
|
||||
import { migration014 } from './014-container-configs.js';
|
||||
import { migration015 } from './015-cli-scope.js';
|
||||
import { migration016 } from './016-messaging-group-instance.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
|
||||
@@ -19,9 +20,18 @@ export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: Database.Database) => void;
|
||||
/**
|
||||
* Run with foreign_keys=OFF. Required for table recreates (SQLite can't
|
||||
* drop a table-level UNIQUE without DROP+RENAME, and DROP fails FK
|
||||
* integrity when child rows exist — see migration 011's header).
|
||||
* PRAGMA foreign_keys is a no-op inside a transaction, so the runner
|
||||
* toggles it around the transaction and runs PRAGMA foreign_key_check
|
||||
* inside it, so violations roll the migration back.
|
||||
*/
|
||||
disableForeignKeys?: boolean;
|
||||
}
|
||||
|
||||
const migrations: Migration[] = [
|
||||
export const migrations: Migration[] = [
|
||||
migration001,
|
||||
migration002,
|
||||
moduleApprovalsPendingApprovals,
|
||||
@@ -35,9 +45,23 @@ const migrations: Migration[] = [
|
||||
migration013,
|
||||
migration014,
|
||||
migration015,
|
||||
migration016,
|
||||
];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
|
||||
* parent-table recreate (child tables aren't touched), so this JSON identity
|
||||
* is a reliable before/after diff key. */
|
||||
interface FkViolation {
|
||||
table: string;
|
||||
rowid: number | null;
|
||||
parent: string;
|
||||
fkid: number;
|
||||
}
|
||||
|
||||
const fkIdentity = (v: FkViolation): string =>
|
||||
JSON.stringify({ table: v.table, rowid: v.rowid, parent: v.parent, fkid: v.fkid });
|
||||
|
||||
export function runMigrations(db: Database.Database, list: Migration[] = migrations): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
@@ -56,22 +80,55 @@ export function runMigrations(db: Database.Database): void {
|
||||
const applied = new Set<string>(
|
||||
(db.prepare('SELECT name FROM schema_version').all() as { name: string }[]).map((r) => r.name),
|
||||
);
|
||||
const pending = migrations.filter((m) => !applied.has(m.name));
|
||||
const pending = list.filter((m) => !applied.has(m.name));
|
||||
if (pending.length === 0) return;
|
||||
|
||||
log.info('Running migrations', { count: pending.length });
|
||||
|
||||
for (const m of pending) {
|
||||
db.transaction(() => {
|
||||
m.up(db);
|
||||
const next = (db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number })
|
||||
.v;
|
||||
db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run(
|
||||
next,
|
||||
m.name,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
})();
|
||||
// Table recreates need FK enforcement off for the DROP+RENAME window.
|
||||
// The pragma must be toggled OUTSIDE the transaction (it's a silent
|
||||
// no-op inside one); foreign_key_check runs INSIDE so a violating
|
||||
// recreate rolls back atomically with nothing committed.
|
||||
if (m.disableForeignKeys) db.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// Snapshot violations BEFORE up() runs: live DBs can carry latent
|
||||
// FK orphans (e.g. parents deleted through a FK-OFF sqlite3 CLI
|
||||
// session — ensureUserDm tolerates exactly this at runtime). The
|
||||
// migration must only fail for violations it INTRODUCED; throwing
|
||||
// on pre-existing ones would crash-loop the host at every boot
|
||||
// (runMigrations runs on startup) until manual DB surgery.
|
||||
const preexisting = m.disableForeignKeys
|
||||
? new Set((db.pragma('foreign_key_check') as FkViolation[]).map(fkIdentity))
|
||||
: null;
|
||||
m.up(db);
|
||||
if (m.disableForeignKeys && preexisting) {
|
||||
const violations = db.pragma('foreign_key_check') as FkViolation[];
|
||||
const introduced = violations.filter((v) => !preexisting.has(fkIdentity(v)));
|
||||
const carried = violations.length - introduced.length;
|
||||
if (carried > 0) {
|
||||
log.warn('Pre-existing FK violations carried through migration (not introduced by it)', {
|
||||
migration: m.name,
|
||||
count: carried,
|
||||
});
|
||||
}
|
||||
if (introduced.length > 0) {
|
||||
throw new Error(`migration ${m.name} left FK violations: ${JSON.stringify(introduced.slice(0, 5))}`);
|
||||
}
|
||||
}
|
||||
const next = (
|
||||
db.prepare('SELECT COALESCE(MAX(version), 0) + 1 AS v FROM schema_version').get() as { v: number }
|
||||
).v;
|
||||
db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run(
|
||||
next,
|
||||
m.name,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
})();
|
||||
} finally {
|
||||
if (m.disableForeignKeys) db.pragma('foreign_keys = ON');
|
||||
}
|
||||
log.info('Migration applied', { name: m.name });
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -22,16 +22,21 @@ CREATE TABLE agent_groups (
|
||||
-- only matters if something inserts without specifying the field, which no
|
||||
-- current callsite does. Router auto-create hardcodes "request_approval"
|
||||
-- (see src/router.ts:151); setup scripts pick per context.
|
||||
-- instance = adapter-instance name; the default instance IS the channel
|
||||
-- type (migration 016 backfill), so single-instance installs never see it.
|
||||
-- Inbound lookups are exact-on-instance; outbound lookups default-first.
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
-- 'strict' | 'request_approval' | 'public'
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
denied_at TEXT,
|
||||
UNIQUE(channel_type, platform_id, instance)
|
||||
);
|
||||
|
||||
-- Which agent groups handle which messaging groups.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Delivery action registry.
|
||||
*
|
||||
* `registerDeliveryAction` is the hook modules use to handle system-kind
|
||||
* outbound messages; `getDeliveryAction` is the read side that makes those
|
||||
* registrations behavior-testable. Goes red if either half of the registry
|
||||
* is removed or the two stop sharing the same map.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('./container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
killContainer: vi.fn(),
|
||||
buildAgentGroupImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { registerDeliveryAction, getDeliveryAction, type DeliveryActionHandler } from './delivery.js';
|
||||
|
||||
describe('delivery action registry', () => {
|
||||
it('getDeliveryAction returns the handler registerDeliveryAction registered', () => {
|
||||
const handler: DeliveryActionHandler = async () => {};
|
||||
registerDeliveryAction('test_registry_action', handler);
|
||||
expect(getDeliveryAction('test_registry_action')).toBe(handler);
|
||||
});
|
||||
|
||||
it('getDeliveryAction returns undefined for unregistered actions', () => {
|
||||
expect(getDeliveryAction('test_never_registered_action')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-registering an action overwrites the previous handler', () => {
|
||||
const first: DeliveryActionHandler = async () => {};
|
||||
const second: DeliveryActionHandler = async () => {};
|
||||
registerDeliveryAction('test_overwrite_action', first);
|
||||
registerDeliveryAction('test_overwrite_action', second);
|
||||
expect(getDeliveryAction('test_overwrite_action')).toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,76 @@ describe('deliverSessionMessages — retry and permanent failure', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverSessionMessages — instance resolution', () => {
|
||||
it('delivers via the origin session instance when sibling rows share (channel_type, platform_id)', async () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Test Agent',
|
||||
folder: 'test-agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
// Two instances own the same chat address. The named row sorts before
|
||||
// 'slack', so a plain by-platform lookup (default-instance-first) would
|
||||
// pick mg-default — only origin-session preference selects mg-tester.
|
||||
createMessagingGroup({
|
||||
id: 'mg-default',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
name: 'Default',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-tester',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
instance: 'alpha-tester',
|
||||
name: 'Tester',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session } = resolveSession('ag-1', 'mg-tester', null, 'shared');
|
||||
const db = new Database(outboundDbPath('ag-1', session.id));
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content)
|
||||
VALUES ('out-inst', datetime('now'), 'chat', 'slack:C1', 'slack', ?)`,
|
||||
).run(JSON.stringify({ text: 'hi' }));
|
||||
db.close();
|
||||
|
||||
const instances: Array<string | undefined> = [];
|
||||
setDeliveryAdapter({
|
||||
async deliver(_ct, _pid, _tid, _kind, _content, _files, instance) {
|
||||
instances.push(instance);
|
||||
return 'plat-1';
|
||||
},
|
||||
});
|
||||
|
||||
await deliverSessionMessages(session);
|
||||
expect(instances).toEqual(['alpha-tester']);
|
||||
});
|
||||
|
||||
it('default session passes the backfilled default instance (= channel_type)', async () => {
|
||||
seedAgentAndChannel();
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
insertOutbound('ag-1', session.id, 'out-default-inst');
|
||||
|
||||
const instances: Array<string | undefined> = [];
|
||||
setDeliveryAdapter({
|
||||
async deliver(_ct, _pid, _tid, _kind, _content, _files, instance) {
|
||||
instances.push(instance);
|
||||
return 'plat-2';
|
||||
},
|
||||
});
|
||||
|
||||
await deliverSessionMessages(session);
|
||||
expect(instances).toEqual(['telegram']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverSessionMessages — permission check', () => {
|
||||
it('rejects delivery to an unauthorized channel destination', async () => {
|
||||
seedAgentAndChannel();
|
||||
|
||||
+23
-3
@@ -12,7 +12,7 @@ import type Database from 'better-sqlite3';
|
||||
import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
import { getMessagingGroup, getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
import {
|
||||
getDueOutboundMessages,
|
||||
getDeliveredIds,
|
||||
@@ -57,8 +57,11 @@ export interface ChannelDeliveryAdapter {
|
||||
kind: string,
|
||||
content: string,
|
||||
files?: OutboundFile[],
|
||||
/** Delivering adapter instance (defaults to channelType downstream).
|
||||
* Host-internal only — containers never see instance. */
|
||||
instance?: string,
|
||||
): Promise<string | undefined>;
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null, instance?: string): Promise<void>;
|
||||
}
|
||||
|
||||
let deliveryAdapter: ChannelDeliveryAdapter | null = null;
|
||||
@@ -286,8 +289,18 @@ async function deliverMessage(
|
||||
// path in deliverSessionMessages and eventually marks the message as failed
|
||||
// (instead of marking it delivered when nothing was actually delivered,
|
||||
// which was the pre-refactor bug).
|
||||
let deliverInstance: string | undefined;
|
||||
if (msg.channel_type && msg.platform_id) {
|
||||
const mg = getMessagingGroupByPlatform(msg.channel_type, msg.platform_id);
|
||||
// Resolve the messaging group ORIGIN-SESSION-FIRST: when the message
|
||||
// targets the session's own chat address, the origin row wins even if
|
||||
// sibling instances share the same (channel_type, platform_id) — so the
|
||||
// reply goes out through the instance the message came in on. Otherwise
|
||||
// fall back to the by-platform lookup (default-instance-first).
|
||||
const originMg = session.messaging_group_id ? getMessagingGroup(session.messaging_group_id) : undefined;
|
||||
const mg =
|
||||
originMg && originMg.channel_type === msg.channel_type && originMg.platform_id === msg.platform_id
|
||||
? originMg
|
||||
: getMessagingGroupByPlatform(msg.channel_type, msg.platform_id);
|
||||
if (!mg) {
|
||||
throw new Error(`unknown messaging group for ${msg.channel_type}/${msg.platform_id} (message ${msg.id})`);
|
||||
}
|
||||
@@ -308,6 +321,7 @@ async function deliverMessage(
|
||||
);
|
||||
}
|
||||
}
|
||||
deliverInstance = mg.instance;
|
||||
}
|
||||
|
||||
// Track pending questions for ask_user_question flow.
|
||||
@@ -360,6 +374,7 @@ async function deliverMessage(
|
||||
msg.kind,
|
||||
msg.content,
|
||||
files,
|
||||
deliverInstance,
|
||||
);
|
||||
log.info('Message delivered', {
|
||||
id: msg.id,
|
||||
@@ -402,6 +417,11 @@ export function registerDeliveryAction(action: string, handler: DeliveryActionHa
|
||||
actionHandlers.set(action, handler);
|
||||
}
|
||||
|
||||
/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */
|
||||
export function getDeliveryAction(action: string): DeliveryActionHandler | undefined {
|
||||
return actionHandlers.get(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle system actions from the container agent.
|
||||
* These are written to messages_out because the container can't write to inbound.db.
|
||||
|
||||
@@ -597,6 +597,189 @@ describe('router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('router — channel instances', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Default Bot',
|
||||
folder: 'default-bot',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createAgentGroup({
|
||||
id: 'ag-2',
|
||||
name: 'Tester Bot',
|
||||
folder: 'tester-bot',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
// Two messaging groups on the SAME (channel_type, platform_id), owned
|
||||
// by different adapter instances and wired to different agents.
|
||||
createMessagingGroup({
|
||||
id: 'mg-default',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
name: 'Default chat',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-tester',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C1',
|
||||
instance: 'slack-tester',
|
||||
name: 'Tester chat',
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
for (const [mgaId, mgId, agId] of [
|
||||
['mga-default', 'mg-default', 'ag-1'],
|
||||
['mga-tester', 'mg-tester', 'ag-2'],
|
||||
] as const) {
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: mgId,
|
||||
agent_group_id: agId,
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('routes by receiving instance: named instance lands in its own mg/agent, default in the default', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { registerChannelAdapter, initChannelAdapters, teardownChannelAdapters } =
|
||||
await import('./channels/channel-registry.js');
|
||||
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
|
||||
|
||||
// Default 'slack' adapter is THREADED; the named instance is NOT.
|
||||
// The same arm therefore also pins the thread-policy lookup at the
|
||||
// receiving instance: if the router resolved the adapter by
|
||||
// channelType, the tester event's threadId would survive.
|
||||
const makeAdapter = (instance: string | undefined, supportsThreads: boolean) => ({
|
||||
name: instance ?? 'slack',
|
||||
channelType: 'slack',
|
||||
instance,
|
||||
supportsThreads,
|
||||
async setup() {},
|
||||
async teardown() {},
|
||||
isConnected: () => true,
|
||||
async deliver() {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
registerChannelAdapter('slack', { factory: () => makeAdapter(undefined, true) });
|
||||
registerChannelAdapter('slack-tester', { factory: () => makeAdapter('slack-tester', false) });
|
||||
await initChannelAdapters(() => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
}));
|
||||
|
||||
try {
|
||||
// Inbound on the named instance, with a threadId the non-threaded
|
||||
// adapter must collapse.
|
||||
await routeInbound({
|
||||
channelType: 'slack',
|
||||
instance: 'slack-tester',
|
||||
platformId: 'slack:C1',
|
||||
threadId: 'thread-9',
|
||||
message: {
|
||||
id: 'msg-tester',
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: 'to tester' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const testerSessions = getSessionsByAgentGroup('ag-2');
|
||||
expect(testerSessions).toHaveLength(1);
|
||||
expect(testerSessions[0].messaging_group_id).toBe('mg-tester');
|
||||
expect(getSessionsByAgentGroup('ag-1')).toHaveLength(0);
|
||||
|
||||
const tDb = new Database(inboundDbPath('ag-2', testerSessions[0].id));
|
||||
const tRow = tDb.prepare('SELECT thread_id, content FROM messages_in').get() as {
|
||||
thread_id: string | null;
|
||||
content: string;
|
||||
};
|
||||
tDb.close();
|
||||
expect(JSON.parse(tRow.content).text).toBe('to tester');
|
||||
// Collapsed by the named instance's thread policy.
|
||||
expect(tRow.thread_id).toBeNull();
|
||||
|
||||
// Same address, no instance ⇒ default instance ⇒ default mg/agent,
|
||||
// and the default adapter is threaded so the threadId survives.
|
||||
await routeInbound({
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: 'thread-9',
|
||||
message: {
|
||||
id: 'msg-default',
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: 'to default' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const defaultSessions = getSessionsByAgentGroup('ag-1');
|
||||
expect(defaultSessions).toHaveLength(1);
|
||||
expect(defaultSessions[0].messaging_group_id).toBe('mg-default');
|
||||
const dDb = new Database(inboundDbPath('ag-1', defaultSessions[0].id));
|
||||
const dRow = dDb.prepare('SELECT thread_id FROM messages_in').get() as { thread_id: string | null };
|
||||
dDb.close();
|
||||
expect(dRow.thread_id).toBe('thread-9');
|
||||
} finally {
|
||||
await teardownChannelAdapters();
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-create persists the receiving instance instead of hijacking the default row', async () => {
|
||||
const { routeInbound } = await import('./router.js');
|
||||
const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js');
|
||||
|
||||
// No row exists for this address on ANY instance yet; create an
|
||||
// unwired default row to prove the named event doesn't reuse it.
|
||||
createMessagingGroup({
|
||||
id: 'mg-plain',
|
||||
channel_type: 'slack',
|
||||
platform_id: 'slack:C-NEW',
|
||||
name: null,
|
||||
is_group: 1,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
await routeInbound({
|
||||
channelType: 'slack',
|
||||
instance: 'slack-tester',
|
||||
platformId: 'slack:C-NEW',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: 'msg-mention',
|
||||
kind: 'chat',
|
||||
content: JSON.stringify({ sender: 'U', text: '@tester hi' }),
|
||||
timestamp: now(),
|
||||
isMention: true,
|
||||
},
|
||||
});
|
||||
|
||||
const created = getMessagingGroupByPlatform('slack', 'slack:C-NEW', 'slack-tester');
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.instance).toBe('slack-tester');
|
||||
expect(created!.id).not.toBe('mg-plain');
|
||||
// The default row is untouched.
|
||||
expect(getMessagingGroupByPlatform('slack', 'slack:C-NEW', 'slack')!.id).toBe('mg-plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing metadata preservation', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Regression test for the wake-tick SLA race in the host sweep.
|
||||
*
|
||||
* Drives the real sweep loop (startHostSweep) against a real central DB and
|
||||
* real on-disk session DBs, mocking only the container runner. Scenario: a
|
||||
* session has a due inbound message AND a stale processing_ack claim left
|
||||
* over from a crashed container. The sweep tick that wakes a fresh container
|
||||
* must NOT kill it in the same tick — the freshly-woken container hasn't had
|
||||
* a chance to clear the stale claim yet (clearStaleProcessingAcks runs on
|
||||
* agent-runner startup). A later tick where the claim is still stale must
|
||||
* kill (claim-stuck). Goes red if the justWoke grace gate
|
||||
* (`if (alive && outDb && !justWoke)`) is removed.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Override DATA_DIR for tests
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-host-sweep-grace' };
|
||||
});
|
||||
|
||||
// Mock container runner to prevent actual Docker spawning
|
||||
vi.mock('./container-runner.js', () => ({
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
wakeContainer: vi.fn().mockResolvedValue(true),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
||||
import { createSession } from './db/sessions.js';
|
||||
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
|
||||
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
||||
import { initSessionFolder, openOutboundDbRw, writeSessionMessage } from './session-manager.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-host-sweep-grace';
|
||||
const AG = 'ag-test';
|
||||
const SESS = 'sess-test';
|
||||
// Mirrors SWEEP_INTERVAL_MS in host-sweep.ts — identifies the sweep's
|
||||
// self-reschedule among other setTimeout calls (e.g. vi.waitFor's polling).
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function seedStaleClaim(messageId: string, ageMs: number): void {
|
||||
const db = openOutboundDbRw(AG, SESS);
|
||||
try {
|
||||
db.prepare('INSERT INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, ?)').run(
|
||||
messageId,
|
||||
'processing',
|
||||
new Date(Date.now() - ageMs).toISOString(),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sweep loop signals tick completion by rescheduling itself via
|
||||
* setTimeout(sweep, SWEEP_INTERVAL_MS). Capture those callbacks instead of
|
||||
* scheduling them, so each tick ends inert and the test drives the next tick
|
||||
* explicitly. All other setTimeout calls pass through untouched.
|
||||
*/
|
||||
const sweepCallbacks: Array<() => void> = [];
|
||||
const realSetTimeout = global.setTimeout;
|
||||
let setTimeoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** Run exactly one sweep tick and wait for it to complete. */
|
||||
async function runSweepTick(): Promise<void> {
|
||||
const before = sweepCallbacks.length;
|
||||
if (before === 0) {
|
||||
startHostSweep();
|
||||
} else {
|
||||
// Invoke the captured self-reschedule — the real next-tick path.
|
||||
sweepCallbacks[before - 1]();
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(sweepCallbacks.length).toBe(before + 1);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isContainerRunning).mockReset().mockReturnValue(false);
|
||||
vi.mocked(killContainer).mockReset();
|
||||
vi.mocked(wakeContainer)
|
||||
.mockReset()
|
||||
// Simulate a successful spawn: after wake, the container reports running.
|
||||
.mockImplementation(async () => {
|
||||
vi.mocked(isContainerRunning).mockReturnValue(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
sweepCallbacks.length = 0;
|
||||
setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => {
|
||||
if (ms === SWEEP_INTERVAL_MS) {
|
||||
sweepCallbacks.push(fn);
|
||||
return 0 as unknown as NodeJS.Timeout;
|
||||
}
|
||||
return realSetTimeout(fn, ms);
|
||||
}) as typeof setTimeout);
|
||||
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createAgentGroup({ id: AG, name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: SESS,
|
||||
agent_group_id: AG,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder(AG, SESS);
|
||||
|
||||
// A due message (wakes the container) + a stale claim from a previous crash
|
||||
// (would trip claim-stuck if the SLA check ran on the wake tick).
|
||||
writeSessionMessage(AG, SESS, { id: 'm-1', kind: 'chat', timestamp: now(), content: '{"text":"hi"}' });
|
||||
seedStaleClaim('m-1', 2 * 60 * 60 * 1000); // claimed 2h ago
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopHostSweep();
|
||||
setTimeoutSpy.mockRestore();
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('host sweep justWoke grace period', () => {
|
||||
it('does not kill the container on the tick that woke it, kills on a later tick if the claim is still stale', async () => {
|
||||
// Tick 1: due message + no running container → wake. The stale claim is
|
||||
// still in outbound.db, but the grace period must skip the SLA check.
|
||||
await runSweepTick();
|
||||
expect(wakeContainer).toHaveBeenCalledTimes(1);
|
||||
expect(killContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Tick 2: container is running (no fresh wake → no grace), the claim is
|
||||
// still stale because our simulated container never cleared it → kill.
|
||||
await runSweepTick();
|
||||
expect(wakeContainer).toHaveBeenCalledTimes(1); // no second wake
|
||||
expect(killContainer).toHaveBeenCalledTimes(1);
|
||||
expect(killContainer).toHaveBeenCalledWith(SESS, 'claim-stuck');
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -189,17 +189,23 @@ async function sweepSession(session: Session): Promise<void> {
|
||||
// would keep bumping process_after into the future, dueCount would stay 0,
|
||||
// and the wake would never fire.
|
||||
const dueCount = countDueMessages(inDb);
|
||||
let justWoke = false;
|
||||
if (dueCount > 0 && !isContainerRunning(session.id)) {
|
||||
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
|
||||
// wakeContainer never throws — transient spawn failures (OneCLI down,
|
||||
// etc.) return false and leave messages pending for the next tick.
|
||||
await wakeContainer(session);
|
||||
justWoke = true;
|
||||
}
|
||||
|
||||
const alive = isContainerRunning(session.id);
|
||||
|
||||
// 3. Running-container SLA: absolute ceiling + per-claim stuck rules.
|
||||
if (alive && outDb) {
|
||||
// Skip on the same iteration that just woke the container — it hasn't
|
||||
// had a chance to clear stale processing_ack rows from a previous crash
|
||||
// yet. Without this grace period, stale claims cause an immediate
|
||||
// spawn-kill loop.
|
||||
if (alive && outDb && !justWoke) {
|
||||
enforceRunningContainerSla(inDb, outDb, session, agentGroup.id);
|
||||
}
|
||||
|
||||
|
||||
+13
-24
@@ -62,7 +62,11 @@ import './cli/delivery-action.js';
|
||||
import { startCliServer, stopCliServer } from './cli/socket-server.js';
|
||||
|
||||
import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js';
|
||||
import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js';
|
||||
import {
|
||||
initChannelAdapters,
|
||||
teardownChannelAdapters,
|
||||
createChannelDeliveryAdapter,
|
||||
} from './channels/channel-registry.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
log.info('NanoClaw starting');
|
||||
@@ -97,6 +101,9 @@ async function main(): Promise<void> {
|
||||
onInbound(platformId, threadId, message) {
|
||||
routeInbound({
|
||||
channelType: adapter.channelType,
|
||||
// The one host-side stamping seam: adapters stay instance-blind,
|
||||
// the host stamps the receiving instance on every inbound event.
|
||||
instance: adapter.instance ?? adapter.channelType,
|
||||
platformId,
|
||||
threadId,
|
||||
message: {
|
||||
@@ -146,29 +153,11 @@ async function main(): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Delivery adapter bridge — dispatches to channel adapters
|
||||
const deliveryAdapter = {
|
||||
async deliver(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
kind: string,
|
||||
content: string,
|
||||
files?: import('./channels/adapter.js').OutboundFile[],
|
||||
): Promise<string | undefined> {
|
||||
const adapter = getChannelAdapter(channelType);
|
||||
if (!adapter) {
|
||||
log.warn('No adapter for channel type', { channelType });
|
||||
return;
|
||||
}
|
||||
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
|
||||
},
|
||||
async setTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
|
||||
const adapter = getChannelAdapter(channelType);
|
||||
await adapter?.setTyping?.(platformId, threadId);
|
||||
},
|
||||
};
|
||||
setDeliveryAdapter(deliveryAdapter);
|
||||
// 4. Delivery adapter bridge — dispatches to channel adapters by EXACT
|
||||
// registry key (instance ?? channelType): a named instance with an
|
||||
// offline adapter is never rerouted through a sibling bot. See
|
||||
// createChannelDeliveryAdapter in channels/channel-registry.ts.
|
||||
setDeliveryAdapter(createChannelDeliveryAdapter());
|
||||
|
||||
// 5. Start delivery polls
|
||||
startActiveDeliveryPoll();
|
||||
|
||||
@@ -443,4 +443,28 @@ describe('routeAgentMessage return-path', () => {
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes');
|
||||
});
|
||||
|
||||
it('file forwarding: skips symlinked source files', async () => {
|
||||
const secretPath = path.join(TEST_DIR, 'host-secret.txt');
|
||||
fs.writeFileSync(secretPath, 'host-secret-bytes');
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-symlink');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.symlinkSync(secretPath, path.join(outboxDir, 'safe-name.txt'));
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-with-symlink',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['safe-name.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface ForwardedAttachment {
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
@@ -57,6 +62,11 @@ export function forwardAttachedFiles(
|
||||
): ForwardedAttachment[] {
|
||||
if (source.filenames.length === 0) return [];
|
||||
|
||||
if (!isSafeAttachmentName(source.messageId)) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox message id', { sourceMsgId: source.messageId });
|
||||
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', {
|
||||
@@ -66,6 +76,26 @@ export function forwardAttachedFiles(
|
||||
return [];
|
||||
}
|
||||
|
||||
let realSourceDir: string;
|
||||
try {
|
||||
const sourceDirStat = fs.lstatSync(sourceDir);
|
||||
if (!sourceDirStat.isDirectory() || sourceDirStat.isSymbolicLink()) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
realSourceDir = fs.realpathSync(sourceDir);
|
||||
} catch (err) {
|
||||
log.warn('agent-route: failed to inspect source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
sourceDir,
|
||||
err,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
||||
|
||||
@@ -79,15 +109,33 @@ export function forwardAttachedFiles(
|
||||
continue;
|
||||
}
|
||||
const src = path.join(sourceDir, filename);
|
||||
if (!fs.existsSync(src)) {
|
||||
let realSrc: string;
|
||||
try {
|
||||
const srcStat = fs.lstatSync(src);
|
||||
if (!srcStat.isFile() || srcStat.isSymbolicLink()) {
|
||||
log.warn('agent-route: rejecting unsafe source outbox file', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
realSrc = fs.realpathSync(src);
|
||||
} catch {
|
||||
log.warn('agent-route: referenced file missing in source outbox, skipped', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!isPathInside(realSourceDir, realSrc)) {
|
||||
log.warn('agent-route: rejecting source file outside source outbox dir', {
|
||||
sourceMsgId: source.messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dst = path.join(targetInboxDir, filename);
|
||||
fs.copyFileSync(src, dst);
|
||||
fs.copyFileSync(realSrc, dst);
|
||||
attachments.push({
|
||||
name: filename,
|
||||
filename,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for create_agent host-side authorization.
|
||||
*
|
||||
* Regression guard for the audit finding: `create_agent` is a privileged
|
||||
* central-DB write with no host-side authz. The fix authorizes by CLI scope —
|
||||
* trusted owner agent groups ('global') create directly; confined groups
|
||||
* ('group', the default and the prompt-injection victim) must get admin
|
||||
* approval. These tests pin that branch decision.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
// Mocks for the collaborators the branch decides between / depends on.
|
||||
const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
const mockCreateAgentGroup = vi.fn();
|
||||
const mockInitGroupFilesystem = vi.fn();
|
||||
const mockWriteDestinations = vi.fn();
|
||||
const mockNotifyWrite = vi.fn();
|
||||
|
||||
vi.mock('../approvals/index.js', () => ({
|
||||
requestApproval: (...a: unknown[]) => mockRequestApproval(...a),
|
||||
}));
|
||||
vi.mock('../../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
|
||||
}));
|
||||
vi.mock('../../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
|
||||
getAgentGroupByFolder: () => undefined,
|
||||
createAgentGroup: (...a: unknown[]) => mockCreateAgentGroup(...a),
|
||||
}));
|
||||
vi.mock('../../group-init.js', () => ({
|
||||
initGroupFilesystem: (...a: unknown[]) => mockInitGroupFilesystem(...a),
|
||||
}));
|
||||
vi.mock('./write-destinations.js', () => ({
|
||||
writeDestinations: (...a: unknown[]) => mockWriteDestinations(...a),
|
||||
}));
|
||||
vi.mock('./db/agent-destinations.js', () => ({
|
||||
getDestinationByName: () => undefined,
|
||||
createDestination: vi.fn(),
|
||||
normalizeName: (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
}));
|
||||
// notifyAgent writes to the session inbound.db + wakes the container; stub both.
|
||||
vi.mock('../../session-manager.js', () => ({
|
||||
writeSessionMessage: (...a: unknown[]) => mockNotifyWrite(...a),
|
||||
}));
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../../db/sessions.js', () => ({
|
||||
getSession: (id: string) => ({ id, agent_group_id: 'ag-1' }),
|
||||
}));
|
||||
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
|
||||
const SESSION = { id: 'sess-1', agent_group_id: 'ag-1' } as Session;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('global scope: creates directly, no approval requested', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
|
||||
expect(mockInitGroupFilesystem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('group scope (default): requires approval, does NOT create directly', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequestApproval.mock.calls[0][0]).toMatchObject({ action: 'create_agent' });
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
expect(mockInitGroupFilesystem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('missing config: fails closed to approval (no direct create)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue(undefined);
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disabled/other scope: requires approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty name: neither creates nor requests approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: '' }, SESSION);
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* `create_agent` delivery-action handler.
|
||||
*
|
||||
* Spawns a new agent group on demand from the parent agent, wires bidirectional
|
||||
* agent_destinations rows, projects the new destination into the parent's
|
||||
* running container, and notifies the parent.
|
||||
* SECURITY: `create_agent` writes to the CENTRAL DB (agent_groups,
|
||||
* container_configs, agent_destinations) and scaffolds host filesystem state —
|
||||
* a privileged operation a confined container is otherwise architecturally
|
||||
* barred from. The container's MCP tool gate is inside the (untrusted)
|
||||
* container and is trivially bypassed by writing the outbound system row
|
||||
* directly, so authorization MUST be enforced host-side. Trusted owner agent
|
||||
* groups (CLI scope 'global') create directly; every other (confined) group
|
||||
* requires admin approval via `requestApproval` — matching `ncl groups create`
|
||||
* (access: 'approval') and the self-mod actions. `applyCreateAgent` runs the
|
||||
* creation on approve; `performCreateAgent` is the shared body.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { AgentGroup, Session } from '../../types.js';
|
||||
import { requestApproval, type ApprovalHandler } from '../approvals/index.js';
|
||||
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
|
||||
import { writeDestinations } from './write-destinations.js';
|
||||
|
||||
@@ -34,23 +43,95 @@ function notifyAgent(session: Session, text: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery-action entry.
|
||||
*
|
||||
* Authorization depends on the calling group's CLI scope:
|
||||
* - `global` (set by init-first-agent for trusted owner agent groups):
|
||||
* create immediately. create_agent is the intended primitive for these
|
||||
* privileged agents, and an approval tap on every sub-agent spawn would be
|
||||
* needless friction.
|
||||
* - anything else (the default `group` scope — the realistic
|
||||
* prompt-injection victim): require an admin to approve before any
|
||||
* central-DB write. `applyCreateAgent` runs on approve.
|
||||
* Unknown/missing config fails closed to the approval path.
|
||||
*/
|
||||
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const requestId = content.requestId as string;
|
||||
const name = content.name as string;
|
||||
const instructions = content.instructions as string | null;
|
||||
const name = typeof content.name === 'string' ? content.name : '';
|
||||
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notifyAgent(session, 'create_agent failed: name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notifyAgent(session, `create_agent failed: source agent group not found.`);
|
||||
notifyAgent(session, 'create_agent failed: source agent group not found.');
|
||||
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
|
||||
const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group';
|
||||
if (cliScope === 'global') {
|
||||
// Trusted owner agent group — create directly, then notify (+wake) it.
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
|
||||
return;
|
||||
}
|
||||
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: sourceGroup.name,
|
||||
action: 'create_agent',
|
||||
payload: { name, instructions },
|
||||
title: `Create agent: ${name}`,
|
||||
question: `Agent "${sourceGroup.name}" wants to create a new sub-agent "${name}" (a new agent group with its own workspace and container). Approve?`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval handler: performs the creation once an admin approves a request from
|
||||
* a confined (non-global) agent group. `session` is the requesting parent.
|
||||
*/
|
||||
export const applyCreateAgent: ApprovalHandler = async ({ session, payload, notify }) => {
|
||||
const name = typeof payload.name === 'string' ? payload.name : '';
|
||||
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notify('create_agent approved but the request had no name.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notify('create_agent approved but the source agent group no longer exists.');
|
||||
log.warn('create_agent apply failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, notify);
|
||||
};
|
||||
|
||||
/**
|
||||
* Core creation: writes the new agent group + bidirectional destinations and
|
||||
* scaffolds its filesystem, then reports via `notify`. Authorization is the
|
||||
* CALLER's responsibility (the global-scope shortcut in handleCreateAgent or
|
||||
* admin approval via applyCreateAgent) — never call this from an unauthorized
|
||||
* path, as it performs privileged central-DB writes a confined container is
|
||||
* otherwise barred from.
|
||||
*/
|
||||
async function performCreateAgent(
|
||||
name: string,
|
||||
instructions: string | null,
|
||||
session: Session,
|
||||
sourceGroup: AgentGroup,
|
||||
notify: (text: string) => void,
|
||||
): Promise<void> {
|
||||
const localName = normalizeName(name);
|
||||
|
||||
// Collision in the creator's destination namespace
|
||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
notify(`Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +147,7 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
|
||||
const resolvedPath = path.resolve(groupPath);
|
||||
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
||||
notify(`Cannot create agent "${name}": invalid folder path.`);
|
||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||
return;
|
||||
}
|
||||
@@ -115,12 +196,6 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
|
||||
// tries to send to the newly-created child.
|
||||
writeDestinations(session.agent_group_id, session.id);
|
||||
|
||||
// Fire-and-forget notification back to the creator
|
||||
notifyAgent(
|
||||
session,
|
||||
`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`,
|
||||
);
|
||||
notify(`Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`);
|
||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
||||
void requestId;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Agent-to-agent module — inter-agent messaging and on-demand agent creation.
|
||||
*
|
||||
* Registers one delivery action (`create_agent`). The sibling `channel_type === 'agent'`
|
||||
* routing path is NOT a system action — core `delivery.ts` dispatches into
|
||||
* `./agent-route.js` via a dynamic import when it sees `msg.channel_type === 'agent'`.
|
||||
* Registers one delivery action (`create_agent`) plus its matching approval
|
||||
* handler — `create_agent` writes central-DB state, so confined (non-global)
|
||||
* groups require admin approval (the delivery action queues the request;
|
||||
* `applyCreateAgent` runs on approve); trusted global-scope groups create
|
||||
* directly. The sibling `channel_type === 'agent'` routing path is NOT a system
|
||||
* action — core `delivery.ts` dispatches into `./agent-route.js` via a dynamic
|
||||
* import when it sees `msg.channel_type === 'agent'`.
|
||||
*
|
||||
* Host integration points:
|
||||
* - `src/container-runner.ts::spawnContainer` dynamically imports
|
||||
@@ -17,6 +21,8 @@
|
||||
* throw because the module isn't installed.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
|
||||
|
||||
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||
registerApprovalHandler('create_agent', applyCreateAgent);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Approval-resolved callback registry.
|
||||
*
|
||||
* Drives the real response-handler entry (`handleApprovalsResponse`) and
|
||||
* asserts that callbacks registered via `registerApprovalResolvedHandler`
|
||||
* fire when an admin resolves a pending approval — the hook modules use to
|
||||
* observe approval resolution (e.g. clearing an "awaiting approval" status
|
||||
* indicator). Goes red if the response handler stops calling
|
||||
* `notifyApprovalResolved`.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createSession, createPendingApproval } from '../../db/sessions.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
import { initSessionFolder } from '../../session-manager.js';
|
||||
import { handleApprovalsResponse } from './response-handler.js';
|
||||
import { registerApprovalHandler, registerApprovalResolvedHandler, type ApprovalResolvedEvent } from './primitive.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approval-resolved' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-approval-resolved';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function seedApproval(approvalId: string, action: string): void {
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: 'sess-1',
|
||||
request_id: approvalId,
|
||||
action,
|
||||
payload: JSON.stringify({}),
|
||||
created_at: now(),
|
||||
title: 'Test approval',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder('ag-1', 'sess-1');
|
||||
|
||||
// Resolution only happens for authorized clicks — seed the clicking admin.
|
||||
upsertUser({ id: 'slack:admin-1', kind: 'slack', display_name: 'Admin', created_at: now() });
|
||||
grantRole({ user_id: 'slack:admin-1', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('approval-resolved callbacks', () => {
|
||||
it('fires registered callbacks on reject with the approval, session, and outcome', async () => {
|
||||
const events: ApprovalResolvedEvent[] = [];
|
||||
registerApprovalResolvedHandler((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
seedApproval('appr-reject-1', 'test_reject_action');
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-reject-1',
|
||||
value: 'reject',
|
||||
userId: 'slack:admin-1',
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].outcome).toBe('reject');
|
||||
expect(events[0].approval.approval_id).toBe('appr-reject-1');
|
||||
expect(events[0].approval.action).toBe('test_reject_action');
|
||||
expect(events[0].session.id).toBe('sess-1');
|
||||
expect(events[0].userId).toBe('slack:admin-1');
|
||||
});
|
||||
|
||||
it('fires registered callbacks on approve after the action handler ran', async () => {
|
||||
const calls: string[] = [];
|
||||
registerApprovalHandler('test_approve_action', async () => {
|
||||
calls.push('handler');
|
||||
});
|
||||
registerApprovalResolvedHandler(({ outcome }) => {
|
||||
calls.push(`resolved:${outcome}`);
|
||||
});
|
||||
|
||||
seedApproval('appr-approve-1', 'test_approve_action');
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-approve-1',
|
||||
value: 'approve',
|
||||
userId: 'slack:admin-1',
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['handler', 'resolved:approve']);
|
||||
});
|
||||
|
||||
it('isolates a throwing callback so later callbacks still fire', async () => {
|
||||
const events: string[] = [];
|
||||
registerApprovalResolvedHandler(() => {
|
||||
events.push('boom');
|
||||
throw new Error('callback exploded');
|
||||
});
|
||||
registerApprovalResolvedHandler(() => {
|
||||
events.push('after');
|
||||
});
|
||||
|
||||
seedApproval('appr-reject-2', 'test_isolation_action');
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-reject-2',
|
||||
value: 'reject',
|
||||
userId: 'slack:admin-1',
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(events).toEqual(['boom', 'after']);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { MessagingGroup, Session } from '../../types.js';
|
||||
import type { MessagingGroup, PendingApproval, Session } from '../../types.js';
|
||||
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
|
||||
@@ -67,6 +67,50 @@ export function getApprovalHandler(action: string): ApprovalHandler | undefined
|
||||
return approvalHandlers.get(action);
|
||||
}
|
||||
|
||||
// ── Approval-resolved callbacks ──
|
||||
// Modules that want to observe approval resolution (any action, approve or
|
||||
// reject) register here at import time. The response handler fires every
|
||||
// registered callback after the admin's decision is applied — e.g. a module
|
||||
// clearing an "awaiting approval" status indicator it set when the card went
|
||||
// out. Callback errors are logged and isolated; they never block resolution.
|
||||
//
|
||||
// Only authorized clicks resolve an approval (the response handler's
|
||||
// isAuthorizedApprovalClick gate runs first), so callbacks never fire for
|
||||
// unauthorized responses.
|
||||
|
||||
export interface ApprovalResolvedEvent {
|
||||
approval: PendingApproval;
|
||||
session: Session;
|
||||
outcome: 'approve' | 'reject';
|
||||
/** Namespaced user ID (`<channel>:<handle>`) of the resolving admin. Empty string if unknown. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type ApprovalResolvedHandler = (event: ApprovalResolvedEvent) => Promise<void> | void;
|
||||
|
||||
const approvalResolvedHandlers: ApprovalResolvedHandler[] = [];
|
||||
|
||||
export function registerApprovalResolvedHandler(handler: ApprovalResolvedHandler): void {
|
||||
approvalResolvedHandlers.push(handler);
|
||||
}
|
||||
|
||||
/** Fire every registered approval-resolved callback. Called by the response handler. */
|
||||
export async function notifyApprovalResolved(event: ApprovalResolvedEvent): Promise<void> {
|
||||
for (const handler of approvalResolvedHandlers) {
|
||||
try {
|
||||
await handler(event);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- isolation is the contract: one bad callback must not block resolution or other callbacks
|
||||
} catch (err) {
|
||||
log.error('Approval-resolved handler threw', {
|
||||
approvalId: event.approval.approval_id,
|
||||
action: event.approval.action,
|
||||
outcome: event.outcome,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approver picking ──
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Regression coverage for approval response authorization.
|
||||
*
|
||||
* Approval cards may be delivered to an admin DM, but the callback payload is
|
||||
* still untrusted input. The response handler must not dispatch sensitive
|
||||
* approval handlers merely because a response carries a valid questionId.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createSession, createPendingApproval, getPendingApproval } from '../../db/sessions.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approval-response-authz' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-approval-response-authz';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('approval response authorization', () => {
|
||||
it('ignores a valid approval id clicked by a non-admin user', async () => {
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('install_packages', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-1',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-1',
|
||||
action: 'install_packages',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-1',
|
||||
value: 'approve',
|
||||
userId: 'stranger',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-stranger',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(getPendingApproval('appr-1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('allows an owner/admin click to dispatch the registered approval handler', async () => {
|
||||
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
|
||||
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('install_packages_allowed', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-2',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-2',
|
||||
action: 'install_packages_allowed',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-2',
|
||||
value: 'approve',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ userId: 'telegram:owner' }));
|
||||
expect(getPendingApproval('appr-2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows global admins to resolve approvals without a session-scoped agent group', async () => {
|
||||
upsertUser({ id: 'telegram:global-admin', kind: 'telegram', display_name: 'Global Admin', created_at: now() });
|
||||
grantRole({
|
||||
user_id: 'telegram:global-admin',
|
||||
role: 'admin',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: now(),
|
||||
});
|
||||
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('global_admin_allowed', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-3',
|
||||
session_id: 'sess-1',
|
||||
agent_group_id: null,
|
||||
request_id: 'appr-3',
|
||||
action: 'global_admin_allowed',
|
||||
payload: JSON.stringify({ packages: ['left-pad'] }),
|
||||
created_at: now(),
|
||||
title: 'Install packages',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const claimed = await handleApprovalsResponse({
|
||||
questionId: 'appr-3',
|
||||
value: 'approve',
|
||||
userId: 'global-admin',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-global-admin',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(claimed).toBe(true);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(getPendingApproval('appr-3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -18,27 +18,35 @@ import type { ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
|
||||
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { getApprovalHandler } from './primitive.js';
|
||||
import { getApprovalHandler, notifyApprovalResolved } from './primitive.js';
|
||||
|
||||
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
// OneCLI credential approvals — resolved via in-memory Promise first.
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DB-backed pending_approvals.
|
||||
const approval = getPendingApproval(payload.questionId);
|
||||
if (!approval) return false;
|
||||
|
||||
if (!isAuthorizedApprovalClick(approval, payload)) {
|
||||
log.warn('Ignoring unauthorized approval response', {
|
||||
approvalId: approval.approval_id,
|
||||
action: approval.action,
|
||||
userId: payload.userId,
|
||||
channelType: payload.channelType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
// Row exists but the in-memory resolver is gone (timer fired or the process
|
||||
// was in a weird state). Nothing to do — just drop the row.
|
||||
deletePendingApproval(payload.questionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
await handleRegisteredApproval(approval, payload.value, payload.userId ?? '');
|
||||
await handleRegisteredApproval(approval, payload.value, namespacedUserId(payload) ?? '');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -73,6 +81,7 @@ async function handleRegisteredApproval(
|
||||
notify(`Your ${approval.action} request was rejected by admin.`);
|
||||
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
await wakeContainer(session);
|
||||
return;
|
||||
}
|
||||
@@ -86,6 +95,7 @@ async function handleRegisteredApproval(
|
||||
});
|
||||
notify(`Your ${approval.action} was approved, but no handler is installed to apply it.`);
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
|
||||
await wakeContainer(session);
|
||||
return;
|
||||
}
|
||||
@@ -102,5 +112,25 @@ async function handleRegisteredApproval(
|
||||
}
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
function namespacedUserId(payload: ResponsePayload): string | null {
|
||||
if (!payload.userId) return null;
|
||||
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
|
||||
}
|
||||
|
||||
function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
|
||||
const userId = namespacedUserId(payload);
|
||||
if (!userId) return false;
|
||||
|
||||
const agentGroupId =
|
||||
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
|
||||
|
||||
if (!agentGroupId) {
|
||||
return isOwner(userId) || isGlobalAdmin(userId);
|
||||
}
|
||||
|
||||
return hasAdminPrivilege(userId, agentGroupId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Typing-refresh instance forwarding tests.
|
||||
*
|
||||
* Three tick sites can fire setTyping — the immediate tick on a new
|
||||
* refresher, the 4s interval tick, and the immediate re-trigger when
|
||||
* startTypingRefresh is called for an already-refreshing session. All three
|
||||
* must forward the adapter instance, or a named instance's typing indicator
|
||||
* fires through the wrong bot.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-typing' };
|
||||
});
|
||||
|
||||
import { setTypingAdapter, startTypingRefresh, stopTypingRefresh } from './index.js';
|
||||
|
||||
type Call = { channelType: string; platformId: string; threadId: string | null; instance?: string };
|
||||
|
||||
function captureAdapter() {
|
||||
const calls: Call[] = [];
|
||||
setTypingAdapter({
|
||||
async setTyping(channelType, platformId, threadId, instance) {
|
||||
calls.push({ channelType, platformId, threadId, instance });
|
||||
},
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopTypingRefresh('sess-1');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('startTypingRefresh — instance forwarding', () => {
|
||||
it('immediate tick passes the instance to the adapter', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', null, 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({
|
||||
channelType: 'slack',
|
||||
platformId: 'slack:C1',
|
||||
threadId: null,
|
||||
instance: 'slack-tester',
|
||||
});
|
||||
});
|
||||
|
||||
it('interval ticks inside the grace window pass the stored entry instance', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', 'T1', 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
calls.length = 0;
|
||||
|
||||
// Two 4s ticks — well inside the 15s grace window, so they fire
|
||||
// unconditionally (no heartbeat file needed) from the stored entry.
|
||||
await vi.advanceTimersByTimeAsync(8_500);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const c of calls) {
|
||||
expect(c.instance).toBe('slack-tester');
|
||||
expect(c.threadId).toBe('T1');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-trigger on an active session passes (and stores) the new instance', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', null, 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
calls.length = 0;
|
||||
|
||||
// Second call for the same session: immediate tick with the new value.
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', null, 'slack-worker');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].instance).toBe('slack-worker');
|
||||
|
||||
// And the stored entry was updated — subsequent interval ticks carry it.
|
||||
calls.length = 0;
|
||||
await vi.advanceTimersByTimeAsync(4_500);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(calls[calls.length - 1].instance).toBe('slack-worker');
|
||||
});
|
||||
|
||||
it('re-trigger with a changed address updates the whole entry — interval ticks stay self-consistent', async () => {
|
||||
const calls = captureAdapter();
|
||||
startTypingRefresh('sess-1', 'ag-1', 'slack', 'slack:C1', 'T1', 'slack-tester');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
calls.length = 0;
|
||||
|
||||
// Same session re-triggered from a different platform and chat
|
||||
// (agent-shared sessions span messaging groups). The stored entry must
|
||||
// not tear: keeping the old address with the new instance would hand a
|
||||
// telegram platformId to the slack-tester adapter on the next tick.
|
||||
startTypingRefresh('sess-1', 'ag-1', 'telegram', 'tg:99', null, 'telegram');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({
|
||||
channelType: 'telegram',
|
||||
platformId: 'tg:99',
|
||||
threadId: null,
|
||||
instance: 'telegram',
|
||||
});
|
||||
|
||||
// Interval ticks fire from the stored entry — all four fields must
|
||||
// have moved together.
|
||||
calls.length = 0;
|
||||
await vi.advanceTimersByTimeAsync(4_500);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
for (const c of calls) {
|
||||
expect(c).toEqual({
|
||||
channelType: 'telegram',
|
||||
platformId: 'tg:99',
|
||||
threadId: null,
|
||||
instance: 'telegram',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ const HEARTBEAT_FRESH_MS = 6000;
|
||||
const POST_DELIVERY_PAUSE_MS = 10000;
|
||||
|
||||
interface TypingAdapter {
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
|
||||
setTyping?(channelType: string, platformId: string, threadId: string | null, instance?: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface TypingTarget {
|
||||
@@ -53,6 +53,8 @@ interface TypingTarget {
|
||||
channelType: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
/** Adapter instance that owns the chat; undefined = default (= channelType). */
|
||||
instance?: string;
|
||||
interval: NodeJS.Timeout;
|
||||
startedAt: number;
|
||||
pausedUntil: number; // epoch ms; 0 = not paused
|
||||
@@ -72,9 +74,14 @@ export function setTypingAdapter(a: TypingAdapter): void {
|
||||
adapter = a;
|
||||
}
|
||||
|
||||
async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
|
||||
async function triggerTyping(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
instance?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await adapter?.setTyping?.(channelType, platformId, threadId);
|
||||
await adapter?.setTyping?.(channelType, platformId, threadId, instance);
|
||||
} catch {
|
||||
// Typing is best-effort — don't let it fail delivery or routing.
|
||||
}
|
||||
@@ -96,6 +103,7 @@ export function startTypingRefresh(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
threadId: string | null,
|
||||
instance?: string,
|
||||
): void {
|
||||
const existing = typingRefreshers.get(sessionId);
|
||||
if (existing) {
|
||||
@@ -104,14 +112,24 @@ export function startTypingRefresh(
|
||||
// the container-wake latency budget. Also clear any lingering
|
||||
// post-delivery pause: a new inbound means the user expects
|
||||
// typing to show immediately.
|
||||
triggerTyping(channelType, platformId, threadId).catch(() => {});
|
||||
triggerTyping(channelType, platformId, threadId, instance).catch(() => {});
|
||||
existing.startedAt = Date.now();
|
||||
existing.pausedUntil = 0;
|
||||
// Keep the stored entry self-consistent: a re-trigger can arrive from
|
||||
// a different chat address (agent-shared sessions span messaging
|
||||
// groups, possibly on different platforms/instances), so the address
|
||||
// fields and the owning instance must move together — a torn entry
|
||||
// (old address + new instance) would hand e.g. a telegram platformId
|
||||
// to a Slack instance's setTyping on the next interval tick.
|
||||
existing.channelType = channelType;
|
||||
existing.platformId = platformId;
|
||||
existing.threadId = threadId;
|
||||
existing.instance = instance;
|
||||
return;
|
||||
}
|
||||
|
||||
// Immediate tick + periodic refresh.
|
||||
triggerTyping(channelType, platformId, threadId).catch(() => {});
|
||||
triggerTyping(channelType, platformId, threadId, instance).catch(() => {});
|
||||
const startedAt = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const entry = typingRefreshers.get(sessionId);
|
||||
@@ -124,7 +142,7 @@ export function startTypingRefresh(
|
||||
|
||||
const withinGrace = Date.now() - entry.startedAt < TYPING_GRACE_MS;
|
||||
if (withinGrace || isHeartbeatFresh(entry.agentGroupId, sessionId)) {
|
||||
triggerTyping(entry.channelType, entry.platformId, entry.threadId).catch(() => {});
|
||||
triggerTyping(entry.channelType, entry.platformId, entry.threadId, entry.instance).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,6 +157,7 @@ export function startTypingRefresh(
|
||||
channelType,
|
||||
platformId,
|
||||
threadId,
|
||||
instance,
|
||||
interval,
|
||||
startedAt,
|
||||
pausedUntil: 0,
|
||||
|
||||
+24
-5
@@ -161,8 +161,10 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
if (messageInterceptor && (await messageInterceptor(event))) return;
|
||||
|
||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel.
|
||||
const adapter = getChannelAdapter(event.channelType);
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel. Resolved
|
||||
// by the RECEIVING instance — sibling instances of one platform can
|
||||
// differ in thread support.
|
||||
const adapter = getChannelAdapter(event.instance ?? event.channelType);
|
||||
if (adapter && !adapter.supportsThreads) {
|
||||
event = { ...event, threadId: null };
|
||||
}
|
||||
@@ -172,8 +174,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// 1. Combined lookup: messaging_group row + count of wired agents in a
|
||||
// single query. Cheap short-circuit for the common "unwired channel"
|
||||
// case — one DB read and we're out, no auto-create, no sender
|
||||
// resolution, no log spam.
|
||||
const found = getMessagingGroupWithAgentCount(event.channelType, event.platformId);
|
||||
// resolution, no log spam. Exact-on-instance: an unknown named
|
||||
// instance falls through to auto-create rather than hijacking a
|
||||
// sibling instance's row.
|
||||
const found = getMessagingGroupWithAgentCount(
|
||||
event.channelType,
|
||||
event.platformId,
|
||||
event.instance ?? event.channelType,
|
||||
);
|
||||
|
||||
let mg: MessagingGroup;
|
||||
let agentCount: number;
|
||||
@@ -187,6 +195,9 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
id: mgId,
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
// Persist the receiving instance — without this, the first bot's row
|
||||
// would absorb every sibling instance's traffic.
|
||||
instance: event.instance ?? event.channelType,
|
||||
name: null,
|
||||
is_group: event.message.isGroup ? 1 : 0,
|
||||
unknown_sender_policy: 'request_approval',
|
||||
@@ -472,7 +483,15 @@ async function deliverToAgent(
|
||||
if (wake) {
|
||||
// Typing indicator + wake are only for the engaged branch; accumulated
|
||||
// messages sit silently until a real trigger fires.
|
||||
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
|
||||
// Typing fires via the adapter instance that owns this chat's row.
|
||||
startTypingRefresh(
|
||||
session.id,
|
||||
session.agent_group_id,
|
||||
event.channelType,
|
||||
event.platformId,
|
||||
event.threadId,
|
||||
mg.instance,
|
||||
);
|
||||
const freshSession = getSession(session.id);
|
||||
if (freshSession) {
|
||||
const woke = await wakeContainer(freshSession);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for session-manager's direct outbound write path.
|
||||
*
|
||||
* Drives the real `writeOutboundDirect` entry against a real session folder
|
||||
* on disk. A previous implementation opened the outbound DB through
|
||||
* `openOutboundDb` (readonly: true), so every INSERT threw SQLITE_READONLY
|
||||
* and the command-gate denial path silently never delivered. Goes red if the
|
||||
* open call reverts to the readonly form.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-write-outbound' };
|
||||
});
|
||||
|
||||
import { initSessionFolder, outboundDbPath, writeOutboundDirect } from './session-manager.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-write-outbound';
|
||||
const AG = 'ag-test';
|
||||
const SESS = 'sess-test';
|
||||
|
||||
function readMessagesOut(): Array<{ id: string; seq: number; kind: string; content: string }> {
|
||||
const db = new Database(outboundDbPath(AG, SESS), { readonly: true });
|
||||
try {
|
||||
return db.prepare('SELECT id, seq, kind, content FROM messages_out ORDER BY seq').all() as Array<{
|
||||
id: string;
|
||||
seq: number;
|
||||
kind: string;
|
||||
content: string;
|
||||
}>;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
initSessionFolder(AG, SESS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('writeOutboundDirect', () => {
|
||||
it('inserts into messages_out with an even host-side seq (requires a writable outbound.db)', () => {
|
||||
// With a readonly open this very call throws SQLITE_READONLY.
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-1',
|
||||
kind: 'chat',
|
||||
platformId: 'slack:C1',
|
||||
channelType: 'slack',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text: 'Admin commands are restricted.' }),
|
||||
});
|
||||
|
||||
const rows = readMessagesOut();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('denial-1');
|
||||
expect(rows[0].seq).toBe(2);
|
||||
expect(rows[0].seq % 2).toBe(0); // host uses even seq numbers
|
||||
expect(JSON.parse(rows[0].content).text).toBe('Admin commands are restricted.');
|
||||
});
|
||||
|
||||
it('keeps host seq numbers even across multiple writes and ignores duplicate ids', () => {
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-1',
|
||||
kind: 'chat',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: '{"text":"first"}',
|
||||
});
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-2',
|
||||
kind: 'chat',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: '{"text":"second"}',
|
||||
});
|
||||
// INSERT OR IGNORE — a delivery retry with the same id must not throw or duplicate.
|
||||
writeOutboundDirect(AG, SESS, {
|
||||
id: 'denial-1',
|
||||
kind: 'chat',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: '{"text":"retry"}',
|
||||
});
|
||||
|
||||
const rows = readMessagesOut();
|
||||
expect(rows.map((r) => r.id)).toEqual(['denial-1', 'denial-2']);
|
||||
expect(rows.map((r) => r.seq)).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
@@ -378,6 +378,12 @@ export function openOutboundDbRw(agentGroupId: string, sessionId: string): Datab
|
||||
* Write a message directly to a session's outbound DB so the host delivery
|
||||
* loop picks it up. Used by the command gate to send denial responses
|
||||
* without waking a container.
|
||||
*
|
||||
* Needs the read-write open — the readonly handle the delivery poll uses
|
||||
* can't INSERT. This is a host-side write to the container-owned outbound.db,
|
||||
* but it's safe even with a container running: both sides open with DELETE
|
||||
* journal + busy_timeout, and the even host seq stays out of the container's
|
||||
* odd-seq space.
|
||||
*/
|
||||
export function writeOutboundDirect(
|
||||
agentGroupId: string,
|
||||
@@ -391,7 +397,7 @@ export function writeOutboundDirect(
|
||||
content: string;
|
||||
},
|
||||
): void {
|
||||
const db = openOutboundDb(agentGroupId, sessionId);
|
||||
const db = openOutboundDbRw(agentGroupId, sessionId);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* SqliteStateAdapter namespace tests.
|
||||
*
|
||||
* All Chat SDK bridges share the chat_sdk_* tables in data/v2.db. Two
|
||||
* same-platform adapter instances see identical thread/message ids, so the
|
||||
* SDK's `dedupe:${adapter.name}:${message.id}` keys collide — the second
|
||||
* bot silently drops every message the first processed — unless each named
|
||||
* instance gets its own key namespace.
|
||||
*
|
||||
* The inverse constraint is just as load-bearing: the DEFAULT instance must
|
||||
* keep today's UNPREFIXED keys byte-identically, or live installs orphan
|
||||
* every existing subscription/lock/kv row on upgrade.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb, getDb } from './db/connection.js';
|
||||
import { runMigrations } from './db/migrations/index.js';
|
||||
import { SqliteStateAdapter } from './state-sqlite.js';
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
async function makeAdapter(namespace?: string): Promise<SqliteStateAdapter> {
|
||||
const state = new SqliteStateAdapter(namespace);
|
||||
await state.connect();
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('default instance — legacy unprefixed keys (live-install regression arm)', () => {
|
||||
it('reads rows written before the namespace dimension existed', async () => {
|
||||
// A pre-existing install's subscription row: bare thread id.
|
||||
getDb().prepare("INSERT INTO chat_sdk_subscriptions (thread_id) VALUES ('T-raw')").run();
|
||||
const state = await makeAdapter();
|
||||
expect(await state.isSubscribed('T-raw')).toBe(true);
|
||||
});
|
||||
|
||||
it('writes raw keys — kv, subscriptions, lists bind the exact input strings', async () => {
|
||||
const state = await makeAdapter();
|
||||
await state.set('k1', { v: 1 });
|
||||
await state.subscribe('slack:T1');
|
||||
await state.appendToList('l1', 'item');
|
||||
|
||||
const kv = getDb().prepare('SELECT key FROM chat_sdk_kv').all() as Array<{ key: string }>;
|
||||
expect(kv.map((r) => r.key)).toEqual(['k1']);
|
||||
const subs = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(subs.map((r) => r.thread_id)).toEqual(['slack:T1']);
|
||||
const lists = getDb().prepare('SELECT key FROM chat_sdk_lists').all() as Array<{ key: string }>;
|
||||
expect(lists.map((r) => r.key)).toEqual(['l1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('namespaced instance — round-trips and raw-key shape', () => {
|
||||
it('kv get/set/setIfNotExists/delete round-trip under a prefixed key', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
await state.set('k1', { v: 42 });
|
||||
expect(await state.get('k1')).toEqual({ v: 42 });
|
||||
|
||||
const raw = getDb().prepare('SELECT key FROM chat_sdk_kv').all() as Array<{ key: string }>;
|
||||
expect(raw.map((r) => r.key)).toEqual(['slack-tester:k1']);
|
||||
|
||||
expect(await state.setIfNotExists('k1', 'other')).toBe(false);
|
||||
expect(await state.setIfNotExists('k2', 'fresh')).toBe(true);
|
||||
await state.delete('k1');
|
||||
expect(await state.get('k1')).toBeNull();
|
||||
expect(await state.get('k2')).toBe('fresh');
|
||||
});
|
||||
|
||||
it('subscribe/isSubscribed/unsubscribe round-trip under a prefixed thread_id', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
await state.subscribe('slack:T1');
|
||||
expect(await state.isSubscribed('slack:T1')).toBe(true);
|
||||
|
||||
const raw = getDb().prepare('SELECT thread_id FROM chat_sdk_subscriptions').all() as Array<{
|
||||
thread_id: string;
|
||||
}>;
|
||||
expect(raw.map((r) => r.thread_id)).toEqual(['slack-tester:slack:T1']);
|
||||
|
||||
await state.unsubscribe('slack:T1');
|
||||
expect(await state.isSubscribed('slack:T1')).toBe(false);
|
||||
});
|
||||
|
||||
it('lists round-trip under a prefixed key', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
await state.appendToList('history', 'a');
|
||||
await state.appendToList('history', 'b');
|
||||
expect(await state.getList('history')).toEqual(['a', 'b']);
|
||||
const raw = getDb().prepare('SELECT DISTINCT key FROM chat_sdk_lists').all() as Array<{ key: string }>;
|
||||
expect(raw.map((r) => r.key)).toEqual(['slack-tester:history']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-namespace isolation', () => {
|
||||
it('setIfNotExists succeeds in BOTH namespaces (the SDK-dedupe collision fix)', async () => {
|
||||
const a = await makeAdapter('slack-worker');
|
||||
const b = await makeAdapter('slack-tester');
|
||||
// Same SDK dedupe key from both bots — each must win in its own space.
|
||||
expect(await a.setIfNotExists('dedupe:slack:m1', 1)).toBe(true);
|
||||
expect(await b.setIfNotExists('dedupe:slack:m1', 1)).toBe(true);
|
||||
// And re-asserting within one namespace still dedupes.
|
||||
expect(await a.setIfNotExists('dedupe:slack:m1', 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("one namespace's subscription is invisible to the other (and to the default)", async () => {
|
||||
const a = await makeAdapter('slack-worker');
|
||||
const b = await makeAdapter('slack-tester');
|
||||
const def = await makeAdapter();
|
||||
await a.subscribe('slack:T1');
|
||||
expect(await a.isSubscribed('slack:T1')).toBe(true);
|
||||
expect(await b.isSubscribed('slack:T1')).toBe(false);
|
||||
expect(await def.isSubscribed('slack:T1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locks under a namespace', () => {
|
||||
it('acquire returns the RAW threadId; extend and release hit the prefixed row', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
const lock = await state.acquireLock('slack:T1', 5000);
|
||||
expect(lock).not.toBeNull();
|
||||
// Raw id on the Lock object — release/extend apply the prefix at their
|
||||
// own SQL sites. A prefixed id here would double-prefix on release.
|
||||
expect(lock!.threadId).toBe('slack:T1');
|
||||
|
||||
const raw = getDb().prepare('SELECT thread_id FROM chat_sdk_locks').all() as Array<{ thread_id: string }>;
|
||||
expect(raw.map((r) => r.thread_id)).toEqual(['slack-tester:slack:T1']);
|
||||
|
||||
expect(await state.extendLock(lock!, 10_000)).toBe(true);
|
||||
await state.releaseLock(lock!);
|
||||
expect(getDb().prepare('SELECT COUNT(*) AS c FROM chat_sdk_locks').get()).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('same-thread locks in different namespaces do not contend', async () => {
|
||||
const a = await makeAdapter('slack-worker');
|
||||
const b = await makeAdapter('slack-tester');
|
||||
expect(await a.acquireLock('slack:T1', 5000)).not.toBeNull();
|
||||
expect(await b.acquireLock('slack:T1', 5000)).not.toBeNull();
|
||||
// Within one namespace the second acquire still fails.
|
||||
expect(await a.acquireLock('slack:T1', 5000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue under a namespace', () => {
|
||||
it('enqueue → queueDepth → dequeue drains to empty; raw key is ns:queue:<tid>', async () => {
|
||||
const state = await makeAdapter('slack-tester');
|
||||
const entry = { message: { id: 'm1' } } as never;
|
||||
expect(await state.enqueue('slack:T1', entry, 10)).toBe(1);
|
||||
|
||||
const raw = getDb().prepare('SELECT DISTINCT key FROM chat_sdk_lists').all() as Array<{ key: string }>;
|
||||
// Single prefix: enqueue must NOT apply k() itself (appendToList does);
|
||||
// a double prefix ('slack-tester:slack-tester:queue:…') never drains.
|
||||
expect(raw.map((r) => r.key)).toEqual(['slack-tester:queue:slack:T1']);
|
||||
|
||||
expect(await state.queueDepth('slack:T1')).toBe(1);
|
||||
const out = await state.dequeue('slack:T1');
|
||||
expect(out).toEqual(entry);
|
||||
expect(await state.queueDepth('slack:T1')).toBe(0);
|
||||
expect(await state.dequeue('slack:T1')).toBeNull();
|
||||
});
|
||||
});
|
||||
+56
-21
@@ -20,6 +20,28 @@ interface Lock {
|
||||
export class SqliteStateAdapter implements StateAdapter {
|
||||
private db!: Database.Database;
|
||||
|
||||
/**
|
||||
* namespace = adapter-instance name; undefined ⇒ legacy unprefixed keys.
|
||||
*
|
||||
* All bridges share the same chat_sdk_* tables, and two same-platform
|
||||
* instances see identical thread/message ids — the SDK's dedupe key is
|
||||
* `dedupe:${adapter.name}:${message.id}`, so without a namespace the
|
||||
* second bot silently drops every message the first processed, locks
|
||||
* serialize across bots, and subscriptions leak engagement between them.
|
||||
*
|
||||
* The default instance MUST stay unprefixed: prefixing it would orphan
|
||||
* every live install's existing chat_sdk_subscriptions/kv/locks/lists
|
||||
* rows (silently killing engaged threads) with no clean way to rewrite
|
||||
* them. `k()` is the single choke point between every public method and
|
||||
* its SQL parameter — with namespace undefined it is the identity
|
||||
* function, so every statement binds the exact same strings as before.
|
||||
*/
|
||||
constructor(private readonly namespace?: string) {}
|
||||
|
||||
private k(key: string): string {
|
||||
return this.namespace ? `${this.namespace}:${key}` : key;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.db = getDb();
|
||||
this.cleanup();
|
||||
@@ -31,12 +53,13 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
|
||||
async get<T = unknown>(key: string): Promise<T | null> {
|
||||
this.cleanup();
|
||||
const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
|
||||
const k = this.k(key);
|
||||
const row = this.db.prepare('SELECT value, expires_at FROM chat_sdk_kv WHERE key = ?').get(k) as
|
||||
| { value: string; expires_at: number | null }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < Date.now()) {
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(k);
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(row.value) as T;
|
||||
@@ -46,39 +69,42 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
|
||||
this.db
|
||||
.prepare('INSERT OR REPLACE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
|
||||
.run(key, JSON.stringify(value), expiresAt);
|
||||
.run(this.k(key), JSON.stringify(value), expiresAt);
|
||||
}
|
||||
|
||||
async setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise<boolean> {
|
||||
const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(key) as
|
||||
const k = this.k(key);
|
||||
const existing = this.db.prepare('SELECT expires_at FROM chat_sdk_kv WHERE key = ?').get(k) as
|
||||
| { expires_at: number | null }
|
||||
| undefined;
|
||||
if (existing?.expires_at && existing.expires_at < Date.now()) {
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(k);
|
||||
}
|
||||
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
|
||||
const result = this.db
|
||||
.prepare('INSERT OR IGNORE INTO chat_sdk_kv (key, value, expires_at) VALUES (?, ?, ?)')
|
||||
.run(key, JSON.stringify(value), expiresAt);
|
||||
.run(k, JSON.stringify(value), expiresAt);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(key);
|
||||
this.db.prepare('DELETE FROM chat_sdk_kv WHERE key = ?').run(this.k(key));
|
||||
}
|
||||
|
||||
// --- Subscriptions ---
|
||||
|
||||
async subscribe(threadId: string): Promise<void> {
|
||||
this.db.prepare('INSERT OR REPLACE INTO chat_sdk_subscriptions (thread_id) VALUES (?)').run(threadId);
|
||||
this.db.prepare('INSERT OR REPLACE INTO chat_sdk_subscriptions (thread_id) VALUES (?)').run(this.k(threadId));
|
||||
}
|
||||
|
||||
async unsubscribe(threadId: string): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_subscriptions WHERE thread_id = ?').run(threadId);
|
||||
this.db.prepare('DELETE FROM chat_sdk_subscriptions WHERE thread_id = ?').run(this.k(threadId));
|
||||
}
|
||||
|
||||
async isSubscribed(threadId: string): Promise<boolean> {
|
||||
const row = this.db.prepare('SELECT 1 FROM chat_sdk_subscriptions WHERE thread_id = ? LIMIT 1').get(threadId);
|
||||
const row = this.db
|
||||
.prepare('SELECT 1 FROM chat_sdk_subscriptions WHERE thread_id = ? LIMIT 1')
|
||||
.get(this.k(threadId));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@@ -88,23 +114,28 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
const now = Date.now();
|
||||
const token = crypto.randomUUID();
|
||||
const expiresAt = now + ttlMs;
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(threadId, now);
|
||||
const k = this.k(threadId);
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND expires_at < ?').run(k, now);
|
||||
const result = this.db
|
||||
.prepare('INSERT OR IGNORE INTO chat_sdk_locks (thread_id, token, expires_at) VALUES (?, ?, ?)')
|
||||
.run(threadId, token, expiresAt);
|
||||
.run(k, token, expiresAt);
|
||||
if (result.changes === 0) return null;
|
||||
// The Lock carries the RAW threadId; release/extend re-apply k() at
|
||||
// their own SQL sites. Uniform — no un/re-prefixing on the caller side.
|
||||
return { threadId, token, expiresAt };
|
||||
}
|
||||
|
||||
async releaseLock(lock: Lock): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND token = ?').run(lock.threadId, lock.token);
|
||||
this.db
|
||||
.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ? AND token = ?')
|
||||
.run(this.k(lock.threadId), lock.token);
|
||||
}
|
||||
|
||||
async extendLock(lock: Lock, ttlMs: number): Promise<boolean> {
|
||||
const newExpiry = Date.now() + ttlMs;
|
||||
const result = this.db
|
||||
.prepare('UPDATE chat_sdk_locks SET expires_at = ? WHERE thread_id = ? AND token = ?')
|
||||
.run(newExpiry, lock.threadId, lock.token);
|
||||
.run(newExpiry, this.k(lock.threadId), lock.token);
|
||||
if (result.changes > 0) {
|
||||
lock.expiresAt = newExpiry;
|
||||
return true;
|
||||
@@ -113,24 +144,25 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
}
|
||||
|
||||
async forceReleaseLock(threadId: string): Promise<void> {
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ?').run(threadId);
|
||||
this.db.prepare('DELETE FROM chat_sdk_locks WHERE thread_id = ?').run(this.k(threadId));
|
||||
}
|
||||
|
||||
// --- Lists ---
|
||||
|
||||
async appendToList(key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }): Promise<void> {
|
||||
const expiresAt = options?.ttlMs ? Date.now() + options.ttlMs : null;
|
||||
const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(key) as
|
||||
const k = this.k(key);
|
||||
const maxRow = this.db.prepare('SELECT MAX(idx) as maxIdx FROM chat_sdk_lists WHERE key = ?').get(k) as
|
||||
| { maxIdx: number | null }
|
||||
| undefined;
|
||||
const nextIdx = (maxRow?.maxIdx ?? -1) + 1;
|
||||
this.db
|
||||
.prepare('INSERT INTO chat_sdk_lists (key, idx, value, expires_at) VALUES (?, ?, ?, ?)')
|
||||
.run(key, nextIdx, JSON.stringify(value), expiresAt);
|
||||
.run(k, nextIdx, JSON.stringify(value), expiresAt);
|
||||
if (options?.maxLength) {
|
||||
const cutoff = nextIdx - options.maxLength;
|
||||
if (cutoff >= 0) {
|
||||
this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx <= ?').run(key, cutoff);
|
||||
this.db.prepare('DELETE FROM chat_sdk_lists WHERE key = ? AND idx <= ?').run(k, cutoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,20 +173,23 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
.prepare(
|
||||
'SELECT value FROM chat_sdk_lists WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY idx ASC',
|
||||
)
|
||||
.all(key, now) as { value: string }[];
|
||||
.all(this.k(key), now) as { value: string }[];
|
||||
return rows.map((r) => JSON.parse(r.value) as T);
|
||||
}
|
||||
|
||||
// --- Queue ---
|
||||
|
||||
async enqueue(threadId: string, entry: QueueEntry, maxSize: number): Promise<number> {
|
||||
// No k() here: appendToList prefixes at its own SQL boundary. Prefixing
|
||||
// twice would write `ns:ns:queue:<tid>` and the queue would never drain.
|
||||
// Resulting on-disk layout is `ns:queue:<tid>`.
|
||||
const key = `queue:${threadId}`;
|
||||
await this.appendToList(key, entry, { maxLength: maxSize });
|
||||
return await this.queueDepth(threadId);
|
||||
}
|
||||
|
||||
async dequeue(threadId: string): Promise<QueueEntry | null> {
|
||||
const key = `queue:${threadId}`;
|
||||
const key = this.k(`queue:${threadId}`);
|
||||
const row = this.db
|
||||
.prepare('SELECT idx, value FROM chat_sdk_lists WHERE key = ? ORDER BY idx ASC LIMIT 1')
|
||||
.get(key) as { idx: number; value: string } | undefined;
|
||||
@@ -164,7 +199,7 @@ export class SqliteStateAdapter implements StateAdapter {
|
||||
}
|
||||
|
||||
async queueDepth(threadId: string): Promise<number> {
|
||||
const key = `queue:${threadId}`;
|
||||
const key = this.k(`queue:${threadId}`);
|
||||
const row = this.db.prepare('SELECT COUNT(*) as count FROM chat_sdk_lists WHERE key = ?').get(key) as {
|
||||
count: number;
|
||||
};
|
||||
|
||||
@@ -34,6 +34,14 @@ export interface MessagingGroup {
|
||||
id: string;
|
||||
channel_type: string;
|
||||
platform_id: string;
|
||||
/**
|
||||
* Adapter-instance name. Defaults to channel_type (the "default instance").
|
||||
* Column is NOT NULL (migration 016 backfills instance = channel_type);
|
||||
* optional on the TS type per the denied_at convention so fixtures that
|
||||
* build MessagingGroup objects don't need updating — createMessagingGroup
|
||||
* stamps the default.
|
||||
*/
|
||||
instance?: string;
|
||||
name: string | null;
|
||||
is_group: number; // 0 | 1
|
||||
unknown_sender_policy: UnknownSenderPolicy;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Guard for the raw-route half of src/webhook-server.ts —
|
||||
* registerWebhookHandler + the rawRoutes dispatch branch.
|
||||
*
|
||||
* Drives the REAL shared HTTP server on an ephemeral WEBHOOK_PORT (no
|
||||
* mocking of the routing layer): a registered raw route must dispatch,
|
||||
* unknown paths must 404, a throwing handler must surface as 500,
|
||||
* raw routes must coexist with Chat SDK adapter routes on the same
|
||||
* server, and stopWebhookServer must clear them.
|
||||
*/
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Chat } from 'chat';
|
||||
|
||||
import { registerWebhookAdapter, registerWebhookHandler, stopWebhookServer } from './webhook-server.js';
|
||||
|
||||
const PORT = 21000 + Math.floor(Math.random() * 20000);
|
||||
|
||||
async function post(path: string, body = '{}'): Promise<globalThis.Response> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await fetch(`http://127.0.0.1:${PORT}/webhook/${path}`, { method: 'POST', body });
|
||||
} catch (err) {
|
||||
if (attempt >= 40) throw err;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await stopWebhookServer();
|
||||
delete process.env.WEBHOOK_PORT;
|
||||
});
|
||||
|
||||
describe('webhook server raw routes', () => {
|
||||
it('dispatches a registered raw route to its handler', async () => {
|
||||
process.env.WEBHOOK_PORT = String(PORT);
|
||||
const methods: string[] = [];
|
||||
registerWebhookHandler('ping', (req, res) => {
|
||||
methods.push(req.method || '');
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('pong');
|
||||
});
|
||||
|
||||
const res = await post('ping');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('pong');
|
||||
expect(methods).toEqual(['POST']);
|
||||
});
|
||||
|
||||
it('returns 404 for paths with no registered route', async () => {
|
||||
const res = await post('nope');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('turns a throwing handler into a 500 response', async () => {
|
||||
registerWebhookHandler('boom', () => {
|
||||
throw new Error('handler exploded');
|
||||
});
|
||||
|
||||
const res = await post('boom');
|
||||
expect(res.status).toBe(500);
|
||||
expect(await res.text()).toBe('Internal Server Error');
|
||||
});
|
||||
|
||||
it('coexists with Chat SDK adapter routes on the same server', async () => {
|
||||
const handler = vi.fn(async () => new Response('ok-chat', { status: 200 }));
|
||||
const chat = { webhooks: { fake: handler } } as unknown as Chat;
|
||||
registerWebhookAdapter(chat, 'fake');
|
||||
|
||||
const chatRes = await post('fake');
|
||||
expect(chatRes.status).toBe(200);
|
||||
expect(await chatRes.text()).toBe('ok-chat');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The raw route registered earlier is still live alongside it.
|
||||
const rawRes = await post('ping');
|
||||
expect(rawRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('clears raw routes on stopWebhookServer', async () => {
|
||||
await stopWebhookServer();
|
||||
|
||||
// Restart the server with a fresh route; the old raw routes must be gone.
|
||||
registerWebhookHandler('fresh', (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('fresh');
|
||||
});
|
||||
|
||||
const stale = await post('ping');
|
||||
expect(stale.status).toBe(404);
|
||||
|
||||
const fresh = await post('fresh');
|
||||
expect(fresh.status).toBe(200);
|
||||
expect(await fresh.text()).toBe('fresh');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Webhook server route/handler split tests.
|
||||
*
|
||||
* The route key (URL segment, `/webhook/<routingPath>`) and the handler key
|
||||
* (`chat.webhooks[adapterName]`) are independent: a named adapter instance
|
||||
* registers its own Chat under its own URL while dispatching to the same
|
||||
* SDK adapter name. The 2-arg default keeps the historical single-instance
|
||||
* route byte-identical. Conventions follow PR #2617: real HTTP server on a
|
||||
* fixed WEBHOOK_PORT, real fetch.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import type { Chat } from 'chat';
|
||||
|
||||
import { registerWebhookAdapter, stopWebhookServer } from './webhook-server.js';
|
||||
|
||||
const PORT = 3917;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
/** Minimal Chat stand-in: only `webhooks` is touched by the server. */
|
||||
function stubChat(tag: string, adapterName = 'slack'): { chat: Chat; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const chat = {
|
||||
webhooks: {
|
||||
[adapterName]: async (req: Request) => {
|
||||
calls.push(await req.text());
|
||||
return new Response(JSON.stringify({ via: tag }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown as Chat;
|
||||
return { chat, calls };
|
||||
}
|
||||
|
||||
async function post(path: string, body: string): Promise<Response> {
|
||||
// The server starts listening asynchronously after registration — retry
|
||||
// briefly on connection refusal instead of sleeping a fixed amount.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await fetch(`${BASE}${path}`, { method: 'POST', body });
|
||||
} catch (err) {
|
||||
if (attempt >= 20) throw err;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.WEBHOOK_PORT = String(PORT);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopWebhookServer();
|
||||
delete process.env.WEBHOOK_PORT;
|
||||
});
|
||||
|
||||
describe('registerWebhookAdapter — route/handler split', () => {
|
||||
it('2-arg default: /webhook/<adapterName> dispatches to chat.webhooks[adapterName]', async () => {
|
||||
const { chat, calls } = stubChat('default');
|
||||
registerWebhookAdapter(chat, 'slack');
|
||||
|
||||
const res = await post('/webhook/slack', 'payload-default');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ via: 'default' });
|
||||
expect(calls).toEqual(['payload-default']);
|
||||
});
|
||||
|
||||
it('3-arg: routes by routingPath, dispatches by adapterName; the bare route stays unregistered', async () => {
|
||||
const { chat, calls } = stubChat('tester');
|
||||
registerWebhookAdapter(chat, 'slack', 'slack-tester');
|
||||
|
||||
const res = await post('/webhook/slack-tester', 'payload-tester');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ via: 'tester' });
|
||||
expect(calls).toEqual(['payload-tester']);
|
||||
|
||||
// Only the routed entry exists — /webhook/slack must 404, not leak into
|
||||
// the named instance's Chat.
|
||||
const miss = await post('/webhook/slack', 'stray');
|
||||
expect(miss.status).toBe(404);
|
||||
expect(calls).toEqual(['payload-tester']);
|
||||
});
|
||||
|
||||
it('two same-adapterName registrations under distinct paths hit their own Chat instances', async () => {
|
||||
const worker = stubChat('worker');
|
||||
const tester = stubChat('tester');
|
||||
registerWebhookAdapter(worker.chat, 'slack');
|
||||
registerWebhookAdapter(tester.chat, 'slack', 'slack-tester');
|
||||
|
||||
const r1 = await post('/webhook/slack', 'to-worker');
|
||||
const r2 = await post('/webhook/slack-tester', 'to-tester');
|
||||
expect(await r1.json()).toEqual({ via: 'worker' });
|
||||
expect(await r2.json()).toEqual({ via: 'tester' });
|
||||
expect(worker.calls).toEqual(['to-worker']);
|
||||
expect(tester.calls).toEqual(['to-tester']);
|
||||
});
|
||||
|
||||
it('unregistered path 404s', async () => {
|
||||
const { chat } = stubChat('only');
|
||||
registerWebhookAdapter(chat, 'slack');
|
||||
const res = await post('/webhook/nope', 'x');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
+54
-12
@@ -3,9 +3,12 @@
|
||||
*
|
||||
* Starts lazily on first adapter registration. Routes requests by path:
|
||||
* /webhook/{adapterName} → chat.webhooks[adapterName](request)
|
||||
* /webhook/{path} → raw handler from registerWebhookHandler(path, ...)
|
||||
*
|
||||
* Multiple Chat instances can register adapters — each adapter name maps
|
||||
* to its owning Chat instance.
|
||||
* to its owning Chat instance. Raw routes let modules receive non-Chat-SDK
|
||||
* webhooks (GitHub, payment providers, health checks) on the same server
|
||||
* without editing this file or opening a second port.
|
||||
*/
|
||||
import http from 'http';
|
||||
|
||||
@@ -20,7 +23,11 @@ interface WebhookEntry {
|
||||
adapterName: string;
|
||||
}
|
||||
|
||||
/** Node-style handler for raw (non-Chat-SDK) webhook routes. */
|
||||
export type RawWebhookHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>;
|
||||
|
||||
const routes = new Map<string, WebhookEntry>();
|
||||
const rawRoutes = new Map<string, RawWebhookHandler>();
|
||||
let server: http.Server | null = null;
|
||||
|
||||
/** Convert Node.js IncomingMessage to a Web API Request. */
|
||||
@@ -69,11 +76,35 @@ async function fromWebResponse(webRes: Response, nodeRes: http.ServerResponse):
|
||||
/**
|
||||
* Register a webhook adapter on the shared server.
|
||||
* Starts the server lazily on first call.
|
||||
*
|
||||
* `routingPath` is the URL segment (`/webhook/<routingPath>`); `adapterName`
|
||||
* stays the handler key into `chat.webhooks`. The split lets N instances of
|
||||
* one platform (each with its own Chat + signing secret) listen on distinct
|
||||
* URLs while dispatching to the same SDK adapter name. Defaulting
|
||||
* routingPath to adapterName keeps the historical single-instance route
|
||||
* byte-identical. Signature adopted verbatim from PR #2617 (@davekim917's
|
||||
* #1804 prototype) so the two changes converge textually.
|
||||
*/
|
||||
export function registerWebhookAdapter(chat: Chat, adapterName: string): void {
|
||||
routes.set(adapterName, { chat, adapterName });
|
||||
export function registerWebhookAdapter(chat: Chat, adapterName: string, routingPath: string = adapterName): void {
|
||||
routes.set(routingPath, { chat, adapterName });
|
||||
ensureServer();
|
||||
log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${adapterName}` });
|
||||
log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${routingPath}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a raw Node-style handler at /webhook/{path} on the shared server.
|
||||
*
|
||||
* For webhooks that don't flow through a Chat SDK adapter (GitHub, payment
|
||||
* providers, health checks): modules register their endpoint here instead of
|
||||
* editing this file or standing up a second HTTP server on another port.
|
||||
* The handler owns the request/response directly.
|
||||
*
|
||||
* Starts the server lazily on first call.
|
||||
*/
|
||||
export function registerWebhookHandler(path: string, handler: RawWebhookHandler): void {
|
||||
rawRoutes.set(path, handler);
|
||||
ensureServer();
|
||||
log.info('Webhook handler registered', { path: `/webhook/${path}` });
|
||||
}
|
||||
|
||||
function ensureServer(): void {
|
||||
@@ -93,14 +124,22 @@ function ensureServer(): void {
|
||||
}
|
||||
|
||||
const adapterName = match[1];
|
||||
const entry = routes.get(adapterName);
|
||||
if (!entry) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end(`Unknown adapter: ${adapterName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Raw routes take priority — the handler writes the response itself.
|
||||
const rawHandler = rawRoutes.get(adapterName);
|
||||
if (rawHandler) {
|
||||
await rawHandler(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = routes.get(adapterName);
|
||||
if (!entry) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end(`Unknown adapter: ${adapterName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const webReq = await toWebRequest(req);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const webhooks = entry.chat.webhooks as Record<string, (r: Request, opts?: any) => Promise<Response>>;
|
||||
@@ -113,8 +152,10 @@ function ensureServer(): void {
|
||||
await fromWebResponse(webRes, res);
|
||||
} catch (err) {
|
||||
log.error('Webhook handler error', { adapter: adapterName, url: req.url, err });
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,6 +170,7 @@ export async function stopWebhookServer(): Promise<void> {
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
routes.clear();
|
||||
rawRoutes.clear();
|
||||
log.info('Webhook server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# The uninstaller lives in the setup driver now (setup/uninstall/).
|
||||
# Translate the short flags the old bash uninstaller accepted.
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-n) ARGS+=("--dry-run") ;;
|
||||
-y) ARGS+=("--yes") ;;
|
||||
*) ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/nanoclaw.sh" --uninstall "${ARGS[@]}"
|
||||
Reference in New Issue
Block a user