mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-03 18:45:07 +08:00
Compare commits
31 Commits
container-limits
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.19",
|
||||
"version": "2.1.24",
|
||||
"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="204k tokens, 102% of context window">
|
||||
<title>204k tokens, 102% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">199k</text>
|
||||
<text x="71" y="14">199k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">204k</text>
|
||||
<text x="71" y="14">204k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+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