mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-15 18:21:47 +08:00
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0683c6ec58 | |||
| 8dbe8c1de8 | |||
| 78bb6cb087 | |||
| ce804afb73 | |||
| 898f4b5f66 | |||
| 4b7bfb0a11 | |||
| 2ab69269ce | |||
| 6418dda3da | |||
| 975a2f0f5b | |||
| d2a015074d | |||
| 8ea451aced | |||
| 5b14ae249a | |||
| 06711b5e47 | |||
| d0139a7c0f | |||
| 2abb34bc78 | |||
| b8d7777740 | |||
| 43ff3a4644 | |||
| 34b9b259ea | |||
| f3d5b82899 | |||
| e603236223 | |||
| 5fff2d2728 | |||
| 529d2db8e2 | |||
| 26eb89c771 | |||
| fa945a1d0c | |||
| bec10fe4e3 | |||
| cbdebe55fc | |||
| 8f30a7aad3 | |||
| b2894bf44c | |||
| ca52d2c6c1 | |||
| b779a0b5c6 | |||
| 4d81dc4e0e | |||
| e263352aed | |||
| d27b1bb291 | |||
| 1d4d920629 | |||
| c9c5ffadc9 | |||
| 001c62c2e4 | |||
| 7334feb8dc | |||
| 2eb6a1c62e | |||
| 61d7ca6bba | |||
| 1baea6b9e9 | |||
| 7f4fa65f3c | |||
| e0f5967128 | |||
| c1fd830add | |||
| 74744599d3 | |||
| fcbc204a24 | |||
| 00ddb3b169 | |||
| a760da7fef | |||
| 48dfb1b1e0 | |||
| 9dfd68d14a | |||
| 8ac3cf2912 | |||
| 0a1b396d12 | |||
| cf7da26c34 | |||
| 6e3c60ce94 | |||
| bda72a4bf4 | |||
| 35d667c3ae | |||
| a98ce59374 | |||
| 069928a445 | |||
| 45189abaf1 | |||
| 43d69a9966 | |||
| e185bb8bad | |||
| c6d5cd7d02 | |||
| b323b55efe | |||
| bf34857d11 | |||
| d8aa46c0a7 | |||
| 610a692519 | |||
| 8a8ec84ef1 | |||
| 47c85d0985 | |||
| f338bd47ea | |||
| 0de46f8b38 | |||
| f49de0fb01 | |||
| a33b1ae8bb | |||
| d8e3f9f959 | |||
| 8d57bdfa3d | |||
| ead25ee6e2 | |||
| 9e1dbdf48c | |||
| 0774667826 | |||
| 0ba4ecadb1 | |||
| ad5d4d2664 | |||
| 9267d52bdb | |||
| 4c57e4d69b | |||
| eff13717f9 | |||
| dc13300fb1 | |||
| d324419d7b | |||
| 0287d71595 | |||
| 05906e4b6a | |||
| 6539c0286a | |||
| 5ba9d23ea8 | |||
| f7a8df0e8e | |||
| 9312d467bd | |||
| bd50ef7e38 | |||
| 25a5b81c59 | |||
| f33f2d89ce | |||
| 661da3969e | |||
| aeeb54a495 | |||
| f9d30e8b9c | |||
| 1c7623ca41 | |||
| faeeba198e | |||
| 04e41fb0ef | |||
| aebcffe180 | |||
| be3a8a97c6 | |||
| a84327573e | |||
| 39e9583820 | |||
| 08698da0d2 | |||
| 9ce82588d9 | |||
| 37b54968ce | |||
| 1efe28ccdc | |||
| 78cf2433a3 | |||
| 4c83a8193b | |||
| 7eebcf74c2 | |||
| 31ccc61b27 | |||
| ef43cbb3d9 | |||
| 0060c6b84a | |||
| e6d470d831 | |||
| 0e11eaf186 | |||
| 4990994204 | |||
| 2d03c94252 | |||
| 93ec82ce38 | |||
| bdb8cf559c | |||
| 5213c98506 | |||
| ff90c8f565 | |||
| 295275df69 | |||
| b92fdb5771 | |||
| d3581bc65e | |||
| ae2c09cbde |
@@ -182,9 +182,12 @@ ATOMIC_CHAT_API_KEY=sk-...
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -93,10 +93,13 @@ Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes
|
||||
|
||||
### 6. Build and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
@@ -23,14 +23,17 @@ DC_SMTP_PORT
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
systemctl --user restart $(systemd_unit)
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
@@ -98,12 +98,16 @@ The `/set-avatar` command (send an image with that caption) is the easiest way t
|
||||
|
||||
### Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
systemctl --user restart $(systemd_unit)
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
|
||||
@@ -232,7 +236,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
|
||||
|
||||
```bash
|
||||
rm -f dc-account/accounts.lock
|
||||
systemctl --user restart nanoclaw
|
||||
systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
@@ -162,10 +162,13 @@ If you changed `EMACS_CHANNEL_PORT` from the default:
|
||||
|
||||
## Restart NanoClaw
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Verify
|
||||
@@ -240,7 +243,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
|
||||
|
||||
### No response from agent
|
||||
|
||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
1. NanoClaw running: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
|
||||
2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
|
||||
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
|
||||
|
||||
@@ -282,13 +285,16 @@ If an agent outputs org-mode directly, markers get double-converted and render i
|
||||
|
||||
## Removal
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
|
||||
# Remove the `import './emacs.js';` line from src/channels/index.ts
|
||||
# Remove EMACS_* lines from .env
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# systemctl --user restart $(systemd_unit) # Linux
|
||||
|
||||
# Remove the NanoClaw block from your Emacs config
|
||||
# Optionally clean up the messaging group:
|
||||
|
||||
@@ -92,7 +92,6 @@ onecli agents list
|
||||
|
||||
```bash
|
||||
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
@@ -121,9 +120,7 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
|
||||
```
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
|
||||
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `calendar` in Phase 3 automatically allows `mcp__calendar__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
@@ -133,40 +130,59 @@ Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'`
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group, merge into `groups/<folder>/container.json`:
|
||||
For each agent group, persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.calendar` entry and an `additionalMounts` entry for `.calendar-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"calendar": {
|
||||
"command": "google-calendar-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
|
||||
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.calendar-mcp",
|
||||
"containerPath": ".calendar-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
### Register the MCP server
|
||||
|
||||
For each chosen `<group-id>` (use `ncl groups list` to enumerate):
|
||||
|
||||
```bash
|
||||
ncl groups config add-mcp-server \
|
||||
--id <group-id> \
|
||||
--name calendar \
|
||||
--command google-calendar-mcp \
|
||||
--args '[]' \
|
||||
--env '{"GOOGLE_OAUTH_CREDENTIALS":"/workspace/extra/.calendar-mcp/gcp-oauth.keys.json","GOOGLE_CALENDAR_MCP_TOKEN_PATH":"/workspace/extra/.calendar-mcp/credentials.json"}'
|
||||
```
|
||||
|
||||
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
|
||||
### Add the `.calendar-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.calendar-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
|
||||
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
Kill any existing agent containers so they respawn with the new mcpServers config:
|
||||
@@ -193,16 +209,28 @@ Common signals:
|
||||
- `command not found: google-calendar-mcp` → image not rebuilt.
|
||||
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
|
||||
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
|
||||
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
|
||||
- Agent says "I don't have calendar tools" → the `calendar` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (`./container/build.sh`, `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
|
||||
1. For each group that had Calendar wired, remove the MCP server from the DB:
|
||||
```bash
|
||||
ncl groups config remove-mcp-server --id <group-id> --name calendar
|
||||
```
|
||||
2. Remove the `.calendar-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
```
|
||||
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
|
||||
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
|
||||
|
||||
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
|
||||
|
||||
## Credits & references
|
||||
|
||||
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
|
||||
|
||||
@@ -136,7 +136,15 @@ Use `per-thread` session mode so each PR/issue gets its own agent session.
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
Otherwise, restart the service to pick up the new channel.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ onecli agents secrets --id <agent-id>
|
||||
|
||||
```bash
|
||||
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
@@ -132,9 +131,7 @@ Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates tr
|
||||
|
||||
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
|
||||
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 automatically allows `mcp__gmail__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
@@ -146,42 +143,63 @@ Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cache
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
|
||||
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.gmail` entry and an `additionalMounts` entry for `.gmail-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
|
||||
|
||||
Merge these into the group's `container.json`:
|
||||
### List groups, pick which ones get Gmail
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"gmail": {
|
||||
"command": "gmail-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
|
||||
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.gmail-mcp",
|
||||
"containerPath": ".gmail-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```bash
|
||||
ncl groups list
|
||||
```
|
||||
|
||||
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
|
||||
### Register the MCP server
|
||||
|
||||
For each chosen `<group-id>`:
|
||||
|
||||
```bash
|
||||
ncl groups config add-mcp-server \
|
||||
--id <group-id> \
|
||||
--name gmail \
|
||||
--command gmail-mcp \
|
||||
--args '[]' \
|
||||
--env '{"GMAIL_OAUTH_PATH":"/workspace/extra/.gmail-mcp/gcp-oauth.keys.json","GMAIL_CREDENTIALS_PATH":"/workspace/extra/.gmail-mcp/credentials.json"}'
|
||||
```
|
||||
|
||||
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
|
||||
|
||||
### Add the `.gmail-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.gmail-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
|
||||
|
||||
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
@@ -206,17 +224,29 @@ Common signals:
|
||||
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
|
||||
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
|
||||
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
|
||||
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
|
||||
- Agent says "I don't have Gmail tools" → the `gmail` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (rebuild with `./container/build.sh`, with `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
|
||||
1. For each group that had Gmail wired, remove the MCP server from the DB:
|
||||
```bash
|
||||
ncl groups config remove-mcp-server --id <group-id> --name gmail
|
||||
```
|
||||
2. Remove the `.gmail-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
```
|
||||
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
|
||||
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
|
||||
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
|
||||
|
||||
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
|
||||
@@ -228,5 +258,5 @@ Common signals:
|
||||
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
|
||||
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
|
||||
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
|
||||
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
|
||||
- **Addresses:** [issue #1500](https://github.com/nanocoai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||
- **Related PRs:** [#1810](https://github.com/nanocoai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
|
||||
|
||||
@@ -75,9 +75,12 @@ If yes, ask the agent to schedule the lint task using the `schedule_task` MCP to
|
||||
|
||||
## Step 6: Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
Tell the user to test by sending a source to the wiki group.
|
||||
|
||||
@@ -156,7 +156,15 @@ The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
Otherwise, restart the service to pick up the new channel.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -89,9 +89,12 @@ docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
### Confirm mnemon hooks are registered
|
||||
|
||||
@@ -130,12 +130,15 @@ file, not from env vars. This file is bind-mounted into the container as `~/.cla
|
||||
|
||||
## 5. Build and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
pnpm run build
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## 6. Verify
|
||||
|
||||
@@ -54,7 +54,7 @@ git remote -v
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
@@ -122,9 +122,12 @@ OLLAMA_HOST=http://your-ollama-host:11434
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -229,19 +229,22 @@ echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Containe
|
||||
|
||||
### 7. Restart Service
|
||||
|
||||
Rebuild the main app and restart:
|
||||
Rebuild the main app and restart.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
Wait 3 seconds for service to start, then verify:
|
||||
```bash
|
||||
sleep 3
|
||||
launchctl list | grep nanoclaw # macOS
|
||||
# Linux: systemctl --user status nanoclaw
|
||||
launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)" # macOS
|
||||
# Linux: systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"
|
||||
```
|
||||
|
||||
### 8. Test Integration
|
||||
@@ -287,4 +290,4 @@ To remove Parallel AI integration:
|
||||
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
|
||||
3. Remove Web Research Tools section from groups/main/CLAUDE.md
|
||||
4. Rebuild: `./container/build.sh && pnpm run build`
|
||||
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
|
||||
5. Restart: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)` (macOS) or `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (Linux)
|
||||
|
||||
@@ -90,17 +90,21 @@ No output = success.
|
||||
|
||||
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
# optionally: --avatar /path/to/avatar.jpg
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
|
||||
# Linux
|
||||
systemctl --user stop nanoclaw
|
||||
systemctl --user stop $(systemd_unit)
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
systemctl --user start nanoclaw
|
||||
systemctl --user start $(systemd_unit)
|
||||
```
|
||||
|
||||
### Path B: Link as secondary device
|
||||
@@ -185,12 +189,16 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Wiring
|
||||
@@ -283,7 +291,7 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA
|
||||
|
||||
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
|
||||
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
|
||||
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
3. Service running: `launchctl print gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
|
||||
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
|
||||
|
||||
### Messages delivered but never arrive (null platformMsgId)
|
||||
|
||||
@@ -60,7 +60,7 @@ pnpm run build
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
||||
2. Name it (e.g., "NanoClaw") and select your workspace
|
||||
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
|
||||
@@ -41,9 +41,12 @@ DELETE FROM messaging_groups WHERE channel_type = 'wechat';
|
||||
|
||||
### 6. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
@@ -82,12 +82,15 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### 2. Start the service and scan the QR
|
||||
|
||||
Restart NanoClaw:
|
||||
Restart NanoClaw.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
|
||||
|
||||
@@ -244,12 +244,15 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
|
||||
|
||||
### "waiting for this message" on reactions
|
||||
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions:
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
systemctl --user stop nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user stop $(systemd_unit)
|
||||
rm store/auth/session-*.json
|
||||
systemctl --user start nanoclaw
|
||||
systemctl --user start $(systemd_unit)
|
||||
```
|
||||
|
||||
### Bot not responding
|
||||
@@ -257,7 +260,7 @@ systemctl --user start nanoclaw
|
||||
1. Auth exists: `test -f store/auth/creds.json`
|
||||
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
|
||||
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
4. Service running: `systemctl --user status nanoclaw`
|
||||
4. Service running: `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"`
|
||||
|
||||
### "conflict" disconnection
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ git remote -v
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
@@ -171,9 +171,12 @@ Expected: Both operations succeed.
|
||||
|
||||
### Full integration test
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
Send a message via WhatsApp and verify the agent responds.
|
||||
|
||||
@@ -88,15 +88,19 @@ Implementation:
|
||||
|
||||
## After Changes
|
||||
|
||||
Always tell the user:
|
||||
Always tell the user.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
# Rebuild and restart
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
# macOS:
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
# Linux:
|
||||
# systemctl --user restart nanoclaw
|
||||
# systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Example Interaction
|
||||
|
||||
@@ -9,7 +9,7 @@ Stand up the first NanoClaw agent for a channel and verify end-to-end delivery b
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
|
||||
- **Service running.** Check: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) or `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux). If stopped, tell the user to run `/setup` first.
|
||||
- **Target channel installed.** At least one `/add-<channel>` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`.
|
||||
- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel.
|
||||
|
||||
|
||||
@@ -236,9 +236,12 @@ pnpm run build
|
||||
|
||||
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first.
|
||||
|
||||
Restart the service:
|
||||
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux (systemd): `systemctl --user restart nanoclaw`
|
||||
Restart the service.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
|
||||
- Linux (systemd): `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
|
||||
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
@@ -41,7 +41,12 @@ npx tsx setup/index.ts --step mounts --force -- --empty
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config:
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
|
||||
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux: `systemctl --user restart nanoclaw`
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
@@ -34,7 +34,7 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
|
||||
|
||||
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
|
||||
|
||||
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
|
||||
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/nanocoai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
|
||||
|
||||
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Run `/update-nanoclaw` in Claude Code.
|
||||
|
||||
## How it works
|
||||
|
||||
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
|
||||
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/nanocoai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
|
||||
|
||||
**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.
|
||||
|
||||
@@ -69,7 +69,7 @@ If output is non-empty:
|
||||
Confirm remotes:
|
||||
- `git remote -v`
|
||||
If `upstream` is missing:
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
|
||||
- Add it: `git remote add upstream <user-provided-url>`
|
||||
- Then: `git fetch upstream --prune`
|
||||
|
||||
@@ -270,9 +270,9 @@ Show:
|
||||
Tell the user:
|
||||
- To rollback: `git reset --hard <backup-tag-from-step-1>`
|
||||
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
|
||||
- Restart the service to apply changes. Detect platform with `uname -s`:
|
||||
- **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- **Linux**: detect the service name with `systemctl --user list-units --type=service | grep nanoclaw | awk '{print $1}'`, then `systemctl --user restart <detected-name>`
|
||||
- Restart the service to apply changes. The unit/label names are per-install — derive them with `setup/lib/install-slug.sh`. Run from your NanoClaw project root:
|
||||
- **macOS (Darwin)**: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)`
|
||||
- **Linux**: `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (or, if you want to confirm the unit name first: `systemctl --user list-units --type=service | grep "$(. setup/lib/install-slug.sh && systemd_unit)"`)
|
||||
- **Manual** (no service found): restart `pnpm run dev`
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Check remotes:
|
||||
- `git remote -v`
|
||||
|
||||
If `upstream` is missing:
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
|
||||
- `git remote add upstream <url>`
|
||||
|
||||
Fetch:
|
||||
|
||||
@@ -40,7 +40,7 @@ git remote -v
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
@@ -128,9 +128,12 @@ echo 'ANTHROPIC_API_KEY=<key>' >> .env
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Then restart the service:
|
||||
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux: `systemctl --user restart nanoclaw`
|
||||
Then restart the service.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
- macOS: `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
|
||||
- Linux: `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
|
||||
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
|
||||
|
||||
2. Check logs for successful proxy startup:
|
||||
|
||||
@@ -38,6 +38,8 @@ Before using this skill, ensure:
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
# 1. Setup authentication (interactive)
|
||||
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
|
||||
@@ -49,9 +51,10 @@ pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/s
|
||||
|
||||
# 3. Rebuild host and restart service
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
# Verify: launchctl list | grep "$(launchd_label)" (macOS) or systemctl --user status $(systemd_unit) (Linux)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -270,16 +273,23 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
|
||||
|
||||
### 4. Restart Service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
**Verify success:**
|
||||
**Verify success.**
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or -
|
||||
# Linux: systemctl --user status nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl list | grep "$(launchd_label)" # macOS — should show PID and exit code 0 or -
|
||||
# Linux: systemctl --user status $(systemd_unit)
|
||||
```
|
||||
|
||||
## Usage via WhatsApp
|
||||
@@ -343,10 +353,13 @@ echo '{"content":"Test"}' | pnpm exec tsx .claude/skills/x-integration/scripts/p
|
||||
|
||||
### Authentication Expired
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
### Browser Lock Files
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'qwibitai/nanoclaw'
|
||||
if: github.repository == 'nanocoai/nanoclaw'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
update-tokens:
|
||||
if: github.repository == 'qwibitai/nanoclaw'
|
||||
if: github.repository == 'nanocoai/nanoclaw'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
|
||||
pnpm run format:fix
|
||||
if [ -n "$staged" ]; then
|
||||
echo "$staged" | xargs git add
|
||||
fi
|
||||
|
||||
+32
-3
@@ -2,12 +2,41 @@
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
|
||||
## [2.0.64] - 2026-05-18
|
||||
|
||||
## [Unreleased]
|
||||
- **`ncl destinations add` and `remove` through the approval flow now reach the receiver immediately.** Approved destinations weren't being projected into the receiving agent's local session state, so a freshly-added destination silently failed at `send_message` with `unknown destination`, and a removed destination stayed resolvable until the next container restart. Both now take effect the moment the approval executes. Direct (non-approval) calls were unaffected.
|
||||
|
||||
## [2.0.63] - 2026-05-15
|
||||
|
||||
Rollup release covering v2.0.55 through v2.0.63 — everything merged since the v2.0.54 tag. Starting with this release, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`; see [RELEASING.md](RELEASING.md).
|
||||
|
||||
- [BREAKING] **Service names are now per-install.** On v2 installs the launchd label and systemd unit are slugged to your project root: `com.nanoclaw.<sha1(projectRoot)[:8]>` and `nanoclaw-<slug>.service`. The old `com.nanoclaw` / `nanoclaw.service` names no longer match a real service — update any copy-pasted restart or status commands. Find your install's names with `source setup/lib/install-slug.sh && launchd_label` (macOS) or `systemd_unit` (Linux). The `ncl` transport-error help text and 26 skill files now use the canonical helper-driven pattern; see [setup/lib/install-slug.sh](setup/lib/install-slug.sh).
|
||||
- **Compaction destination reminder placement fixed.** The reminder injected after SDK auto-compaction now appears at the end of the compaction summary so it isn't stripped during truncation. Replaces the placement shipped in v2.0.54.
|
||||
- **Stronger message-wrapping enforcement.** The poll loop nudges the agent when its output lacks `<message>` wrapping, and `CLAUDE.md` core instructions now require wrapping even for single-destination agents. The welcome flow no longer double-greets.
|
||||
- **OneCLI credentials after MCP install.** MCP servers added through `add_mcp_server` now inherit OneCLI gateway routing — fixes the case where the agent kept asking for API keys after installing a new server.
|
||||
- **CLI scope hardening.** `scopeField` now fails closed when scope is missing, and `sessions get` is guarded against cross-group oracle access from group-scoped agents.
|
||||
- **gmail/gcal skills aligned with v2.** `/add-gmail-tool` and `/add-gcal-tool` now reflect the v2 container-config model — DB-backed mounts, no dead `TOOL_ALLOWLIST` edits, no `container.json` writes that get clobbered on next spawn. Manual sqlite3/JSON1 invocations corrected.
|
||||
- **Repo-rename cleanup.** Remaining `qwibitai/nanoclaw` references swept to `nanocoai/nanoclaw` across code and docs; CI workflow guards updated so they no longer no-op after the rename.
|
||||
- Slack scope checklist now includes `files:read` and `files:write` for skills that read or post attachments.
|
||||
- The internal-tag description in destination instructions no longer mentions scratchpads (which confused agents into routing them incorrectly).
|
||||
- Container startup is now graceful when the `on_wake` column is missing on older sessions DBs.
|
||||
|
||||
## [2.0.54] - 2026-05-10
|
||||
|
||||
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
|
||||
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
|
||||
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
|
||||
|
||||
## [2.0.48] - 2026-05-09
|
||||
|
||||
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
|
||||
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
|
||||
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
|
||||
|
||||
## [2.0.45] - 2026-05-08
|
||||
|
||||
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
|
||||
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
|
||||
- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:<id>` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default.
|
||||
|
||||
## [2.0.0] - 2026-04-22
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
|
||||
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
@@ -93,7 +96,7 @@ ncl help
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) |
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
|
||||
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
|
||||
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
|
||||
@@ -120,10 +123,32 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
|
||||
|
||||
One tier of agent self-modification today:
|
||||
|
||||
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
|
||||
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
|
||||
|
||||
## Container Config
|
||||
|
||||
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
|
||||
|
||||
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||
|
||||
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||
|
||||
## Container Restart
|
||||
|
||||
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
|
||||
|
||||
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
|
||||
|
||||
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
|
||||
|
||||
## Secrets / Credentials / OneCLI
|
||||
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@
|
||||
|
||||
1. **Check for existing work.** Search open PRs and issues before starting:
|
||||
```bash
|
||||
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
|
||||
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
|
||||
gh pr list --repo nanocoai/nanoclaw --search "<your feature>"
|
||||
gh issue list --repo nanocoai/nanoclaw --search "<your feature>"
|
||||
```
|
||||
If a related PR or issue exists, build on it rather than duplicating effort.
|
||||
|
||||
@@ -43,7 +43,7 @@ Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setu
|
||||
3. Claude walks through interactive setup (env vars, bot creation, etc.)
|
||||
|
||||
**Contributing a feature skill:**
|
||||
1. Fork `qwibitai/nanoclaw` and branch from `main`
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
@@ -39,7 +39,7 @@ bash nanoclaw.sh
|
||||
Run from a fresh v2 checkout next to your v1 install:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ NanoClawは同じコア機能を提供しますが、理解できる規模のコ
|
||||
## クイックスタート
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Releasing NanoClaw
|
||||
|
||||
Starting with v2.0.63, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`. Releases are cut manually by a maintainer, so there can be lag between a bump merging and its release being published. The intent is *timeliness*, not strict 1:1 correlation with every bump.
|
||||
|
||||
Each release ships:
|
||||
|
||||
- A tagged commit on `main` (`vX.Y.Z`).
|
||||
- A `CHANGELOG.md` entry under `## [<version>] - <YYYY-MM-DD>`.
|
||||
- A GitHub Release whose body mirrors the CHANGELOG entry plus a contributors section.
|
||||
|
||||
## When to cut a release
|
||||
|
||||
A release is cut by a maintainer publishing it. The trigger is a `package.json` bump on `main`, but the publish step is manual — there is no fixed schedule, and bumps that land back-to-back may be rolled into a single release (as v2.0.55 through v2.0.63 were). Cutting more frequently is preferable to batching: smaller releases are easier to read, pin, and revert.
|
||||
|
||||
## What goes in a release
|
||||
|
||||
`CHANGELOG.md` is the canonical record of user-visible change. The release body on GitHub mirrors it. Aim for:
|
||||
|
||||
- **Bold lead-ins** per major feature or fix, then a sentence-case prose explanation.
|
||||
- **`[BREAKING]` prefix** for any change that requires user action. Always include the workaround inline — never link to a separate doc for the fix.
|
||||
- **Doc links** for major features (relative paths into the repo, e.g. `[setup/lib/install-slug.sh](setup/lib/install-slug.sh)`).
|
||||
- **Inline commands** for actionable steps, in backticks.
|
||||
- **Minor items** as single plain bullets at the bottom of the entry, no bold lead-in.
|
||||
- **No PR numbers** in the user-facing prose. PR references can live in the GitHub Release's `## Contributors` section.
|
||||
|
||||
## Publishing the release
|
||||
|
||||
1. Bump `package.json` and add a `CHANGELOG.md` entry in the same commit (commit message: `chore: bump version to vX.Y.Z`).
|
||||
2. Once the bump commit lands on `main`, open a draft GitHub Release:
|
||||
- **Tag:** `vX.Y.Z`, target `main`.
|
||||
- **Title:** `vX.Y.Z` (bare version — descriptive content lives in the body, matching the CHANGELOG header pattern).
|
||||
- **Body:** copy the CHANGELOG entry verbatim. Append a `## Contributors` section listing every PR author who landed work in the release window. Append a `**Full Changelog**: https://github.com/nanocoai/nanoclaw/compare/<prev-tag>...vX.Y.Z` line at the bottom.
|
||||
3. If anyone in the window opened their first NanoClaw PR, add a `## New Contributors` section above `## Contributors`, with each first-timer's first PR link and an invite to Discord.
|
||||
4. Publish (not just save draft).
|
||||
|
||||
## Rollup releases
|
||||
|
||||
If multiple `package.json` bumps land between two GitHub Releases (as happened between v2.0.54 and v2.0.63), the next release is a rollup: its CHANGELOG entry covers everything merged since the last released tag, and the body opens with a one-line "Rollup release covering vX.Y.Z through vX.Y.W." note. After the catchup, return to one release per bump.
|
||||
|
||||
## Channels and stability
|
||||
|
||||
NanoClaw currently ships a single channel: every published release is a stable release.
|
||||
|
||||
- **Latest** — the most recent release on `main`, shown as "Latest release" on the GitHub Releases page. Consumers that want auto-bump follow GitHub's `/releases/latest` pointer.
|
||||
- **Stable** — currently identical to latest. NanoClaw has no separate stable branch and no pre-release/RC channel.
|
||||
- **Pinned** — any tagged release. Reproducible and the recommended choice for packagers and forks; published tags are not moved or retracted.
|
||||
|
||||
If a pre-release channel is introduced later (e.g. `vX.Y.Z-rc.N`), those releases will be marked "Pre-release" on GitHub so they do not become the `latest` pointer, and this section will be updated to describe the promotion path.
|
||||
|
||||
The tag is the source of truth — a GitHub Release's `target_commitish` always points to a tagged commit.
|
||||
@@ -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.116
|
||||
ARG CLAUDE_CODE_VERSION=2.1.128
|
||||
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.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
@@ -18,23 +18,23 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.138", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -165,12 +165,9 @@ function parseArgv(argv: string[]): {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0];
|
||||
|
||||
// Third positional is the target ID
|
||||
if (positional.length >= 3) {
|
||||
args.id = positional[2];
|
||||
}
|
||||
// Join all positionals with dashes. The dispatcher trims the last
|
||||
// segment as a target ID if the full name isn't a registered command.
|
||||
const command = positional.join('-');
|
||||
|
||||
return { command, args, json };
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ const instructions = [
|
||||
'2. Preserve the chronological message/reply sequence of recent exchanges.',
|
||||
' The agent needs to see: who said what, in what order, and from which destination.',
|
||||
'',
|
||||
'3. The `from` attribute identifies which destination sent the message.',
|
||||
' The agent MUST wrap all responses in <message to="name">...</message> blocks.',
|
||||
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
|
||||
'3. At the END of the compaction summary, include this verbatim reminder:',
|
||||
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
|
||||
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
|
||||
];
|
||||
|
||||
console.log(instructions.join('\n'));
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface RunnerConfig {
|
||||
agentGroupId: string;
|
||||
maxMessagesPerPrompt: number;
|
||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 10;
|
||||
@@ -43,6 +45,8 @@ export function loadConfig(): RunnerConfig {
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||
model: (raw.model as string) || undefined,
|
||||
effort: (raw.effort as string) || undefined,
|
||||
};
|
||||
|
||||
return _config;
|
||||
|
||||
@@ -196,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL
|
||||
content TEXT NOT NULL,
|
||||
on_wake INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE delivered (
|
||||
message_out_id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
import { getConfig } from '../config.js';
|
||||
import { openInboundDb, getOutboundDb } from './connection.js';
|
||||
|
||||
// Cache whether inbound.db has the on_wake column (added in v2.0.48).
|
||||
// The container opens inbound.db read-only, so it can't ALTER —
|
||||
// gracefully degrade when running against an older session DB.
|
||||
let _hasOnWake: boolean | null = null;
|
||||
function hasOnWakeColumn(db: ReturnType<typeof openInboundDb>): boolean {
|
||||
if (_hasOnWake !== null) return _hasOnWake;
|
||||
const cols = new Set(
|
||||
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
|
||||
);
|
||||
_hasOnWake = cols.has('on_wake');
|
||||
return _hasOnWake;
|
||||
}
|
||||
|
||||
export interface MessageInRow {
|
||||
id: string;
|
||||
seq: number | null;
|
||||
@@ -49,20 +62,22 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
* trigger=1 separately (see src/db/session-db.ts).
|
||||
*/
|
||||
export function getPendingMessages(): MessageInRow[] {
|
||||
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
|
||||
const inbound = openInboundDb();
|
||||
const outbound = getOutboundDb();
|
||||
|
||||
try {
|
||||
const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : '';
|
||||
const pending = inbound
|
||||
.prepare(
|
||||
`SELECT * FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
||||
${onWakeFilter}
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?`,
|
||||
LIMIT ?2`,
|
||||
)
|
||||
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
|
||||
@@ -27,18 +27,18 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('default to addressing the destination it came `from`');
|
||||
expect(prompt).toContain('from="name"');
|
||||
expect(prompt).toContain('`casa`');
|
||||
expect(prompt).toContain('`whatsapp-mg-17780`');
|
||||
});
|
||||
|
||||
it('requires explicit wrapping even for a single destination', () => {
|
||||
it('describes message wrapping for a single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Every response must be wrapped');
|
||||
expect(prompt).toContain('Wrap each delivered message');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('no configured destinations');
|
||||
expect(prompt).not.toContain('Default routing');
|
||||
expect(prompt).not.toContain('default to addressing');
|
||||
});
|
||||
|
||||
it('includes default-routing and wrapping instructions for single destination', () => {
|
||||
@@ -55,9 +55,9 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Every response must be wrapped');
|
||||
expect(prompt).toContain('Wrap each delivered message');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('default to addressing the destination it came `from`');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,17 +115,16 @@ function buildDestinationsSection(): string {
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
|
||||
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
||||
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
||||
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
|
||||
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
|
||||
'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `<message>` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").',
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ async function main(): Promise<void> {
|
||||
mcpServers,
|
||||
env: { ...process.env },
|
||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||
model: config.model,
|
||||
effort: config.effort,
|
||||
});
|
||||
|
||||
await runPollLoop({
|
||||
|
||||
@@ -295,115 +295,8 @@ describe('poll loop integration', () => {
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should inject destination reminder after a compacted event', async () => {
|
||||
// Two destinations — required for the reminder to fire (single-destination
|
||||
// groups have a fallback path that works without <message to="…"> wrapping).
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new CompactingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
|
||||
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
|
||||
expect(reminder).toBeDefined();
|
||||
expect(reminder).toContain('2 destinations');
|
||||
expect(reminder).toContain('discord-test');
|
||||
expect(reminder).toContain('discord-second');
|
||||
expect(reminder).toContain('<message to="name">');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should NOT inject destination reminder with a single destination', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new CompactingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||
controller.abort();
|
||||
|
||||
// Only the original prompt push (if any) — no reminder, since beforeEach
|
||||
// seeds exactly one destination.
|
||||
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
|
||||
expect(reminders).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider that emits a single compacted event mid-stream, then returns a
|
||||
* result. Captures every push() call so tests can assert on the injected
|
||||
* reminder content.
|
||||
*/
|
||||
class CompactingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
readonly pushes: string[] = [];
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query(_input: { prompt: string; cwd: string }) {
|
||||
const pushes = this.pushes;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
let resolveWaiter: (() => void) | null = null;
|
||||
|
||||
async function* events() {
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'init' as const, continuation: 'compaction-test-session' };
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
|
||||
|
||||
// Wait for poll-loop to push the reminder (or end / abort)
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveWaiter = resolve;
|
||||
// Belt-and-braces: don't hang forever if the reminder never arrives
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
|
||||
while (!ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveWaiter = resolve;
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push(message: string) {
|
||||
pushes.push(message);
|
||||
resolveWaiter?.();
|
||||
},
|
||||
end() {
|
||||
ended = true;
|
||||
resolveWaiter?.();
|
||||
},
|
||||
abort() {
|
||||
aborted = true;
|
||||
resolveWaiter?.();
|
||||
},
|
||||
events: events(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: run poll loop until aborted or timeout
|
||||
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
||||
return Promise.race([
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more.
|
||||
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
ncl <resource> <verb> [<id>] [--flags]
|
||||
ncl <resource> <verb> [--flags]
|
||||
ncl <resource> help
|
||||
ncl help
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
|
||||
|
||||
### Resources
|
||||
|
||||
Run `ncl help` for the full list. Common resources:
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) |
|
||||
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
|
||||
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
|
||||
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
|
||||
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
|
||||
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
|
||||
|
||||
### When to use
|
||||
|
||||
- **Looking up your own config** — `ncl groups get <your-group-id>` to see your agent group settings.
|
||||
- **Finding who you're wired to** — `ncl wirings list` to see which messaging groups route to which agent groups.
|
||||
- **Checking user roles** — `ncl roles list` to see who is an owner/admin.
|
||||
- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `ncl` rather than guessing.
|
||||
- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config.
|
||||
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||
- **Checking who's in your group** — `ncl members list`.
|
||||
- **Seeing your destinations** — `ncl destinations list`.
|
||||
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||
|
||||
### Access rules
|
||||
|
||||
Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it.
|
||||
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
|
||||
|
||||
### Approval flow
|
||||
|
||||
Write commands (create, update, delete, grant, revoke, add, remove) require admin approval. Here's what happens:
|
||||
Write commands require admin approval. Here's what happens:
|
||||
|
||||
1. You run the command (e.g. `ncl groups create --name "Research" --folder research`).
|
||||
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
|
||||
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
|
||||
3. An admin or owner gets a notification (on the same channel when possible) showing exactly what you requested, with approve/reject options.
|
||||
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
|
||||
4. Once the admin responds:
|
||||
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
|
||||
- **Rejected:** you get a system message saying the request was rejected.
|
||||
@@ -54,25 +56,28 @@ You don't need to poll or retry — the result arrives automatically.
|
||||
|
||||
```bash
|
||||
# Read commands (no approval needed)
|
||||
ncl groups list
|
||||
ncl groups get abc123
|
||||
ncl wirings list --messaging-group-id mg_xyz
|
||||
ncl roles list
|
||||
ncl wirings help
|
||||
ncl groups get
|
||||
ncl groups config get
|
||||
ncl sessions list
|
||||
ncl destinations list
|
||||
ncl members list
|
||||
|
||||
# Write commands (approval required)
|
||||
ncl groups create --name "Research" --folder research
|
||||
ncl groups update abc123 --name "Research v2"
|
||||
ncl roles grant --user telegram:jane --role admin
|
||||
ncl roles grant --user discord:bob --role admin --group abc123
|
||||
ncl members add --user-id telegram:jane --agent-group-id abc123
|
||||
ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz
|
||||
ncl groups restart
|
||||
ncl groups restart --rebuild --message "Config updated."
|
||||
ncl groups config update --model claude-sonnet-4-5-20250514
|
||||
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
|
||||
ncl groups config add-package --npm some-package
|
||||
ncl members add --user telegram:jane
|
||||
```
|
||||
|
||||
### Important
|
||||
|
||||
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
|
||||
|
||||
### Tips
|
||||
|
||||
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are required or updatable.
|
||||
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
|
||||
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
|
||||
- `list` supports filtering by any non-auto column (e.g. `ncl wirings list --messaging-group-id mg_xyz`). Default limit is 200 rows; override with `--limit N`.
|
||||
- For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete.
|
||||
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
|
||||
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Sending messages
|
||||
|
||||
Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `<message to="name">...</message>` blocks). See that section for the current destination list.
|
||||
**Every response** must be wrapped in `<message to="name">...</message>` blocks — even if you only have one destination. Bare text outside of `<message>` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names.
|
||||
|
||||
### Mid-turn updates (`send_message`)
|
||||
|
||||
|
||||
@@ -22,4 +22,4 @@ Use **`add_mcp_server`** to add an MCP server to your configuration. Browse avai
|
||||
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
|
||||
```
|
||||
|
||||
Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential.
|
||||
Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery).
|
||||
|
||||
@@ -14,13 +14,18 @@ afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
|
||||
function insertMessage(
|
||||
id: string,
|
||||
kind: string,
|
||||
content: object,
|
||||
opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 },
|
||||
) {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
|
||||
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content));
|
||||
}
|
||||
|
||||
describe('formatter', () => {
|
||||
@@ -131,6 +136,58 @@ describe('accumulate gate (trigger column)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on_wake filtering', () => {
|
||||
it('first poll returns on_wake=1 messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(true);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].id).toBe('m1');
|
||||
});
|
||||
|
||||
it('subsequent polls skip on_wake=1 messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(false);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('normal messages returned regardless of isFirstPoll', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
|
||||
expect(getPendingMessages(true)).toHaveLength(1);
|
||||
|
||||
// Reset: mark completed so we can re-test with a fresh message
|
||||
markCompleted(['m1']);
|
||||
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
|
||||
expect(getPendingMessages(false)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mixed batch: first poll returns both normal and on_wake messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(true);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('mixed batch: subsequent poll returns only normal messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(false);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].id).toBe('m1');
|
||||
});
|
||||
|
||||
it('on_wake defaults to 0 for inserts without explicit value', () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content)
|
||||
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
|
||||
)
|
||||
.run();
|
||||
// Should be returned even on non-first poll (on_wake=0)
|
||||
expect(getPendingMessages(false)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing', () => {
|
||||
it('should extract routing from messages', () => {
|
||||
getInboundDb()
|
||||
|
||||
@@ -67,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
clearStaleProcessingAcks();
|
||||
|
||||
let pollCount = 0;
|
||||
let isFirstPoll = true;
|
||||
while (true) {
|
||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
||||
const messages = getPendingMessages().filter((m) => m.kind !== 'system');
|
||||
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
|
||||
isFirstPoll = false;
|
||||
pollCount++;
|
||||
|
||||
// Periodic heartbeat so we know the loop is alive
|
||||
@@ -263,6 +265,7 @@ async function processQuery(
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
let unwrappedNudged = false;
|
||||
|
||||
// Concurrent polling: push follow-ups into the active query as they arrive.
|
||||
// We do NOT force-end the stream on silence — keeping the query open avoids
|
||||
@@ -336,6 +339,7 @@ async function processQuery(
|
||||
const keptIds = keep.map((m) => m.id);
|
||||
const prompt = formatMessages(keep);
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
unwrappedNudged = false;
|
||||
query.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
@@ -374,24 +378,18 @@ async function processQuery(
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
} else if (event.type === 'compacted') {
|
||||
// The SDK auto-compacted the conversation. After compaction the
|
||||
// model often drops the learned `<message to="…">` wrapping
|
||||
// discipline (the destinations are still in the system prompt,
|
||||
// but the behavioral pattern is summarized away). Inject a
|
||||
// reminder back into the live query so the next turn re-anchors
|
||||
// on the destination model. Only do this when there's >1
|
||||
// destination — single-destination groups have a fallback that
|
||||
// works without wrapping. See qwibitai/nanoclaw#2325.
|
||||
const destinations = getAllDestinations();
|
||||
if (destinations.length > 1) {
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
|
||||
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
|
||||
);
|
||||
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
if (hasUnwrapped && !unwrappedNudged) {
|
||||
unwrappedNudged = true;
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
|
||||
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
|
||||
`Your destinations: ${names}. ` +
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,9 +417,6 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
case 'progress':
|
||||
log(`Progress: ${event.message}`);
|
||||
break;
|
||||
case 'compacted':
|
||||
log(`Compacted: ${event.text}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +428,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
* The agent must always wrap output in <message to="name">...</message>
|
||||
* blocks, even with a single destination. Bare text is scratchpad only.
|
||||
*/
|
||||
function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
|
||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -468,9 +463,11 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
||||
}
|
||||
|
||||
if (sent === 0 && text.trim()) {
|
||||
const hasUnwrapped = sent === 0 && !!scratchpad;
|
||||
if (hasUnwrapped) {
|
||||
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
|
||||
}
|
||||
return { sent, hasUnwrapped };
|
||||
}
|
||||
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
|
||||
@@ -257,11 +257,15 @@ export class ClaudeProvider implements AgentProvider {
|
||||
private mcpServers: Record<string, McpServerConfig>;
|
||||
private env: Record<string, string | undefined>;
|
||||
private additionalDirectories?: string[];
|
||||
private model?: string;
|
||||
private effort?: string;
|
||||
|
||||
constructor(options: ProviderOptions = {}) {
|
||||
this.assistantName = options.assistantName;
|
||||
this.mcpServers = options.mcpServers ?? {};
|
||||
this.additionalDirectories = options.additionalDirectories;
|
||||
this.model = options.model;
|
||||
this.effort = options.effort;
|
||||
this.env = {
|
||||
...(options.env ?? {}),
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
|
||||
@@ -293,6 +297,9 @@ export class ClaudeProvider implements AgentProvider {
|
||||
],
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
env: this.env,
|
||||
model: this.model,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
effort: this.effort as any,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
@@ -329,7 +336,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
||||
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
|
||||
yield { type: 'compacted', text: `Context compacted${detail}.` };
|
||||
yield { type: 'result', text: `Context compacted${detail}.` };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||
const tn = message as { summary?: string };
|
||||
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
||||
|
||||
@@ -25,6 +25,16 @@ export interface ProviderOptions {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
env?: Record<string, string | undefined>;
|
||||
additionalDirectories?: string[];
|
||||
/**
|
||||
* Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
|
||||
* to the underlying SDK. If omitted, the SDK default is used.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
|
||||
* through to the underlying SDK. If omitted, the SDK default is used.
|
||||
*/
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
export interface QueryInput {
|
||||
@@ -79,12 +89,4 @@ export type ProviderEvent =
|
||||
* event (tool call, thinking, partial message, anything) so the
|
||||
* poll-loop's idle timer stays honest during long tool runs.
|
||||
*/
|
||||
| { type: 'activity' }
|
||||
/**
|
||||
* The provider's underlying SDK auto-compacted the conversation context.
|
||||
* The poll-loop reacts by injecting a destination reminder back into
|
||||
* the live query so the agent doesn't drop `<message to="…">` wrapping
|
||||
* after compaction. Distinct from `result` so it doesn't mark the turn
|
||||
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
|
||||
*/
|
||||
| { type: 'compacted'; text: string };
|
||||
| { type: 'activity' };
|
||||
|
||||
@@ -4,4 +4,4 @@ Your HTTP requests go through the OneCLI proxy, which injects real credentials a
|
||||
|
||||
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
|
||||
|
||||
If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI.
|
||||
If you get a `401`/`403`/`app_not_connected`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.
|
||||
|
||||
@@ -9,7 +9,7 @@ You've just been connected to a new user. This your time to shine and make a str
|
||||
|
||||
## What to do
|
||||
|
||||
1. Send a short, warm greeting using `send_message`
|
||||
1. Send a short, warm greeting
|
||||
2. State your name (from your system prompt / CLAUDE.md)
|
||||
3. Signal that you're capable of a lot — but don't list everything upfront. Be intriguing, not encyclopedic
|
||||
4. Ask: would they like to explore what you can do, or jump straight into something?
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Structure
|
||||
|
||||
**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
|
||||
**`nanocoai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
|
||||
|
||||
**Channel forks** (`nanoclaw-whatsapp`, `nanoclaw-telegram`, `nanoclaw-slack`, etc.) — each fork = upstream + one channel's code applied. Users clone upstream, then merge a fork into their clone to add a channel.
|
||||
|
||||
|
||||
+29
-1
@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
|
||||
|
||||
### 1.1 `agent_groups`
|
||||
|
||||
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB.
|
||||
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_groups (
|
||||
@@ -294,6 +294,32 @@ CREATE TABLE schema_version (
|
||||
);
|
||||
```
|
||||
|
||||
### 1.15 `container_configs`
|
||||
|
||||
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.
|
||||
|
||||
```sql
|
||||
CREATE TABLE container_configs (
|
||||
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
effort TEXT,
|
||||
image_tag TEXT,
|
||||
assistant_name TEXT,
|
||||
max_messages_per_prompt INTEGER,
|
||||
skills TEXT NOT NULL DEFAULT '"all"',
|
||||
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||
packages_npm TEXT NOT NULL DEFAULT '[]',
|
||||
additional_mounts TEXT NOT NULL DEFAULT '[]',
|
||||
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
|
||||
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration system
|
||||
@@ -313,6 +339,8 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
|
||||
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
|
||||
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
|
||||
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||
|
||||
|
||||
+16
-13
@@ -33,19 +33,22 @@ Every message landing in the session: user chat, scheduled task, recurring task,
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
|
||||
process_after TEXT,
|
||||
recurrence TEXT, -- cron expr for recurring
|
||||
series_id TEXT, -- groups occurrences of a recurring task
|
||||
tries INTEGER DEFAULT 0,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL -- JSON; shape depends on kind
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
|
||||
process_after TEXT,
|
||||
recurrence TEXT, -- cron expr for recurring
|
||||
series_id TEXT, -- groups occurrences of a recurring task
|
||||
tries INTEGER DEFAULT 0,
|
||||
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL, -- JSON; shape depends on kind
|
||||
source_session_id TEXT, -- agent-to-agent return path
|
||||
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll
|
||||
);
|
||||
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||
```
|
||||
|
||||
@@ -77,7 +77,7 @@ NanoClaw must live inside the workspace directory — Docker-in-Docker can only
|
||||
```bash
|
||||
# Clone to home first (virtiofs can corrupt git pack files during clone)
|
||||
cd ~
|
||||
git clone https://github.com/qwibitai/nanoclaw.git
|
||||
git clone https://github.com/nanocoai/nanoclaw.git
|
||||
|
||||
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
|
||||
WORKSPACE=/Users/you/nanoclaw-workspace
|
||||
@@ -347,7 +347,7 @@ docker sandbox network proxy <sandbox-name> \
|
||||
### Git clone fails with "inflate: data stream error"
|
||||
Clone to a non-workspace path first, then move:
|
||||
```bash
|
||||
cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
```
|
||||
|
||||
### WhatsApp QR code doesn't display
|
||||
|
||||
+22
-22
@@ -23,7 +23,7 @@ This replaces the previous `skills-engine/` system (three-way file merging, `.na
|
||||
|
||||
### Repository structure
|
||||
|
||||
The upstream repo (`qwibitai/nanoclaw`) maintains:
|
||||
The upstream repo (`nanocoai/nanoclaw`) maintains:
|
||||
|
||||
- `main` — core NanoClaw (no skill code)
|
||||
- `skill/discord` — main + Discord integration
|
||||
@@ -46,7 +46,7 @@ Skills are split into two categories:
|
||||
**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 (`qwibitai/nanoclaw-skills`)
|
||||
- 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:
|
||||
|
||||
@@ -78,7 +78,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "qwibitai/nanoclaw-skills"
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
|
||||
The marketplace repo uses Claude Code's plugin structure:
|
||||
|
||||
```
|
||||
qwibitai/nanoclaw-skills/
|
||||
nanocoai/nanoclaw-skills/
|
||||
.claude-plugin/
|
||||
marketplace.json # Plugin catalog
|
||||
plugins/
|
||||
@@ -213,7 +213,7 @@ A GitHub Action runs on every push to `main`:
|
||||
|
||||
### New users (recommended)
|
||||
|
||||
1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button)
|
||||
1. Fork `nanocoai/nanoclaw` on GitHub (click the Fork button)
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/<you>/nanoclaw.git
|
||||
@@ -229,9 +229,9 @@ Forking is recommended because it gives users a remote to push their customizati
|
||||
|
||||
### Existing users migrating from clone
|
||||
|
||||
Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations:
|
||||
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
|
||||
|
||||
1. Fork `qwibitai/nanoclaw` on GitHub
|
||||
1. Fork `nanocoai/nanoclaw` on GitHub
|
||||
2. Reroute remotes:
|
||||
```bash
|
||||
git remote rename origin upstream
|
||||
@@ -239,7 +239,7 @@ Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` an
|
||||
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` = qwibitai/nanoclaw
|
||||
3. From this point, `origin` = their fork, `upstream` = nanocoai/nanoclaw
|
||||
|
||||
### Existing users migrating from the old skills engine
|
||||
|
||||
@@ -316,7 +316,7 @@ git fetch upstream main
|
||||
git checkout -b my-fix upstream/main
|
||||
# Make changes
|
||||
git push origin my-fix
|
||||
# Create PR from my-fix to qwibitai/nanoclaw:main
|
||||
# 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.
|
||||
@@ -327,7 +327,7 @@ The flow below is for **feature skills** (branch-based). For utility skills (sel
|
||||
|
||||
### Contributor flow (feature skills)
|
||||
|
||||
1. Fork `qwibitai/nanoclaw`
|
||||
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`
|
||||
@@ -345,7 +345,7 @@ When a skill PR is reviewed and approved:
|
||||
```
|
||||
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 (`qwibitai/nanoclaw-skills`)
|
||||
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)
|
||||
@@ -388,7 +388,7 @@ If the community contributor is trusted, they can open a PR to add their marketp
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "qwibitai/nanoclaw-skills"
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
},
|
||||
"alice-nanoclaw-skills": {
|
||||
@@ -434,7 +434,7 @@ A flavor is a curated fork of NanoClaw — a combination of skills, custom chang
|
||||
|
||||
### Creating a flavor
|
||||
|
||||
1. Fork `qwibitai/nanoclaw`
|
||||
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
|
||||
@@ -462,7 +462,7 @@ Then setup continues normally (dependencies, auth, container, service).
|
||||
|
||||
After installation, the user's fork has three remotes:
|
||||
- `origin` — their fork (push customizations here)
|
||||
- `upstream` — `qwibitai/nanoclaw` (core updates)
|
||||
- `upstream` — `nanocoai/nanoclaw` (core updates)
|
||||
- `<flavor-name>` — the flavor fork (flavor updates)
|
||||
|
||||
### Updating a flavor
|
||||
@@ -538,14 +538,14 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
|
||||
|
||||
Before:
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/NanoClaw.git
|
||||
git clone https://github.com/nanocoai/NanoClaw.git
|
||||
cd NanoClaw
|
||||
claude
|
||||
```
|
||||
|
||||
After:
|
||||
```
|
||||
1. Fork qwibitai/nanoclaw on GitHub
|
||||
1. Fork nanocoai/nanoclaw on GitHub
|
||||
2. git clone https://github.com/<you>/nanoclaw.git
|
||||
3. cd nanoclaw
|
||||
4. claude
|
||||
@@ -556,8 +556,8 @@ After:
|
||||
|
||||
Updates to the setup flow:
|
||||
|
||||
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git`
|
||||
- Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration.
|
||||
- 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)
|
||||
@@ -573,7 +573,7 @@ Marketplace configuration so the official marketplace is auto-registered:
|
||||
"nanoclaw-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "qwibitai/nanoclaw-skills"
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -601,7 +601,7 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
|
||||
|
||||
### New infrastructure
|
||||
|
||||
- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
|
||||
- **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
|
||||
@@ -650,7 +650,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
|
||||
> **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 `qwibitai/nanoclaw` on GitHub
|
||||
> 1. Fork `nanocoai/nanoclaw` on GitHub
|
||||
> 2. Run:
|
||||
> ```
|
||||
> git remote rename origin upstream
|
||||
@@ -668,7 +668,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
|
||||
> **Contributing skills**
|
||||
>
|
||||
> To contribute a skill:
|
||||
> 1. Fork `qwibitai/nanoclaw`
|
||||
> 1. Fork `nanocoai/nanoclaw`
|
||||
> 2. Branch from `main` and make your code changes
|
||||
> 3. Open a regular PR
|
||||
>
|
||||
|
||||
+1
-1
@@ -240,7 +240,7 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
|
||||
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
|
||||
printf ' %s\n' "$(dim '4. Log out: exit')"
|
||||
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
|
||||
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
|
||||
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/nanocoai/nanoclaw.git && cd nanoclaw')"
|
||||
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
|
||||
exit 1
|
||||
;;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.45",
|
||||
"version": "2.0.64",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ A GitHub Action that calculates the size of your codebase in terms of tokens and
|
||||
## Usage
|
||||
|
||||
```yaml
|
||||
- uses: qwibitai/nanoclaw/repo-tokens@v1
|
||||
- uses: nanocoai/nanoclaw/repo-tokens@v1
|
||||
with:
|
||||
include: 'src/**/*.ts'
|
||||
exclude: 'src/**/*.test.ts'
|
||||
@@ -34,7 +34,7 @@ Repos using repo-tokens:
|
||||
|
||||
| Repo | Badge |
|
||||
|------|-------|
|
||||
| [NanoClaw](https://github.com/qwibitai/NanoClaw) |  |
|
||||
| [NanoClaw](https://github.com/nanocoai/NanoClaw) |  |
|
||||
|
||||
### Full workflow example
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: qwibitai/nanoclaw/repo-tokens@v1
|
||||
- uses: nanocoai/nanoclaw/repo-tokens@v1
|
||||
id: tokens
|
||||
with:
|
||||
include: 'src/**/*.ts'
|
||||
|
||||
@@ -114,7 +114,7 @@ runs:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
|
||||
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
|
||||
linked_badge = f'<a href="{repo_tokens_url}">{badge}</a>'
|
||||
new_content = marker_re.sub(rf"\1{linked_badge}\2", content)
|
||||
|
||||
@@ -148,7 +148,7 @@ runs:
|
||||
lx = label_w // 2
|
||||
vx = label_w + value_w // 2
|
||||
|
||||
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
|
||||
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total_w}" height="20" role="img" aria-label="{full_desc}">
|
||||
<title>{full_desc}</title>
|
||||
|
||||
@@ -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="150k tokens, 75% of context window">
|
||||
<title>150k tokens, 75% 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="174k tokens, 87% of context window">
|
||||
<title>174k tokens, 87% 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"/>
|
||||
@@ -7,7 +7,7 @@
|
||||
<clipPath id="r">
|
||||
<rect width="90" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
|
||||
<a xlink:href="https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens">
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="52" height="20" fill="#555"/>
|
||||
<rect x="52" width="38" height="20" fill="#e05d44"/>
|
||||
@@ -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">150k</text>
|
||||
<text x="71" y="14">150k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">174k</text>
|
||||
<text x="71" y="14">174k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -47,6 +47,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
|
||||
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
@@ -231,6 +232,8 @@ async function main(): Promise<void> {
|
||||
granted_at: now,
|
||||
});
|
||||
}
|
||||
// Owner's agent group gets global CLI access
|
||||
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
|
||||
} else if (args.role === 'admin') {
|
||||
const alreadyAdmin = existingRoles.some(
|
||||
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
|
||||
|
||||
@@ -146,6 +146,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
' • chat:write',
|
||||
' • users:read',
|
||||
' • reactions:write',
|
||||
' • files:read, files:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
@@ -317,9 +318,9 @@ async function collectSlackUserId(): Promise<string> {
|
||||
[
|
||||
"To get your Slack member ID:",
|
||||
'',
|
||||
' 1. In Slack, click your profile picture (top right)',
|
||||
' 1. In Slack, click your profile picture (bottom left)',
|
||||
' 2. Click "Profile"',
|
||||
' 3. Click the three dots (⋯) → "Copy member ID"',
|
||||
' 3. Click the three dots (⋮) → "Copy member ID"',
|
||||
].join('\n'),
|
||||
'Find your Slack user ID',
|
||||
);
|
||||
|
||||
Regular → Executable
+4
-4
@@ -6,10 +6,10 @@
|
||||
# `upstream`, with `origin` pointing at the user's fork. The channels branch
|
||||
# only lives upstream, so a hardcoded `git fetch origin channels` fails for
|
||||
# forks. This helper walks `git remote -v`, picks the remote whose URL points
|
||||
# at qwibitai/nanoclaw, and prints its name.
|
||||
# at nanocoai/nanoclaw, and prints its name.
|
||||
#
|
||||
# Fallback: if no existing remote matches, add `upstream` pointing at
|
||||
# github.com/qwibitai/nanoclaw and return that — keeps forks without an
|
||||
# github.com/nanocoai/nanoclaw and return that — keeps forks without an
|
||||
# explicit upstream configured working on the first try.
|
||||
#
|
||||
# Explicit override: set NANOCLAW_CHANNELS_REMOTE=<name> to skip detection.
|
||||
@@ -23,7 +23,7 @@ resolve_channels_remote() {
|
||||
local remote url
|
||||
while IFS=$'\t' read -r remote url; do
|
||||
case "$url" in
|
||||
*qwibitai/nanoclaw*)
|
||||
*qwibitai/nanoclaw*|*nanocoai/nanoclaw*)
|
||||
printf '%s' "$remote"
|
||||
return 0
|
||||
;;
|
||||
@@ -33,6 +33,6 @@ resolve_channels_remote() {
|
||||
# No matching remote — add `upstream` and use it. Silent on failure so
|
||||
# callers see the eventual `git fetch` error rather than a cryptic
|
||||
# remote-add failure.
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git 2>/dev/null || true
|
||||
printf '%s' "upstream"
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async function getJson<T>(url: string, token: string, fetchImpl: FetchFn): Promi
|
||||
const res = await fetchImpl(url, {
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
'User-Agent': 'NanoClaw-Migration (https://github.com/qwibitai/nanoclaw, 2.x)',
|
||||
'User-Agent': 'NanoClaw-Migration (https://github.com/nanocoai/nanoclaw, 2.x)',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
+2
-1
@@ -105,6 +105,7 @@ function writeEnvOnecliUrl(url: string): void {
|
||||
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
||||
// and the redirect-based version probe fail. Bump deliberately when a
|
||||
// new CLI release ships.
|
||||
const ONECLI_GATEWAY_VERSION = '1.23.0';
|
||||
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
|
||||
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
|
||||
|
||||
@@ -153,7 +154,7 @@ function installOnecli(): { stdout: string; ok: boolean } {
|
||||
if (cleanup) stdout += cleanup + '\n';
|
||||
|
||||
// Gateway install (docker-compose based, no rate-limit concerns).
|
||||
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
|
||||
const gw = runInstall(`export ONECLI_VERSION=${ONECLI_GATEWAY_VERSION} && curl -fsSL onecli.sh/install | sh`);
|
||||
stdout += gw.stdout;
|
||||
if (!gw.ok) {
|
||||
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* One-time backfill: seed `container_configs` rows from existing
|
||||
* `groups/<folder>/container.json` files and `agent_groups.agent_provider`.
|
||||
*
|
||||
* Runs after migrations, before channel adapters start. Idempotent — skips
|
||||
* groups that already have a config row.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import type { McpServerConfig, AdditionalMountConfig } from './container-config.js';
|
||||
import { getAllAgentGroups } from './db/agent-groups.js';
|
||||
import { getContainerConfig, createContainerConfig } from './db/container-configs.js';
|
||||
import { log } from './log.js';
|
||||
import type { ContainerConfigRow } from './types.js';
|
||||
|
||||
interface LegacyContainerJson {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
packages?: { apt?: string[]; npm?: string[] };
|
||||
imageTag?: string;
|
||||
additionalMounts?: AdditionalMountConfig[];
|
||||
skills?: string[] | 'all';
|
||||
provider?: string;
|
||||
assistantName?: string;
|
||||
maxMessagesPerPrompt?: number;
|
||||
}
|
||||
|
||||
export function backfillContainerConfigs(): void {
|
||||
const groups = getAllAgentGroups();
|
||||
let backfilled = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
// Skip if already has a config row
|
||||
if (getContainerConfig(group.id)) continue;
|
||||
|
||||
// Read legacy container.json from disk
|
||||
const filePath = path.join(GROUPS_DIR, group.folder, 'container.json');
|
||||
let legacy: LegacyContainerJson = {};
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson;
|
||||
} catch (err) {
|
||||
log.warn('Backfill: failed to parse container.json, using defaults', {
|
||||
folder: group.folder,
|
||||
err: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DB agent_provider wins over file provider (matches old cascade)
|
||||
const provider = group.agent_provider || legacy.provider || null;
|
||||
|
||||
const row: ContainerConfigRow = {
|
||||
agent_group_id: group.id,
|
||||
provider,
|
||||
model: null,
|
||||
effort: null,
|
||||
image_tag: legacy.imageTag ?? null,
|
||||
assistant_name: legacy.assistantName ?? null,
|
||||
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
|
||||
skills: JSON.stringify(legacy.skills ?? 'all'),
|
||||
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
|
||||
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
|
||||
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
|
||||
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
|
||||
cli_scope: 'group',
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
createContainerConfig(row);
|
||||
backfilled++;
|
||||
}
|
||||
|
||||
if (backfilled > 0) {
|
||||
log.info('Backfilled container_configs from disk', { count: backfilled });
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import { readContainerConfig } from './container-config.js';
|
||||
import type { McpServerConfig } from './container-config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { log } from './log.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
@@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
|
||||
// Desired fragment set.
|
||||
const config = readContainerConfig(group.folder);
|
||||
const configRow = getContainerConfig(group.id);
|
||||
const mcpServers: Record<string, McpServerConfig> = configRow
|
||||
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
|
||||
: {};
|
||||
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
|
||||
|
||||
// Skill fragments — every skill that ships an `instructions.md`.
|
||||
@@ -75,13 +79,15 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
// Built-in module fragments — every MCP tool source file that ships a
|
||||
// sibling `<name>.instructions.md`. These describe how the agent should
|
||||
// use that module's MCP tools (schedule_task, install_packages, etc.).
|
||||
// Always included — these are built-in, not toggleable.
|
||||
// Skip cli.instructions.md when cli_scope is disabled.
|
||||
const cliDisabled = configRow?.cli_scope === 'disabled';
|
||||
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
|
||||
if (fs.existsSync(mcpToolsHostDir)) {
|
||||
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
|
||||
const match = entry.match(/^(.+)\.instructions\.md$/);
|
||||
if (!match) continue;
|
||||
const moduleName = match[1];
|
||||
if (moduleName === 'cli' && cliDisabled) continue;
|
||||
desired.set(`module-${moduleName}.md`, {
|
||||
type: 'symlink',
|
||||
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
|
||||
@@ -91,7 +97,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
|
||||
// MCP server fragments — inline instructions from container.json for
|
||||
// user-added external MCP servers.
|
||||
for (const [name, mcp] of Object.entries(config.mcpServers)) {
|
||||
for (const [name, mcp] of Object.entries(mcpServers)) {
|
||||
if (mcp.instructions) {
|
||||
desired.set(`mcp-${name}.md`, {
|
||||
type: 'inline',
|
||||
|
||||
+6
-29
@@ -21,6 +21,7 @@ import { formatResponse } from './format.js';
|
||||
import type { RequestFrame } from './frame.js';
|
||||
import { SocketTransport } from './socket-client.js';
|
||||
import type { Transport } from './transport.js';
|
||||
import { formatTransportError } from './transport-errors.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -85,20 +86,11 @@ function parseArgv(argv: string[]): {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Single word: `ncl help`
|
||||
// Two words: `ncl groups list`, `ncl groups help`
|
||||
// Three words: `ncl groups get abc123`
|
||||
let command: string;
|
||||
if (positional.length === 1) {
|
||||
command = positional[0];
|
||||
} else {
|
||||
command = `${positional[0]}-${positional[1]}`;
|
||||
}
|
||||
|
||||
// Third positional is the target ID
|
||||
if (positional.length >= 3) {
|
||||
args.id = positional[2];
|
||||
}
|
||||
// Join all positionals with dashes to form the command name.
|
||||
// If the full name isn't a command, the dispatcher will try trimming
|
||||
// the last segment and using it as the target ID (e.g. `groups get abc`
|
||||
// → command "groups-get", id "abc").
|
||||
const command = positional.join('-');
|
||||
|
||||
return { command, args, json };
|
||||
}
|
||||
@@ -114,21 +106,6 @@ function printUsage(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTransportError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
|
||||
return [
|
||||
`ncl: cannot reach NanoClaw host (${msg}).`,
|
||||
`Is the host running? Start it with: pnpm run dev`,
|
||||
`Or, if installed as a service:`,
|
||||
` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
|
||||
` Linux: systemctl --user restart nanoclaw`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
return `ncl: transport error: ${msg}\n`;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(2);
|
||||
|
||||
@@ -4,19 +4,38 @@
|
||||
* ncl help — list all resources and commands
|
||||
* ncl groups help — show group resource details (verbs, columns, enums)
|
||||
*/
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getResource, getResources } from '../crud.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import { listCommands, register } from '../registry.js';
|
||||
|
||||
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
|
||||
function getCliScope(ctx: CallerContext): string | undefined {
|
||||
if (ctx.caller !== 'agent') return undefined;
|
||||
return getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group';
|
||||
}
|
||||
|
||||
register({
|
||||
name: 'help',
|
||||
description: 'List available resources and commands.',
|
||||
access: 'open',
|
||||
parseArgs: () => ({}),
|
||||
handler: async () => {
|
||||
const resources = getResources();
|
||||
handler: async (_args, ctx) => {
|
||||
const cliScope = getCliScope(ctx);
|
||||
let resources = getResources();
|
||||
if (cliScope === 'group') {
|
||||
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
|
||||
}
|
||||
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (cliScope === 'group') {
|
||||
lines.push('CLI scope: group (--id and group args are auto-filled to your agent group)');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (resources.length > 0) {
|
||||
lines.push('Resources:');
|
||||
for (const r of resources) {
|
||||
@@ -61,18 +80,27 @@ export function registerResourceHelpCommands(): void {
|
||||
access: 'open',
|
||||
resource: res.plural,
|
||||
parseArgs: () => ({}),
|
||||
handler: async () => {
|
||||
handler: async (_args, ctx) => {
|
||||
const cliScope = getCliScope(ctx);
|
||||
const lines: string[] = [];
|
||||
lines.push(`${res.plural}: ${res.description}`);
|
||||
|
||||
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
|
||||
lines.push('');
|
||||
lines.push('Note: --id and group args are auto-filled to your agent group. You do not need to pass them.');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Verbs
|
||||
const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
|
||||
const idHint = idAutoFilled ? '' : ' <id>';
|
||||
const verbs: string[] = [];
|
||||
if (res.operations.list) verbs.push(`list [open]`);
|
||||
if (res.operations.get) verbs.push(`get <id> [open]`);
|
||||
if (res.operations.get) verbs.push(`get${idHint} [open]`);
|
||||
if (res.operations.create) verbs.push(`create [approval]`);
|
||||
if (res.operations.update) verbs.push(`update <id> [approval]`);
|
||||
if (res.operations.delete) verbs.push(`delete <id> [approval]`);
|
||||
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
|
||||
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
|
||||
if (res.customOperations) {
|
||||
for (const [verb, op] of Object.entries(res.customOperations)) {
|
||||
verbs.push(`${verb} [${op.access}] — ${op.description}`);
|
||||
@@ -83,9 +111,12 @@ export function registerResourceHelpCommands(): void {
|
||||
lines.push('');
|
||||
|
||||
// Columns
|
||||
const autoFilledFields =
|
||||
cliScope === 'group' ? new Set(['id', 'agent_group_id', 'group']) : new Set<string>();
|
||||
lines.push('Fields:');
|
||||
for (const col of res.columns) {
|
||||
const tags: string[] = [];
|
||||
if (autoFilledFields.has(col.name)) tags.push('auto-filled');
|
||||
if (col.generated) tags.push('auto');
|
||||
if (col.required) tags.push('required');
|
||||
if (col.updatable) tags.push('updatable');
|
||||
|
||||
+9
-1
@@ -52,6 +52,12 @@ export interface ResourceDef {
|
||||
description: string;
|
||||
/** Primary key column name. */
|
||||
idColumn: string;
|
||||
/**
|
||||
* Column that carries the agent group ID for group-scope enforcement.
|
||||
* Required on every resource in the CLI whitelist (groups, sessions,
|
||||
* destinations, members). When absent, post-handler filtering fails closed.
|
||||
*/
|
||||
scopeField?: string;
|
||||
columns: ColumnDef[];
|
||||
/** Which standard CRUD operations are enabled. */
|
||||
operations: {
|
||||
@@ -226,6 +232,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
description: `List all ${def.plural}.`,
|
||||
access: def.operations.list,
|
||||
resource: def.plural,
|
||||
generic: 'list',
|
||||
parseArgs: (raw) => normalizeArgs(raw),
|
||||
handler: genericList(def),
|
||||
});
|
||||
@@ -237,6 +244,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
description: `Get a ${def.name} by ID.`,
|
||||
access: def.operations.get,
|
||||
resource: def.plural,
|
||||
generic: 'get',
|
||||
parseArgs: (raw) => normalizeArgs(raw),
|
||||
handler: genericGet(def),
|
||||
});
|
||||
@@ -279,7 +287,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
if (def.customOperations) {
|
||||
for (const [verb, op] of Object.entries(def.customOperations)) {
|
||||
register({
|
||||
name: `${def.plural}-${verb}`,
|
||||
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
|
||||
description: op.description,
|
||||
access: op.access,
|
||||
resource: def.plural,
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
vi.mock('../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
|
||||
}));
|
||||
|
||||
const mockGetAgentGroup = vi.fn();
|
||||
vi.mock('../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (...args: unknown[]) => mockGetAgentGroup(...args),
|
||||
}));
|
||||
|
||||
const mockGetSession = vi.fn();
|
||||
vi.mock('../db/sessions.js', () => ({
|
||||
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||
}));
|
||||
|
||||
// dispatch's post-handler looks up the resource's `scopeField` via getResource.
|
||||
// The real resources aren't registered in this unit test, so mock it.
|
||||
const mockGetResource = vi.fn();
|
||||
vi.mock('./crud.js', () => ({
|
||||
getResource: (...args: unknown[]) => mockGetResource(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../modules/approvals/index.js', () => ({
|
||||
registerApprovalHandler: vi.fn(),
|
||||
requestApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
// Register a test command so dispatch has something to find
|
||||
import { register } from './registry.js';
|
||||
|
||||
register({
|
||||
name: 'test-cmd',
|
||||
description: 'test command (non-group resource)',
|
||||
resource: 'test',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-test',
|
||||
description: 'test command (groups resource)',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'general-cmd',
|
||||
description: 'test command (no resource, like help)',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'sessions-list',
|
||||
description: 'test command (sessions resource)',
|
||||
resource: 'sessions',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'destinations-list',
|
||||
description: 'test command (destinations resource)',
|
||||
resource: 'destinations',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'members-add',
|
||||
description: 'test command (members resource)',
|
||||
resource: 'members',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'wirings-list',
|
||||
description: 'test command (wirings resource — not allowed)',
|
||||
resource: 'wirings',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
// Commands that return data shaped like real resources (for post-handler filtering tests)
|
||||
register({
|
||||
name: 'groups-list-data',
|
||||
description: 'returns mock group rows',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
generic: 'list',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => [
|
||||
{ id: 'g1', name: 'my-group' },
|
||||
{ id: 'g2', name: 'other-group' },
|
||||
],
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'sessions-get-data',
|
||||
description: 'returns a mock session row',
|
||||
resource: 'sessions',
|
||||
access: 'open',
|
||||
generic: 'get',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({
|
||||
id: args.id,
|
||||
agent_group_id: (args as Record<string, unknown>).belongs_to ?? 'g1',
|
||||
}),
|
||||
});
|
||||
|
||||
// A custom op under the `groups` resource that returns a config-shaped object
|
||||
// (no `id` key). The post-handler must not touch this — only `generic` handlers.
|
||||
register({
|
||||
name: 'groups-config-get',
|
||||
description: 'custom op returning a config object (no id)',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => ({ agent_group_id: 'g1', model: 'opus' }),
|
||||
});
|
||||
|
||||
// The real `sessions-get` name — triggers the pre-handler ownership check.
|
||||
register({
|
||||
name: 'sessions-get',
|
||||
description: 'generic sessions get',
|
||||
resource: 'sessions',
|
||||
access: 'open',
|
||||
generic: 'get',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ id: (args as Record<string, unknown>).id, agent_group_id: 'g1' }),
|
||||
});
|
||||
|
||||
import { dispatch } from './dispatch.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: the four CLI-whitelisted resources with their real scopeFields.
|
||||
const scopeFields: Record<string, string> = {
|
||||
groups: 'id',
|
||||
sessions: 'agent_group_id',
|
||||
destinations: 'agent_group_id',
|
||||
members: 'agent_group_id',
|
||||
};
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }>>): CallerContext {
|
||||
return {
|
||||
caller: 'agent',
|
||||
sessionId: 's1',
|
||||
agentGroupId: 'g1',
|
||||
messagingGroupId: 'mg1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('CLI scope enforcement', () => {
|
||||
it('disabled: rejects all CLI requests from agent', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: auto-fills --id with caller agent group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe('g1');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: rejects cross-group access', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'other-group' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('scoped');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows same-group id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'g1' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('group: blocks cli_scope escalation', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('cli_scope');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: blocks cli-scope escalation (hyphenated)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: blocks non-group resources', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('test');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows general commands with no resource (e.g. help)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'general-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('group: allows sessions, auto-fills --agent_group_id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'sessions-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.agent_group_id).toBe('g1');
|
||||
// --id should NOT be auto-filled for sessions (it's session UUID, not group)
|
||||
expect(data.echo.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows destinations, auto-fills --id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'destinations-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe('g1');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows members, auto-fills --group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'members-add', args: { user: 'u1' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.group).toBe('g1');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: blocks non-whitelisted resources (wirings)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('wirings');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: rejects cross-group --agent_group_id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: '1', command: 'sessions-list', args: { agent_group_id: 'other-group' } },
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: rejects cross-group --group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: '1', command: 'members-add', args: { user: 'u1', group: 'other-group' } },
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('global: allows cross-group access', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'other-group' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('global: allows non-group resources', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('global: does not auto-fill --id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { foo: 'bar' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to group when cli_scope is missing', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({});
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('host caller bypasses CLI scope enforcement', async () => {
|
||||
// No config check should happen for host callers
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'any-group' } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(mockGetContainerConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Post-handler filtering ---
|
||||
|
||||
it('group: groups list filters out other groups', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as Array<{ id: string }>;
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].id).toBe('g1');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: sessions get rejects cross-group session', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'other-group' } },
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('different agent group');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: sessions get allows own-group session', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'g1' } },
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('global: no post-handler filtering', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as Array<{ id: string }>;
|
||||
expect(data).toHaveLength(2); // both groups returned
|
||||
}
|
||||
});
|
||||
|
||||
// --- Custom ops bypass post-handler row filtering (regression: #2392 review) ---
|
||||
|
||||
it('group: a custom op returning a non-row object is not falsely rejected', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
// groups-config-get is access:open and reachable by a group-scoped agent;
|
||||
// it returns { agent_group_id, model } with no `id` field. Before this fix
|
||||
// the post-handler compared data['id'] (undefined) and returned forbidden.
|
||||
const resp = await dispatch({ id: '1', command: 'groups-config-get', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect((resp.data as { model: string }).model).toBe('opus');
|
||||
}
|
||||
});
|
||||
|
||||
// --- sessions-get pre-handler ownership check (no existence oracle) ---
|
||||
|
||||
it('group: sessions-get returns "session not found" for a foreign session UUID', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's-x', agent_group_id: 'other-group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-x' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('handler-error');
|
||||
expect(resp.error.message).toContain('session not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: sessions-get returns "session not found" for a non-existent UUID', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue(undefined);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-nope' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('handler-error');
|
||||
expect(resp.error.message).toContain('session not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: sessions-get allows the caller’s own session', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's-mine', agent_group_id: 'g1' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-mine' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
// --- Fail-closed regression guard for a missing scopeField ---
|
||||
|
||||
it('group: generic list/get fails closed when the resource declares no scopeField', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetResource.mockReturnValue(undefined); // a whitelisted resource that forgot scopeField
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('not available in group scope');
|
||||
}
|
||||
});
|
||||
});
|
||||
+123
-2
@@ -6,18 +6,101 @@
|
||||
* Approval gating for risky calls from the container is the only branch
|
||||
* that differs by caller. Host callers and `open` commands run inline.
|
||||
*/
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { getAgentGroup } from '../db/agent-groups.js';
|
||||
import { getSession } from '../db/sessions.js';
|
||||
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
|
||||
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { getResource } from './crud.js';
|
||||
import { lookup } from './registry.js';
|
||||
|
||||
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
|
||||
const cmd = lookup(req.command);
|
||||
let cmd = lookup(req.command);
|
||||
|
||||
// Fallback: if the full command isn't registered, trim the last
|
||||
// dash-segment and treat it as the target ID. This lets clients join
|
||||
// all positional args with dashes (e.g. `ncl groups get abc123`
|
||||
// → command "groups-get-abc123" → trim → "groups-get" + id "abc123").
|
||||
if (!cmd) {
|
||||
const idx = req.command.lastIndexOf('-');
|
||||
if (idx > 0) {
|
||||
const shortened = req.command.slice(0, idx);
|
||||
const tail = req.command.slice(idx + 1);
|
||||
const fallback = lookup(shortened);
|
||||
if (fallback) {
|
||||
cmd = fallback;
|
||||
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cmd) {
|
||||
return err(req.id, 'unknown-command', `no command "${req.command}"`);
|
||||
}
|
||||
|
||||
// CLI scope enforcement for agent callers
|
||||
if (ctx.caller === 'agent') {
|
||||
const configRow = getContainerConfig(ctx.agentGroupId);
|
||||
const cliScope = configRow?.cli_scope ?? 'group';
|
||||
|
||||
if (cliScope === 'disabled') {
|
||||
return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.');
|
||||
}
|
||||
|
||||
if (cliScope === 'group') {
|
||||
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
// Only allow whitelisted resources and general commands (no resource, like help)
|
||||
if (cmd.resource && !allowed.has(cmd.resource)) {
|
||||
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
|
||||
}
|
||||
|
||||
// Enforce group scope on all agent-group-related args.
|
||||
// Different resources use different arg names for the agent group ID.
|
||||
// Only check --id for resources where it IS the agent group ID.
|
||||
const groupArgs = ['agent_group_id', 'group'] as const;
|
||||
for (const key of groupArgs) {
|
||||
if (req.args[key] && req.args[key] !== ctx.agentGroupId) {
|
||||
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
|
||||
}
|
||||
}
|
||||
if (
|
||||
(cmd.resource === 'groups' || cmd.resource === 'destinations') &&
|
||||
req.args.id &&
|
||||
req.args.id !== ctx.agentGroupId
|
||||
) {
|
||||
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
|
||||
}
|
||||
|
||||
// Block cli_scope changes from group-scoped agents (privilege escalation)
|
||||
if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) {
|
||||
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
|
||||
}
|
||||
|
||||
// Auto-fill agent-group-related args so the agent doesn't need
|
||||
// to pass its own group ID explicitly.
|
||||
const fill: Record<string, unknown> = {
|
||||
agent_group_id: req.args.agent_group_id ?? ctx.agentGroupId,
|
||||
group: req.args.group ?? ctx.agentGroupId,
|
||||
};
|
||||
// Only auto-fill --id for resources where it IS the agent group ID
|
||||
// (groups, destinations). For sessions/members --id is a different key.
|
||||
if (cmd.resource === 'groups' || cmd.resource === 'destinations') {
|
||||
fill.id = req.args.id ?? ctx.agentGroupId;
|
||||
}
|
||||
req = { ...req, args: { ...req.args, ...fill } };
|
||||
|
||||
// Fail-closed pre-handler check for sessions-get: returns "not found"
|
||||
// regardless of whether the UUID exists in another group, preventing an
|
||||
// existence oracle across group boundaries.
|
||||
if (cmd.resource === 'sessions' && req.command === 'sessions-get' && req.args.id) {
|
||||
const s = getSession(req.args.id as string);
|
||||
if (!s || s.agent_group_id !== ctx.agentGroupId) {
|
||||
return err(req.id, 'handler-error', `session not found: ${req.args.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval') {
|
||||
const session = getSession(ctx.sessionId);
|
||||
if (!session) {
|
||||
@@ -50,7 +133,45 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await cmd.handler(parsed, ctx);
|
||||
let data = await cmd.handler(parsed, ctx);
|
||||
|
||||
// Post-handler group-scope enforcement. Applies only to the auto-generated
|
||||
// `list` / `get` handlers (`cmd.generic`), which return raw DB rows carrying
|
||||
// the resource's `scopeField`:
|
||||
// - `list` → drop rows that don't belong to the caller's agent group
|
||||
// (covers `groups list`, where the generic list handler ignores
|
||||
// the auto-filled `--id`)
|
||||
// - `get` → reject if the single row belongs to another group
|
||||
// Custom operations return ad-hoc shapes (e.g. `groups config get` → a config
|
||||
// object with no `id`) and are NOT checked here — they would be falsely
|
||||
// rejected, and they're already pinned to the caller's group by the
|
||||
// pre-handler `--id` auto-fill (groups/destinations) or gated behind approval,
|
||||
// so they can't reach another group's data anyway.
|
||||
if (ctx.caller === 'agent' && cmd.resource && cmd.generic) {
|
||||
const configRow = getContainerConfig(ctx.agentGroupId);
|
||||
if ((configRow?.cli_scope ?? 'group') === 'group') {
|
||||
const def = getResource(cmd.resource);
|
||||
const groupField = def?.scopeField;
|
||||
if (!groupField) {
|
||||
// Fail closed: a whitelisted resource exposing list/get must declare
|
||||
// `scopeField` so its rows can be filtered.
|
||||
return err(req.id, 'forbidden', `"${cmd.resource}" is not available in group scope.`);
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
data = data.filter(
|
||||
(row) =>
|
||||
typeof row === 'object' &&
|
||||
row !== null &&
|
||||
(row as Record<string, unknown>)[groupField] === ctx.agentGroupId,
|
||||
);
|
||||
} else if (data && typeof data === 'object') {
|
||||
if ((data as Record<string, unknown>)[groupField] !== ctx.agentGroupId) {
|
||||
return err(req.id, 'forbidden', 'Resource belongs to a different agent group.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { id: req.id, ok: true, data };
|
||||
} catch (e) {
|
||||
return err(req.id, 'handler-error', errMsg(e));
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ErrorCode =
|
||||
| 'unknown-command'
|
||||
| 'invalid-args'
|
||||
| 'permission-denied'
|
||||
| 'forbidden'
|
||||
| 'approval-pending'
|
||||
| 'not-found'
|
||||
| 'handler-error'
|
||||
|
||||
@@ -15,6 +15,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
access: Access;
|
||||
/** Resource this command belongs to (for help grouping). */
|
||||
resource?: string;
|
||||
/**
|
||||
* Set on the auto-generated `list` / `get` handlers (see `registerResource`).
|
||||
* These return raw DB rows that carry the resource's `scopeField`, so the
|
||||
* dispatcher applies post-handler group-scope filtering to their output.
|
||||
* Custom operations return ad-hoc shapes and leave this undefined.
|
||||
*/
|
||||
generic?: 'list' | 'get';
|
||||
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
|
||||
parseArgs: (raw: Record<string, unknown>) => TArgs;
|
||||
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Regression test for #2465 — approval-path `ncl destinations add/remove`
|
||||
* must hydrate every active session's `inbound.db` `destinations` table,
|
||||
* not just the central `agent_destinations` row.
|
||||
*
|
||||
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
|
||||
* `caller: 'host'` after admin approval, so this test invokes dispatch
|
||||
* with the host caller — same code path as a real approval payload.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-destinations' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-destinations';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `destinations-add` / `destinations-remove` commands.
|
||||
import './destinations.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readSessionDestinations(agentGroupId: string, sessionId: string) {
|
||||
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
|
||||
const rows = db.prepare('SELECT name, type, agent_group_id FROM destinations ORDER BY name').all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
agent_group_id: string | null;
|
||||
}>;
|
||||
db.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe('destinations CLI custom ops project to inbound.db (#2465)', () => {
|
||||
const SOURCE = 'ag-source';
|
||||
const TARGET = 'ag-target';
|
||||
const SESSION_A = 'sess-source-1';
|
||||
const SESSION_B = 'sess-source-2';
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: SOURCE, name: 'source', folder: 'source', agent_provider: null, created_at: now() });
|
||||
createAgentGroup({ id: TARGET, name: 'target', folder: 'target', agent_provider: null, created_at: now() });
|
||||
|
||||
// Two active sessions for the source agent — both must receive the
|
||||
// projected destination row. Fixing only the "newest" session is a
|
||||
// common regression shape, so the second session catches that.
|
||||
for (const sid of [SESSION_A, SESSION_B]) {
|
||||
createSession({
|
||||
id: sid,
|
||||
agent_group_id: SOURCE,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder(SOURCE, sid);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('add: projects the new destination into every active session inbound.db', async () => {
|
||||
// Sanity: inbound.db starts with no destinations.
|
||||
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
|
||||
|
||||
// caller: 'host' is what the cli_command approval handler in dispatch.ts
|
||||
// uses when it re-enters dispatch after admin approval.
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'destinations-add',
|
||||
args: {
|
||||
agent_group_id: SOURCE,
|
||||
local_name: 'helper',
|
||||
target_type: 'agent',
|
||||
target_id: TARGET,
|
||||
},
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
|
||||
for (const sid of [SESSION_A, SESSION_B]) {
|
||||
const rows = readSessionDestinations(SOURCE, sid);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ name: 'helper', type: 'agent', agent_group_id: TARGET });
|
||||
}
|
||||
});
|
||||
|
||||
it('remove: clears the destination from every active session inbound.db', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'req-add',
|
||||
command: 'destinations-add',
|
||||
args: { agent_group_id: SOURCE, local_name: 'helper', target_type: 'agent', target_id: TARGET },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
// Precondition: add succeeded and projected to both sessions.
|
||||
expect(readSessionDestinations(SOURCE, SESSION_A)).toHaveLength(1);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_B)).toHaveLength(1);
|
||||
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-remove',
|
||||
command: 'destinations-remove',
|
||||
args: { agent_group_id: SOURCE, local_name: 'helper' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,32 @@
|
||||
import { getDb } from '../../db/connection.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSessionsByAgentGroup } from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
/**
|
||||
* Project the agent's central `agent_destinations` rows into every active
|
||||
* session's `inbound.db`. The agent-to-agent module is optional, so we guard
|
||||
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily —
|
||||
* same pattern as container-runner.ts on container wake.
|
||||
*
|
||||
* Called from both `add` and `remove` so the live container picks up the
|
||||
* change without waiting for the next spawn. Without this, send_message to
|
||||
* the new local_name silently drops with "unknown destination" until restart.
|
||||
* See the destination-projection invariant in
|
||||
* src/modules/agent-to-agent/db/agent-destinations.ts.
|
||||
*/
|
||||
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
|
||||
for (const session of getSessionsByAgentGroup(agentGroupId)) {
|
||||
try {
|
||||
writeDestinations(agentGroupId, session.id);
|
||||
} catch (err) {
|
||||
log.warn('Failed to project destinations to session inbound.db', { agentGroupId, sessionId: session.id, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'destination',
|
||||
plural: 'destinations',
|
||||
@@ -8,6 +34,7 @@ registerResource({
|
||||
description:
|
||||
'Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.',
|
||||
idColumn: 'agent_group_id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{
|
||||
name: 'agent_group_id',
|
||||
@@ -55,6 +82,7 @@ registerResource({
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
)
|
||||
.run(agentGroupId, localName, targetType, targetId);
|
||||
await projectDestinationsToSessions(agentGroupId);
|
||||
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
|
||||
},
|
||||
},
|
||||
@@ -70,6 +98,7 @@ registerResource({
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||
.run(agentGroupId, localName);
|
||||
if (result.changes === 0) throw new Error('destination not found');
|
||||
await projectDestinationsToSessions(agentGroupId);
|
||||
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
|
||||
},
|
||||
},
|
||||
|
||||
+254
-8
@@ -1,5 +1,36 @@
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import {
|
||||
getContainerConfig,
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
} from '../../db/container-configs.js';
|
||||
import type { ContainerConfigRow } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
/** Deserialize JSON columns for display. */
|
||||
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
|
||||
return {
|
||||
agent_group_id: row.agent_group_id,
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
effort: row.effort,
|
||||
image_tag: row.image_tag,
|
||||
assistant_name: row.assistant_name,
|
||||
max_messages_per_prompt: row.max_messages_per_prompt,
|
||||
skills: JSON.parse(row.skills),
|
||||
mcp_servers: JSON.parse(row.mcp_servers),
|
||||
packages_apt: JSON.parse(row.packages_apt),
|
||||
packages_npm: JSON.parse(row.packages_npm),
|
||||
additional_mounts: JSON.parse(row.additional_mounts),
|
||||
cli_scope: row.cli_scope,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'group',
|
||||
plural: 'groups',
|
||||
@@ -7,6 +38,7 @@ registerResource({
|
||||
description:
|
||||
'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.',
|
||||
idColumn: 'id',
|
||||
scopeField: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{
|
||||
@@ -23,15 +55,229 @@ registerResource({
|
||||
'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'agent_provider',
|
||||
type: 'string',
|
||||
description:
|
||||
'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-<provider>.',
|
||||
updatable: true,
|
||||
default: null,
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
customOperations: {
|
||||
restart: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Restart containers for a group. Use --id <group-id> [--rebuild] [--message <text>]. ' +
|
||||
'From inside a container, --id is auto-filled and only the calling session is restarted. ' +
|
||||
'--rebuild rebuilds the container image first (required for package changes). ' +
|
||||
'--message sets an on-wake instruction for the fresh container to act on when it starts — ' +
|
||||
'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' +
|
||||
'Without --message, the container stops and only starts again on the next user message.',
|
||||
handler: async (args, ctx) => {
|
||||
const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined);
|
||||
if (!id) throw new Error('--id is required');
|
||||
if (args.rebuild) {
|
||||
await buildAgentGroupImage(id);
|
||||
}
|
||||
const message = args.message as string | undefined;
|
||||
|
||||
// From an agent: scope to the calling session only
|
||||
if (ctx.caller === 'agent') {
|
||||
if (message) {
|
||||
writeSessionMessage(id, ctx.sessionId, {
|
||||
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }),
|
||||
onWake: 1,
|
||||
});
|
||||
}
|
||||
killContainer(
|
||||
ctx.sessionId,
|
||||
'restarted via ncl',
|
||||
message
|
||||
? () => {
|
||||
const s = getSession(ctx.sessionId);
|
||||
if (s) wakeContainer(s);
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
return { restarted: 1, rebuilt: !!args.rebuild };
|
||||
}
|
||||
|
||||
// From the host: restart all running containers in the group
|
||||
const count = restartAgentGroupContainers(id, 'restarted via ncl', message);
|
||||
return { restarted: count, rebuilt: !!args.rebuild };
|
||||
},
|
||||
},
|
||||
'config get': {
|
||||
access: 'open',
|
||||
description: 'Show the container config for a group. Use --id <group-id>.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
return presentConfig(row);
|
||||
},
|
||||
},
|
||||
'config update': {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
|
||||
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
|
||||
const updates: Partial<
|
||||
Pick<
|
||||
ContainerConfigRow,
|
||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
||||
>
|
||||
> = {};
|
||||
if (args.provider !== undefined) updates.provider = args.provider as string;
|
||||
if (args.model !== undefined) updates.model = args.model as string;
|
||||
if (args.effort !== undefined) updates.effort = args.effort as string;
|
||||
if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string;
|
||||
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
|
||||
if (args.max_messages_per_prompt !== undefined)
|
||||
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
|
||||
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
|
||||
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
|
||||
if (!['disabled', 'group', 'global'].includes(scope)) {
|
||||
throw new Error('--cli-scope must be one of: disabled, group, global');
|
||||
}
|
||||
updates.cli_scope = scope;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error(
|
||||
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
|
||||
);
|
||||
}
|
||||
|
||||
updateContainerConfigScalars(id, updates);
|
||||
|
||||
const updated = getContainerConfig(id)!;
|
||||
return presentConfig(updated);
|
||||
},
|
||||
},
|
||||
'config add-mcp-server': {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Add an MCP server to a group. Requires `ncl groups restart` to take effect. ' +
|
||||
'Use --id <group-id> --name <server-name> --command <cmd> [--args <json-array>] [--env <json-object>].',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const name = args.name as string;
|
||||
if (!name) throw new Error('--name is required');
|
||||
const command = args.command as string;
|
||||
if (!command) throw new Error('--command is required');
|
||||
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
|
||||
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
|
||||
servers[name] = {
|
||||
command,
|
||||
args: args.args ? (JSON.parse(args.args as string) as string[]) : [],
|
||||
env: args.env ? (JSON.parse(args.env as string) as Record<string, string>) : {},
|
||||
};
|
||||
updateContainerConfigJson(id, 'mcp_servers', servers);
|
||||
|
||||
return { added: name, servers };
|
||||
},
|
||||
},
|
||||
'config remove-mcp-server': {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Remove an MCP server from a group. Requires `ncl groups restart` to take effect. Use --id <group-id> --name <server-name>.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const name = args.name as string;
|
||||
if (!name) throw new Error('--name is required');
|
||||
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
|
||||
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
|
||||
if (!servers[name]) throw new Error(`MCP server "${name}" not found`);
|
||||
delete servers[name];
|
||||
updateContainerConfigJson(id, 'mcp_servers', servers);
|
||||
|
||||
return { removed: name };
|
||||
},
|
||||
},
|
||||
'config add-package': {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Add a package to a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
|
||||
const apt = args.apt as string | undefined;
|
||||
const npm = args.npm as string | undefined;
|
||||
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
|
||||
|
||||
if (apt) {
|
||||
const existing = JSON.parse(row.packages_apt) as string[];
|
||||
if (!existing.includes(apt)) {
|
||||
existing.push(apt);
|
||||
updateContainerConfigJson(id, 'packages_apt', existing);
|
||||
}
|
||||
}
|
||||
if (npm) {
|
||||
const existing = JSON.parse(row.packages_npm) as string[];
|
||||
if (!existing.includes(npm)) {
|
||||
existing.push(npm);
|
||||
updateContainerConfigJson(id, 'packages_npm', existing);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
added: { apt: apt || null, npm: npm || null },
|
||||
note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.',
|
||||
};
|
||||
},
|
||||
},
|
||||
'config remove-package': {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Remove a package from a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
|
||||
const row = getContainerConfig(id);
|
||||
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||
|
||||
const apt = args.apt as string | undefined;
|
||||
const npm = args.npm as string | undefined;
|
||||
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
|
||||
|
||||
if (apt) {
|
||||
const existing = JSON.parse(row.packages_apt) as string[];
|
||||
const filtered = existing.filter((p) => p !== apt);
|
||||
updateContainerConfigJson(id, 'packages_apt', filtered);
|
||||
}
|
||||
if (npm) {
|
||||
const existing = JSON.parse(row.packages_npm) as string[];
|
||||
const filtered = existing.filter((p) => p !== npm);
|
||||
updateContainerConfigJson(id, 'packages_npm', filtered);
|
||||
}
|
||||
|
||||
return {
|
||||
removed: { apt: apt || null, npm: npm || null },
|
||||
note: 'Image rebuild required for package changes to take effect.',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ registerResource({
|
||||
description:
|
||||
'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".',
|
||||
idColumn: 'user_id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{
|
||||
name: 'user_id',
|
||||
|
||||
@@ -7,6 +7,7 @@ registerResource({
|
||||
description:
|
||||
'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.',
|
||||
idColumn: 'id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../install-slug.js';
|
||||
import { formatTransportError } from './transport-errors.js';
|
||||
|
||||
describe('formatTransportError', () => {
|
||||
it('renders per-install service names on ENOENT, not the bare v1 names', () => {
|
||||
const out = formatTransportError(new Error('connect ENOENT /tmp/nanoclaw.sock'));
|
||||
|
||||
// Regression for #2484: pre-fix, this string was a hardcoded
|
||||
// `com.nanoclaw` / `nanoclaw`, which doesn't match the actual
|
||||
// v2 per-install slug-suffixed unit and label.
|
||||
expect(out).toContain(`gui/$(id -u)/${getLaunchdLabel()}`);
|
||||
expect(out).toContain(`systemctl --user restart ${getSystemdUnit()}`);
|
||||
expect(out).not.toMatch(/gui\/\$\(id -u\)\/com\.nanoclaw\b(?!-v2)/);
|
||||
expect(out).not.toMatch(/systemctl --user restart nanoclaw\b(?!-v2)/);
|
||||
});
|
||||
|
||||
it('renders the same on ECONNREFUSED', () => {
|
||||
const out = formatTransportError(new Error('connect ECONNREFUSED'));
|
||||
expect(out).toContain(getLaunchdLabel());
|
||||
expect(out).toContain(getSystemdUnit());
|
||||
});
|
||||
|
||||
it('falls back to a generic transport error for other failures', () => {
|
||||
const out = formatTransportError(new Error('some unrelated failure'));
|
||||
expect(out).toBe('ncl: transport error: some unrelated failure\n');
|
||||
expect(out).not.toContain('launchctl');
|
||||
expect(out).not.toContain('systemctl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../install-slug.js';
|
||||
|
||||
export function formatTransportError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
|
||||
// `bin/ncl` cd's to the project root before exec'ing client.ts, so
|
||||
// process.cwd() is the install dir — install-slug helpers pick up
|
||||
// the right per-checkout suffix.
|
||||
return [
|
||||
`ncl: cannot reach NanoClaw host (${msg}).`,
|
||||
`Is the host running? Start it with: pnpm run dev`,
|
||||
`Or, if installed as a service:`,
|
||||
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
|
||||
` Linux: systemctl --user restart ${getSystemdUnit()}`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
return `ncl: transport error: ${msg}\n`;
|
||||
}
|
||||
+42
-83
@@ -1,26 +1,25 @@
|
||||
/**
|
||||
* Per-group container config, stored as a plain JSON file at
|
||||
* `groups/<folder>/container.json`. Mounted read-only inside the container
|
||||
* at `/workspace/agent/container.json` — the runner reads it at startup but
|
||||
* cannot modify it. Config changes go through the self-mod approval flow.
|
||||
* Container config types and materialization.
|
||||
*
|
||||
* All fields are optional — a missing file or a partial file both resolve
|
||||
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
|
||||
* worth the ceremony here since there's only one writer in practice: the
|
||||
* host, from the delivery thread that processes approved system actions).
|
||||
* Source of truth is the `container_configs` table in the central DB.
|
||||
* This module provides:
|
||||
* - Type definitions for the file shape (read by the container runner)
|
||||
* - `materializeContainerJson()` — writes `groups/<folder>/container.json`
|
||||
* from the DB at spawn time
|
||||
* - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import type { AgentGroup, ContainerConfigRow } from './types.js';
|
||||
|
||||
export interface McpServerConfig {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
// Optional always-in-context guidance. When set, the host writes the
|
||||
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
|
||||
// into the composed CLAUDE.md.
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
@@ -30,101 +29,61 @@ export interface AdditionalMountConfig {
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/** Shape of the materialized `container.json` file read by the container runner. */
|
||||
export interface ContainerConfig {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
packages: { apt: string[]; npm: string[] };
|
||||
imageTag?: string;
|
||||
additionalMounts: AdditionalMountConfig[];
|
||||
/** Which skills to enable — array of skill names or "all" (default). */
|
||||
skills: string[] | 'all';
|
||||
/** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */
|
||||
provider?: string;
|
||||
/** Agent group display name (used in transcript archiving). */
|
||||
groupName?: string;
|
||||
/** Assistant display name (used in system prompt / responses). */
|
||||
assistantName?: string;
|
||||
/** Agent group ID — set by the host, read by the runner. */
|
||||
agentGroupId?: string;
|
||||
/** Max messages per prompt. Falls back to code default if unset. */
|
||||
maxMessagesPerPrompt?: number;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
function emptyConfig(): ContainerConfig {
|
||||
/** Build a `ContainerConfig` from a DB row + agent group identity. */
|
||||
export function configFromDb(row: ContainerConfigRow, group: AgentGroup): ContainerConfig {
|
||||
return {
|
||||
mcpServers: {},
|
||||
packages: { apt: [], npm: [] },
|
||||
additionalMounts: [],
|
||||
skills: 'all',
|
||||
mcpServers: JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>,
|
||||
packages: {
|
||||
apt: JSON.parse(row.packages_apt) as string[],
|
||||
npm: JSON.parse(row.packages_npm) as string[],
|
||||
},
|
||||
imageTag: row.image_tag ?? undefined,
|
||||
additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[],
|
||||
skills: JSON.parse(row.skills) as string[] | 'all',
|
||||
provider: row.provider ?? undefined,
|
||||
groupName: group.name,
|
||||
assistantName: row.assistant_name ?? group.name,
|
||||
agentGroupId: group.id,
|
||||
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
|
||||
model: row.model ?? undefined,
|
||||
effort: row.effort ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function configPath(folder: string): string {
|
||||
return path.join(GROUPS_DIR, folder, 'container.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the container config for a group, returning sensible defaults for
|
||||
* any missing fields (or an entirely empty config if the file is absent).
|
||||
* Never throws for missing / malformed files — corruption logs a warning
|
||||
* via console.error and falls back to empty.
|
||||
* Materialize `container.json` from the DB. Called at spawn time so the
|
||||
* container always sees fresh config. Returns the `ContainerConfig` for
|
||||
* use by the caller (buildMounts, buildContainerArgs, etc.).
|
||||
*/
|
||||
export function readContainerConfig(folder: string): ContainerConfig {
|
||||
const p = configPath(folder);
|
||||
if (!fs.existsSync(p)) return emptyConfig();
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial<ContainerConfig>;
|
||||
return {
|
||||
mcpServers: raw.mcpServers ?? {},
|
||||
packages: {
|
||||
apt: raw.packages?.apt ?? [],
|
||||
npm: raw.packages?.npm ?? [],
|
||||
},
|
||||
imageTag: raw.imageTag,
|
||||
additionalMounts: raw.additionalMounts ?? [],
|
||||
skills: raw.skills ?? 'all',
|
||||
provider: raw.provider,
|
||||
groupName: raw.groupName,
|
||||
assistantName: raw.assistantName,
|
||||
agentGroupId: raw.agentGroupId,
|
||||
maxMessagesPerPrompt: raw.maxMessagesPerPrompt,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[container-config] failed to parse ${p}: ${String(err)}`);
|
||||
return emptyConfig();
|
||||
}
|
||||
}
|
||||
export function materializeContainerJson(agentGroupId: string): ContainerConfig {
|
||||
const group = getAgentGroup(agentGroupId);
|
||||
if (!group) throw new Error(`Agent group not found: ${agentGroupId}`);
|
||||
|
||||
/**
|
||||
* Write the container config for a group, creating the groups/<folder>/
|
||||
* directory if necessary. Pretty-printed JSON so diffs in the activation
|
||||
* flow are reviewable.
|
||||
*/
|
||||
export function writeContainerConfig(folder: string, config: ContainerConfig): void {
|
||||
const p = configPath(folder);
|
||||
const row = getContainerConfig(agentGroupId);
|
||||
if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`);
|
||||
|
||||
const config = configFromDb(row, group);
|
||||
|
||||
const p = path.join(GROUPS_DIR, group.folder, 'container.json');
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a mutator function to a group's container config and persist the
|
||||
* result. Convenient for append-style changes like `install_packages` and
|
||||
* `add_mcp_server` handlers.
|
||||
*/
|
||||
export function updateContainerConfig(folder: string, mutate: (config: ContainerConfig) => void): ContainerConfig {
|
||||
const config = readContainerConfig(folder);
|
||||
mutate(config);
|
||||
writeContainerConfig(folder, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an empty container.json for a group if one doesn't already
|
||||
* exist. Idempotent — used from `group-init.ts`.
|
||||
*/
|
||||
export function initContainerConfig(folder: string): boolean {
|
||||
const p = configPath(folder);
|
||||
if (fs.existsSync(p)) return false;
|
||||
writeContainerConfig(folder, emptyConfig());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('./log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockIsContainerRunning = vi.fn<(id: string) => boolean>();
|
||||
const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void) => void>();
|
||||
const mockWakeContainer = vi.fn();
|
||||
vi.mock('./container-runner.js', () => ({
|
||||
isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string),
|
||||
killContainer: (...args: unknown[]) =>
|
||||
mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined),
|
||||
wakeContainer: (...args: unknown[]) => mockWakeContainer(...args),
|
||||
}));
|
||||
|
||||
const mockGetSessionsByAgentGroup = vi.fn();
|
||||
const mockGetSession = vi.fn();
|
||||
vi.mock('./db/sessions.js', () => ({
|
||||
getSessionsByAgentGroup: (...args: unknown[]) => mockGetSessionsByAgentGroup(...args),
|
||||
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||
}));
|
||||
|
||||
const mockWriteSessionMessage = vi.fn();
|
||||
vi.mock('./session-manager.js', () => ({
|
||||
writeSessionMessage: (...args: unknown[]) => mockWriteSessionMessage(...args),
|
||||
}));
|
||||
|
||||
import { restartAgentGroupContainers } from './container-restart.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeSession(id: string, agentGroupId: string, status = 'active') {
|
||||
return { id, agent_group_id: agentGroupId, status };
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('restartAgentGroupContainers', () => {
|
||||
it('skips sessions without a running container', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(false);
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test');
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockKillContainer).not.toHaveBeenCalled();
|
||||
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-active sessions', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1', 'closed')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test');
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockKillContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('kills running containers and returns count', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||
mockIsContainerRunning.mockImplementation((id) => id === 's1');
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test');
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockKillContainer).toHaveBeenCalledTimes(1);
|
||||
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
|
||||
});
|
||||
|
||||
it('does not write wake message when wakeMessage is omitted', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
|
||||
restartAgentGroupContainers('g1', 'test');
|
||||
|
||||
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
|
||||
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
|
||||
});
|
||||
|
||||
it('writes on_wake message and passes onExit callback when wakeMessage is provided', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
|
||||
restartAgentGroupContainers('g1', 'test', 'Resuming.');
|
||||
|
||||
// Should write an on-wake message
|
||||
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(1);
|
||||
const [agentGroupId, sessionId, msg] = mockWriteSessionMessage.mock.calls[0];
|
||||
expect(agentGroupId).toBe('g1');
|
||||
expect(sessionId).toBe('s1');
|
||||
expect(msg.onWake).toBe(1);
|
||||
expect(JSON.parse(msg.content).text).toBe('Resuming.');
|
||||
|
||||
// Should pass an onExit callback to killContainer
|
||||
expect(mockKillContainer).toHaveBeenCalledTimes(1);
|
||||
const onExit = mockKillContainer.mock.calls[0][2];
|
||||
expect(typeof onExit).toBe('function');
|
||||
});
|
||||
|
||||
it('onExit callback calls wakeContainer with refreshed session', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
const freshSession = makeSession('s1', 'g1');
|
||||
mockGetSession.mockReturnValue(freshSession);
|
||||
|
||||
restartAgentGroupContainers('g1', 'test', 'Resuming.');
|
||||
|
||||
// Simulate container exit by calling the onExit callback
|
||||
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
|
||||
onExit();
|
||||
|
||||
expect(mockGetSession).toHaveBeenCalledWith('s1');
|
||||
expect(mockWakeContainer).toHaveBeenCalledWith(freshSession);
|
||||
});
|
||||
|
||||
it('onExit callback does not wake if session no longer exists', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
mockGetSession.mockReturnValue(undefined);
|
||||
|
||||
restartAgentGroupContainers('g1', 'test', 'Resuming.');
|
||||
|
||||
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
|
||||
onExit();
|
||||
|
||||
expect(mockWakeContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles multiple running sessions with wake message', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test', 'Config updated.');
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(mockKillContainer).toHaveBeenCalledTimes(2);
|
||||
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Each session gets its own on-wake message
|
||||
expect(mockWriteSessionMessage.mock.calls[0][1]).toBe('s1');
|
||||
expect(mockWriteSessionMessage.mock.calls[1][1]).toBe('s2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Helper to restart all running containers for an agent group.
|
||||
*
|
||||
* Writes an on_wake message to each session, kills the container, then
|
||||
* wakes a fresh container via the onExit callback — race-free.
|
||||
*/
|
||||
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
|
||||
import { getSession, getSessionsByAgentGroup } from './db/sessions.js';
|
||||
import { log } from './log.js';
|
||||
import { writeSessionMessage } from './session-manager.js';
|
||||
|
||||
/**
|
||||
* Kill all running containers for an agent group and respawn them.
|
||||
*
|
||||
* Only targets sessions that actually have a running container.
|
||||
* If `wakeMessage` is provided, each session gets an on_wake message
|
||||
* (picked up only by the fresh container's first poll) and a
|
||||
* wakeContainer call on exit. Without it, containers are killed and
|
||||
* only come back on the next real user message.
|
||||
*/
|
||||
export function restartAgentGroupContainers(agentGroupId: string, reason: string, wakeMessage?: string): number {
|
||||
const sessions = getSessionsByAgentGroup(agentGroupId).filter(
|
||||
(s) => s.status === 'active' && isContainerRunning(s.id),
|
||||
);
|
||||
|
||||
for (const session of sessions) {
|
||||
if (wakeMessage) {
|
||||
writeSessionMessage(agentGroupId, session.id, {
|
||||
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: agentGroupId,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
text: wakeMessage,
|
||||
sender: 'system',
|
||||
senderId: 'system',
|
||||
}),
|
||||
onWake: 1,
|
||||
});
|
||||
}
|
||||
killContainer(
|
||||
session.id,
|
||||
reason,
|
||||
wakeMessage
|
||||
? () => {
|
||||
const s = getSession(session.id);
|
||||
if (s) wakeContainer(s);
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length > 0) {
|
||||
log.info('Restarting agent group containers', { agentGroupId, reason, count: sessions.length });
|
||||
}
|
||||
return sessions.length;
|
||||
}
|
||||
@@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest';
|
||||
import { resolveProviderName } from './container-runner.js';
|
||||
|
||||
describe('resolveProviderName', () => {
|
||||
it('prefers session over group and container.json', () => {
|
||||
expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex');
|
||||
it('prefers session over container config', () => {
|
||||
expect(resolveProviderName('codex', 'claude')).toBe('codex');
|
||||
});
|
||||
|
||||
it('falls back to group when session is null', () => {
|
||||
expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex');
|
||||
});
|
||||
|
||||
it('falls back to container.json when session and group are null', () => {
|
||||
expect(resolveProviderName(null, null, 'opencode')).toBe('opencode');
|
||||
it('falls back to container config when session is null', () => {
|
||||
expect(resolveProviderName(null, 'opencode')).toBe('opencode');
|
||||
});
|
||||
|
||||
it('defaults to claude when nothing is set', () => {
|
||||
expect(resolveProviderName(null, null, undefined)).toBe('claude');
|
||||
expect(resolveProviderName(null, undefined)).toBe('claude');
|
||||
});
|
||||
|
||||
it('lowercases the resolved name', () => {
|
||||
expect(resolveProviderName('CODEX', null, null)).toBe('codex');
|
||||
expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode');
|
||||
expect(resolveProviderName(null, null, 'Claude')).toBe('claude');
|
||||
expect(resolveProviderName('CODEX', null)).toBe('codex');
|
||||
expect(resolveProviderName(null, 'Claude')).toBe('claude');
|
||||
});
|
||||
|
||||
it('treats empty string as unset (falls through)', () => {
|
||||
expect(resolveProviderName('', 'codex', null)).toBe('codex');
|
||||
expect(resolveProviderName(null, '', 'opencode')).toBe('opencode');
|
||||
expect(resolveProviderName('', 'opencode')).toBe('opencode');
|
||||
expect(resolveProviderName(null, '')).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
+23
-52
@@ -19,7 +19,9 @@ import {
|
||||
ONECLI_URL,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readContainerConfig, writeContainerConfig } from './container-config.js';
|
||||
import { materializeContainerJson } from './container-config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js';
|
||||
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
@@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise<void> {
|
||||
}
|
||||
writeSessionRouting(agentGroup.id, session.id);
|
||||
|
||||
// Read container config once — threaded through provider resolution,
|
||||
// buildMounts, and buildContainerArgs so we don't re-read the file.
|
||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
||||
|
||||
// Ensure container.json has the agent group identity fields the runner needs.
|
||||
// Written at spawn time so the runner can read them from the RO mount.
|
||||
ensureRuntimeFields(containerConfig, agentGroup);
|
||||
// Materialize container.json from DB — writes fresh file and returns
|
||||
// the config object, threaded through provider resolution, buildMounts,
|
||||
// and buildContainerArgs so we don't re-read.
|
||||
const containerConfig = materializeContainerJson(agentGroup.id);
|
||||
|
||||
// Resolve the effective provider + any host-side contribution it declares
|
||||
// (extra mounts, env passthrough). Computed once and threaded through both
|
||||
@@ -191,10 +190,14 @@ async function spawnContainer(session: Session): Promise<void> {
|
||||
}
|
||||
|
||||
/** Kill a container for a session. */
|
||||
export function killContainer(sessionId: string, reason: string): void {
|
||||
export function killContainer(sessionId: string, reason: string, onExit?: () => void): void {
|
||||
const entry = activeContainers.get(sessionId);
|
||||
if (!entry) return;
|
||||
|
||||
if (onExit) {
|
||||
entry.process.once('close', onExit);
|
||||
}
|
||||
|
||||
log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
|
||||
try {
|
||||
stopContainer(entry.containerName);
|
||||
@@ -204,22 +207,19 @@ export function killContainer(sessionId: string, reason: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider name for a session using the precedence documented in
|
||||
* the provider-install skills:
|
||||
* Resolve the provider name for a session:
|
||||
*
|
||||
* sessions.agent_provider
|
||||
* → agent_groups.agent_provider
|
||||
* → container.json `provider`
|
||||
* → container_configs.provider
|
||||
* → 'claude'
|
||||
*
|
||||
* Pure so the precedence can be unit-tested without a DB or filesystem.
|
||||
*/
|
||||
export function resolveProviderName(
|
||||
sessionProvider: string | null | undefined,
|
||||
agentGroupProvider: string | null | undefined,
|
||||
containerConfigProvider: string | null | undefined,
|
||||
): string {
|
||||
return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase();
|
||||
return (sessionProvider || containerConfigProvider || 'claude').toLowerCase();
|
||||
}
|
||||
|
||||
function resolveProviderContribution(
|
||||
@@ -227,7 +227,7 @@ function resolveProviderContribution(
|
||||
agentGroup: AgentGroup,
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
): { provider: string; contribution: ProviderContainerContribution } {
|
||||
const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider);
|
||||
const provider = resolveProviderName(session.agent_provider, containerConfig.provider);
|
||||
const fn = getProviderContainerConfig(provider);
|
||||
const contribution = fn
|
||||
? fn({
|
||||
@@ -396,34 +396,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure container.json has the runtime identity fields the runner needs.
|
||||
* Written at spawn time so they're always current even if the DB values
|
||||
* change (e.g. group rename). Only writes if values differ to avoid
|
||||
* unnecessary file churn.
|
||||
*/
|
||||
function ensureRuntimeFields(
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
agentGroup: AgentGroup,
|
||||
): void {
|
||||
let dirty = false;
|
||||
if (containerConfig.agentGroupId !== agentGroup.id) {
|
||||
containerConfig.agentGroupId = agentGroup.id;
|
||||
dirty = true;
|
||||
}
|
||||
if (containerConfig.groupName !== agentGroup.name) {
|
||||
containerConfig.groupName = agentGroup.name;
|
||||
dirty = true;
|
||||
}
|
||||
if (containerConfig.assistantName !== agentGroup.name) {
|
||||
containerConfig.assistantName = agentGroup.name;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
writeContainerConfig(agentGroup.folder, containerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
@@ -497,10 +469,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
||||
const agentGroup = getAgentGroup(agentGroupId);
|
||||
if (!agentGroup) throw new Error('Agent group not found');
|
||||
|
||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
||||
const aptPackages = containerConfig.packages.apt;
|
||||
const npmPackages = containerConfig.packages.npm;
|
||||
|
||||
const configRow = getContainerConfig(agentGroup.id);
|
||||
if (!configRow) throw new Error('Container config not found');
|
||||
const aptPackages = JSON.parse(configRow.packages_apt) as string[];
|
||||
const npmPackages = JSON.parse(configRow.packages_npm) as string[];
|
||||
if (aptPackages.length === 0 && npmPackages.length === 0) {
|
||||
throw new Error('No packages to install. Use install_packages first.');
|
||||
}
|
||||
@@ -530,15 +502,14 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
|
||||
cwd: DATA_DIR,
|
||||
stdio: 'pipe',
|
||||
timeout: 300_000,
|
||||
timeout: 900_000,
|
||||
});
|
||||
} finally {
|
||||
fs.unlinkSync(tmpDockerfile);
|
||||
}
|
||||
|
||||
// Store the image tag in groups/<folder>/container.json
|
||||
containerConfig.imageTag = imageTag;
|
||||
writeContainerConfig(agentGroup.folder, containerConfig);
|
||||
// Store the image tag in the DB
|
||||
updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag });
|
||||
|
||||
log.info('Per-agent-group image built', { agentGroupId, imageTag });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ContainerConfigRow } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
const SCALAR_COLUMNS = new Set([
|
||||
'provider',
|
||||
'model',
|
||||
'effort',
|
||||
'image_tag',
|
||||
'assistant_name',
|
||||
'max_messages_per_prompt',
|
||||
'cli_scope',
|
||||
]);
|
||||
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
|
||||
|
||||
export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
|
||||
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
|
||||
| ContainerConfigRow
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function getAllContainerConfigs(): ContainerConfigRow[] {
|
||||
return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[];
|
||||
}
|
||||
|
||||
/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */
|
||||
export function createContainerConfig(config: ContainerConfigRow): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO container_configs (
|
||||
agent_group_id, provider, model, effort, image_tag, assistant_name,
|
||||
max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
|
||||
additional_mounts, updated_at
|
||||
) VALUES (
|
||||
@agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
|
||||
@max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
|
||||
@additional_mounts, @updated_at
|
||||
)`,
|
||||
)
|
||||
.run(config);
|
||||
}
|
||||
|
||||
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
|
||||
export function ensureContainerConfig(agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
|
||||
VALUES (?, ?)`,
|
||||
)
|
||||
.run(agentGroupId, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
|
||||
export function updateContainerConfigScalars(
|
||||
agentGroupId: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
ContainerConfigRow,
|
||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { agent_group_id: agentGroupId };
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
if (!SCALAR_COLUMNS.has(key)) throw new Error(`Invalid scalar column: ${key}`);
|
||||
fields.push(`${key} = @${key}`);
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
|
||||
fields.push('updated_at = @updated_at');
|
||||
values.updated_at = new Date().toISOString();
|
||||
|
||||
getDb()
|
||||
.prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`)
|
||||
.run(values);
|
||||
}
|
||||
|
||||
/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
|
||||
export function updateContainerConfigJson(
|
||||
agentGroupId: string,
|
||||
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
|
||||
value: unknown,
|
||||
): void {
|
||||
if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
|
||||
const now = new Date().toISOString();
|
||||
getDb()
|
||||
.prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`)
|
||||
.run(JSON.stringify(value), now, agentGroupId);
|
||||
}
|
||||
|
||||
export function deleteContainerConfig(agentGroupId: string): void {
|
||||
getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId);
|
||||
}
|
||||
@@ -42,3 +42,12 @@ export {
|
||||
deletePendingApproval,
|
||||
getPendingApprovalsByAction,
|
||||
} from './sessions.js';
|
||||
export {
|
||||
getContainerConfig,
|
||||
getAllContainerConfigs,
|
||||
createContainerConfig,
|
||||
ensureContainerConfig,
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
deleteContainerConfig,
|
||||
} from './container-configs.js';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
export const migration014: Migration = {
|
||||
version: 14,
|
||||
name: 'container-configs',
|
||||
up(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE container_configs (
|
||||
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
effort TEXT,
|
||||
image_tag TEXT,
|
||||
assistant_name TEXT,
|
||||
max_messages_per_prompt INTEGER,
|
||||
skills TEXT NOT NULL DEFAULT '"all"',
|
||||
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||
packages_npm TEXT NOT NULL DEFAULT '[]',
|
||||
additional_mounts TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
export const migration015: Migration = {
|
||||
version: 15,
|
||||
name: 'cli-scope',
|
||||
up(db: Database.Database) {
|
||||
db.prepare("ALTER TABLE container_configs ADD COLUMN cli_scope TEXT NOT NULL DEFAULT 'group'").run();
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user