Compare commits

...

15 Commits

Author SHA1 Message Date
gavrielc 6f21fe7bd7 Merge branch 'main' into feat/onecli-reject-with-reason 2026-07-04 16:26:57 +03:00
gavrielc 2938bbf94a Merge pull request #2928 from nanocoai/cleanup/remove-dead-global-mount
Remove the dead /workspace/global mount and untrack v1 group seed files
2026-07-04 16:26:42 +03:00
gavrielc d1a2b04d32 Merge branch 'main' into cleanup/remove-dead-global-mount 2026-07-04 16:26:10 +03:00
github-actions[bot] 455014e7b9 chore: bump version to 2.1.26 2026-07-04 13:25:56 +00:00
gavrielc 5032c431ae Merge pull request #2927 from nanocoai/cleanup/unregister-mock-provider
Unregister the mock provider from the production container barrel
2026-07-04 16:25:40 +03:00
gavrielc ac8273c698 Merge branch 'main' into cleanup/unregister-mock-provider 2026-07-04 16:24:37 +03:00
gavrielc b24bf2c6f7 feat(approvals): reject-with-reason on OneCLI credential cards
Extends the reject-with-reason capture flow (previously module-initiated
approvals only) to OneCLI credential cards. The OneCLI SDK decision wire
carries only 'approve' | 'deny', so a reason can never reach the gateway —
instead the gateway decision is HELD open while the admin types the reason,
the reason is dropped into the session workspace, and the agent-runner's
PostToolUse hook injects it into the failed tool call's context. The agent
sees one coherent event: its gateway call fails carrying the admin's reason.

- primitive.ts: action-keyed reason-reject finalizer registry so an action
  can override the default chat-message relay.
- reason-capture.ts: dispatch through the registry; resolve sessions via
  agent_group_id fallback (OneCLI rows carry no session_id).
- response-handler.ts: on "Reject with reason…" for a OneCLI card, hold the
  in-memory decision and arm reason capture instead of resolving.
- onecli-approvals.ts: third card option; heldForReason state; TTL timer
  keeps the awaiting_reason row alive as the safety valve; approval-resolved
  handler releases a still-held deny after any fallback finalizeReject.
- finalize.ts: describe OneCLI rejects to the agent as "credential request
  to <host>" instead of the internal action key.
- agent-runner claude.ts: PostToolUse/PostToolUseFailure hook consumes
  /workspace/onecli-rejection.json (failure-shaped results only, single-use,
  10-min freshness) and returns the reason as additionalContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:31 +03:00
gavrielc f4be00e6ed Remove dead /workspace/global mount and untrack v1 group seed files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:14 +03:00
gavrielc c9aa69dea9 Unregister the mock provider from the production container barrel
The container self-registration barrel imported mock.js, so every container
registered a 'mock' provider returning canned text. A typo'd --provider mock
would silently 'work' instead of failing loudly. Drop the import (mock.ts stays
for direct test use) and tidy the now-stale host comments and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:09 +03:00
github-actions[bot] b28c917997 docs: update token count to 207k tokens · 104% of context window 2026-07-04 08:08:53 +00:00
github-actions[bot] a00a5610bd chore: bump version to 2.1.25 2026-07-04 08:08:50 +00:00
gavrielc 05dc1b0a3c Merge pull request #2611 from Hinotoi-agent/fix/approval-cli-caller-context
[security] fix(cli): preserve caller context after approval
2026-07-04 11:08:39 +03:00
glifocat a7b34bc872 Merge branch 'main' into fix/approval-cli-caller-context 2026-07-04 09:11:27 +02:00
Hinotobi 0516bea638 Merge branch 'main' into fix/approval-cli-caller-context 2026-06-11 11:29:11 +08:00
hinotoi-agent 32f067f5bb fix(cli): preserve caller context after approval 2026-05-25 19:51:54 +08:00
17 changed files with 360 additions and 513 deletions
+50 -2
View File
@@ -179,13 +179,61 @@ const preToolUseHook: HookCallback = async (input) => {
return { continue: true };
};
/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */
const postToolUseHook: HookCallback = async () => {
/**
* OneCLI reject-with-reason handoff: when an admin rejects a gateway request
* with a reason, the host drops it at /workspace/onecli-rejection.json BEFORE
* releasing the deny, so the failed tool call and the reason arrive as one
* event. Consume the file (single use) and hand the reason to the model as
* additional context on this tool result. Freshness-gated: a stale file from
* an interrupted run must not annotate an unrelated failure.
*/
const ONECLI_REJECTION_FILE = '/workspace/onecli-rejection.json';
const ONECLI_REJECTION_FRESH_MS = 10 * 60 * 1000;
function consumeOneCLIRejection(input: unknown): string | null {
try {
if (!fs.existsSync(ONECLI_REJECTION_FILE)) return null;
const info = JSON.parse(fs.readFileSync(ONECLI_REJECTION_FILE, 'utf8')) as {
rejectedAt?: string;
reason?: string;
host?: string | null;
};
// The rejection annotates a FAILED gateway call — require failure-shaped
// output (or the target host) in this tool result before consuming.
const resp = JSON.stringify((input as { tool_response?: unknown }).tool_response ?? '');
const looksRelated =
/denied|reject|403|approval|forbidden/i.test(resp) || (info.host ? resp.includes(info.host) : false);
if (!looksRelated) return null;
fs.unlinkSync(ONECLI_REJECTION_FILE);
if (!info.reason || Date.now() - new Date(info.rejectedAt ?? 0).getTime() > ONECLI_REJECTION_FRESH_MS) {
return null;
}
return (
`This request was rejected by the admin with the following reason: "${info.reason}". ` +
'Do not retry the same request as-is — address the reason first (revise and re-attempt, or explain to the user).'
);
} catch (err) {
log(`PostToolUse: rejection-file check failed: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
/** Clear in-flight tool on PostToolUse / PostToolUseFailure; inject a held
* OneCLI rejection reason into the failed call's context when present. */
const postToolUseHook: HookCallback = async (input) => {
try {
clearContainerToolInFlight();
} catch (err) {
log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`);
}
const rejection = consumeOneCLIRejection(input);
if (rejection) {
log('PostToolUse: injected OneCLI rejection reason into tool response context');
return {
continue: true,
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: rejection },
} as unknown as Awaited<ReturnType<HookCallback>>;
}
return { continue: true };
};
@@ -3,4 +3,3 @@
// 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, mock; todo: codex)"]
Provider["Agent providers<br/>(claude, opencode; 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
@@ -1,166 +0,0 @@
# 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
@@ -1,312 +0,0 @@
@./.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.24",
"version": "2.1.26",
"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="204k tokens, 102% of context window">
<title>204k tokens, 102% 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="207k tokens, 104% of context window">
<title>207k tokens, 104% 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">204k</text>
<text x="71" y="14">204k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
<text x="71" y="14">207k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+74 -2
View File
@@ -2,6 +2,32 @@ 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() },
}));
@@ -29,8 +55,8 @@ vi.mock('./crud.js', () => ({
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
registerApprovalHandler: approvalState.registerApprovalHandler,
requestApproval: approvalState.requestApproval,
}));
// Register a test command so dispatch has something to find
@@ -98,6 +124,18 @@ 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',
@@ -152,6 +190,7 @@ 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',
@@ -391,6 +430,39 @@ 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 () => {
+35 -5
View File
@@ -14,7 +14,16 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr
import { getResource } from './crud.js';
import { lookup } from './registry.js';
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
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> {
let cmd = lookup(req.command);
// Fallback: if the full command isn't registered, trim the last
@@ -101,7 +110,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
}
}
if (ctx.caller !== 'host' && cmd.access === 'approval') {
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
const session = getSession(ctx.sessionId);
if (!session) {
return err(req.id, 'handler-error', 'Session not found.');
@@ -117,7 +126,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
session,
agentName,
action: 'cli_command',
payload: { frame: { id: req.id, command: req.command, args: req.args } },
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
title: `CLI: ${req.command}`,
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
});
@@ -178,9 +187,10 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
}
}
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
const frame = payload.frame as RequestFrame;
const response = await dispatch(frame, { caller: 'host' });
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
const response = await dispatch(frame, callerContext, { approved: true });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
@@ -190,6 +200,26 @@ registerApprovalHandler('cli_command', async ({ session, payload, userId, 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 } };
}
-6
View File
@@ -320,12 +320,6 @@ 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');
+13 -3
View File
@@ -32,9 +32,19 @@ export async function finalizeReject(
userId: string,
reason?: string,
): Promise<void> {
const text = reason
? `Your ${approval.action} request was rejected by admin: "${reason}"`
: `Your ${approval.action} request was rejected by admin.`;
// 'onecli_credential' is an internal action key — describe the actual
// request (host from the persisted payload) to the agent instead.
let what = `${approval.action} request`;
if (approval.action === 'onecli_credential') {
let host: string | undefined;
try {
host = (JSON.parse(approval.payload ?? '{}') as { host?: string }).host;
} catch {
/* keep generic */
}
what = host ? `credential request to ${host}` : 'credential request';
}
const text = reason ? `Your ${what} was rejected by admin: "${reason}"` : `Your ${what} was rejected by admin.`;
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+119 -3
View File
@@ -17,9 +17,20 @@
* Startup sweep edits any leftover cards from a previous process to
* "Expired (host restarted)" and drops the rows.
*/
import fs from 'fs';
import path from 'path';
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
import { pickApprovalDelivery, pickApprover } from './primitive.js';
import { finalizeReject } from './finalize.js';
import {
notifyApprovalResolved,
pickApprovalDelivery,
pickApprover,
registerApprovalResolvedHandler,
registerReasonRejectFinalizer,
REJECT_WITH_REASON_VALUE,
} from './primitive.js';
import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import {
@@ -30,7 +41,8 @@ import {
} from '../../db/sessions.js';
import type { ChannelDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js';
import type { PendingApproval } from '../../types.js';
import { sessionDir } from '../../session-manager.js';
import type { PendingApproval, Session } from '../../types.js';
export const ONECLI_ACTION = 'onecli_credential';
@@ -41,6 +53,9 @@ const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY });
interface PendingState {
resolve: (decision: Decision) => void;
timer: NodeJS.Timeout;
/** Reject-with-reason: the gateway decision is held open until the reason
* is captured and relayed (or the gateway TTL forces the deny out). */
heldForReason?: boolean;
}
const pending = new Map<string, PendingState>();
@@ -82,10 +97,101 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
return true;
}
/**
* Reject-with-reason: keep the gateway decision UNRESOLVED while the reason is
* being captured, so the agent's held HTTP call fails only after the reason is
* available to it. The row stays for reason-capture; the TTL timer stays armed
* as the safety valve if the approver types slower than the gateway TTL, the
* deny goes out then and a late reason still relays through the
* awaiting_reason row.
*/
export function holdOneCLIApprovalForReason(approvalId: string): boolean {
const state = pending.get(approvalId);
if (!state) return false;
state.heldForReason = true;
log.info('OneCLI approval held awaiting rejection reason', { approvalId });
return true;
}
/**
* Custom reason delivery for OneCLI rejects (registered with reason-capture's
* finalizer registry): drop the reason where the agent-runner's PostToolUse
* hook can inject it into the FAILED TOOL CALL itself the session workspace,
* mounted at /workspace in the container then release the held deny. The
* agent experiences one coherent event: its gmail call fails carrying the
* admin's reason, instead of a bare denial plus a separate chat message.
*
* Falls back to the chat-message relay when the decision is no longer held
* (gateway TTL beat the approver, or a restart lost the resolver) the tool
* call already failed bare, so a session message is the only remaining path.
*/
async function finalizeOneCLIReasonReject(
approval: PendingApproval,
session: Session,
userId: string,
reason?: string,
): Promise<void> {
const state = pending.get(approval.approval_id);
if (!state || !reason) {
await finalizeReject(approval, session, userId, reason);
return;
}
let requestMeta: { host?: string; method?: string; path?: string } = {};
try {
requestMeta = JSON.parse(approval.payload ?? '{}') as typeof requestMeta;
} catch {
/* best-effort metadata */
}
fs.writeFileSync(
path.join(sessionDir(session.agent_group_id, session.id), 'onecli-rejection.json'),
JSON.stringify({
rejectedAt: new Date().toISOString(),
reason,
host: requestMeta.host ?? null,
method: requestMeta.method ?? null,
path: requestMeta.path ?? null,
}),
);
pending.delete(approval.approval_id);
clearTimeout(state.timer);
updatePendingApprovalStatus(approval.approval_id, 'rejected');
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
// Release the deny LAST — the reason file is already in place when the
// gateway fails the agent's held HTTP call.
state.resolve('deny');
log.info('OneCLI approval denied with reason injected into tool response', {
approvalId: approval.approval_id,
sessionId: session.id,
});
}
registerReasonRejectFinalizer(ONECLI_ACTION, finalizeOneCLIReasonReject);
let resolvedHandlerRegistered = false;
export function startOneCLIApprovalHandler(deliveryAdapter: ChannelDeliveryAdapter): void {
if (handle) return;
adapterRef = deliveryAdapter;
// Releases a held reject-with-reason decision once finalizeReject has
// relayed the reason (or the sweep gave up on the approver) — the reason
// reaches the agent's session BEFORE its held HTTP call fails.
if (!resolvedHandlerRegistered) {
resolvedHandlerRegistered = true;
registerApprovalResolvedHandler(async (event) => {
if (event.approval.action !== ONECLI_ACTION) return;
const state = pending.get(event.approval.approval_id);
if (!state) return; // TTL already released the deny — nothing held
pending.delete(event.approval.approval_id);
clearTimeout(state.timer);
state.resolve('deny');
log.info('OneCLI approval denied after reason relay', { approvalId: event.approval.approval_id });
});
}
// Sweep any rows left over from a previous process.
sweepStaleApprovals().catch((err) => log.error('OneCLI approval sweep failed', { err }));
@@ -149,6 +255,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
const onecliOptions = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected — reason requested', value: REJECT_WITH_REASON_VALUE },
];
let platformMessageId: string | undefined;
try {
@@ -202,8 +309,17 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
return new Promise<Decision>((resolve) => {
const timer = setTimeout(() => {
if (!pending.has(approvalId)) return;
const state = pending.get(approvalId);
if (!state) return;
pending.delete(approvalId);
if (state.heldForReason) {
// Gateway TTL caught up while the approver was still typing the
// reason. Release the deny; keep the awaiting_reason row so the late
// reason (or the ghost sweep) still relays to the agent.
log.info('OneCLI approval TTL reached while awaiting reason — denying now', { approvalId });
resolve('deny');
return;
}
expireApproval(approvalId, 'no response').catch((err) =>
log.error('Failed to mark OneCLI approval expired', { approvalId, err }),
);
+23
View File
@@ -107,6 +107,29 @@ export function registerApprovalResolvedHandler(handler: ApprovalResolvedHandler
approvalResolvedHandlers.push(handler);
}
// ── Reason-reject finalizer overrides ──
// By default a captured rejection reason is relayed as a session chat message
// (finalizeReject). An action can register a custom finalizer to deliver the
// reason differently — e.g. OneCLI credential rejects inject it into the
// agent's failed tool call instead of a separate message.
export type ReasonRejectFinalizer = (
approval: PendingApproval,
session: Session,
userId: string,
reason?: string,
) => Promise<void>;
const reasonRejectFinalizers = new Map<string, ReasonRejectFinalizer>();
export function registerReasonRejectFinalizer(action: string, finalizer: ReasonRejectFinalizer): void {
reasonRejectFinalizers.set(action, finalizer);
}
export function getReasonRejectFinalizer(action: string): ReasonRejectFinalizer | undefined {
return reasonRejectFinalizers.get(action);
}
/** Fire every registered approval-resolved callback. Called by the response handler. */
export async function notifyApprovalResolved(event: ApprovalResolvedEvent): Promise<void> {
for (const handler of approvalResolvedHandlers) {
+14 -3
View File
@@ -22,6 +22,7 @@ import type { InboundEvent } from '../../channels/adapter.js';
import { getDeliveryAdapter } from '../../delivery.js';
import {
deletePendingApproval,
findSessionByAgentGroup,
getExpiredAwaitingReasonApprovals,
getPendingApproval,
getSession,
@@ -32,6 +33,7 @@ import { registerMessageInterceptor } from '../../router.js';
import type { PendingApproval, Session } from '../../types.js';
import { ensureUserDm } from '../permissions/user-dm.js';
import { finalizeReject } from './finalize.js';
import { getReasonRejectFinalizer } from './primitive.js';
/** How long an awaiting-reason hold waits for the admin's reply before the sweep finalizes a plain reject. */
const REASON_CAPTURE_WINDOW_MS = 5 * 60 * 1000;
@@ -136,14 +138,21 @@ export async function captureReasonReply(event: InboundEvent): Promise<boolean>
return false;
}
const session = approval.session_id ? getSession(approval.session_id) : null;
// OneCLI credential rows carry no session_id — fall back to the originating
// agent group's active session so the reason still reaches the agent.
const session =
(approval.session_id ? getSession(approval.session_id) : null) ??
(approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : null);
if (!session) {
deletePendingApproval(approval.approval_id);
return true;
}
const reason = clampReason(extractText(event));
await finalizeReject(approval, session, arming.userId, reason || undefined);
// Action-specific delivery (e.g. OneCLI injects the reason into the failed
// tool call); default is the chat-message relay.
const finalizer = getReasonRejectFinalizer(approval.action) ?? finalizeReject;
await finalizer(approval, session, arming.userId, reason || undefined);
log.info('reject-with-reason: reason captured and relayed', {
approvalId: approval.approval_id,
hasReason: reason.length > 0,
@@ -162,7 +171,9 @@ registerMessageInterceptor(captureReasonReply);
export async function sweepAwaitingReasonRejects(): Promise<void> {
const rows = getExpiredAwaitingReasonApprovals(new Date().toISOString());
for (const approval of rows) {
const session = approval.session_id ? getSession(approval.session_id) : null;
const session =
(approval.session_id ? getSession(approval.session_id) : null) ??
(approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : null);
if (!session) {
deletePendingApproval(approval.approval_id);
continue;
+24 -2
View File
@@ -16,14 +16,14 @@
* core iterates handlers and the first one to return `true` claims the response.
*/
import { wakeContainer } from '../../container-runner.js';
import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
import { deletePendingApproval, findSessionByAgentGroup, getPendingApproval, getSession } from '../../db/sessions.js';
import type { ResponsePayload } from '../../response-registry.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { PendingApproval } from '../../types.js';
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
import { finalizeReject } from './finalize.js';
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
import { holdOneCLIApprovalForReason, ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
import { getApprovalHandler, notifyApprovalResolved, REJECT_WITH_REASON_VALUE } from './primitive.js';
import { armReasonCapture } from './reason-capture.js';
@@ -42,6 +42,28 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
}
if (approval.action === ONECLI_ACTION) {
// "Reject with reason…" — the gateway decision is HELD open while the
// reason is captured, so the agent's pending HTTP call fails only after
// the reason is already relayed into its session (finalizeReject relays,
// then the approval-resolved handler in onecli-approvals releases the
// deny; the gateway TTL is the safety valve). OneCLI rows carry no
// session_id; resolve the session via the originating agent group.
if (payload.value === REJECT_WITH_REASON_VALUE) {
const session = approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : undefined;
if (!session) {
log.warn('OneCLI reject-with-reason: no active session to relay to — plain reject', {
approvalId: approval.approval_id,
agentGroupId: approval.agent_group_id,
});
if (!resolveOneCLIApproval(payload.questionId, 'reject')) deletePendingApproval(payload.questionId);
return true;
}
// false ⇒ the resolver died with a previous process (the gateway side is
// already settled) — the reason relay is still worth arming.
holdOneCLIApprovalForReason(payload.questionId);
await armReasonCapture(approval, session, namespacedUserId(payload) ?? '');
return true;
}
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
return true;
}
+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, mock) don't appear here.
// needs (claude) 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`, `mock`) don't appear in
* Providers without host-side needs (e.g. `claude`) don't appear in
* this registry at all the lookup returns `undefined` and the spawn path
* proceeds with only the default mounts and env.
*