Compare commits

..

1 Commits

Author SHA1 Message Date
gavrielc e52f4eaa54 ncl wirings create: create the send-authorization ACL row
Delegate `ncl wirings create` to createMessagingGroupAgent instead of the
generic single-table INSERT, so the matching agent_destinations ACL row is
auto-created. Without it, ncl-wired agents silently lacked the send
authorization skill-wired agents get and delivery rejected non-origin sends
with "unauthorized channel destination".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:54 +03:00
14 changed files with 660 additions and 157 deletions
@@ -3,3 +3,4 @@
// level. Skills add a new provider by appending one import line below.
import './claude.js';
import './mock.js';
+1 -1
View File
@@ -31,7 +31,7 @@ flowchart TB
subgraph Session["Per-Session Container (Docker / Apple Container)"]
direction TB
PollLoop["Poll Loop<br/>(container/agent-runner)"]
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
Skills["Container Skills<br/>(container/skills/)"]
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
+166
View File
@@ -0,0 +1,166 @@
# Main
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Be concise — every message costs the reader's attention.
### Destinations
Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `<message to="name">...</message>` block:
```
<message to="family">On my way home, 15 minutes</message>
<message to="worker-1">kick off the pipeline</message>
```
Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name.
### Mid-turn updates
Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response.
- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message.
- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did.
### Internal thoughts
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `<message>` blocks is also treated as scratchpad. With a single destination, only explicit `<internal>` tags are scratchpad; the rest of your response is sent.
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research…
```
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Your Workspace
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
Format messages based on the channel you're responding to. Check your group folder name:
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord channels (folder starts with `discord_`)
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Installing Packages & Tools
Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools:
1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval.
2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes.
Example flow:
```
install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" })
# → Admin gets an approval card → approves
request_rebuild({ reason: "Apply ffmpeg + transformers" })
# → Admin approves → image rebuilt with the packages
```
**When to use this vs workspace pnpm install:**
- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies
- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH
### MCP Servers
Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.:
```
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
request_rebuild({ reason: "Add memory MCP server" })
```
## Task Scripts
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`.
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows.
Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency
+312
View File
@@ -0,0 +1,312 @@
@./.claude-global.md
# Main
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
Format messages based on the channel. Check the group folder name prefix:
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes like `:white_check_mark:`, `:rocket:`
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord (folder starts with `discord_`)
Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Admin Context
This is the **main channel**, which has elevated privileges.
## Authentication
Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
## Container Mounts
Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder:
| Container Path | Host Path | Access |
|----------------|-----------|--------|
| `/workspace/project` | Project root | read-only |
| `/workspace/project/store` | `store/` | read-write |
| `/workspace/group` | `groups/main/` | read-write |
Key paths inside the container:
- `/workspace/project/store/messages.db` - SQLite database (read-write)
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
- `/workspace/project/groups/` - All group folders
---
## Managing Groups
### Finding Available Groups
Available groups are provided in `/workspace/ipc/available_groups.json`:
```json
{
"groups": [
{
"jid": "120363336345536173@g.us",
"name": "Family Chat",
"lastActivity": "2026-01-31T12:00:00.000Z",
"isRegistered": false
}
],
"lastSync": "2026-01-31T12:00:00.000Z"
}
```
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
If a group the user mentions isn't in the list, request a fresh sync:
```bash
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
```
Then wait a moment and re-read `available_groups.json`.
**Fallback**: Query the SQLite database directly:
```bash
sqlite3 /workspace/project/store/messages.db "
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
ORDER BY last_message_time DESC
LIMIT 10;
"
```
### Registered Groups Config
Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
"folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
}
```
Fields:
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
2. Ask the user whether the group should require a trigger word before registering
3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting
4. Optionally include `containerConfig` for additional mounts
5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
6. Optionally create an initial `CLAUDE.md` for the group
Folder naming convention — channel prefix with underscore separator:
- WhatsApp "Family Chat" → `whatsapp_family-chat`
- Telegram "Dev Team" → `telegram_dev-team`
- Discord "General" → `discord_general`
- Slack "Engineering" → `slack_engineering`
- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
Groups can have extra directories mounted. Add `containerConfig` to their entry:
```json
{
"1234567890@g.us": {
"name": "Dev Team",
"folder": "dev-team",
"trigger": "@Andy",
"added_at": "2026-01-31T12:00:00Z",
"containerConfig": {
"additionalMounts": [
{
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}
]
}
}
}
```
The directory will appear at `/workspace/extra/webapp` in that group's container.
#### Sender Allowlist
After registering a group, explain the sender allowlist feature to the user:
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
>
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
>
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
```json
{
"default": { "allow": "*", "mode": "trigger" },
"chats": {
"<chat-jid>": {
"allow": ["sender-id-1", "sender-id-2"],
"mode": "trigger"
}
},
"logDenied": true
}
```
Notes:
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
### Removing a Group
1. Read `/workspace/project/data/registered_groups.json`
2. Remove the entry for that group
3. Write the updated JSON back
4. The group folder and its files remain (don't delete them)
### Listing Groups
Read `/workspace/project/data/registered_groups.json` and format it nicely.
---
## Global Memory
You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
---
## Scheduling for Other Groups
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory.
---
## Task Scripts
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
Use `list_tasks` to see existing tasks (one row per series with the stable id), and `update_task` / `cancel_task` / `pause_task` / `resume_task` to modify them. Prefer `update_task` over cancel + reschedule when adjusting an existing task.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.27",
"version": "2.1.24",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -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="207k tokens, 104% of context window">
<title>207k tokens, 104% 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="204k tokens, 102% of context window">
<title>204k tokens, 102% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
<text x="71" y="14">207k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">204k</text>
<text x="71" y="14">204k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -74
View File
@@ -2,32 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mocks ---
const approvalState = vi.hoisted(() => ({
requestApproval: vi.fn(),
approvalHandler: null as
| null
| ((args: {
session: unknown;
payload: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>),
registerApprovalHandler: vi.fn(
(
action: string,
handler: (args: {
session: unknown;
payload: Record<string, unknown>;
userId: string;
notify: (text: string) => void;
}) => Promise<void>,
) => {
if (action === 'cli_command') approvalState.approvalHandler = handler;
},
),
observedContexts: [] as CallerContext[],
}));
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
@@ -55,8 +29,8 @@ vi.mock('./crud.js', () => ({
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: approvalState.registerApprovalHandler,
requestApproval: approvalState.requestApproval,
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
}));
// Register a test command so dispatch has something to find
@@ -124,18 +98,6 @@ register({
handler: async (args) => ({ echo: args }),
});
register({
name: 'approval-context-command',
description: 'approval command that records caller context',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async (_args, ctx) => {
approvalState.observedContexts.push(ctx);
return { caller: ctx.caller };
},
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
@@ -190,7 +152,6 @@ import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
approvalState.observedContexts.length = 0;
// Default: the four CLI-whitelisted resources with their real scopeFields.
const scopeFields: Record<string, string> = {
groups: 'id',
@@ -430,39 +391,6 @@ describe('CLI scope enforcement', () => {
expect(mockGetContainerConfig).not.toHaveBeenCalled();
});
it('approval replay preserves the original agent caller context', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
const ctx = agentCtx();
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
expect(resp.ok).toBe(false);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
expect(approval.payload).toEqual({
frame: {
id: '1',
command: 'approval-context-command',
args: { agent_group_id: 'g1', group: 'g1', id: 'g1' },
},
callerContext: ctx,
});
expect(approvalState.approvalHandler).toBeTypeOf('function');
await approvalState.approvalHandler!({
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
payload: approval.payload,
userId: 'telegram:admin',
notify: vi.fn(),
});
expect(approvalState.observedContexts).toEqual([ctx]);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
});
// --- Post-handler filtering ---
it('group: groups list filters out other groups', async () => {
+5 -35
View File
@@ -14,16 +14,7 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr
import { getResource } from './crud.js';
import { lookup } from './registry.js';
type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
};
export async function dispatch(
req: RequestFrame,
ctx: CallerContext,
opts: DispatchOptions = {},
): Promise<ResponseFrame> {
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
let cmd = lookup(req.command);
// Fallback: if the full command isn't registered, trim the last
@@ -110,7 +101,7 @@ export async function dispatch(
}
}
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
if (ctx.caller !== 'host' && cmd.access === 'approval') {
const session = getSession(ctx.sessionId);
if (!session) {
return err(req.id, 'handler-error', 'Session not found.');
@@ -126,7 +117,7 @@ export async function dispatch(
session,
agentName,
action: 'cli_command',
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
payload: { frame: { id: req.id, command: req.command, args: req.args } },
title: `CLI: ${req.command}`,
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
});
@@ -187,10 +178,9 @@ export async function dispatch(
}
}
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
const frame = payload.frame as RequestFrame;
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
const response = await dispatch(frame, callerContext, { approved: true });
const response = await dispatch(frame, { caller: 'host' });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
@@ -200,26 +190,6 @@ registerApprovalHandler('cli_command', async ({ payload, notify }) => {
}
});
function parseCallerContext(value: unknown): CallerContext | undefined {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
if (record.caller === 'host') return { caller: 'host' };
if (
record.caller === 'agent' &&
typeof record.sessionId === 'string' &&
typeof record.agentGroupId === 'string' &&
typeof record.messagingGroupId === 'string'
) {
return {
caller: 'agent',
sessionId: record.sessionId,
agentGroupId: record.agentGroupId,
messagingGroupId: record.messagingGroupId,
};
}
return undefined;
}
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
return { id, ok: false, error: { code, message } };
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Regression test `ncl wirings create` must delegate to
* `createMessagingGroupAgent` so the matching `agent_destinations` ACL row is
* auto-created. The generic single-table INSERT skipped it, leaving ncl-wired
* agents silently without the send authorization skill-wired agents get
* (delivery throws "unauthorized channel destination" for non-origin sends).
*
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
* `caller: 'host'` after admin approval, so the test invokes dispatch with the
* host caller same code path a real approval would take.
*/
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-wirings' };
});
const TEST_DIR = '/tmp/nanoclaw-test-cli-wirings';
import { initTestDb, closeDb, runMigrations, createAgentGroup, getDb } from '../../db/index.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `wirings-*` commands (including create).
import './wirings.js';
function now(): string {
return new Date().toISOString();
}
describe('wirings CLI create auto-creates the send-authorization ACL row', () => {
const GID = 'ag-1';
const MGID = 'mg-1';
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: GID, name: 'agent', folder: 'agent', agent_provider: null, created_at: now() });
db.prepare(
`INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES (?, 'telegram', 'tg-1', 'telegram', 'chat', 1, 'strict', ?)`,
).run(MGID, now());
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('creates the wiring and the matching agent_destinations row', async () => {
// Precondition: no destination exists yet.
const before = getDb()
.prepare('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?')
.get(GID) as { c: number };
expect(before.c).toBe(0);
const resp = await dispatch(
{
id: 'req-create',
command: 'wirings-create',
args: { messaging_group_id: MGID, agent_group_id: GID },
},
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
// The wiring row exists.
const wiring = getDb()
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?')
.get(MGID, GID) as Record<string, unknown> | undefined;
expect(wiring).toBeDefined();
// The send-authorization ACL row was auto-created and points at the chat.
const dest = getDb()
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?')
.get(GID, 'channel', MGID) as Record<string, unknown> | undefined;
expect(dest).toBeDefined();
});
});
+60 -1
View File
@@ -1,3 +1,7 @@
import { randomUUID } from 'crypto';
import { createMessagingGroupAgent } from '../../db/messaging-groups.js';
import type { EngageMode, IgnoredMessagePolicy, MessagingGroupAgent, SenderScope } from '../../types.js';
import { registerResource } from '../crud.js';
registerResource({
@@ -66,5 +70,60 @@ registerResource({
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
// `create` is intentionally not in `operations` — the generic single-table
// INSERT skips the `agent_destinations` ACL row that the canonical helper
// `createMessagingGroupAgent` auto-creates, so ncl-wired agents would
// silently lack the send authorization skill-wired agents get (delivery
// throws "unauthorized channel destination" for non-origin sends). Provided
// as a `customOperation` that delegates to the helper instead.
operations: { list: 'open', get: 'open', update: 'approval', delete: 'approval' },
customOperations: {
create: {
access: 'approval',
description:
'Wire a messaging group to an agent group. Delegates to createMessagingGroupAgent so the ' +
'matching agent_destinations ACL row is auto-created (a bare INSERT would skip it, leaving ' +
'the agent unauthorized to send to the chat). Use --messaging-group-id and --agent-group-id, ' +
'plus optional --engage-mode, --engage-pattern, --sender-scope, --ignored-message-policy, --session-mode.',
handler: async (args) => {
const messagingGroupId = args.messaging_group_id ? String(args.messaging_group_id) : '';
const agentGroupId = args.agent_group_id ? String(args.agent_group_id) : '';
if (!messagingGroupId) throw new Error('--messaging-group-id is required');
if (!agentGroupId) throw new Error('--agent-group-id is required');
const engageMode = args.engage_mode !== undefined ? String(args.engage_mode) : 'mention';
if (!['pattern', 'mention', 'mention-sticky'].includes(engageMode)) {
throw new Error('engage_mode must be one of: pattern, mention, mention-sticky');
}
const senderScope = args.sender_scope !== undefined ? String(args.sender_scope) : 'all';
if (!['all', 'known'].includes(senderScope)) {
throw new Error('sender_scope must be one of: all, known');
}
const ignoredMessagePolicy =
args.ignored_message_policy !== undefined ? String(args.ignored_message_policy) : 'drop';
if (!['drop', 'accumulate'].includes(ignoredMessagePolicy)) {
throw new Error('ignored_message_policy must be one of: drop, accumulate');
}
const sessionMode = args.session_mode !== undefined ? String(args.session_mode) : 'shared';
if (!['shared', 'per-thread', 'agent-shared'].includes(sessionMode)) {
throw new Error('session_mode must be one of: shared, per-thread, agent-shared');
}
const mga: MessagingGroupAgent = {
id: randomUUID(),
messaging_group_id: messagingGroupId,
agent_group_id: agentGroupId,
engage_mode: engageMode as EngageMode,
engage_pattern: args.engage_pattern !== undefined ? String(args.engage_pattern) : null,
sender_scope: senderScope as SenderScope,
ignored_message_policy: ignoredMessagePolicy as IgnoredMessagePolicy,
session_mode: sessionMode as MessagingGroupAgent['session_mode'],
priority: 0,
created_at: new Date().toISOString(),
};
createMessagingGroupAgent(mga);
return mga;
},
},
},
});
+6
View File
@@ -320,6 +320,12 @@ export function buildMounts(
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
// Global memory directory — always read-only.
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
}
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
+9 -39
View File
@@ -254,46 +254,16 @@ async function sweepStaleApprovals(): Promise<void> {
}
}
/** The hosted gateway's structured request summary not yet in the SDK's
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
* performed plus labeled fields (To / Subject / Body for email sends). */
interface ApprovalSummary {
action?: string;
details?: { label: string; value: string }[];
}
const SUMMARY_VALUE_EXCERPT_CHARS = 900;
function buildQuestion(request: ApprovalRequest, agentName: string): string {
const lines = [`*Agent:* ${agentName}`];
const summary = (request as ApprovalRequest & { summary?: ApprovalSummary }).summary;
if (summary?.details?.length) {
if (summary.action) lines.push(`*Action:* ${summary.action}`);
// A render bug here must never decide the request: handleRequest's catch
// returns 'deny', so stay defensive — coerce non-string values instead of
// assuming the gateway's shape, and keep the card under Slack's 3000-char
// section limit or delivery itself fails.
let budget = 2600;
for (const { label, value } of summary.details) {
const raw = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
const cap = Math.min(SUMMARY_VALUE_EXCERPT_CHARS, Math.max(0, budget));
if (cap === 0) {
lines.push(`_…${summary.details.length} field(s) omitted for length — see the audit payload._`);
break;
}
const v = raw.length > cap ? `${raw.slice(0, cap)}` : raw;
budget -= v.length + String(label).length + 8;
// Multi-line values (message bodies) read better fenced; short labeled
// fields (To, Subject) inline.
if (v.includes('\n')) lines.push(`*${label}:*`, '```', v, '```');
else lines.push(`*${label}:* ${v}`);
}
} else if (request.bodyPreview) {
lines.push('```', request.bodyPreview.slice(0, SUMMARY_VALUE_EXCERPT_CHARS * 2), '```');
lines.push(`_${request.method} ${request.host}${request.path}_`);
} else {
lines.push(`_${request.method} ${request.host}${request.path}_`);
const lines = [
'Credential access request',
`Agent: ${agentName}`,
'```',
`${request.method} ${request.host}${request.path}`,
'```',
];
if (request.bodyPreview) {
lines.push('Body:', '```', request.bodyPreview, '```');
}
return lines.join('\n');
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Host-side provider container-config barrel.
// Providers that need host-side container setup (extra mounts, env passthrough,
// per-session directories) self-register on import. Providers with no host
// needs (claude) don't appear here.
// needs (claude, mock) don't appear here.
//
// Skills add a new provider by appending one import line below.
+1 -1
View File
@@ -7,7 +7,7 @@
* the registered config fn, and merges the returned mounts/env into the spawn
* args.
*
* Providers without host-side needs (e.g. `claude`) don't appear in
* Providers without host-side needs (e.g. `claude`, `mock`) don't appear in
* this registry at all the lookup returns `undefined` and the spawn path
* proceeds with only the default mounts and env.
*