mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba2555032 | |||
| 687d7d13ac | |||
| 2938bbf94a | |||
| d1a2b04d32 | |||
| 455014e7b9 | |||
| 5032c431ae | |||
| ac8273c698 | |||
| f4be00e6ed | |||
| c9aa69dea9 | |||
| 3731e26769 | |||
| b28c917997 | |||
| a00a5610bd | |||
| 05dc1b0a3c | |||
| a7b34bc872 | |||
| aecad864e6 | |||
| c87f2e55dc | |||
| 411f5e71df | |||
| cb6e3d117c | |||
| ed7e3f70da | |||
| 557e073c2f | |||
| 91ebc9def2 | |||
| 14c89e9716 | |||
| 549c424a38 | |||
| 186b9befcd | |||
| 863d413d32 | |||
| cf8478ffbb | |||
| 8be5be93ba | |||
| add3fc8f70 | |||
| 0d841bcd05 | |||
| dd1d0e5677 | |||
| 36afa40857 | |||
| 2afbd18233 | |||
| 953496dc37 | |||
| 797491d8b3 | |||
| 2df754459b | |||
| 0896d4089e | |||
| d153d91307 | |||
| ce55af12d5 | |||
| 545800a94e | |||
| bfb309bd0c | |||
| 38d9390eea | |||
| 520ec44aec | |||
| 8c6a243ffd | |||
| 2ac7809385 | |||
| e8148bc0a7 | |||
| 0516bea638 | |||
| 32f067f5bb |
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: learn
|
||||
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
|
||||
---
|
||||
|
||||
# Learn — Distill a Skill from Anything
|
||||
|
||||
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
|
||||
|
||||
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
|
||||
|
||||
## When to use
|
||||
|
||||
Invoke when the user wants to *capture* a workflow as a reusable skill:
|
||||
|
||||
- `/learn <path>` — read a project/dir and build a skill for working with it
|
||||
- `/learn <url>` — read docs / an API page and build a usage skill
|
||||
- `/learn what we just did` — distill the current conversation's workflow
|
||||
- `/learn` + pasted notes — turn notes into a structured skill
|
||||
|
||||
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the source — and whether this is a new skill or a refine
|
||||
- A **path** → read the code/files.
|
||||
- A **URL** → fetch and read the page.
|
||||
- **"what we just did" / "this"** → use the current conversation as the source.
|
||||
- **Pasted text** → use it directly.
|
||||
|
||||
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
|
||||
|
||||
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
|
||||
|
||||
### 2. Gather the material
|
||||
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
|
||||
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
|
||||
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
|
||||
|
||||
### 3. Distill — find the reusable procedure
|
||||
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
|
||||
|
||||
- the trigger / when-to-use,
|
||||
- the step-by-step procedure (commands, file paths, decision points),
|
||||
- the non-obvious **gotchas** that were hit — usually the most valuable part,
|
||||
- any scripts or templates worth shipping alongside.
|
||||
|
||||
### 4. Author the SKILL.md
|
||||
|
||||
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
|
||||
- Keep what is still correct; weave the new learnings into the right sections.
|
||||
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
|
||||
- Correct anything the new source proves stale (a changed path, command, or flag).
|
||||
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
|
||||
|
||||
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
|
||||
|
||||
**Frontmatter (required):**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <kebab-case, matches the folder>
|
||||
description: "<what it does + when to use it + likely trigger phrases>"
|
||||
---
|
||||
```
|
||||
|
||||
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
|
||||
|
||||
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
|
||||
|
||||
**House authoring rules (from `docs/skill-guidelines.md`):**
|
||||
|
||||
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
|
||||
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
|
||||
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
|
||||
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
|
||||
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
|
||||
|
||||
### 5. Place and verify
|
||||
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
|
||||
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
|
||||
- Tell the user the skill exists and how to invoke it (`/<name>`).
|
||||
|
||||
## Example
|
||||
|
||||
`/learn what we just did` after a multi-step setup:
|
||||
|
||||
1. Re-read the conversation's commands and gotchas.
|
||||
2. Distill the repeatable procedure.
|
||||
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
|
||||
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
|
||||
- The most valuable content is the **gotchas**, not the happy path.
|
||||
- This skill is prose and safe to re-run — use it again to refine an existing skill.
|
||||
@@ -280,6 +280,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
|
||||
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
|
||||
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
|
||||
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ Instructions here...
|
||||
- Put code in separate files, not inline in the markdown
|
||||
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
|
||||
|
||||
## Templates
|
||||
|
||||
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Testing
|
||||
|
||||
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
|
||||
|
||||
@@ -82,6 +82,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
@@ -19,25 +19,25 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.197", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.197" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.108.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-XBnl7Nszpbzg0aLnOCmdBi0bOU5goAsQ/L+NPNiuUPowDj8Mbzx0vlIIc1M79BjIvmw5nUM5G3jbrCBStT/0fQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './mock.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[
|
||||
{ "name": "vercel", "version": "52.2.1" },
|
||||
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.170", "onlyBuilt": true }
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.197", "onlyBuilt": true }
|
||||
]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Agent Templates
|
||||
|
||||
A **template** is a reusable folder you stamp into a working agent group: it
|
||||
carries the agent's standing instructions, its MCP tool servers, and its skills,
|
||||
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
|
||||
you get a configured agent in seconds; you choose the runtime/provider
|
||||
separately.
|
||||
|
||||
Templates are purely additive: no DB migration, no new dependency. **At runtime,
|
||||
templates are resolved only from a local directory**: `templates/` at the
|
||||
project root by default (committed but shipped empty), or whatever
|
||||
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
|
||||
discover templates from the public registry
|
||||
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
|
||||
and copy a chosen one into your local `templates/` before stamping.
|
||||
|
||||
## Using a template
|
||||
|
||||
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
|
||||
setup**, then either **NanoClaw template library** (clones the public registry,
|
||||
copies the template you pick into your local `templates/`) or **Local templates**
|
||||
(lists what's already in `templates/`). The normal auth step then picks the
|
||||
runtime, and the wizard stamps and wires your first agent.
|
||||
|
||||
**Anytime, via the CLI:**
|
||||
|
||||
```bash
|
||||
ncl groups create --template sales/sdr --name "SDR Agent"
|
||||
```
|
||||
|
||||
This stamps the group but does **not** wire it to a channel. Run
|
||||
`/manage-channels` (or `ncl wirings create`) afterward, exactly as for a
|
||||
hand-built group.
|
||||
|
||||
### The template ref
|
||||
|
||||
`--template <ref>` is a path **relative to the local templates directory**
|
||||
(`templates/` by default, or `NANOCLAW_TEMPLATES_DIR`). Refs are multi-segment,
|
||||
e.g. `sales/sdr` → `templates/sales/sdr`.
|
||||
|
||||
For safety the ref must stay inside the templates directory: absolute paths, a
|
||||
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
|
||||
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
|
||||
the setup wizard's library option), then stamp.
|
||||
|
||||
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
|
||||
is never a URL and never changes at runtime.
|
||||
|
||||
## What's in a template
|
||||
|
||||
The full authoring reference lives in the
|
||||
[templates repo README](https://github.com/nanocoai/nanoclaw-templates#anatomy-of-a-template).
|
||||
The short version: only `context/instructions.md` is required; everything else
|
||||
is optional and defaults sensibly:
|
||||
|
||||
```
|
||||
<template>/
|
||||
├── context/
|
||||
│ ├── instructions.md # REQUIRED: the agent's standing persona; marks the folder as a template
|
||||
│ └── additional_context/ # optional: extra .md files, referenced from instructions.md by relative path
|
||||
│ └── *.md
|
||||
├── .mcp.json # optional: MCP servers (command + args), NO secrets
|
||||
├── skills/<name>/ # optional: one folder per skill (SKILL.md + any references/), copied whole
|
||||
└── README.md # recommended: per-template docs
|
||||
```
|
||||
|
||||
| Path | Loaded as | Required |
|
||||
|------|-----------|----------|
|
||||
| `context/instructions.md` | The agent's persona, prepended to its `CLAUDE.md`/`AGENTS.md` every spawn (system-prompt tier, any provider) | **Yes** |
|
||||
| `context/**/*.md` (others) | Extra context, copied into the agent's workspace with the same layout relative to `instructions.md` | No |
|
||||
| `.mcp.json` → `mcpServers` | MCP tool servers (written verbatim to container config) | No |
|
||||
| `skills/<name>/` | A skill, auto-triggered by its `description` | No |
|
||||
|
||||
Notes:
|
||||
|
||||
- **No provider, model, effort, or packages in a template.** Those are set on
|
||||
the agent later via `ncl groups config update`. The runtime defaults to the
|
||||
install's configured provider.
|
||||
- **Keep `instructions.md` focused (under ~200 lines).** It's always in the
|
||||
agent's prompt, and some providers cap that doc (Codex ~32 KB), so an over-long
|
||||
persona gets truncated. Put bulk material in `skills/` or extra context files instead.
|
||||
- Skills are copied into the agent's own skills overlay, keyed to that group,
|
||||
never shared across groups.
|
||||
|
||||
### Referencing extra context files
|
||||
|
||||
Extra `.md` files under `context/` (by convention in an `additional_context/`
|
||||
subfolder) are copied into the agent's workspace preserving their position
|
||||
relative to `instructions.md` — a template file at
|
||||
`context/additional_context/pricing.md` is readable by the agent as
|
||||
`additional_context/pricing.md`, the same relative path you'd use from
|
||||
`instructions.md` itself. Nothing is injected automatically: the agent only
|
||||
reads an extra file if `instructions.md` points to it, so reference every file
|
||||
you ship.
|
||||
|
||||
```markdown
|
||||
Pricing rules live in `additional_context/pricing.md`. Read it before quoting a price.
|
||||
```
|
||||
|
||||
Context files are copied when you stamp, so files added to the template later
|
||||
won't reach an already-created agent. Re-stamp the same name to update it.
|
||||
|
||||
## MCP servers and credentials
|
||||
|
||||
**Templates declare MCP servers, not secrets.** `.mcp.json` carries `command` +
|
||||
`args` only:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hubspot": { "command": "npx", "args": ["-y", "@hubspot/mcp-server"] },
|
||||
"exa": { "command": "npx", "args": ["-y", "exa-mcp-server"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Credentials are held by the **credentials proxy** and injected into outbound
|
||||
HTTPS calls at the proxy boundary, matched by API host, at request time. The key
|
||||
never sits in `.mcp.json`, the container env, or chat context. See
|
||||
[the credentials proxy section in CLAUDE.md](../CLAUDE.md#secrets--credentials--onecli)
|
||||
for the model.
|
||||
|
||||
Two ways a credential gets connected:
|
||||
|
||||
1. **Up front.** Register the secret with the credentials proxy (its web UI or
|
||||
CLI), matched to the service's API host (e.g. `api.example.com`). Matching
|
||||
credentials are injected automatically, so usually nothing else is needed.
|
||||
2. **On demand (the common path).** Don't set anything up first. The first time
|
||||
the agent calls a service with no credential, the API returns **401/403** and
|
||||
the agent replies with a prefilled connect link for that host. The user opens
|
||||
it, pastes the key, and asks the agent to retry. The key lands in the
|
||||
credentials proxy, which injects it on every later call.
|
||||
|
||||
### MCP servers that require an env var to boot
|
||||
|
||||
Some MCP servers refuse to start unless an env var is *present*, even though the
|
||||
real credential should come from the credentials proxy, not the env. Because `.mcp.json`'s `env`
|
||||
block passes through verbatim to the agent's container config, put a **placeholder
|
||||
value** there to satisfy the boot check:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"acme": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@acme/mcp-server"],
|
||||
"env": { "ACME_API_KEY": "placeholder" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server starts; its real outbound calls are still authenticated by the
|
||||
credentials proxy. **Never put a real key in `env`**: a placeholder only, and only when
|
||||
the server won't boot without one.
|
||||
|
||||
### Approval-gating sensitive actions
|
||||
|
||||
The credentials proxy can *hold* a credentialed outbound request and require a
|
||||
human to approve it before it leaves the proxy: enforcement the agent can't talk
|
||||
around. This is matched on the outbound HTTP request (host + method + path),
|
||||
configured on the credentials proxy, and answered by NanoClaw (it DMs an approver). The host side is
|
||||
already wired; see
|
||||
[the credentialed-approval flow in CLAUDE.md](../CLAUDE.md#requiring-approval-for-credential-use)
|
||||
and the [`sales/sdr` template README](https://github.com/nanocoai/nanoclaw-templates/blob/main/sales/sdr/README.md)
|
||||
for a worked example.
|
||||
|
||||
## Contributing a template
|
||||
|
||||
Templates ship in the separate
|
||||
[`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates)
|
||||
repo, not this one. To add one: fork that repo, drop a folder at
|
||||
`<category>/<template>/` with at least `context/instructions.md`, test it end to
|
||||
end (copy it under `templates/` and run
|
||||
`ncl groups create --template <category>/<template> --name Test`), confirm
|
||||
no secrets are committed, and open a PR. The repo's README has the full anatomy,
|
||||
category conventions, and checklist.
|
||||
@@ -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
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.19",
|
||||
"version": "2.1.27",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="199k tokens, 100% of context window">
|
||||
<title>199k tokens, 100% 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">199k</text>
|
||||
<text x="71" y="14">199k</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 |
+13
-5
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
|
||||
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
@@ -41,8 +42,10 @@ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
||||
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
|
||||
# SLACK_SIGNING_SECRET. Require at least one.
|
||||
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -98,7 +101,12 @@ upsert_env() {
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
|
||||
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
|
||||
fi
|
||||
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
|
||||
+124
-21
@@ -4,15 +4,18 @@
|
||||
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||
* Slack app through the welcome DM:
|
||||
*
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Ask for the operator's Slack user ID
|
||||
* 6. conversations.open to get the DM channel ID
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
|
||||
* URL) or a public webhook
|
||||
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* events, and the mode-specific credential (app-level token for
|
||||
* Socket Mode, signing secret for webhook)
|
||||
* 3. Paste the bot token + that credential (clack password prompts)
|
||||
* 4. Validate via auth.test → resolves workspace + bot identity
|
||||
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 6. Ask for the operator's Slack user ID
|
||||
* 7. conversations.open to get the DM channel ID
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 9. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
||||
* works without Event Subscriptions being configured. The user sees the
|
||||
@@ -45,14 +48,26 @@ interface WorkspaceInfo {
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
|
||||
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
|
||||
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
|
||||
// credential to collect and which post-install guidance to show.
|
||||
type SlackMode = 'socket' | 'webhook';
|
||||
|
||||
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const intro = await walkThroughAppCreation();
|
||||
const mode = await askSlackMode();
|
||||
const intro = await walkThroughAppCreation(mode);
|
||||
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
|
||||
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
|
||||
if (appToken) env.SLACK_APP_TOKEN = appToken;
|
||||
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
@@ -62,11 +77,9 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
SLACK_BOT_TOKEN: token,
|
||||
SLACK_SIGNING_SECRET: signingSecret,
|
||||
},
|
||||
env,
|
||||
extraFields: {
|
||||
MODE: mode,
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
@@ -122,10 +135,45 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
|
||||
);
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info);
|
||||
showPostInstallChecklist(info, mode);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
async function askSlackMode(): Promise<SlackMode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<SlackMode>({
|
||||
message: 'How should Slack deliver events to NanoClaw?',
|
||||
initialValue: 'socket',
|
||||
options: [
|
||||
{
|
||||
value: 'socket',
|
||||
label: 'Socket Mode',
|
||||
hint: 'no public URL — recommended for local or behind NAT',
|
||||
},
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'Public webhook',
|
||||
hint: 'needs a public HTTPS Request URL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('slack_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(mode: SlackMode): Promise<'continue' | 'back'> {
|
||||
const credSteps =
|
||||
mode === 'socket'
|
||||
? [
|
||||
' 4. Basic Information → App-Level Tokens → "Generate Token and',
|
||||
' Scopes" → add the connections:write scope → copy it (xapp-…)',
|
||||
' 5. Socket Mode → toggle "Enable Socket Mode" on',
|
||||
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
]
|
||||
: [
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
];
|
||||
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
|
||||
// per-line formatter so the URL stands out against the rest of the body.
|
||||
const linkBlock = isHeadless()
|
||||
@@ -149,8 +197,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
' • 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"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
...credSteps,
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
@@ -171,7 +218,10 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Got your bot token and signing secret?',
|
||||
message:
|
||||
mode === 'socket'
|
||||
? 'Got your bot token and app-level token?'
|
||||
: 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
@@ -249,6 +299,40 @@ async function collectSigningSecret(): Promise<string> {
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function collectAppToken(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_APP_TOKEN');
|
||||
if (existing && existing.startsWith('xapp-') && existing.length >= 24) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Slack app-level token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_app_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack app-level token (Socket Mode)',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'App-level token is required for Socket Mode';
|
||||
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack app-level token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_app_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
@@ -416,7 +500,26 @@ async function resolveAgentName(): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
|
||||
if (mode === 'socket') {
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`Your agent is wired to Slack and a welcome DM is on its way.`,
|
||||
`Socket Mode is on — ${info.teamName} reaches NanoClaw over an outbound`,
|
||||
`WebSocket, so there's no public URL to configure.`,
|
||||
'',
|
||||
' • Just DM @' + info.botName + ' from Slack — replies flow straight away.',
|
||||
'',
|
||||
' • Keep the NanoClaw host running to hold the socket open —',
|
||||
' Slack does not retry delivery while it is down.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
return;
|
||||
}
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
|
||||
@@ -43,7 +43,6 @@ interface V1Group {
|
||||
folder: string;
|
||||
trigger_pattern: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -65,7 +64,7 @@ async function main(): Promise<void> {
|
||||
// v1 schema varies — channel_name was a late addition. Query only the
|
||||
// columns we know exist in all v1 installs.
|
||||
const v1Groups = v1Db
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main FROM registered_groups')
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger FROM registered_groups')
|
||||
.all() as V1Group[];
|
||||
v1Db.close();
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-claude-md-compose-test';
|
||||
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
|
||||
|
||||
vi.mock('./config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config.js')>()),
|
||||
GROUPS_DIR: '/tmp/nanoclaw-claude-md-compose-test/groups',
|
||||
}));
|
||||
|
||||
vi.mock('./log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { PERSONA_PREPEND_FILE } from './group-persona.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
function group(id: string, folder: string): AgentGroup {
|
||||
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
|
||||
}
|
||||
|
||||
function seed(ag: AgentGroup): void {
|
||||
createAgentGroup(ag);
|
||||
ensureContainerConfig(ag.id);
|
||||
}
|
||||
|
||||
function writePersona(folder: string, text: string): void {
|
||||
const dir = path.join(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, PERSONA_PREPEND_FILE), text);
|
||||
}
|
||||
|
||||
function importsOf(folder: string): string[] {
|
||||
const md = fs.readFileSync(path.join(GROUPS_DIR, folder, 'CLAUDE.md'), 'utf-8');
|
||||
return md.split('\n').filter((line) => line.startsWith('@'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('composeGroupClaudeMd persona prepend', () => {
|
||||
it('imports the persona fragment FIRST, before the shared base', () => {
|
||||
const ag = group('ag-persona', 'persona-group');
|
||||
seed(ag);
|
||||
writePersona(ag.folder, 'You are an SDR agent.\n');
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports[0]).toBe('@./.claude-fragments/persona.md');
|
||||
expect(imports[1]).toBe('@./.claude-shared.md');
|
||||
expect(fs.readFileSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'), 'utf-8')).toBe(
|
||||
'You are an SDR agent.',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the persona across a second compose (not pruned)', () => {
|
||||
const ag = group('ag-persona-2', 'persona-group-2');
|
||||
seed(ag);
|
||||
writePersona(ag.folder, 'persona body');
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(true);
|
||||
expect(importsOf(ag.folder)[0]).toBe('@./.claude-fragments/persona.md');
|
||||
});
|
||||
|
||||
it('is inert when no persona file is present (non-template groups)', () => {
|
||||
const ag = group('ag-no-persona', 'no-persona-group');
|
||||
seed(ag);
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports[0]).toBe('@./.claude-shared.md');
|
||||
expect(imports).not.toContain('@./.claude-fragments/persona.md');
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,14 @@ import path from 'path';
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import type { McpServerConfig } from './container-config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { readGroupPersona } from './group-persona.js';
|
||||
import { log } from './log.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
// Fragment holding a template's persona prepend. Imported FIRST (before the
|
||||
// shared base) so the persona is the top of the composed system prompt.
|
||||
const PERSONA_FRAGMENT = 'persona.md';
|
||||
|
||||
// Symlink targets are container paths — dangling on host (hence the readlink
|
||||
// dance instead of existsSync), valid inside the container via RO mounts.
|
||||
const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md';
|
||||
@@ -106,6 +111,13 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Template persona (if any) — inline so it survives the prune below; imported
|
||||
// first (see the imports assembly) so it prepends the composed system prompt.
|
||||
const persona = readGroupPersona(groupDir);
|
||||
if (persona) {
|
||||
desired.set(PERSONA_FRAGMENT, { type: 'inline', content: persona });
|
||||
}
|
||||
|
||||
// Reconcile: drop stale, write desired.
|
||||
for (const existing of fs.readdirSync(fragmentsDir)) {
|
||||
if (!desired.has(existing)) {
|
||||
@@ -121,9 +133,14 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Composed entry — imports only.
|
||||
const imports = ['@./.claude-shared.md'];
|
||||
for (const name of [...desired.keys()].sort()) {
|
||||
// Composed entry — imports only. Persona first (top of the system prompt),
|
||||
// then the shared base, then the remaining fragments sorted.
|
||||
const imports: string[] = [];
|
||||
if (desired.has(PERSONA_FRAGMENT)) {
|
||||
imports.push(`@./.claude-fragments/${PERSONA_FRAGMENT}`);
|
||||
}
|
||||
imports.push('@./.claude-shared.md');
|
||||
for (const name of [...desired.keys()].filter((n) => n !== PERSONA_FRAGMENT).sort()) {
|
||||
imports.push(`@./.claude-fragments/${name}`);
|
||||
}
|
||||
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface ColumnDef {
|
||||
updatable?: boolean;
|
||||
/** Default value on create when not provided. */
|
||||
default?: unknown;
|
||||
/** Default to another column's resolved value on create when not provided. */
|
||||
defaultFrom?: string;
|
||||
/** Allowed values (shown in help). */
|
||||
enum?: string[];
|
||||
}
|
||||
@@ -150,6 +152,8 @@ function genericCreate(def: ResourceDef) {
|
||||
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
||||
} else if (col.default !== undefined) {
|
||||
values[col.name] = col.default;
|
||||
} else if (col.defaultFrom !== undefined && values[col.defaultFrom] !== undefined) {
|
||||
values[col.name] = values[col.defaultFrom];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
@@ -9,7 +12,8 @@ import {
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
} from '../../db/container-configs.js';
|
||||
import type { ContainerConfigRow } from '../../types.js';
|
||||
import { createAgentFromTemplate } from '../../templates/create-agent.js';
|
||||
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
/** Deserialize JSON columns for display. */
|
||||
@@ -58,11 +62,37 @@ registerResource({
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
// `delete` is intentionally not in `operations` — the generic single-table
|
||||
// DELETE violates FK constraints (see #2525). The cascading handler is
|
||||
// provided as `customOperations.delete` below.
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
// `create` and `delete` are intentionally not in `operations` — create needs
|
||||
// a `--template` branch (below); the generic single-table DELETE violates FK
|
||||
// constraints (see #2525). Both are provided as `customOperations`.
|
||||
operations: { list: 'open', get: 'open', update: 'approval' },
|
||||
customOperations: {
|
||||
create: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Create an agent group. With --template <ref>, stamp from a local template under templates/ ' +
|
||||
'(MCP servers + instructions + skills); else insert a bare row (--name, --folder).',
|
||||
handler: async (args) => {
|
||||
if (args.template) {
|
||||
return createAgentFromTemplate(String(args.template), {
|
||||
name: args.name ? String(args.name) : undefined,
|
||||
});
|
||||
}
|
||||
const name = args.name ? String(args.name) : '';
|
||||
const folder = args.folder ? String(args.folder) : '';
|
||||
if (!name) throw new Error('--name is required');
|
||||
if (!folder) throw new Error('--folder is required');
|
||||
const group: AgentGroup = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createAgentGroup(group);
|
||||
return group;
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
access: 'approval',
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Regression test: `ncl messaging-groups create` must satisfy the NOT NULL
|
||||
* `instance` column without an operator-supplied `--instance`. The column has
|
||||
* no CLI flag at the operator's altitude (the default instance IS the channel
|
||||
* type), so the generic CRUD insert defaults it to `channel_type` — matching
|
||||
* `createMessagingGroup`'s `instance ?? channel_type` fallback on the router
|
||||
* path. Delete the `instance` column / `defaultFrom` wiring in
|
||||
* `messaging-groups.ts` and this goes red: the insert fails the NOT NULL.
|
||||
*/
|
||||
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-msggroups' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-msggroups';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `messaging-groups-create` command.
|
||||
import './messaging-groups.js';
|
||||
|
||||
describe('messaging-groups CLI create defaults instance to channel_type', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('create without --instance sets instance = channel_type', async () => {
|
||||
// caller: 'host' is the post-approval re-entry path for create (approval op).
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'messaging-groups-create',
|
||||
args: { channel_type: 'telegram', platform_id: '12345' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const row = getMessagingGroupByPlatform('telegram', '12345');
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.instance).toBe('telegram');
|
||||
});
|
||||
|
||||
it('create with an explicit --instance keeps that value', async () => {
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-2',
|
||||
command: 'messaging-groups-create',
|
||||
args: { channel_type: 'telegram', platform_id: '67890', instance: 'work' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('telegram', '67890', 'work')?.instance).toBe('work');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,14 @@ registerResource({
|
||||
'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'instance',
|
||||
type: 'string',
|
||||
description:
|
||||
'Adapter instance that owns this chat, when running N adapters of one channel type. Defaults to channel_type (the default instance) when omitted.',
|
||||
defaultFrom: 'channel_type',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
|
||||
@@ -22,6 +22,12 @@ export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw',
|
||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||
// Local agent-template library. Committed but ships empty (+ README). Resolved
|
||||
// once at load. Override to another LOCAL path via NANOCLAW_TEMPLATES_DIR; never
|
||||
// a remote URL, never an ncl flag, never runtime-mutable.
|
||||
export const TEMPLATES_DIR = process.env.NANOCLAW_TEMPLATES_DIR
|
||||
? path.resolve(process.env.NANOCLAW_TEMPLATES_DIR)
|
||||
: path.resolve(PROJECT_ROOT, 'templates');
|
||||
|
||||
// Per-checkout image tag so two installs on the same host don't share
|
||||
// `nanoclaw-agent:latest` and clobber each other on rebuild.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -185,6 +185,28 @@ export function updatePendingApprovalStatus(approvalId: string, status: PendingA
|
||||
getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Park an approval in the "rejected, awaiting reason" hold: the admin clicked
|
||||
* "Reject with reason…" and we're waiting for their one-line reply. `expiresAt`
|
||||
* is the deadline after which the host sweep finalizes a plain reject (so a
|
||||
* ghosted hold never strands the requesting agent). Reuses the otherwise-unused
|
||||
* `expires_at` column on module-initiated rows.
|
||||
*/
|
||||
export function markApprovalAwaitingReason(approvalId: string, expiresAt: string): void {
|
||||
getDb()
|
||||
.prepare("UPDATE pending_approvals SET status = 'awaiting_reason', expires_at = ? WHERE approval_id = ?")
|
||||
.run(expiresAt, approvalId);
|
||||
}
|
||||
|
||||
/** Awaiting-reason approvals whose reply window has elapsed — the sweep's ghost set. */
|
||||
export function getExpiredAwaitingReasonApprovals(nowIso: string): PendingApproval[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM pending_approvals WHERE status = 'awaiting_reason' AND expires_at IS NOT NULL AND expires_at <= ?",
|
||||
)
|
||||
.all(nowIso) as PendingApproval[];
|
||||
}
|
||||
|
||||
export function deletePendingApproval(approvalId: string): void {
|
||||
getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { PERSONA_PREPEND_FILE, readGroupPersona } from './group-persona.js';
|
||||
|
||||
const TMP = '/tmp/nanoclaw-group-persona-test';
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
fs.mkdirSync(TMP, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('readGroupPersona', () => {
|
||||
it('returns null when the prepend file is absent', () => {
|
||||
expect(readGroupPersona(TMP)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty / whitespace-only file', () => {
|
||||
fs.writeFileSync(path.join(TMP, PERSONA_PREPEND_FILE), ' \n\n');
|
||||
expect(readGroupPersona(TMP)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the trimmed content when present', () => {
|
||||
fs.writeFileSync(path.join(TMP, PERSONA_PREPEND_FILE), '\nYou are an SDR agent.\n\n');
|
||||
expect(readGroupPersona(TMP)).toBe('You are an SDR agent.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Provider-neutral per-group persona ("instructions prepend").
|
||||
*
|
||||
* A template stamps its standing instructions here (src/templates/create-agent.ts).
|
||||
* Each provider's project-doc composer inlines this content at the TOP of the
|
||||
* doc it generates every spawn — `CLAUDE.md` (Claude, src/claude-md-compose.ts)
|
||||
* or `AGENTS.md` (Codex, src/providers/codex-agents-md.ts on the providers
|
||||
* branch) — so a template persona lands at system-prompt tier on every provider
|
||||
* rather than in a recall-tier memory file.
|
||||
*
|
||||
* This module is the single owner of the filename + read semantics so the two
|
||||
* composers (one on main, one on the providers donor branch) never hardcode the
|
||||
* path independently. Absent file ⇒ null ⇒ no-op for non-template groups.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** Per-group host file holding the persona prepend. Never regenerated — persistent. */
|
||||
export const PERSONA_PREPEND_FILE = 'instructions.prepend.md';
|
||||
|
||||
/**
|
||||
* Read a group's persona prepend from its host dir, or null if absent/empty.
|
||||
* `groupDir` is the per-group host directory (`GROUPS_DIR/<folder>`).
|
||||
*/
|
||||
export function readGroupPersona(groupDir: string): string | null {
|
||||
const file = path.join(groupDir, PERSONA_PREPEND_FILE);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const content = fs.readFileSync(file, 'utf-8').trim();
|
||||
return content.length > 0 ? content : null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-group-skills-test';
|
||||
const DATA_DIR = path.join(TEST_ROOT, 'data');
|
||||
|
||||
vi.mock('./config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config.js')>()),
|
||||
DATA_DIR: '/tmp/nanoclaw-group-skills-test/data',
|
||||
}));
|
||||
|
||||
import { materializeTemplateSkills } from './group-skills.js';
|
||||
|
||||
function templateSkill(groupId: string, name: string, file: string, content: string): void {
|
||||
const dir = path.join(DATA_DIR, 'v2-sessions', groupId, '.claude-shared', 'skills', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), content);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('materializeTemplateSkills', () => {
|
||||
it('copies real template-skill dirs into the provider skills dir', () => {
|
||||
templateSkill('g1', 'widget', 'SKILL.md', 'body');
|
||||
const dest = path.join(TEST_ROOT, 'grp1', '.agents', 'skills');
|
||||
|
||||
materializeTemplateSkills('g1', dest);
|
||||
|
||||
expect(fs.readFileSync(path.join(dest, 'widget', 'SKILL.md'), 'utf-8')).toBe('body');
|
||||
expect(fs.lstatSync(path.join(dest, 'widget')).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op when the group has no template skills', () => {
|
||||
const dest = path.join(TEST_ROOT, 'grp2', '.agents', 'skills');
|
||||
materializeTemplateSkills('g2', dest);
|
||||
expect(fs.existsSync(dest)).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites its own skill dirs but leaves other destination entries intact', () => {
|
||||
templateSkill('g3', 'widget', 'SKILL.md', 'new');
|
||||
const dest = path.join(TEST_ROOT, 'grp3', '.agents', 'skills');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
// Stale copy of the same skill (should be refreshed) + a coexisting
|
||||
// shared-skill symlink (must NOT be touched — it is provider-owned).
|
||||
fs.mkdirSync(path.join(dest, 'widget'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'widget', 'SKILL.md'), 'old');
|
||||
fs.symlinkSync('/app/skills/shared', path.join(dest, 'shared'));
|
||||
|
||||
materializeTemplateSkills('g3', dest);
|
||||
|
||||
expect(fs.readFileSync(path.join(dest, 'widget', 'SKILL.md'), 'utf-8')).toBe('new');
|
||||
expect(fs.lstatSync(path.join(dest, 'shared')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not destroy skills when dest equals the source (Claude reads source directly)', () => {
|
||||
templateSkill('g4', 'widget', 'SKILL.md', 'body');
|
||||
const src = path.join(DATA_DIR, 'v2-sessions', 'g4', '.claude-shared', 'skills');
|
||||
|
||||
materializeTemplateSkills('g4', src);
|
||||
|
||||
expect(fs.existsSync(path.join(src, 'widget', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Provider-agnostic template-skill materialization.
|
||||
*
|
||||
* A template stamps its skills as REAL directories into the group-private store
|
||||
* `data/v2-sessions/<group-id>/.claude-shared/skills/<name>` (src/templates/create-agent.ts).
|
||||
* Claude reads that store directly — it is mounted at `~/.claude/skills`, and
|
||||
* real dirs survive the symlink-only skill-link prune. Every OTHER surfaces-owning
|
||||
* provider (codex, opencode, pi, …) reads a DIFFERENT per-group skills directory,
|
||||
* often READ-ONLY-mounted, so the skills must be copied there host-side, before
|
||||
* the container starts.
|
||||
*
|
||||
* This is the single shared spot that does that copy. Each provider's host-side
|
||||
* container contribution calls it once with its own skills dir (codex →
|
||||
* `.agents/skills`; a future provider → whatever it reads). Adding a provider
|
||||
* therefore adds one call, not a new mirror implementation. The copied dirs are
|
||||
* real (not symlinks), so they survive providers' symlink-only prunes and persist
|
||||
* across respawns.
|
||||
*
|
||||
* This module is a main-owned seam that provider payloads (on the `providers`
|
||||
* donor branch) import — mirrors src/group-persona.ts.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
|
||||
/** The group-private store templates stamp skills into (Claude's read plane). */
|
||||
function templateSkillsSource(agentGroupId: string): string {
|
||||
return path.join(DATA_DIR, 'v2-sessions', agentGroupId, '.claude-shared', 'skills');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a group's template skills into a provider's per-group skills directory.
|
||||
* No-op if the group has no template skills, or if `destSkillsDir` IS the source
|
||||
* (Claude, which reads the source directly — copying onto itself would delete it).
|
||||
* Idempotent: overwrites each template skill so edits propagate on respawn. It
|
||||
* manages only its own skill dirs — other entries in the destination (e.g. a
|
||||
* provider's shared-skill symlinks) are left untouched.
|
||||
*/
|
||||
export function materializeTemplateSkills(agentGroupId: string, destSkillsDir: string): void {
|
||||
const src = templateSkillsSource(agentGroupId);
|
||||
if (!fs.existsSync(src)) return;
|
||||
if (path.resolve(src) === path.resolve(destSkillsDir)) return;
|
||||
|
||||
fs.mkdirSync(destSkillsDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(src)) {
|
||||
if (!fs.statSync(path.join(src, name)).isDirectory()) continue;
|
||||
const dest = path.join(destSkillsDir, name);
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(src, name), dest, { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,18 @@ async function sweep(): Promise<void> {
|
||||
log.error('Host sweep error', { err });
|
||||
}
|
||||
|
||||
// Finalize any "Reject with reason…" holds whose reply window elapsed (admin
|
||||
// ghosted, or the host restarted mid-capture). Central-DB scan, once per tick
|
||||
// — not per session.
|
||||
// MODULE-HOOK:approvals-reason-sweep:start
|
||||
try {
|
||||
const { sweepAwaitingReasonRejects } = await import('./modules/approvals/index.js');
|
||||
await sweepAwaitingReasonRejects();
|
||||
} catch (err) {
|
||||
log.error('Reject-with-reason sweep failed', { err });
|
||||
}
|
||||
// MODULE-HOOK:approvals-reason-sweep:end
|
||||
|
||||
setTimeout(sweep, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared containment guards for per-message inbox directories.
|
||||
*
|
||||
* Session dirs are mounted writable into agent containers, so a compromised
|
||||
* agent can pre-place a symlink inside its own session dir and wait for the
|
||||
* host to write through it — landing attacker-influenced bytes outside the
|
||||
* sandbox (CWE-59). Both inbound paths that materialise files into a session's
|
||||
* `inbox/<messageId>/` directory route through `ensureContainedInboxDir`:
|
||||
* - channel-inbound attachments (`extractAttachmentFiles` in session-manager)
|
||||
* - agent-to-agent forwarded files (`forwardAttachedFiles` in agent-route)
|
||||
*
|
||||
* Keeping the guard in one place means both paths defend identically; the fix
|
||||
* for GHSA #2828 originally lived only in the A2A path and the channel path had
|
||||
* the same gap (a symlinked `inbox` root was followed silently).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from './log.js';
|
||||
|
||||
/** True if `child` is `parent` itself or nested within it (no traversal/escape). */
|
||||
export function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and create `<inboxRoot>/<messageId>`, refusing pre-placed symlinks a
|
||||
* compromised container could use to redirect host writes outside the session.
|
||||
*
|
||||
* Guards, in order:
|
||||
* 1. lstat the inbox ROOT — reject if it is a symlink or a non-directory.
|
||||
* Without this, a symlinked `inbox` is silently followed by mkdir AND the
|
||||
* containment check in step 4 passes, because it compares against the
|
||||
* already-followed (escaped) root. This is the gap that affected the
|
||||
* channel-inbound path.
|
||||
* 2. lstat the per-message subdir — reject a pre-placed symlink/non-dir.
|
||||
* lstat does not follow the final path component, so it sees the link
|
||||
* itself even when the link target does not exist.
|
||||
* 3. mkdir the subdir (recursive).
|
||||
* 4. realpath containment — the resolved subdir must stay within the resolved
|
||||
* inbox root (defence in depth; symlinks are already ruled out above).
|
||||
*
|
||||
* Returns the resolved, contained subdir path (write into it with an exclusive
|
||||
* flag — `COPYFILE_EXCL` / `wx` — so a pre-existing symlinked *file* can't be
|
||||
* followed either), or `null` if any guard tripped. On `null` the caller logs
|
||||
* its own context and skips; `context` is merged into the warn logs here so
|
||||
* each call site stays diagnosable.
|
||||
*/
|
||||
export function ensureContainedInboxDir(
|
||||
inboxRoot: string,
|
||||
messageId: string,
|
||||
context: Record<string, unknown>,
|
||||
): string | null {
|
||||
const inboxDir = path.join(inboxRoot, messageId);
|
||||
|
||||
for (const dir of [inboxRoot, inboxDir]) {
|
||||
try {
|
||||
const st = fs.lstatSync(dir);
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) {
|
||||
log.warn('inbox-safety: rejecting unsafe inbox path', { ...context, dir });
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Does not exist yet — fine, mkdir below creates it.
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const realInboxDir = fs.realpathSync(inboxDir);
|
||||
const realInboxRoot = fs.realpathSync(inboxRoot);
|
||||
if (!isPathInside(realInboxRoot, realInboxDir)) {
|
||||
log.warn('inbox-safety: inbox dir escaped inbox root', { ...context, inboxDir });
|
||||
return null;
|
||||
}
|
||||
return realInboxDir;
|
||||
} catch (err) {
|
||||
log.warn('inbox-safety: failed to resolve inbox dir', { ...context, inboxDir, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
|
||||
import { forwardAttachedFiles, isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
|
||||
import { log } from '../../log.js';
|
||||
import { createDestination } from './db/agent-destinations.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession, updateSession } from '../../db/sessions.js';
|
||||
@@ -467,4 +468,129 @@ describe('routeAgentMessage return-path', () => {
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
// #2828 — target-side symlink containment. A compromised target agent can
|
||||
// write inside its own session dir; these tests prove it cannot redirect a
|
||||
// forwarded attachment outside the session sandbox via a pre-placed symlink.
|
||||
|
||||
it('file forwarding (#2828): skips a symlinked target inbox dir, writes nothing outside', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside-inbox');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
// Source has a real attachment to forward.
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-inbox');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'pwn.txt'), 'attacker-bytes');
|
||||
|
||||
// Target pre-places its whole `inbox` as a symlink pointing outside.
|
||||
const targetInbox = path.join(sessionDir(B, SB.id), 'inbox');
|
||||
fs.rmSync(targetInbox, { recursive: true, force: true });
|
||||
fs.symlinkSync(canaryDir, targetInbox);
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-evil-inbox',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['pwn.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
// Message still routes — just with no attachments.
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
expect(JSON.parse(bRows[0].content).attachments).toHaveLength(0);
|
||||
|
||||
// Nothing was written through the symlink to the canary location.
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828): skips a symlinked inbox/<msgId> subdir, writes nothing outside', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside-subdir');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-subdir');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'pwn.txt'), 'attacker-bytes');
|
||||
|
||||
// The forwarded a2a msg id generated inside routeAgentMessage is random, so
|
||||
// a symlink can't be pre-placed at inbox/<that-id>. Drive forwardAttachedFiles
|
||||
// directly with a fixed target message id and plant the symlink at that path.
|
||||
const targetMsgId = 'evil-subdir-msg';
|
||||
const realInbox = path.join(sessionDir(B, SB.id), 'inbox');
|
||||
fs.mkdirSync(realInbox, { recursive: true });
|
||||
fs.symlinkSync(canaryDir, path.join(realInbox, targetMsgId));
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{ agentGroupId: A, sessionId: S1.id, messageId: 'msg-evil-subdir', filenames: ['pwn.txt'] },
|
||||
{ agentGroupId: B, sessionId: SB.id, messageId: targetMsgId },
|
||||
);
|
||||
|
||||
expect(attachments).toHaveLength(0);
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828): refuses a pre-existing symlinked dst file (COPYFILE_EXCL)', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryFile = path.join(TEST_DIR, 'canary-dst-target.txt');
|
||||
fs.writeFileSync(canaryFile, 'original-canary');
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-dst');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'doc.txt'), 'attacker-bytes');
|
||||
|
||||
// inbox/<msgId>/ is a real dir, but contains a pre-placed symlink named
|
||||
// exactly like the incoming attachment, pointing at the canary file.
|
||||
// We can only do this once we know the a2a msg id, which is generated
|
||||
// inside routeAgentMessage. So we instead drive forwardAttachedFiles
|
||||
// directly with a fixed target message id.
|
||||
const targetMsgId = 'fixed-evil-dst';
|
||||
const realInboxSubdir = path.join(sessionDir(B, SB.id), 'inbox', targetMsgId);
|
||||
fs.mkdirSync(realInboxSubdir, { recursive: true });
|
||||
fs.symlinkSync(canaryFile, path.join(realInboxSubdir, 'doc.txt'));
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{ agentGroupId: A, sessionId: S1.id, messageId: 'msg-evil-dst', filenames: ['doc.txt'] },
|
||||
{ agentGroupId: B, sessionId: SB.id, messageId: targetMsgId },
|
||||
);
|
||||
|
||||
// The exclusive write failed → nothing forwarded.
|
||||
expect(attachments).toHaveLength(0);
|
||||
// Canary file untouched (symlink not followed/overwritten).
|
||||
expect(fs.readFileSync(canaryFile, 'utf-8')).toBe('original-canary');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828 regression): a normal forward still works end-to-end', async () => {
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-ok-file');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'ok.txt'), 'legit-bytes');
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-ok-file',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['ok.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(1);
|
||||
expect(parsed.attachments[0].name).toBe('ok.txt');
|
||||
const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath);
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('legit-bytes');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { isSafeAttachmentName } from '../../attachment-safety.js';
|
||||
import { ensureContainedInboxDir, isPathInside } from '../../inbox-safety.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
@@ -42,11 +43,6 @@ export interface ForwardedAttachment {
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
@@ -98,8 +94,20 @@ export function forwardAttachedFiles(
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
||||
// Target-side containment — shared with the channel-inbound path. A
|
||||
// compromised target agent can write inside its own session dir, so it could
|
||||
// pre-place `inbox` (or `inbox/<future-msgId>`) as a symlink pointing
|
||||
// anywhere host-writable; ensureContainedInboxDir refuses the symlink before
|
||||
// any copy lands outside the sandbox (#2828, CWE-59).
|
||||
const inboxRoot = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox');
|
||||
const targetInboxDir = ensureContainedInboxDir(inboxRoot, target.messageId, {
|
||||
targetGroup: target.agentGroupId,
|
||||
targetSession: target.sessionId,
|
||||
targetMsgId: target.messageId,
|
||||
});
|
||||
if (!targetInboxDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments: ForwardedAttachment[] = [];
|
||||
for (const filename of source.filenames) {
|
||||
@@ -137,7 +145,20 @@ export function forwardAttachedFiles(
|
||||
continue;
|
||||
}
|
||||
const dst = path.join(targetInboxDir, filename);
|
||||
fs.copyFileSync(realSrc, dst);
|
||||
try {
|
||||
// COPYFILE_EXCL: fail with EEXIST rather than follow or overwrite a
|
||||
// pre-placed symlink / existing file at dst — the host is the sole
|
||||
// writer of these attachments.
|
||||
fs.copyFileSync(realSrc, dst, fs.constants.COPYFILE_EXCL);
|
||||
} catch (err) {
|
||||
log.warn('agent-route: refusing to write target inbox file', {
|
||||
sourceMsgId: source.messageId,
|
||||
targetMsgId: target.messageId,
|
||||
filename,
|
||||
err,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
attachments.push({
|
||||
name: filename,
|
||||
filename,
|
||||
@@ -278,7 +299,10 @@ function buildGateQuestion(sourceName: string, targetName: string, contentStr: s
|
||||
const body = text.length > GATE_CARD_BODY_MAX ? `${text.slice(0, GATE_CARD_BODY_MAX)}… (truncated)` : text;
|
||||
const lines = [`Agent "${sourceName}" wants to send a message to "${targetName}":`, '', body];
|
||||
if (files.length > 0) lines.push('', `Attachments: ${files.join(', ')}`);
|
||||
lines.push('', 'Approve delivery?');
|
||||
lines.push(
|
||||
'',
|
||||
`Approve, Reject, or "Reject with reason…" to decline and then type a short reason I'll relay to "${sourceName}".`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Shared "finalize a rejected approval" path.
|
||||
*
|
||||
* Three entry points land here so they relay one message and clean up
|
||||
* identically:
|
||||
* 1. The instant Reject button (response-handler.ts)
|
||||
* 2. A captured Reject-with-reason reply (reason-capture.ts)
|
||||
* 3. The host-sweep ghost finalizer (reason-capture.ts, via host-sweep)
|
||||
*
|
||||
* Kept in its own leaf file so both response-handler.ts and reason-capture.ts
|
||||
* can import it without an import cycle (finalize → primitive only).
|
||||
*/
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { deletePendingApproval } from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import { notifyApprovalResolved } from './primitive.js';
|
||||
|
||||
/**
|
||||
* Notify the requesting agent that its action was rejected, drop the pending
|
||||
* row, fire approval-resolved callbacks, and wake the container.
|
||||
*
|
||||
* When `reason` is provided it's appended to the agent-facing note with generic
|
||||
* attribution — the why, not the who (the rejecting admin may belong to a
|
||||
* different owner than the requesting agent). Callers are responsible for
|
||||
* clamping the reason length before passing it in.
|
||||
*/
|
||||
export async function finalizeReject(
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
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.`;
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
|
||||
log.info('Approval rejected', {
|
||||
approvalId: approval.approval_id,
|
||||
action: approval.action,
|
||||
userId,
|
||||
withReason: reason !== undefined,
|
||||
});
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
await wakeContainer(session);
|
||||
}
|
||||
@@ -8,10 +8,16 @@
|
||||
* - A response handler that claims pending_approvals rows and dispatches
|
||||
* to whatever module registered for the row's `action` string. Also
|
||||
* resolves in-memory OneCLI credential approvals.
|
||||
* - A message-interceptor (via ./reason-capture.js) that captures an admin's
|
||||
* one-line reply after they click "Reject with reason…".
|
||||
* - An adapter-ready callback that starts the OneCLI manual-approval handler
|
||||
* once the delivery adapter is set.
|
||||
* - A shutdown callback that stops the OneCLI handler cleanly.
|
||||
*
|
||||
* Exposes `sweepAwaitingReasonRejects` for the host sweep to finalize ghosted
|
||||
* reject-with-reason holds (re-exported here, which also loads reason-capture
|
||||
* so its interceptor registers).
|
||||
*
|
||||
* Self-mod flows (install_packages, add_mcp_server) moved out to
|
||||
* `src/modules/self-mod/` in PR #7 — they now register delivery actions
|
||||
* + approval handlers via this module's public API.
|
||||
@@ -24,6 +30,9 @@ import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-
|
||||
// Public API re-exports so consumers import from the module root.
|
||||
export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js';
|
||||
export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } from './primitive.js';
|
||||
// Host-sweep hook for ghosted "Reject with reason…" holds. The re-export also
|
||||
// loads reason-capture.js, registering its message-interceptor on import.
|
||||
export { sweepAwaitingReasonRejects } from './reason-capture.js';
|
||||
|
||||
registerResponseHandler(handleApprovalsResponse);
|
||||
|
||||
|
||||
@@ -254,16 +254,46 @@ 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 = [
|
||||
'Credential access request',
|
||||
`Agent: ${agentName}`,
|
||||
'```',
|
||||
`${request.method} ${request.host}${request.path}`,
|
||||
'```',
|
||||
];
|
||||
if (request.bodyPreview) {
|
||||
lines.push('Body:', '```', request.bodyPreview, '```');
|
||||
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}_`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -32,10 +32,23 @@ import type { MessagingGroup, PendingApproval, Session } from '../../types.js';
|
||||
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
|
||||
/** Two-button approval UI — the only options the primitive supports today. */
|
||||
/**
|
||||
* Card value for the "Reject with reason…" button. Selecting it doesn't
|
||||
* finalize the reject — it holds the row and captures the approver's next DM
|
||||
* as a one-line reason relayed to the requesting agent. See reason-capture.ts.
|
||||
*/
|
||||
export const REJECT_WITH_REASON_VALUE = 'reject_with_reason';
|
||||
|
||||
/**
|
||||
* Three-button approval UI. Plain Reject is the instant fast path; "Reject with
|
||||
* reason…" opts into the reason-capture flow. Shared by every module approval
|
||||
* (create_agent, install_packages, add_mcp_server); OneCLI credential cards
|
||||
* keep their own two-button set in onecli-approvals.ts.
|
||||
*/
|
||||
const APPROVAL_OPTIONS: RawOption[] = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected (awaiting reason)', value: REJECT_WITH_REASON_VALUE },
|
||||
];
|
||||
|
||||
// ── Approval handler registry ──
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* "Reject with reason…" capture flow.
|
||||
*
|
||||
* Covers the three entry points end to end against the real central DB:
|
||||
* - arming (handleApprovalsResponse with the third option) holds the row and
|
||||
* prompts the admin instead of finalizing;
|
||||
* - the captured reply relays one combined message, clamped to 280 chars;
|
||||
* - the host sweep finalizes a ghosted hold as a plain reject.
|
||||
*
|
||||
* writeSessionMessage is mocked so the relayed agent-facing text can be read
|
||||
* back directly; the delivery adapter is a fake that records prompt sends.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
createPendingApproval,
|
||||
deletePendingApproval,
|
||||
getPendingApproval,
|
||||
markApprovalAwaitingReason,
|
||||
} from '../../db/sessions.js';
|
||||
import { setDeliveryAdapter, type ChannelDeliveryAdapter } from '../../delivery.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { upsertUserDm } from '../permissions/db/user-dms.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
import { REJECT_WITH_REASON_VALUE } from './primitive.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-reject-reason' };
|
||||
});
|
||||
|
||||
vi.mock('../../session-manager.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../session-manager.js')>('../../session-manager.js');
|
||||
return { ...actual, writeSessionMessage: vi.fn() };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-reject-reason';
|
||||
const DM_CHANNEL = 'slack';
|
||||
const DM_PLATFORM = 'D-admin-1';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
let delivered: Array<{ channelType: string; platformId: string; content: string }>;
|
||||
|
||||
const fakeAdapter: ChannelDeliveryAdapter = {
|
||||
async deliver(channelType, platformId, _threadId, _kind, content) {
|
||||
delivered.push({ channelType, platformId, content });
|
||||
return 'pm-1';
|
||||
},
|
||||
};
|
||||
|
||||
function seedApproval(approvalId: string, action = 'create_agent'): void {
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: 'sess-1',
|
||||
request_id: approvalId,
|
||||
action,
|
||||
payload: JSON.stringify({ name: 'child' }),
|
||||
created_at: now(),
|
||||
title: 'Approval',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
function dmReply(text?: string): InboundEvent {
|
||||
const content: Record<string, unknown> = { sender: 'admin-1', senderId: 'admin-1' };
|
||||
if (text !== undefined) content.text = text;
|
||||
return {
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: DM_PLATFORM,
|
||||
threadId: null,
|
||||
message: { id: 'm-1', kind: 'chat', content: JSON.stringify(content), timestamp: now() },
|
||||
};
|
||||
}
|
||||
|
||||
/** Click the "Reject with reason…" button as the seeded admin. */
|
||||
async function clickRejectWithReason(approvalId: string): Promise<void> {
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
await handleApprovalsResponse({
|
||||
questionId: approvalId,
|
||||
value: REJECT_WITH_REASON_VALUE,
|
||||
userId: 'admin-1',
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: '', // not surfaced by the click payload — resolved via ensureUserDm
|
||||
threadId: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** The text of the most recent agent-facing note written via writeSessionMessage. */
|
||||
function lastRelayedText(): string | undefined {
|
||||
const call = vi.mocked(writeSessionMessage).mock.calls.at(-1);
|
||||
if (!call) return undefined;
|
||||
return (JSON.parse(call[2].content) as { text: string }).text;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
delivered = [];
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
// Authorized approver + a cached DM so ensureUserDm resolves without a
|
||||
// platform openDM call.
|
||||
upsertUser({ id: 'slack:admin-1', kind: 'slack', display_name: 'Admin', created_at: now() });
|
||||
grantRole({ user_id: 'slack:admin-1', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
createMessagingGroup({
|
||||
id: 'mg-dm-1',
|
||||
channel_type: DM_CHANNEL,
|
||||
platform_id: DM_PLATFORM,
|
||||
name: 'Admin DM',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
upsertUserDm({
|
||||
user_id: 'slack:admin-1',
|
||||
channel_type: DM_CHANNEL,
|
||||
messaging_group_id: 'mg-dm-1',
|
||||
resolved_at: now(),
|
||||
});
|
||||
|
||||
setDeliveryAdapter(fakeAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('reject with reason', () => {
|
||||
it('holds the row and prompts the admin instead of finalizing', async () => {
|
||||
seedApproval('appr-1');
|
||||
await clickRejectWithReason('appr-1');
|
||||
|
||||
const row = getPendingApproval('appr-1');
|
||||
expect(row?.status).toBe('awaiting_reason');
|
||||
expect(row?.expires_at).toBeTruthy();
|
||||
|
||||
// Prompt went to the admin's resolved DM, not the (empty) click platformId.
|
||||
expect(delivered).toHaveLength(1);
|
||||
expect(delivered[0].channelType).toBe(DM_CHANNEL);
|
||||
expect(delivered[0].platformId).toBe(DM_PLATFORM);
|
||||
expect((JSON.parse(delivered[0].content) as { text: string }).text).toMatch(/reason/i);
|
||||
|
||||
// Agent is not notified yet — the hold is still open.
|
||||
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('relays the captured reason as one combined message and clears the row', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-2', 'install_packages');
|
||||
await clickRejectWithReason('appr-2');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply('too risky for prod'));
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(getPendingApproval('appr-2')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin: "too risky for prod"');
|
||||
});
|
||||
|
||||
it('truncates an over-long reason to 280 chars with an ellipsis', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-3');
|
||||
await clickRejectWithReason('appr-3');
|
||||
|
||||
await captureReasonReply(dmReply('x'.repeat(400)));
|
||||
|
||||
const reason = lastRelayedText()!.match(/: "(.*)"$/)![1];
|
||||
expect(reason).toHaveLength(280);
|
||||
expect(reason.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('finalizes a plain reject when the captured reply carries no text', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-4');
|
||||
await clickRejectWithReason('appr-4');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply(undefined));
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(getPendingApproval('appr-4')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your create_agent request was rejected by admin.');
|
||||
});
|
||||
|
||||
it('does not swallow a later DM once the hold was already finalized', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-5');
|
||||
await clickRejectWithReason('appr-5');
|
||||
// Simulate the sweep (or any other path) finalizing first.
|
||||
deletePendingApproval('appr-5');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply('late reason'));
|
||||
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores DMs on channels with no armed reason capture', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
const consumed = await captureReasonReply({
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: 'D-someone-else',
|
||||
threadId: null,
|
||||
message: { id: 'm', kind: 'chat', content: JSON.stringify({ text: 'hi' }), timestamp: now() },
|
||||
});
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject-with-reason host sweep', () => {
|
||||
it('finalizes a hold whose window elapsed as a plain reject', async () => {
|
||||
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
|
||||
seedApproval('appr-ghost', 'add_mcp_server');
|
||||
markApprovalAwaitingReason('appr-ghost', new Date(Date.now() - 1000).toISOString());
|
||||
|
||||
await sweepAwaitingReasonRejects();
|
||||
|
||||
expect(getPendingApproval('appr-ghost')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your add_mcp_server request was rejected by admin.');
|
||||
});
|
||||
|
||||
it('leaves a still-open hold untouched', async () => {
|
||||
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
|
||||
seedApproval('appr-open');
|
||||
markApprovalAwaitingReason('appr-open', new Date(Date.now() + 60_000).toISOString());
|
||||
|
||||
await sweepAwaitingReasonRejects();
|
||||
|
||||
expect(getPendingApproval('appr-open')?.status).toBe('awaiting_reason');
|
||||
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain reject (regression)', () => {
|
||||
it('finalizes immediately with no reason and no DM prompt', async () => {
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
seedApproval('appr-plain', 'install_packages');
|
||||
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-plain',
|
||||
value: 'reject',
|
||||
userId: 'admin-1',
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: '',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(getPendingApproval('appr-plain')).toBeUndefined();
|
||||
expect(delivered).toHaveLength(0);
|
||||
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* "Reject with reason…" capture flow.
|
||||
*
|
||||
* When an admin clicks the third approval button, the reject is held instead of
|
||||
* finalized: the row is parked at status='awaiting_reason' and the admin is
|
||||
* prompted in their DM for a one-line reason. Their next DM (≤ 280 chars) is
|
||||
* captured by a router message-interceptor and relayed to the requesting agent
|
||||
* as one combined message — `Your <action> request was rejected by admin:
|
||||
* "<reason>"`. A plain Reject never arms this, so an unrelated DM is never
|
||||
* swallowed.
|
||||
*
|
||||
* Restart-safety: arming lives in an in-memory map (lost on restart, like the
|
||||
* agent-naming capture it mirrors), but the hold is a durable DB row. If the
|
||||
* admin never replies — or the host restarts mid-capture — the host sweep
|
||||
* (sweepAwaitingReasonRejects, run each tick) finalizes a plain reject once the
|
||||
* row's window elapses, so the requesting agent is never stranded.
|
||||
*
|
||||
* Reuses, not reinvents: the agent-naming prompt-then-capture pattern
|
||||
* (in-memory map + next-DM interceptor) and the shared finalizeReject path.
|
||||
*/
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import {
|
||||
deletePendingApproval,
|
||||
getExpiredAwaitingReasonApprovals,
|
||||
getPendingApproval,
|
||||
getSession,
|
||||
markApprovalAwaitingReason,
|
||||
} from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { registerMessageInterceptor } from '../../router.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
import { finalizeReject } from './finalize.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;
|
||||
/** Cap on the relayed reason — one cheap guardrail against a wall of text landing in another team's agent context. */
|
||||
const MAX_REASON_LEN = 280;
|
||||
|
||||
const PROMPT_TEXT =
|
||||
"Reply with a one-line reason for the rejection — I'll relay it to the agent. " +
|
||||
'No reply within ~5 min declines it without a reason.';
|
||||
|
||||
interface ReasonArming {
|
||||
approvalId: string;
|
||||
/** Namespaced id of the admin who clicked, for resolution attribution. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approvers waiting to type a rejection reason, keyed by their DM channel
|
||||
* (`<channelType>:<dmPlatformId>`). A DM's platform id is unique per user, so
|
||||
* the inbound reply matches by channel alone — no sender re-parsing needed, and
|
||||
* a group message can never collide with an armed DM. Cleared on receipt,
|
||||
* staleness, or restart.
|
||||
*/
|
||||
const awaitingReason = new Map<string, ReasonArming>();
|
||||
|
||||
function dmKey(channelType: string, platformId: string): string {
|
||||
return `${channelType}:${platformId}`;
|
||||
}
|
||||
|
||||
function clampReason(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length <= MAX_REASON_LEN) return trimmed;
|
||||
return trimmed.slice(0, MAX_REASON_LEN - 1) + '…';
|
||||
}
|
||||
|
||||
function extractText(event: InboundEvent): string {
|
||||
try {
|
||||
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
return typeof parsed.text === 'string' ? parsed.text : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin the reject-with-reason hold for an approval the admin chose not to
|
||||
* finalize outright. Prompts the admin's DM, then parks the row and arms
|
||||
* capture. If we can't reach the admin (no DM, no adapter, delivery throws) we
|
||||
* finalize a plain reject immediately rather than strand the requesting agent.
|
||||
*/
|
||||
export async function armReasonCapture(approval: PendingApproval, session: Session, userId: string): Promise<void> {
|
||||
const dm = userId ? await ensureUserDm(userId) : null;
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!dm || !adapter) {
|
||||
log.warn('reject-with-reason: cannot reach approver, finalizing plain reject', {
|
||||
approvalId: approval.approval_id,
|
||||
userId,
|
||||
hasDm: Boolean(dm),
|
||||
hasAdapter: Boolean(adapter),
|
||||
});
|
||||
await finalizeReject(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.deliver(dm.channel_type, dm.platform_id, null, 'chat-sdk', JSON.stringify({ text: PROMPT_TEXT }));
|
||||
} catch (err) {
|
||||
log.error('reject-with-reason: reason prompt delivery failed, finalizing plain reject', {
|
||||
approvalId: approval.approval_id,
|
||||
err,
|
||||
});
|
||||
await finalizeReject(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt is out — now hold the row and arm capture. Order matters: a reply
|
||||
// can't arrive before the prompt is read, so there's no lost-message window.
|
||||
const expiresAt = new Date(Date.now() + REASON_CAPTURE_WINDOW_MS).toISOString();
|
||||
markApprovalAwaitingReason(approval.approval_id, expiresAt);
|
||||
awaitingReason.set(dmKey(dm.channel_type, dm.platform_id), { approvalId: approval.approval_id, userId });
|
||||
log.info('reject-with-reason: awaiting reason reply', { approvalId: approval.approval_id, userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Router message-interceptor: capture the next DM from an admin who armed a
|
||||
* reason. Returns true (consume the message) when this DM is an armed reason
|
||||
* channel and still holds a live row; false otherwise so normal routing runs.
|
||||
*
|
||||
* Exported for tests; registered as the interceptor below.
|
||||
*/
|
||||
export async function captureReasonReply(event: InboundEvent): Promise<boolean> {
|
||||
const arming = awaitingReason.get(dmKey(event.channelType, event.platformId));
|
||||
if (!arming) return false;
|
||||
|
||||
// This DM is an armed reason channel — disarm regardless of outcome.
|
||||
awaitingReason.delete(dmKey(event.channelType, event.platformId));
|
||||
|
||||
const approval = getPendingApproval(arming.approvalId);
|
||||
if (!approval || approval.status !== 'awaiting_reason') {
|
||||
// Already finalized (e.g. ghosted by the sweep). The reply is no longer a
|
||||
// reason — let it route normally instead of swallowing it.
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = approval.session_id ? getSession(approval.session_id) : null;
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const reason = clampReason(extractText(event));
|
||||
await finalizeReject(approval, session, arming.userId, reason || undefined);
|
||||
log.info('reject-with-reason: reason captured and relayed', {
|
||||
approvalId: approval.approval_id,
|
||||
hasReason: reason.length > 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
registerMessageInterceptor(captureReasonReply);
|
||||
|
||||
/**
|
||||
* Host-sweep finalizer: any reject-with-reason hold whose window elapsed (admin
|
||||
* ghosted, or the host restarted mid-capture and lost the in-memory arming) is
|
||||
* finalized as a plain reject. Restart-safe — the hold is a durable row, so the
|
||||
* requesting agent always gets its decision. Called once per sweep tick.
|
||||
*/
|
||||
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;
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
continue;
|
||||
}
|
||||
// Plain reject, unknown resolver — the admin opted in but never typed.
|
||||
await finalizeReject(approval, session, '');
|
||||
log.info('reject-with-reason: window elapsed, finalized as plain reject', { approvalId: approval.approval_id });
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
* 1. Module-initiated actions — the module called `requestApproval()` with
|
||||
* some free-form `action` string and registered a handler via
|
||||
* `registerApprovalHandler(action, handler)`. On approve, we look up the
|
||||
* handler and call it; on reject, we notify the agent and move on.
|
||||
* handler and call it; on plain reject we relay a decline to the agent; on
|
||||
* "Reject with reason…" we hold the row and capture the admin's next DM as
|
||||
* a one-line reason (see reason-capture.ts). Reject finalization is shared
|
||||
* via finalizeReject.
|
||||
* 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved
|
||||
* via an in-memory Promise — see onecli-approvals.ts.
|
||||
*
|
||||
@@ -19,8 +22,10 @@ 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 { getApprovalHandler, notifyApprovalResolved } from './primitive.js';
|
||||
import { getApprovalHandler, notifyApprovalResolved, REJECT_WITH_REASON_VALUE } from './primitive.js';
|
||||
import { armReasonCapture } from './reason-capture.js';
|
||||
|
||||
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const approval = getPendingApproval(payload.questionId);
|
||||
@@ -65,6 +70,21 @@ async function handleRegisteredApproval(
|
||||
return;
|
||||
}
|
||||
|
||||
// "Reject with reason…" — hold the row and capture the admin's next DM
|
||||
// instead of finalizing now. The agent is notified exactly once: after the
|
||||
// reason arrives, or after the sweep's timeout if the admin ghosts.
|
||||
if (selectedOption === REJECT_WITH_REASON_VALUE) {
|
||||
await armReasonCapture(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain Reject (or any other non-approve value) — instant fast path.
|
||||
if (selectedOption !== 'approve') {
|
||||
await finalizeReject(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved — dispatch to the module that registered for this action.
|
||||
const notify = (text: string): void => {
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -77,16 +97,6 @@ async function handleRegisteredApproval(
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedOption !== 'approve') {
|
||||
notify(`Your ${approval.action} request was rejected by admin.`);
|
||||
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
await wakeContainer(session);
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved — dispatch to the module that registered for this action.
|
||||
const handler = getApprovalHandler(approval.action);
|
||||
if (!handler) {
|
||||
log.warn('No approval handler registered — row dropped', {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
routeInbound,
|
||||
setAccessGate,
|
||||
setChannelRequestGate,
|
||||
setMessageInterceptor,
|
||||
registerMessageInterceptor,
|
||||
setSenderResolver,
|
||||
setSenderScopeGate,
|
||||
type AccessGateResult,
|
||||
@@ -521,7 +521,7 @@ registerResponseHandler(handleChannelApprovalResponse);
|
||||
// Captures the next DM from an approver who clicked "Create new agent",
|
||||
// creates the agent immediately, wires the channel, and replays.
|
||||
|
||||
setMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return false;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+17
-9
@@ -110,16 +110,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
||||
|
||||
/**
|
||||
* Message-interceptor hook. Runs at the very top of routeInbound, before
|
||||
* messaging-group resolution. When the interceptor returns true the message
|
||||
* is consumed and routing stops. Used by the permissions module to capture
|
||||
* free-text replies during multi-step approval flows (e.g. agent naming).
|
||||
* messaging-group resolution. When an interceptor returns true the message is
|
||||
* consumed and routing stops. Multiple interceptors may register; they run in
|
||||
* registration order and the first to claim the message (return true) wins.
|
||||
*
|
||||
* Used by modules to capture free-text DM replies during multi-step approval
|
||||
* flows — the permissions module (agent naming during channel registration)
|
||||
* and the approvals module (reject-with-reason capture).
|
||||
*/
|
||||
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||
|
||||
let messageInterceptor: MessageInterceptorFn | null = null;
|
||||
const messageInterceptors: MessageInterceptorFn[] = [];
|
||||
|
||||
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptor = fn;
|
||||
export function registerMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptors.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,9 +160,13 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
|
||||
* Creates messaging group + session if they don't exist yet.
|
||||
*/
|
||||
export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// Pre-route interceptor — lets modules consume messages before any routing
|
||||
// (e.g. free-text replies during multi-step approval flows).
|
||||
if (messageInterceptor && (await messageInterceptor(event))) return;
|
||||
// Pre-route interceptors — let modules consume messages before any routing
|
||||
// (e.g. free-text DM replies during multi-step approval flows). They run in
|
||||
// registration order; the first to claim the message stops routing. The
|
||||
// sequential await is intentional — first-to-claim is order-dependent.
|
||||
for (const intercept of messageInterceptors) {
|
||||
if (await intercept(event)) return;
|
||||
}
|
||||
|
||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel. Resolved
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Security regression for the channel-inbound attachment path (#2828 sibling).
|
||||
*
|
||||
* `extractAttachmentFiles` (via `writeSessionMessage`) hardens the per-message
|
||||
* inbox subdir against pre-placed symlinks, but NOT the `inbox` root itself.
|
||||
* A compromised container can write inside its own session dir, so it can
|
||||
* replace `inbox` with a symlink pointing outside the session sandbox. The
|
||||
* existing guard then:
|
||||
* - skips the lstat branch (it only lstats `inbox/<msgId>`, not `inbox`),
|
||||
* - mkdirs `inbox/<msgId>` *through* the symlink,
|
||||
* - passes the containment check, because it compares against
|
||||
* `realpathSync(inboxRoot)` which has already followed the symlink, and
|
||||
* - writes a brand-new file (the `wx` flag only blocks an existing dst).
|
||||
*
|
||||
* Result: the host writes attacker-influenced bytes outside the session root —
|
||||
* the same class of bug fixed for the A2A path in forwardAttachedFiles (#2828).
|
||||
*
|
||||
* This test asserts the SECURE behaviour (nothing written outside). It FAILS
|
||||
* against the current code, demonstrating the gap.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-saveatt-gap' };
|
||||
});
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
||||
import { createSession } from './db/sessions.js';
|
||||
import { initSessionFolder, sessionDir, writeSessionMessage } from './session-manager.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-saveatt-gap';
|
||||
const AG = 'ag-saveatt';
|
||||
const SESS = 'sess-saveatt';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
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: AG, name: 'SaveAtt', folder: 'saveatt', agent_provider: null, created_at: now() });
|
||||
const sess: Session = {
|
||||
id: SESS,
|
||||
agent_group_id: AG,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
};
|
||||
createSession(sess);
|
||||
initSessionFolder(AG, SESS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('extractAttachmentFiles — inbox-root symlink containment (#2828 sibling)', () => {
|
||||
it('does not write an attachment outside the session root via a symlinked inbox root', () => {
|
||||
// Attacker-controlled location outside the session sandbox.
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
// Container pre-places its whole `inbox` as a symlink pointing outside.
|
||||
const inboxRoot = path.join(sessionDir(AG, SESS), 'inbox');
|
||||
fs.rmSync(inboxRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(canaryDir, inboxRoot);
|
||||
|
||||
const content = JSON.stringify({
|
||||
text: 'see attached',
|
||||
attachments: [{ name: 'pwn.txt', data: Buffer.from('attacker-bytes').toString('base64') }],
|
||||
});
|
||||
|
||||
writeSessionMessage(AG, SESS, {
|
||||
id: 'evil-inbox-root',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
platformId: 'whatsapp:123',
|
||||
channelType: 'whatsapp',
|
||||
threadId: null,
|
||||
content,
|
||||
});
|
||||
|
||||
// SECURE expectation: nothing was written through the symlink to the
|
||||
// attacker-controlled canary location.
|
||||
const escaped = path.join(canaryDir, 'evil-inbox-root', 'pwn.txt');
|
||||
expect(fs.existsSync(escaped)).toBe(false);
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+14
-30
@@ -18,6 +18,7 @@ import { deriveAttachmentName } from './attachment-naming.js';
|
||||
import { isSafeAttachmentName } from './attachment-safety.js';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { ensureContainedInboxDir, isPathInside } from './inbox-safety.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
@@ -38,11 +39,6 @@ import {
|
||||
import { log } from './log.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/** Root directory for all session data. */
|
||||
export function sessionsBaseDir(): string {
|
||||
return path.join(DATA_DIR, 'v2-sessions');
|
||||
@@ -288,6 +284,14 @@ function extractAttachmentFiles(
|
||||
return contentStr;
|
||||
}
|
||||
|
||||
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
|
||||
// Resolved lazily on the first attachment that actually carries bytes, so a
|
||||
// message whose attachments have no inline `data` never creates an inbox dir.
|
||||
// ensureContainedInboxDir refuses a pre-placed symlink at the inbox root or
|
||||
// the per-message subdir before any write lands outside the sandbox (#2828).
|
||||
let inboxDir: string | null = null;
|
||||
let inboxResolved = false;
|
||||
|
||||
let changed = false;
|
||||
for (const att of attachments) {
|
||||
if (typeof att.data !== 'string') continue;
|
||||
@@ -302,32 +306,12 @@ function extractAttachmentFiles(
|
||||
});
|
||||
}
|
||||
|
||||
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
|
||||
|
||||
// Refuse to mkdir through a symlink that the container may have pre placed
|
||||
// at inboxDir. With recursive:true, mkdirSync would silently no op on a
|
||||
// pre existing symlink and the subsequent writeFileSync would follow it.
|
||||
if (fs.existsSync(inboxDir)) {
|
||||
const stat = fs.lstatSync(inboxDir);
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
let realInboxDir: string;
|
||||
try {
|
||||
realInboxDir = fs.realpathSync(inboxDir);
|
||||
} catch (err) {
|
||||
log.warn('Failed to resolve inbox directory', { messageId, err });
|
||||
continue;
|
||||
}
|
||||
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
|
||||
if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) {
|
||||
log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir });
|
||||
continue;
|
||||
if (!inboxResolved) {
|
||||
inboxDir = ensureContainedInboxDir(inboxRoot, messageId, { messageId });
|
||||
inboxResolved = true;
|
||||
}
|
||||
// Unsafe inbox (symlink / escape) — no attachment can be written safely.
|
||||
if (!inboxDir) break;
|
||||
|
||||
const filePath = path.join(inboxDir, filename);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-create-agent-test';
|
||||
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
|
||||
const DATA_DIR = path.join(TEST_ROOT, 'data');
|
||||
const TEMPLATES_DIR = path.join(TEST_ROOT, 'templates');
|
||||
|
||||
vi.mock('../config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../config.js')>()),
|
||||
GROUPS_DIR: '/tmp/nanoclaw-create-agent-test/groups',
|
||||
DATA_DIR: '/tmp/nanoclaw-create-agent-test/data',
|
||||
TEMPLATES_DIR: '/tmp/nanoclaw-create-agent-test/templates',
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
import { closeDb, initTestDb, runMigrations } from '../db/index.js';
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
|
||||
import { createAgentFromTemplate } from './create-agent.js';
|
||||
|
||||
function writeTemplate(): void {
|
||||
const t = path.join(TEMPLATES_DIR, 'sales', 'sdr');
|
||||
fs.mkdirSync(path.join(t, 'context', 'additional_context'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t, 'context', 'instructions.md'), 'You are an SDR agent.\n');
|
||||
fs.writeFileSync(path.join(t, 'context', 'playbook.md'), '# Playbook\n');
|
||||
fs.writeFileSync(path.join(t, 'context', 'additional_context', 'faq.md'), '# FAQ\n');
|
||||
fs.writeFileSync(
|
||||
path.join(t, '.mcp.json'),
|
||||
JSON.stringify({ mcpServers: { hubspot: { command: 'npx', args: ['-y', '@hubspot/mcp-server'] } } }),
|
||||
);
|
||||
const skillDir = path.join(t, 'skills', 'widget');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: widget\n---\n');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
writeTemplate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('createAgentFromTemplate', () => {
|
||||
it('writes the persona prepend verbatim — no injected context refs, no .seed.md', () => {
|
||||
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Test' });
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, g.folder);
|
||||
const prepend = fs.readFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), 'utf-8');
|
||||
expect(prepend).toBe('You are an SDR agent.\n');
|
||||
expect(fs.existsSync(path.join(groupDir, '.seed.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('copies template skills into the group-private Claude-plane skills dir', () => {
|
||||
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Skills' });
|
||||
|
||||
const skill = path.join(DATA_DIR, 'v2-sessions', g.id, '.claude-shared', 'skills', 'widget', 'SKILL.md');
|
||||
expect(fs.existsSync(skill)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes MCP servers to the container config and context extras at their template-relative paths', () => {
|
||||
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Mcp' });
|
||||
|
||||
const cfg = getContainerConfig(g.id);
|
||||
expect(cfg).toBeTruthy();
|
||||
expect(JSON.parse(cfg!.mcp_servers)).toHaveProperty('hubspot');
|
||||
// Extras land relative to the group root, exactly as they sit relative to
|
||||
// instructions.md in the template — no context/ prefix in between.
|
||||
const groupDir = path.join(GROUPS_DIR, g.folder);
|
||||
expect(fs.existsSync(path.join(groupDir, 'playbook.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(groupDir, 'additional_context', 'faq.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(groupDir, 'context'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from '../config.js';
|
||||
import { createAgentGroup } from '../db/agent-groups.js';
|
||||
import { ensureContainerConfig, updateContainerConfigJson } from '../db/container-configs.js';
|
||||
import { assertValidGroupFolder, resolveGroupFolderPath } from '../group-folder.js';
|
||||
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
|
||||
import { normalizeName } from '../modules/agent-to-agent/db/agent-destinations.js';
|
||||
import type { AgentGroup } from '../types.js';
|
||||
import { resolveLocalTemplate } from './local-dir.js';
|
||||
import { parseTemplate } from './parse.js';
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a self-contained agent group from a LOCAL template ref under
|
||||
* TEMPLATES_DIR. The template carries MCP servers, instructions, optional
|
||||
* context extras, and optional skills — nothing else (no policy, no packages,
|
||||
* no provider).
|
||||
*
|
||||
* The template persona is written to the provider-neutral `instructions.prepend.md`
|
||||
* (see src/group-persona.ts). Each provider's project-doc composer inlines it at
|
||||
* the TOP of the doc it generates every spawn, so the persona is system-prompt
|
||||
* tier regardless of which provider the group ends up running. Because the file
|
||||
* is provider-agnostic, placement needs no provider knowledge at stamp time (the
|
||||
* provider is DB-resolved later, at first spawn).
|
||||
*
|
||||
* Returns the created group; the caller wires it to a channel as usual.
|
||||
*/
|
||||
export function createAgentFromTemplate(ref: string, opts?: CreateAgentOptions): AgentGroup {
|
||||
const dir = resolveLocalTemplate(ref);
|
||||
const tpl = parseTemplate(dir);
|
||||
|
||||
const id = randomUUID();
|
||||
const name = opts?.name ?? path.basename(dir);
|
||||
let folder = normalizeName(name);
|
||||
assertValidGroupFolder(folder);
|
||||
if (fs.existsSync(resolveGroupFolderPath(folder))) folder = `${folder}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const group: AgentGroup = { id, name, folder, agent_provider: null, created_at: new Date().toISOString() };
|
||||
createAgentGroup(group);
|
||||
ensureContainerConfig(id);
|
||||
|
||||
// group-init.ts owns the mkdir at first spawn, but it isn't called here — so we
|
||||
// create the dir ourselves to land instructions.prepend.md + context/.
|
||||
const groupDir = path.resolve(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
// Persona → provider-neutral prepend, inlined at the top of the group's
|
||||
// CLAUDE.md/AGENTS.md every spawn (system-prompt tier on any provider).
|
||||
fs.writeFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), tpl.instructions + '\n');
|
||||
|
||||
// Context extras keep their template-relative layout, placed next to the doc
|
||||
// the persona is inlined into — so a reference written in instructions.md
|
||||
// (e.g. `additional_context/faq.md`) resolves unchanged in the agent's
|
||||
// workspace. Nothing is injected into the persona; referencing each file from
|
||||
// instructions.md is the template author's job (docs/templates.md).
|
||||
for (const { name: file, content } of tpl.contextExtras) {
|
||||
const dest = path.join(groupDir, file);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, content);
|
||||
}
|
||||
|
||||
updateContainerConfigJson(id, 'mcp_servers', tpl.mcpServers);
|
||||
|
||||
// Per-group skills overlay — keyed by group id, never shared. cpSync creates
|
||||
// intermediate dirs, so .claude-shared/skills need not exist yet.
|
||||
const skillsDir = path.join(DATA_DIR, 'v2-sessions', id, '.claude-shared', 'skills');
|
||||
for (const { name: skill, srcDir } of tpl.skills) {
|
||||
fs.cpSync(srcDir, path.join(skillsDir, skill), { recursive: true });
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveLocalTemplate } from './local-dir.js';
|
||||
|
||||
let base: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-local-'));
|
||||
fs.mkdirSync(path.join(base, 'sales', 'sdr'), { recursive: true });
|
||||
fs.writeFileSync(path.join(base, 'afile.md'), 'not a directory');
|
||||
});
|
||||
afterEach(() => fs.rmSync(base, { recursive: true, force: true }));
|
||||
|
||||
describe('resolveLocalTemplate', () => {
|
||||
it('resolves a valid multi-segment relative ref under the base', () => {
|
||||
expect(resolveLocalTemplate('sales/sdr', base)).toBe(path.join(base, 'sales', 'sdr'));
|
||||
});
|
||||
|
||||
it('rejects a ref that escapes the base via ../', () => {
|
||||
expect(() => resolveLocalTemplate('../escape', base)).toThrow(/escapes/);
|
||||
});
|
||||
|
||||
it('rejects a multi-segment escape like sales/../../etc', () => {
|
||||
expect(() => resolveLocalTemplate('sales/../../etc', base)).toThrow(/escapes/);
|
||||
});
|
||||
|
||||
it('rejects an absolute ref', () => {
|
||||
expect(() => resolveLocalTemplate('/etc', base)).toThrow(/relative/);
|
||||
});
|
||||
|
||||
it('rejects a ~-prefixed ref', () => {
|
||||
expect(() => resolveLocalTemplate('~/x', base)).toThrow(/relative/);
|
||||
});
|
||||
|
||||
it('rejects empty and whitespace-only refs', () => {
|
||||
expect(() => resolveLocalTemplate('', base)).toThrow(/Invalid/);
|
||||
expect(() => resolveLocalTemplate(' ', base)).toThrow(/Invalid/);
|
||||
});
|
||||
|
||||
it('rejects an untrimmed ref', () => {
|
||||
expect(() => resolveLocalTemplate(' sales/sdr', base)).toThrow(/Invalid/);
|
||||
});
|
||||
|
||||
it('throws when the ref does not exist', () => {
|
||||
expect(() => resolveLocalTemplate('nope', base)).toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('throws when the ref is a file, not a directory', () => {
|
||||
expect(() => resolveLocalTemplate('afile.md', base)).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { TEMPLATES_DIR } from '../config.js';
|
||||
|
||||
/**
|
||||
* Resolve a LOCAL template ref to an absolute directory under `base`
|
||||
* (TEMPLATES_DIR by default). Lexical containment only — no realpathSync, no
|
||||
* symlink resolution (out of threat model). Mirrors ensureWithinBase() in
|
||||
* group-folder.ts. Refs are legitimately multi-segment (e.g. "sales/sdr"), so
|
||||
* this does NOT reuse isValidGroupFolder (which rejects "/").
|
||||
*
|
||||
* Rejects: empty / untrimmed refs, absolute paths, a leading "~", and any ref
|
||||
* that escapes `base` after resolution. Throws if the resolved path is missing
|
||||
* or not a directory.
|
||||
*/
|
||||
export function resolveLocalTemplate(ref: string, base: string = TEMPLATES_DIR): string {
|
||||
if (!ref || ref !== ref.trim()) {
|
||||
throw new Error(`Invalid template ref: "${ref}"`);
|
||||
}
|
||||
if (path.isAbsolute(ref) || ref.startsWith('~')) {
|
||||
throw new Error(`Template ref must be relative to the templates directory: "${ref}"`);
|
||||
}
|
||||
const candidate = path.resolve(base, ref);
|
||||
const rel = path.relative(base, candidate);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(`Template ref escapes the templates directory: "${ref}"`);
|
||||
}
|
||||
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) {
|
||||
throw new Error(`Template not found: "${ref}" (looked in ${base})`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseTemplate } from './parse.js';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-parse-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(rel: string, content: string): void {
|
||||
const full = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
describe('parseTemplate', () => {
|
||||
it('parses mcpServers, instructions, context extras, and skills', () => {
|
||||
write('.mcp.json', JSON.stringify({ mcpServers: { fs: { command: 'mcp-fs', args: ['/data'] } } }));
|
||||
write('context/instructions.md', 'Be helpful.\n\n');
|
||||
write('context/playbook.md', '# Playbook');
|
||||
write('context/additional_context/faq.md', '# FAQ');
|
||||
write('skills/research/SKILL.md', 'do research');
|
||||
fs.writeFileSync(path.join(dir, 'context', 'notes.txt'), 'ignored'); // non-.md is ignored
|
||||
|
||||
const tpl = parseTemplate(dir);
|
||||
|
||||
expect(tpl.mcpServers).toEqual({ fs: { command: 'mcp-fs', args: ['/data'] } });
|
||||
expect(tpl.instructions).toBe('Be helpful.'); // trimEnd, instructions.md excluded from extras
|
||||
// Nested extras keep their context/-relative path as the name.
|
||||
expect(tpl.contextExtras.map((c) => c.name).sort()).toEqual(['additional_context/faq.md', 'playbook.md']);
|
||||
expect(tpl.skills.map((s) => s.name)).toEqual(['research']);
|
||||
});
|
||||
|
||||
it('defaults the optionals when only instructions.md is present', () => {
|
||||
write('context/instructions.md', 'Only instructions.');
|
||||
const tpl = parseTemplate(dir);
|
||||
expect(tpl.mcpServers).toEqual({});
|
||||
expect(tpl.contextExtras).toEqual([]);
|
||||
expect(tpl.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when context/instructions.md is missing', () => {
|
||||
expect(() => parseTemplate(dir)).toThrow(/instructions\.md/);
|
||||
});
|
||||
|
||||
it('throws when the folder does not exist', () => {
|
||||
expect(() => parseTemplate(path.join(dir, 'nope'))).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** A parsed template folder. Pure data — no DB, no side effects. */
|
||||
export interface Template {
|
||||
mcpServers: Record<string, unknown>; // .mcp.json .mcpServers — name -> launch config
|
||||
instructions: string; // context/instructions.md (required)
|
||||
contextExtras: { name: string; content: string }[]; // context/**/*.md except instructions.md; name relative to context/
|
||||
skills: { name: string; srcDir: string }[]; // skills/<name>/ real folders
|
||||
}
|
||||
|
||||
function readJson(file: string): unknown {
|
||||
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf-8')) : undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and lightly validate a template folder into a typed object. Throws only
|
||||
* if the folder is missing or `context/instructions.md` (the one required file)
|
||||
* is absent. `unknown`-in / parsed-out at the .mcp.json boundary.
|
||||
*/
|
||||
export function parseTemplate(dir: string): Template {
|
||||
if (!fs.existsSync(dir)) throw new Error(`Template folder not found: ${dir}`);
|
||||
|
||||
const mcpServers = asRecord(asRecord(readJson(path.join(dir, '.mcp.json'))).mcpServers);
|
||||
|
||||
const instructionsFile = path.join(dir, 'context', 'instructions.md');
|
||||
if (!fs.existsSync(instructionsFile)) {
|
||||
throw new Error(`Template missing required context/instructions.md: ${dir}`);
|
||||
}
|
||||
const instructions = fs.readFileSync(instructionsFile, 'utf-8').trimEnd();
|
||||
|
||||
return {
|
||||
mcpServers,
|
||||
instructions,
|
||||
contextExtras: readContextExtras(path.join(dir, 'context')),
|
||||
skills: readSkills(path.join(dir, 'skills')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every context/**\/*.md except the top-level instructions.md, recursively.
|
||||
* `name` keeps the path relative to context/ so stamping can preserve the
|
||||
* layout — a reference like `additional_context/faq.md` written in
|
||||
* instructions.md resolves unchanged in the agent's workspace.
|
||||
*/
|
||||
function readContextExtras(contextDir: string): { name: string; content: string }[] {
|
||||
if (!fs.existsSync(contextDir)) return [];
|
||||
return (fs.readdirSync(contextDir, { recursive: true }) as string[])
|
||||
.filter((f) => f.endsWith('.md') && f !== 'instructions.md' && fs.statSync(path.join(contextDir, f)).isFile())
|
||||
.map((name) => ({ name, content: fs.readFileSync(path.join(contextDir, name), 'utf-8') }));
|
||||
}
|
||||
|
||||
/** Each immediate subdirectory of skills/ is a packaged skill. */
|
||||
function readSkills(skillsDir: string): { name: string; srcDir: string }[] {
|
||||
if (!fs.existsSync(skillsDir)) return [];
|
||||
return fs
|
||||
.readdirSync(skillsDir)
|
||||
.map((name) => ({ name, srcDir: path.join(skillsDir, name) }))
|
||||
.filter(({ srcDir }) => fs.statSync(srcDir).isDirectory());
|
||||
}
|
||||
+6
-1
@@ -200,8 +200,13 @@ export interface PendingApproval {
|
||||
channel_type: string | null;
|
||||
platform_id: string | null;
|
||||
platform_message_id: string | null;
|
||||
/**
|
||||
* For OneCLI credential rows, the gateway's request TTL. For a module
|
||||
* approval held by "Reject with reason…", the deadline after which the
|
||||
* host sweep finalizes a plain reject (set by markApprovalAwaitingReason).
|
||||
*/
|
||||
expires_at: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'awaiting_reason';
|
||||
title: string;
|
||||
options_json: string;
|
||||
/** When set, only this exact user may resolve the approval. */
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Templates
|
||||
|
||||
Local agent-template library for this NanoClaw install. **This folder ships
|
||||
empty.** Anything you drop here is a template you can stamp into an agent:
|
||||
|
||||
```bash
|
||||
ncl groups create --template <relative-ref> --name "My Agent"
|
||||
```
|
||||
|
||||
`<relative-ref>` is a path *relative to this folder* (e.g. `sales/sdr`). Refs
|
||||
must stay inside this directory — absolute paths, `~`, and `../` escapes are
|
||||
rejected. Override the location with `NANOCLAW_TEMPLATES_DIR=/another/local/path`
|
||||
(a local path only — never a URL).
|
||||
|
||||
The setup wizard's **Template setup → NanoClaw template library** option clones
|
||||
the public registry and copies your chosen template *into this folder*, after
|
||||
which it stamps from the local copy. **Local templates** lists whatever is here.
|
||||
|
||||
## Anatomy of a template
|
||||
|
||||
Only `context/instructions.md` is required; it both supplies the agent's
|
||||
standing brief and marks the folder as a template.
|
||||
|
||||
```
|
||||
<template>/
|
||||
├── context/
|
||||
│ ├── instructions.md # REQUIRED: the agent's standing persona, prepended to its
|
||||
│ │ # CLAUDE.md/AGENTS.md every spawn
|
||||
│ └── additional_context/ # optional: extra .md files
|
||||
│ └── *.md
|
||||
├── .mcp.json # optional: { "mcpServers": { ... } } — command + args, NO secrets
|
||||
├── skills/<name>/ # optional: one folder per skill (SKILL.md + references/), copied whole
|
||||
└── README.md # recommended: per-template docs
|
||||
```
|
||||
|
||||
Notes:
|
||||
- **Extra context is copied preserving its layout relative to `instructions.md`**
|
||||
(`context/additional_context/faq.md` → `additional_context/faq.md` in the
|
||||
agent's workspace). Nothing is referenced automatically — `instructions.md`
|
||||
must point to each file (e.g. "Pricing rules live in
|
||||
`additional_context/pricing.md`").
|
||||
- **No provider, no model, no packages.** A template is instructions + MCP
|
||||
servers + skills. The agent's runtime/provider is chosen separately
|
||||
(`ncl groups config update --provider …` or during setup).
|
||||
- **No secrets.** `.mcp.json` carries launch config only; credentials are
|
||||
injected by the credentials proxy at request time. If an MCP server refuses
|
||||
to boot without an env var, use a placeholder value — never a real key.
|
||||
- Skills are copied into the agent's own per-group overlay, never shared.
|
||||
Reference in New Issue
Block a user