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/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/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/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
|
## Container Build Cache
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ Instructions here...
|
|||||||
- Put code in separate files, not inline in the markdown
|
- 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
|
- 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
|
## Testing
|
||||||
|
|
||||||
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
|
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
|
- **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
|
- **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.
|
- **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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "nanoclaw-agent-runner",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||||
"@anthropic-ai/sdk": "^0.100.0",
|
"@anthropic-ai/sdk": "^0.108.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0",
|
"zod": "^4.0.0",
|
||||||
@@ -19,25 +19,25 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||||
"@anthropic-ai/sdk": "^0.100.0",
|
"@anthropic-ai/sdk": "^0.108.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[
|
[
|
||||||
{ "name": "vercel", "version": "52.2.1" },
|
{ "name": "vercel", "version": "52.2.1" },
|
||||||
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
|
{ "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",
|
"name": "nanoclaw",
|
||||||
"version": "2.1.19",
|
"version": "2.1.24",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"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">
|
<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>199k tokens, 100% of context window</title>
|
<title>204k tokens, 102% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">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 aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">204k</text>
|
||||||
<text x="71" y="14">199k</text>
|
<text x="71" y="14">204k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</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
|
#!/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
|
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||||
# operator-facing app creation walkthrough + credential paste live in
|
# operator-facing app creation walkthrough + credential paste live in
|
||||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
# 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
|
# 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
|
# 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"
|
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
|
||||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -98,7 +101,12 @@ upsert_env() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
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).
|
# Container reads from data/env/env (the host mounts it).
|
||||||
mkdir -p data/env
|
mkdir -p data/env
|
||||||
|
|||||||
+124
-21
@@ -4,15 +4,18 @@
|
|||||||
* `runSlackChannel(displayName)` owns the full branch from creating a
|
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||||
* Slack app through the welcome DM:
|
* Slack app through the welcome DM:
|
||||||
*
|
*
|
||||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
|
||||||
* event subscriptions, and signing secret
|
* URL) or a public webhook
|
||||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
* events, and the mode-specific credential (app-level token for
|
||||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
* Socket Mode, signing secret for webhook)
|
||||||
* 5. Ask for the operator's Slack user ID
|
* 3. Paste the bot token + that credential (clack password prompts)
|
||||||
* 6. conversations.open to get the DM channel ID
|
* 4. Validate via auth.test → resolves workspace + bot identity
|
||||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
* 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
|
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
||||||
* works without Event Subscriptions being configured. The user sees the
|
* works without Event Subscriptions being configured. The user sees the
|
||||||
@@ -45,14 +48,26 @@ interface WorkspaceInfo {
|
|||||||
botUserId: string;
|
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> {
|
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;
|
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
|
||||||
const token = await collectBotToken();
|
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 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(
|
const install = await runQuietChild(
|
||||||
'slack-install',
|
'slack-install',
|
||||||
'bash',
|
'bash',
|
||||||
@@ -62,11 +77,9 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
|
|||||||
done: 'Slack adapter installed.',
|
done: 'Slack adapter installed.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
env: {
|
env,
|
||||||
SLACK_BOT_TOKEN: token,
|
|
||||||
SLACK_SIGNING_SECRET: signingSecret,
|
|
||||||
},
|
|
||||||
extraFields: {
|
extraFields: {
|
||||||
|
MODE: mode,
|
||||||
BOT_NAME: info.botName,
|
BOT_NAME: info.botName,
|
||||||
TEAM_NAME: info.teamName,
|
TEAM_NAME: info.teamName,
|
||||||
TEAM_ID: info.teamId,
|
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
|
// 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.
|
// per-line formatter so the URL stands out against the rest of the body.
|
||||||
const linkBlock = isHeadless()
|
const linkBlock = isHeadless()
|
||||||
@@ -149,8 +197,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
|||||||
' • files:read, files:write',
|
' • files:read, files:write',
|
||||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||||
' slash commands and messages from the messages tab"',
|
' slash commands and messages from the messages tab"',
|
||||||
' 4. Basic Information → copy the "Signing Secret"',
|
...credSteps,
|
||||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Create a Slack app',
|
'Create a Slack app',
|
||||||
);
|
);
|
||||||
@@ -171,7 +218,10 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
|||||||
|
|
||||||
ensureAnswer(
|
ensureAnswer(
|
||||||
await p.confirm({
|
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,
|
initialValue: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -249,6 +299,40 @@ async function collectSigningSecret(): Promise<string> {
|
|||||||
return secret;
|
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> {
|
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -416,7 +500,26 @@ async function resolveAgentName(): Promise<string> {
|
|||||||
return value;
|
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(
|
note(
|
||||||
wrapForGutter(
|
wrapForGutter(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ interface V1Group {
|
|||||||
folder: string;
|
folder: string;
|
||||||
trigger_pattern: string | null;
|
trigger_pattern: string | null;
|
||||||
requires_trigger: number | null;
|
requires_trigger: number | null;
|
||||||
is_main: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
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
|
// v1 schema varies — channel_name was a late addition. Query only the
|
||||||
// columns we know exist in all v1 installs.
|
// columns we know exist in all v1 installs.
|
||||||
const v1Groups = v1Db
|
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[];
|
.all() as V1Group[];
|
||||||
v1Db.close();
|
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 { GROUPS_DIR } from './config.js';
|
||||||
import type { McpServerConfig } from './container-config.js';
|
import type { McpServerConfig } from './container-config.js';
|
||||||
import { getContainerConfig } from './db/container-configs.js';
|
import { getContainerConfig } from './db/container-configs.js';
|
||||||
|
import { readGroupPersona } from './group-persona.js';
|
||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import type { AgentGroup } from './types.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
|
// Symlink targets are container paths — dangling on host (hence the readlink
|
||||||
// dance instead of existsSync), valid inside the container via RO mounts.
|
// dance instead of existsSync), valid inside the container via RO mounts.
|
||||||
const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md';
|
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.
|
// Reconcile: drop stale, write desired.
|
||||||
for (const existing of fs.readdirSync(fragmentsDir)) {
|
for (const existing of fs.readdirSync(fragmentsDir)) {
|
||||||
if (!desired.has(existing)) {
|
if (!desired.has(existing)) {
|
||||||
@@ -121,9 +133,14 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composed entry — imports only.
|
// Composed entry — imports only. Persona first (top of the system prompt),
|
||||||
const imports = ['@./.claude-shared.md'];
|
// then the shared base, then the remaining fragments sorted.
|
||||||
for (const name of [...desired.keys()].sort()) {
|
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}`);
|
imports.push(`@./.claude-fragments/${name}`);
|
||||||
}
|
}
|
||||||
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
|
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface ColumnDef {
|
|||||||
updatable?: boolean;
|
updatable?: boolean;
|
||||||
/** Default value on create when not provided. */
|
/** Default value on create when not provided. */
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
|
/** Default to another column's resolved value on create when not provided. */
|
||||||
|
defaultFrom?: string;
|
||||||
/** Allowed values (shown in help). */
|
/** Allowed values (shown in help). */
|
||||||
enum?: string[];
|
enum?: string[];
|
||||||
}
|
}
|
||||||
@@ -150,6 +152,8 @@ function genericCreate(def: ResourceDef) {
|
|||||||
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
||||||
} else if (col.default !== undefined) {
|
} else if (col.default !== undefined) {
|
||||||
values[col.name] = col.default;
|
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 type { McpServerConfig } from '../../container-config.js';
|
||||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||||
|
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||||
import { getDb, hasTable } from '../../db/connection.js';
|
import { getDb, hasTable } from '../../db/connection.js';
|
||||||
import { getSession } from '../../db/sessions.js';
|
import { getSession } from '../../db/sessions.js';
|
||||||
import { writeSessionMessage } from '../../session-manager.js';
|
import { writeSessionMessage } from '../../session-manager.js';
|
||||||
@@ -9,7 +12,8 @@ import {
|
|||||||
updateContainerConfigScalars,
|
updateContainerConfigScalars,
|
||||||
updateContainerConfigJson,
|
updateContainerConfigJson,
|
||||||
} from '../../db/container-configs.js';
|
} 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';
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
/** Deserialize JSON columns for display. */
|
/** Deserialize JSON columns for display. */
|
||||||
@@ -58,11 +62,37 @@ registerResource({
|
|||||||
},
|
},
|
||||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||||
],
|
],
|
||||||
// `delete` is intentionally not in `operations` — the generic single-table
|
// `create` and `delete` are intentionally not in `operations` — create needs
|
||||||
// DELETE violates FK constraints (see #2525). The cascading handler is
|
// a `--template` branch (below); the generic single-table DELETE violates FK
|
||||||
// provided as `customOperations.delete` below.
|
// constraints (see #2525). Both are provided as `customOperations`.
|
||||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
operations: { list: 'open', get: 'open', update: 'approval' },
|
||||||
customOperations: {
|
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: {
|
delete: {
|
||||||
access: 'approval',
|
access: 'approval',
|
||||||
description:
|
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.',
|
'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.',
|
||||||
required: true,
|
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',
|
name: 'name',
|
||||||
type: 'string',
|
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 STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
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
|
// Per-checkout image tag so two installs on the same host don't share
|
||||||
// `nanoclaw-agent:latest` and clobber each other on rebuild.
|
// `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);
|
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 {
|
export function deletePendingApproval(approvalId: string): void {
|
||||||
getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId);
|
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 });
|
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);
|
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 path from 'path';
|
||||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
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 { createDestination } from './db/agent-destinations.js';
|
||||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||||
import { createSession, updateSession } from '../../db/sessions.js';
|
import { createSession, updateSession } from '../../db/sessions.js';
|
||||||
@@ -467,4 +468,129 @@ describe('routeAgentMessage return-path', () => {
|
|||||||
const parsed = JSON.parse(bRows[0].content);
|
const parsed = JSON.parse(bRows[0].content);
|
||||||
expect(parsed.attachments).toHaveLength(0);
|
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 path from 'path';
|
||||||
|
|
||||||
import { isSafeAttachmentName } from '../../attachment-safety.js';
|
import { isSafeAttachmentName } from '../../attachment-safety.js';
|
||||||
|
import { ensureContainedInboxDir, isPathInside } from '../../inbox-safety.js';
|
||||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||||
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
||||||
import { getSession } from '../../db/sessions.js';
|
import { getSession } from '../../db/sessions.js';
|
||||||
@@ -42,11 +43,6 @@ export interface ForwardedAttachment {
|
|||||||
localPath: string;
|
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
|
* Copy file attachments from the source agent's outbox into the target
|
||||||
* agent's inbox. Returns attachments using the formatter's existing
|
* agent's inbox. Returns attachments using the formatter's existing
|
||||||
@@ -98,8 +94,20 @@ export function forwardAttachedFiles(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
// Target-side containment — shared with the channel-inbound path. A
|
||||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
// 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[] = [];
|
const attachments: ForwardedAttachment[] = [];
|
||||||
for (const filename of source.filenames) {
|
for (const filename of source.filenames) {
|
||||||
@@ -137,7 +145,20 @@ export function forwardAttachedFiles(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dst = path.join(targetInboxDir, filename);
|
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({
|
attachments.push({
|
||||||
name: filename,
|
name: filename,
|
||||||
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 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];
|
const lines = [`Agent "${sourceName}" wants to send a message to "${targetName}":`, '', body];
|
||||||
if (files.length > 0) lines.push('', `Attachments: ${files.join(', ')}`);
|
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');
|
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
|
* - A response handler that claims pending_approvals rows and dispatches
|
||||||
* to whatever module registered for the row's `action` string. Also
|
* to whatever module registered for the row's `action` string. Also
|
||||||
* resolves in-memory OneCLI credential approvals.
|
* 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
|
* - An adapter-ready callback that starts the OneCLI manual-approval handler
|
||||||
* once the delivery adapter is set.
|
* once the delivery adapter is set.
|
||||||
* - A shutdown callback that stops the OneCLI handler cleanly.
|
* - 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
|
* Self-mod flows (install_packages, add_mcp_server) moved out to
|
||||||
* `src/modules/self-mod/` in PR #7 — they now register delivery actions
|
* `src/modules/self-mod/` in PR #7 — they now register delivery actions
|
||||||
* + approval handlers via this module's public API.
|
* + 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.
|
// Public API re-exports so consumers import from the module root.
|
||||||
export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js';
|
export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js';
|
||||||
export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } 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);
|
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 { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
|
||||||
import { ensureUserDm } from '../permissions/user-dm.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[] = [
|
const APPROVAL_OPTIONS: RawOption[] = [
|
||||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||||
|
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected (awaiting reason)', value: REJECT_WITH_REASON_VALUE },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Approval handler registry ──
|
// ── 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
|
* 1. Module-initiated actions — the module called `requestApproval()` with
|
||||||
* some free-form `action` string and registered a handler via
|
* some free-form `action` string and registered a handler via
|
||||||
* `registerApprovalHandler(action, handler)`. On approve, we look up the
|
* `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
|
* 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved
|
||||||
* via an in-memory Promise — see onecli-approvals.ts.
|
* 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 { writeSessionMessage } from '../../session-manager.js';
|
||||||
import type { PendingApproval } from '../../types.js';
|
import type { PendingApproval } from '../../types.js';
|
||||||
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.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 { 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> {
|
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||||
const approval = getPendingApproval(payload.questionId);
|
const approval = getPendingApproval(payload.questionId);
|
||||||
@@ -65,6 +70,21 @@ async function handleRegisteredApproval(
|
|||||||
return;
|
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 => {
|
const notify = (text: string): void => {
|
||||||
writeSessionMessage(session.agent_group_id, session.id, {
|
writeSessionMessage(session.agent_group_id, session.id, {
|
||||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
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);
|
const handler = getApprovalHandler(approval.action);
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
log.warn('No approval handler registered — row dropped', {
|
log.warn('No approval handler registered — row dropped', {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
routeInbound,
|
routeInbound,
|
||||||
setAccessGate,
|
setAccessGate,
|
||||||
setChannelRequestGate,
|
setChannelRequestGate,
|
||||||
setMessageInterceptor,
|
registerMessageInterceptor,
|
||||||
setSenderResolver,
|
setSenderResolver,
|
||||||
setSenderScopeGate,
|
setSenderScopeGate,
|
||||||
type AccessGateResult,
|
type AccessGateResult,
|
||||||
@@ -521,7 +521,7 @@ registerResponseHandler(handleChannelApprovalResponse);
|
|||||||
// Captures the next DM from an approver who clicked "Create new agent",
|
// Captures the next DM from an approver who clicked "Create new agent",
|
||||||
// creates the agent immediately, wires the channel, and replays.
|
// 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);
|
const userId = extractAndUpsertUser(event);
|
||||||
if (!userId) return false;
|
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
|
* Message-interceptor hook. Runs at the very top of routeInbound, before
|
||||||
* messaging-group resolution. When the interceptor returns true the message
|
* messaging-group resolution. When an interceptor returns true the message is
|
||||||
* is consumed and routing stops. Used by the permissions module to capture
|
* consumed and routing stops. Multiple interceptors may register; they run in
|
||||||
* free-text replies during multi-step approval flows (e.g. agent naming).
|
* 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>;
|
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||||
|
|
||||||
let messageInterceptor: MessageInterceptorFn | null = null;
|
const messageInterceptors: MessageInterceptorFn[] = [];
|
||||||
|
|
||||||
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
|
export function registerMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||||
messageInterceptor = fn;
|
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.
|
* Creates messaging group + session if they don't exist yet.
|
||||||
*/
|
*/
|
||||||
export async function routeInbound(event: InboundEvent): Promise<void> {
|
export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||||
// Pre-route interceptor — lets modules consume messages before any routing
|
// Pre-route interceptors — let modules consume messages before any routing
|
||||||
// (e.g. free-text replies during multi-step approval flows).
|
// (e.g. free-text DM replies during multi-step approval flows). They run in
|
||||||
if (messageInterceptor && (await messageInterceptor(event))) return;
|
// 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,
|
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||||
// WhatsApp, iMessage, email) collapse threads to the channel. Resolved
|
// 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 { isSafeAttachmentName } from './attachment-safety.js';
|
||||||
import type { OutboundFile } from './channels/adapter.js';
|
import type { OutboundFile } from './channels/adapter.js';
|
||||||
import { DATA_DIR } from './config.js';
|
import { DATA_DIR } from './config.js';
|
||||||
|
import { ensureContainedInboxDir, isPathInside } from './inbox-safety.js';
|
||||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
@@ -38,11 +39,6 @@ import {
|
|||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import type { Session } from './types.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. */
|
/** Root directory for all session data. */
|
||||||
export function sessionsBaseDir(): string {
|
export function sessionsBaseDir(): string {
|
||||||
return path.join(DATA_DIR, 'v2-sessions');
|
return path.join(DATA_DIR, 'v2-sessions');
|
||||||
@@ -288,6 +284,14 @@ function extractAttachmentFiles(
|
|||||||
return contentStr;
|
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;
|
let changed = false;
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
if (typeof att.data !== 'string') continue;
|
if (typeof att.data !== 'string') continue;
|
||||||
@@ -302,32 +306,12 @@ function extractAttachmentFiles(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
|
if (!inboxResolved) {
|
||||||
|
inboxDir = ensureContainedInboxDir(inboxRoot, messageId, { messageId });
|
||||||
// Refuse to mkdir through a symlink that the container may have pre placed
|
inboxResolved = true;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
// Unsafe inbox (symlink / escape) — no attachment can be written safely.
|
||||||
|
if (!inboxDir) break;
|
||||||
|
|
||||||
const filePath = path.join(inboxDir, filename);
|
const filePath = path.join(inboxDir, filename);
|
||||||
try {
|
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;
|
channel_type: string | null;
|
||||||
platform_id: string | null;
|
platform_id: string | null;
|
||||||
platform_message_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;
|
expires_at: string | null;
|
||||||
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'awaiting_reason';
|
||||||
title: string;
|
title: string;
|
||||||
options_json: string;
|
options_json: string;
|
||||||
/** When set, only this exact user may resolve the approval. */
|
/** 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