mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-03 18:45:07 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77f3be57fa | |||
| 35248f1bfa | |||
| aecad864e6 | |||
| c87f2e55dc | |||
| 411f5e71df | |||
| 551660a2bf | |||
| a13bb24300 | |||
| ec35d9c3f7 | |||
| 96f924f2ac | |||
| 5945a19655 | |||
| cb6e3d117c | |||
| ed7e3f70da | |||
| 557e073c2f | |||
| 91ebc9def2 | |||
| 14c89e9716 | |||
| 549c424a38 | |||
| 186b9befcd | |||
| 863d413d32 | |||
| cf8478ffbb | |||
| 8be5be93ba | |||
| add3fc8f70 | |||
| 0d841bcd05 | |||
| dd1d0e5677 | |||
| 36afa40857 | |||
| 2afbd18233 | |||
| 953496dc37 | |||
| 797491d8b3 | |||
| 2df754459b | |||
| 0896d4089e |
@@ -135,7 +135,22 @@ ncl groups restart --id <group-id>
|
||||
|
||||
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
|
||||
|
||||
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
|
||||
### Default new groups to codex (optional)
|
||||
|
||||
New groups are created on the **instance default** (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). Installing this skill wires codex in but does NOT change that default — "installed" is not "authenticated", so the default stays claude until you opt in explicitly.
|
||||
|
||||
After install, ask the operator before flipping it:
|
||||
|
||||
> "Codex is installed. Default new agent groups to codex? Existing groups keep their current provider."
|
||||
|
||||
On yes — set it, then restart the host so it takes effect:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step set-env -- --key DEFAULT_AGENT_PROVIDER --value codex
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS; Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
This affects only groups created afterward. Per-group `ncl groups config update --provider` still overrides the default in either direction. Creation itself stays provider-agnostic (no `--provider` flag — provider is a DB property stamped from the instance default at creation).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
|
||||
--agent-name "${AGENT_NAME}"
|
||||
```
|
||||
|
||||
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
The new group is created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To put it on a different provider, switch after creation with `ncl groups config update --id <group-id> --provider <name>`. Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
|
||||
The script:
|
||||
1. Upserts the `users` row and grants `owner` role if no owner exists.
|
||||
|
||||
@@ -67,7 +67,7 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
|
||||
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
|
||||
New agent groups are created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To run a group on a different provider, switch it after creation with `ncl groups config update --provider <name>` (e.g. `codex`).
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ Parse the diff output for lines that contain `[BREAKING]` anywhere in the line.
|
||||
```
|
||||
|
||||
If no `[BREAKING]` lines are found:
|
||||
- Skip this step silently. Proceed to Step 7 (skill updates check).
|
||||
- Skip this step silently. Proceed to Step 7.
|
||||
|
||||
If one or more `[BREAKING]` lines are found:
|
||||
- Display a warning header to the user: "This update includes breaking changes that may require action:"
|
||||
@@ -244,7 +244,7 @@ If one or more `[BREAKING]` lines are found:
|
||||
- "Skip — I'll handle these manually"
|
||||
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7.
|
||||
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
||||
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
|
||||
- **New groups inherit an instance-wide default provider.** `DEFAULT_AGENT_PROVIDER` in `.env` (default `claude`) sets which provider newly created agent groups get at creation; provider stays a per-group DB property, overridable via `ncl groups config update --provider` + restart. Existing groups are untouched — no migration, no retroactive flips.
|
||||
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
|
||||
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
|
||||
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
|
||||
|
||||
@@ -280,6 +280,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
|
||||
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
|
||||
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
|
||||
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ Instructions here...
|
||||
- Put code in separate files, not inline in the markdown
|
||||
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
|
||||
|
||||
## Templates
|
||||
|
||||
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Testing
|
||||
|
||||
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
|
||||
|
||||
@@ -82,6 +82,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
@@ -19,25 +19,25 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.197", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.197" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.108.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-XBnl7Nszpbzg0aLnOCmdBi0bOU5goAsQ/L+NPNiuUPowDj8Mbzx0vlIIc1M79BjIvmw5nUM5G3jbrCBStT/0fQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
|
||||
"@anthropic-ai/sdk": "^0.108.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[
|
||||
{ "name": "vercel", "version": "52.2.1" },
|
||||
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.170", "onlyBuilt": true }
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.197", "onlyBuilt": true }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Agent Templates
|
||||
|
||||
A **template** is a reusable folder you stamp into a working agent group: it
|
||||
carries the agent's standing instructions, its MCP tool servers, and its skills,
|
||||
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
|
||||
you get a configured agent in seconds; you choose the runtime/provider
|
||||
separately.
|
||||
|
||||
Templates are purely additive: no DB migration, no new dependency. **At runtime,
|
||||
templates are resolved only from a local directory**: `templates/` at the
|
||||
project root by default (committed but shipped empty), or whatever
|
||||
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
|
||||
discover templates from the public registry
|
||||
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
|
||||
and copy a chosen one into your local `templates/` before stamping.
|
||||
|
||||
## Using a template
|
||||
|
||||
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
|
||||
setup**, then either **NanoClaw template library** (clones the public registry,
|
||||
copies the template you pick into your local `templates/`) or **Local templates**
|
||||
(lists what's already in `templates/`). The normal auth step then picks the
|
||||
runtime, and the wizard stamps and wires your first agent.
|
||||
|
||||
**Anytime, via the CLI:**
|
||||
|
||||
```bash
|
||||
ncl groups create --template sales/sdr --name "SDR Agent"
|
||||
```
|
||||
|
||||
This stamps the group but does **not** wire it to a channel. Run
|
||||
`/manage-channels` (or `ncl wirings create`) afterward, exactly as for a
|
||||
hand-built group.
|
||||
|
||||
### The template ref
|
||||
|
||||
`--template <ref>` is a path **relative to the local templates directory**
|
||||
(`templates/` by default, or `NANOCLAW_TEMPLATES_DIR`). Refs are multi-segment,
|
||||
e.g. `sales/sdr` → `templates/sales/sdr`.
|
||||
|
||||
For safety the ref must stay inside the templates directory: absolute paths, a
|
||||
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
|
||||
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
|
||||
the setup wizard's library option), then stamp.
|
||||
|
||||
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
|
||||
is never a URL and never changes at runtime.
|
||||
|
||||
## What's in a template
|
||||
|
||||
The full authoring reference lives in the
|
||||
[templates repo README](https://github.com/nanocoai/nanoclaw-templates#anatomy-of-a-template).
|
||||
The short version: only `context/instructions.md` is required; everything else
|
||||
is optional and defaults sensibly:
|
||||
|
||||
```
|
||||
<template>/
|
||||
├── context/
|
||||
│ ├── instructions.md # REQUIRED: the agent's standing persona; marks the folder as a template
|
||||
│ └── additional_context/ # optional: extra .md files, referenced from instructions.md by relative path
|
||||
│ └── *.md
|
||||
├── .mcp.json # optional: MCP servers (command + args), NO secrets
|
||||
├── skills/<name>/ # optional: one folder per skill (SKILL.md + any references/), copied whole
|
||||
└── README.md # recommended: per-template docs
|
||||
```
|
||||
|
||||
| Path | Loaded as | Required |
|
||||
|------|-----------|----------|
|
||||
| `context/instructions.md` | The agent's persona, prepended to its `CLAUDE.md`/`AGENTS.md` every spawn (system-prompt tier, any provider) | **Yes** |
|
||||
| `context/**/*.md` (others) | Extra context, copied into the agent's workspace with the same layout relative to `instructions.md` | No |
|
||||
| `.mcp.json` → `mcpServers` | MCP tool servers (written verbatim to container config) | No |
|
||||
| `skills/<name>/` | A skill, auto-triggered by its `description` | No |
|
||||
|
||||
Notes:
|
||||
|
||||
- **No provider, model, effort, or packages in a template.** Those are set on
|
||||
the agent later via `ncl groups config update`. The runtime defaults to the
|
||||
install's configured provider.
|
||||
- **Keep `instructions.md` focused (under ~200 lines).** It's always in the
|
||||
agent's prompt, and some providers cap that doc (Codex ~32 KB), so an over-long
|
||||
persona gets truncated. Put bulk material in `skills/` or extra context files instead.
|
||||
- Skills are copied into the agent's own skills overlay, keyed to that group,
|
||||
never shared across groups.
|
||||
|
||||
### Referencing extra context files
|
||||
|
||||
Extra `.md` files under `context/` (by convention in an `additional_context/`
|
||||
subfolder) are copied into the agent's workspace preserving their position
|
||||
relative to `instructions.md` — a template file at
|
||||
`context/additional_context/pricing.md` is readable by the agent as
|
||||
`additional_context/pricing.md`, the same relative path you'd use from
|
||||
`instructions.md` itself. Nothing is injected automatically: the agent only
|
||||
reads an extra file if `instructions.md` points to it, so reference every file
|
||||
you ship.
|
||||
|
||||
```markdown
|
||||
Pricing rules live in `additional_context/pricing.md`. Read it before quoting a price.
|
||||
```
|
||||
|
||||
Context files are copied when you stamp, so files added to the template later
|
||||
won't reach an already-created agent. Re-stamp the same name to update it.
|
||||
|
||||
## MCP servers and credentials
|
||||
|
||||
**Templates declare MCP servers, not secrets.** `.mcp.json` carries `command` +
|
||||
`args` only:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hubspot": { "command": "npx", "args": ["-y", "@hubspot/mcp-server"] },
|
||||
"exa": { "command": "npx", "args": ["-y", "exa-mcp-server"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Credentials are held by the **credentials proxy** and injected into outbound
|
||||
HTTPS calls at the proxy boundary, matched by API host, at request time. The key
|
||||
never sits in `.mcp.json`, the container env, or chat context. See
|
||||
[the credentials proxy section in CLAUDE.md](../CLAUDE.md#secrets--credentials--onecli)
|
||||
for the model.
|
||||
|
||||
Two ways a credential gets connected:
|
||||
|
||||
1. **Up front.** Register the secret with the credentials proxy (its web UI or
|
||||
CLI), matched to the service's API host (e.g. `api.example.com`). Matching
|
||||
credentials are injected automatically, so usually nothing else is needed.
|
||||
2. **On demand (the common path).** Don't set anything up first. The first time
|
||||
the agent calls a service with no credential, the API returns **401/403** and
|
||||
the agent replies with a prefilled connect link for that host. The user opens
|
||||
it, pastes the key, and asks the agent to retry. The key lands in the
|
||||
credentials proxy, which injects it on every later call.
|
||||
|
||||
### MCP servers that require an env var to boot
|
||||
|
||||
Some MCP servers refuse to start unless an env var is *present*, even though the
|
||||
real credential should come from the credentials proxy, not the env. Because `.mcp.json`'s `env`
|
||||
block passes through verbatim to the agent's container config, put a **placeholder
|
||||
value** there to satisfy the boot check:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"acme": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@acme/mcp-server"],
|
||||
"env": { "ACME_API_KEY": "placeholder" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server starts; its real outbound calls are still authenticated by the
|
||||
credentials proxy. **Never put a real key in `env`**: a placeholder only, and only when
|
||||
the server won't boot without one.
|
||||
|
||||
### Approval-gating sensitive actions
|
||||
|
||||
The credentials proxy can *hold* a credentialed outbound request and require a
|
||||
human to approve it before it leaves the proxy: enforcement the agent can't talk
|
||||
around. This is matched on the outbound HTTP request (host + method + path),
|
||||
configured on the credentials proxy, and answered by NanoClaw (it DMs an approver). The host side is
|
||||
already wired; see
|
||||
[the credentialed-approval flow in CLAUDE.md](../CLAUDE.md#requiring-approval-for-credential-use)
|
||||
and the [`sales/sdr` template README](https://github.com/nanocoai/nanoclaw-templates/blob/main/sales/sdr/README.md)
|
||||
for a worked example.
|
||||
|
||||
## Contributing a template
|
||||
|
||||
Templates ship in the separate
|
||||
[`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates)
|
||||
repo, not this one. To add one: fork that repo, drop a folder at
|
||||
`<category>/<template>/` with at least `context/instructions.md`, test it end to
|
||||
end (copy it under `templates/` and run
|
||||
`ncl groups create --template <category>/<template> --name Test`), confirm
|
||||
no secrets are committed, and open a PR. The repo's README has the full anatomy,
|
||||
category conventions, and checklist.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.20",
|
||||
"version": "2.1.24",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="199k tokens, 100% of context window">
|
||||
<title>199k tokens, 100% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="204k tokens, 102% of context window">
|
||||
<title>204k tokens, 102% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">199k</text>
|
||||
<text x="71" y="14">199k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">204k</text>
|
||||
<text x="71" y="14">204k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -21,7 +21,6 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
@@ -124,11 +123,11 @@ async function main(): Promise<void> {
|
||||
`# ${args.agentName}\n\n` +
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
// The operator's setup pick (NANOCLAW_PICKED_PROVIDER) when set; otherwise
|
||||
// undefined, so initGroupFilesystem falls back to the instance default and
|
||||
// stamps it onto the fresh config row.
|
||||
provider: pickedProvider,
|
||||
});
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
|
||||
// 3. CLI messaging group + wiring.
|
||||
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
|
||||
|
||||
@@ -205,15 +205,13 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
console.log(`Reusing agent group: ${ag.id} (${folder})`);
|
||||
}
|
||||
// Ensure the config row exists; defer workspace scaffolding to the first
|
||||
// spawn (group-init), where the DB-resolved provider decides the surface
|
||||
// (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory scaffold)
|
||||
// — so a non-Claude group never gets stale CLAUDE.* files written here.
|
||||
ensureContainerConfig(ag.id);
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
// Seed the config row, stamped with the effective provider: the operator's
|
||||
// setup pick (NANOCLAW_PICKED_PROVIDER) when this runs inside a setup run,
|
||||
// otherwise the persisted instance default. Workspace scaffolding is deferred
|
||||
// to the first spawn (group-init), where the DB-resolved provider decides the
|
||||
// surface (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory
|
||||
// scaffold). A reused group keeps its provider (INSERT OR IGNORE).
|
||||
ensureContainerConfig(ag.id, pickedProvider);
|
||||
const groupDir = path.resolve(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
||||
+13
-5
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
|
||||
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
@@ -41,8 +42,10 @@ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
||||
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
|
||||
# SLACK_SIGNING_SECRET. Require at least one.
|
||||
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -98,7 +101,12 @@ upsert_env() {
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
|
||||
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
|
||||
fi
|
||||
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
|
||||
+16
-4
@@ -46,6 +46,7 @@ import './providers/index.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||
import { setPickedProvider } from './lib/picked-provider.js';
|
||||
import { upsertEnvVar } from './set-env.js';
|
||||
import {
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
@@ -65,6 +66,7 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './l
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
import { DEFAULT_AGENT_PROVIDER } from '../src/config.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
@@ -375,6 +377,12 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
await runAuthStep();
|
||||
}
|
||||
// Persist the pick as the instance-wide default so every future group
|
||||
// (channel-approved, ncl-created) is created on this provider. Read from
|
||||
// .env at host start; per-group `ncl groups config update --provider` wins.
|
||||
// Only after install + auth succeeded — a failed setup must not leave new
|
||||
// groups defaulting to an unauthenticated runtime.
|
||||
upsertEnvVar('DEFAULT_AGENT_PROVIDER', agentProvider);
|
||||
}
|
||||
|
||||
if (!skip.has('mounts')) {
|
||||
@@ -827,14 +835,18 @@ async function askAgentProviderChoice(): Promise<string> {
|
||||
phEmit('agent_provider_chosen', { provider: preset, preset: true });
|
||||
return preset;
|
||||
}
|
||||
// The pick installs and authenticates a runtime — it is not an
|
||||
// install-wide default, so re-runs safely Enter-through on claude (its
|
||||
// auth flow short-circuits when the secret already exists).
|
||||
// The pick is persisted as the instance default (DEFAULT_AGENT_PROVIDER), so
|
||||
// pre-select the current default — a re-run Enter-through then preserves it
|
||||
// instead of silently resetting it to claude. Fall back to claude if the
|
||||
// persisted default isn't an offered option (e.g. its provider was removed).
|
||||
const currentDefault = options.some((o) => o.value === DEFAULT_AGENT_PROVIDER)
|
||||
? DEFAULT_AGENT_PROVIDER
|
||||
: 'claude';
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: 'Which agent runtime should power your assistant?',
|
||||
options,
|
||||
initialValue: 'claude',
|
||||
initialValue: currentDefault,
|
||||
}),
|
||||
) as string;
|
||||
setupLog.userInput('agent_provider', choice);
|
||||
|
||||
+124
-21
@@ -4,15 +4,18 @@
|
||||
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||
* Slack app through the welcome DM:
|
||||
*
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Ask for the operator's Slack user ID
|
||||
* 6. conversations.open to get the DM channel ID
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
|
||||
* URL) or a public webhook
|
||||
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* events, and the mode-specific credential (app-level token for
|
||||
* Socket Mode, signing secret for webhook)
|
||||
* 3. Paste the bot token + that credential (clack password prompts)
|
||||
* 4. Validate via auth.test → resolves workspace + bot identity
|
||||
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 6. Ask for the operator's Slack user ID
|
||||
* 7. conversations.open to get the DM channel ID
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 9. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
||||
* works without Event Subscriptions being configured. The user sees the
|
||||
@@ -45,14 +48,26 @@ interface WorkspaceInfo {
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
|
||||
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
|
||||
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
|
||||
// credential to collect and which post-install guidance to show.
|
||||
type SlackMode = 'socket' | 'webhook';
|
||||
|
||||
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const intro = await walkThroughAppCreation();
|
||||
const mode = await askSlackMode();
|
||||
const intro = await walkThroughAppCreation(mode);
|
||||
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
|
||||
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
|
||||
if (appToken) env.SLACK_APP_TOKEN = appToken;
|
||||
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
@@ -62,11 +77,9 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
SLACK_BOT_TOKEN: token,
|
||||
SLACK_SIGNING_SECRET: signingSecret,
|
||||
},
|
||||
env,
|
||||
extraFields: {
|
||||
MODE: mode,
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
@@ -122,10 +135,45 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
|
||||
);
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info);
|
||||
showPostInstallChecklist(info, mode);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
async function askSlackMode(): Promise<SlackMode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<SlackMode>({
|
||||
message: 'How should Slack deliver events to NanoClaw?',
|
||||
initialValue: 'socket',
|
||||
options: [
|
||||
{
|
||||
value: 'socket',
|
||||
label: 'Socket Mode',
|
||||
hint: 'no public URL — recommended for local or behind NAT',
|
||||
},
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'Public webhook',
|
||||
hint: 'needs a public HTTPS Request URL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('slack_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(mode: SlackMode): Promise<'continue' | 'back'> {
|
||||
const credSteps =
|
||||
mode === 'socket'
|
||||
? [
|
||||
' 4. Basic Information → App-Level Tokens → "Generate Token and',
|
||||
' Scopes" → add the connections:write scope → copy it (xapp-…)',
|
||||
' 5. Socket Mode → toggle "Enable Socket Mode" on',
|
||||
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
]
|
||||
: [
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
];
|
||||
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
|
||||
// per-line formatter so the URL stands out against the rest of the body.
|
||||
const linkBlock = isHeadless()
|
||||
@@ -149,8 +197,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
' • files:read, files:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
...credSteps,
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
@@ -171,7 +218,10 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Got your bot token and signing secret?',
|
||||
message:
|
||||
mode === 'socket'
|
||||
? 'Got your bot token and app-level token?'
|
||||
: 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
@@ -249,6 +299,40 @@ async function collectSigningSecret(): Promise<string> {
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function collectAppToken(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_APP_TOKEN');
|
||||
if (existing && existing.startsWith('xapp-') && existing.length >= 24) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Slack app-level token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_app_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack app-level token (Socket Mode)',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'App-level token is required for Socket Mode';
|
||||
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack app-level token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_app_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
@@ -416,7 +500,26 @@ async function resolveAgentName(): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
|
||||
if (mode === 'socket') {
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`Your agent is wired to Slack and a welcome DM is on its way.`,
|
||||
`Socket Mode is on — ${info.teamName} reaches NanoClaw over an outbound`,
|
||||
`WebSocket, so there's no public URL to configure.`,
|
||||
'',
|
||||
' • Just DM @' + info.botName + ' from Slack — replies flow straight away.',
|
||||
'',
|
||||
' • Keep the NanoClaw host running to hold the socket open —',
|
||||
' Slack does not retry delivery while it is down.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
return;
|
||||
}
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* The agent runtime the operator picked in THIS setup run.
|
||||
* The agent runtime the operator picked in THIS setup run, carried to the
|
||||
* group-creation child processes over the process boundary.
|
||||
*
|
||||
* There is no install-wide default provider and no `--provider` in the
|
||||
* creation contract — provider is a DB property of a group. Setup is the one
|
||||
* orchestrator that knows the operator's pick, so it stashes it here (set once
|
||||
* at the auth step). The group-creation scripts (`init-first-agent`,
|
||||
* `init-cli-agent`) run as **child processes**, so the pick is carried over the
|
||||
* process boundary via an environment variable they inherit; they apply it to
|
||||
* the group at creation, before the welcome wakes the container. This is the
|
||||
* only place the value lives — a setup-run-scoped global, NOT a persisted
|
||||
* install default. `undefined` / `'claude'` means the built-in default and no
|
||||
* provider write at all.
|
||||
* There is no `--provider` flag in the creation contract — provider is a DB
|
||||
* property of a group. Setup persists the pick two ways: as the install-wide
|
||||
* default (`DEFAULT_AGENT_PROVIDER` in `.env`, see src/config.ts), which every
|
||||
* future group inherits at creation via the `ensureContainerConfig` chokepoint;
|
||||
* and here, in a setup-run-scoped env var, so the FIRST agent created in the
|
||||
* same run (by `init-first-agent` / `init-cli-agent`, which run as child
|
||||
* processes) is stamped with the pick before the welcome wakes the container —
|
||||
* without waiting for the host to restart and reload `.env`. `undefined` /
|
||||
* `'claude'` means no run-scoped pick; the creation scripts then fall back to
|
||||
* the install-wide default.
|
||||
*/
|
||||
const ENV_KEY = 'NANOCLAW_PICKED_PROVIDER';
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ interface V1Group {
|
||||
folder: string;
|
||||
trigger_pattern: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -65,7 +64,7 @@ async function main(): Promise<void> {
|
||||
// v1 schema varies — channel_name was a late addition. Query only the
|
||||
// columns we know exist in all v1 installs.
|
||||
const v1Groups = v1Db
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main FROM registered_groups')
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger FROM registered_groups')
|
||||
.all() as V1Group[];
|
||||
v1Db.close();
|
||||
|
||||
|
||||
@@ -55,12 +55,19 @@ describe('setup carries the picked provider to creation via a setup-run env var'
|
||||
// The creation scripts run as child processes, inherit the env var, and apply
|
||||
// it to the group's runtime config — container_configs.provider, the source of
|
||||
// truth materialized into container.json (agent_provider is deprecated) — before
|
||||
// the welcome wakes the container. No `--provider` flag in the contract (above).
|
||||
for (const file of ['scripts/init-first-agent.ts', 'scripts/init-cli-agent.ts']) {
|
||||
// the welcome wakes the container, falling back to the instance default
|
||||
// (DEFAULT_AGENT_PROVIDER) when the env var is unset. No `--provider` flag in
|
||||
// the contract (above). init-first-agent stamps directly via
|
||||
// ensureContainerConfig; init-cli-agent threads it through initGroupFilesystem.
|
||||
const applyPattern: Record<string, RegExp> = {
|
||||
'scripts/init-first-agent.ts': /ensureContainerConfig\([^)]*pickedProvider/,
|
||||
'scripts/init-cli-agent.ts': /provider:\s*pickedProvider/,
|
||||
};
|
||||
for (const [file, pattern] of Object.entries(applyPattern)) {
|
||||
it(`${file} applies the env-carried provider to container_configs.provider`, () => {
|
||||
const src = read(file);
|
||||
expect(src).toContain('NANOCLAW_PICKED_PROVIDER');
|
||||
expect(src).toMatch(/updateContainerConfigScalars\([^)]*provider:\s*pickedProvider/);
|
||||
expect(src).toMatch(pattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+6
-5
@@ -126,11 +126,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
const db = initDb(dbPath);
|
||||
runMigrations(db);
|
||||
|
||||
// 1. Create or find agent group. Provider-agnostic: provider is a DB
|
||||
// property set via `ncl groups config update --provider`, not a creation
|
||||
// flag. The workspace is scaffolded at the first spawn (group-init), where
|
||||
// the DB-resolved provider is known; here we only ensure the config row
|
||||
// exists so that update has a row to write.
|
||||
// 1. Create or find agent group. The workspace is scaffolded at the first
|
||||
// spawn (group-init), where the DB-resolved provider is known; here we only
|
||||
// seed the config row — stamped with the instance default so a newly wired
|
||||
// channel group is created on the operator's chosen provider (per-group
|
||||
// `ncl groups config update --provider` still overrides). A reused group
|
||||
// keeps its existing provider (INSERT OR IGNORE).
|
||||
let agentGroup = getAgentGroupByFolder(parsed.folder);
|
||||
if (!agentGroup) {
|
||||
const agId = generateId('ag');
|
||||
|
||||
+31
-25
@@ -18,6 +18,34 @@ import path from 'path';
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
/**
|
||||
* Upsert a `KEY=VALUE` line into the project's `.env`, returning whether the
|
||||
* key already existed. The canonical writer for new `.env` edits (legacy setup
|
||||
* steps still write directly) so flows don't invent grep/sed pipelines (which
|
||||
* can't be allowlisted tightly).
|
||||
*/
|
||||
export function upsertEnvVar(key: string, value: string): { existed: boolean } {
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
|
||||
}
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = '';
|
||||
if (fs.existsSync(envFile)) {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
}
|
||||
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
|
||||
const existed = lineRegex.test(content);
|
||||
const newLine = `${key}=${value}`;
|
||||
if (existed) {
|
||||
content = content.replace(lineRegex, newLine);
|
||||
} else {
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
content = content + sep + newLine + '\n';
|
||||
}
|
||||
fs.writeFileSync(envFile, content);
|
||||
return { existed };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const keyIdx = args.indexOf('--key');
|
||||
const valueIdx = args.indexOf('--value');
|
||||
@@ -33,37 +61,15 @@ export async function run(args: string[]): Promise<void> {
|
||||
const key = args[keyIdx + 1];
|
||||
const value = args[valueIdx + 1];
|
||||
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(`Invalid env key: ${key} (must be UPPER_SNAKE_CASE)`);
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
|
||||
let content = '';
|
||||
if (fs.existsSync(envFile)) {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
}
|
||||
|
||||
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
|
||||
const newLine = `${key}=${value}`;
|
||||
const existed = lineRegex.test(content);
|
||||
|
||||
if (existed) {
|
||||
content = content.replace(lineRegex, newLine);
|
||||
} else {
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
content = content + sep + newLine + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(envFile, content);
|
||||
const { existed } = upsertEnvVar(key, value);
|
||||
log.info('Updated .env', { key, existed });
|
||||
|
||||
let synced = false;
|
||||
if (syncContainer) {
|
||||
const projectRoot = process.cwd();
|
||||
const dataEnvDir = path.join(projectRoot, 'data', 'env');
|
||||
fs.mkdirSync(dataEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
|
||||
fs.copyFileSync(path.join(projectRoot, '.env'), path.join(dataEnvDir, 'env'));
|
||||
synced = true;
|
||||
log.info('Synced .env to container mount', { path: 'data/env/env' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-claude-md-compose-test';
|
||||
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
|
||||
|
||||
vi.mock('./config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config.js')>()),
|
||||
GROUPS_DIR: '/tmp/nanoclaw-claude-md-compose-test/groups',
|
||||
}));
|
||||
|
||||
vi.mock('./log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { PERSONA_PREPEND_FILE } from './group-persona.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
function group(id: string, folder: string): AgentGroup {
|
||||
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
|
||||
}
|
||||
|
||||
function seed(ag: AgentGroup): void {
|
||||
createAgentGroup(ag);
|
||||
ensureContainerConfig(ag.id);
|
||||
}
|
||||
|
||||
function writePersona(folder: string, text: string): void {
|
||||
const dir = path.join(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, PERSONA_PREPEND_FILE), text);
|
||||
}
|
||||
|
||||
function importsOf(folder: string): string[] {
|
||||
const md = fs.readFileSync(path.join(GROUPS_DIR, folder, 'CLAUDE.md'), 'utf-8');
|
||||
return md.split('\n').filter((line) => line.startsWith('@'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('composeGroupClaudeMd persona prepend', () => {
|
||||
it('imports the persona fragment FIRST, before the shared base', () => {
|
||||
const ag = group('ag-persona', 'persona-group');
|
||||
seed(ag);
|
||||
writePersona(ag.folder, 'You are an SDR agent.\n');
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports[0]).toBe('@./.claude-fragments/persona.md');
|
||||
expect(imports[1]).toBe('@./.claude-shared.md');
|
||||
expect(fs.readFileSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'), 'utf-8')).toBe(
|
||||
'You are an SDR agent.',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the persona across a second compose (not pruned)', () => {
|
||||
const ag = group('ag-persona-2', 'persona-group-2');
|
||||
seed(ag);
|
||||
writePersona(ag.folder, 'persona body');
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(true);
|
||||
expect(importsOf(ag.folder)[0]).toBe('@./.claude-fragments/persona.md');
|
||||
});
|
||||
|
||||
it('is inert when no persona file is present (non-template groups)', () => {
|
||||
const ag = group('ag-no-persona', 'no-persona-group');
|
||||
seed(ag);
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports[0]).toBe('@./.claude-shared.md');
|
||||
expect(imports).not.toContain('@./.claude-fragments/persona.md');
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,14 @@ import path from 'path';
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import type { McpServerConfig } from './container-config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { readGroupPersona } from './group-persona.js';
|
||||
import { log } from './log.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
// Fragment holding a template's persona prepend. Imported FIRST (before the
|
||||
// shared base) so the persona is the top of the composed system prompt.
|
||||
const PERSONA_FRAGMENT = 'persona.md';
|
||||
|
||||
// Symlink targets are container paths — dangling on host (hence the readlink
|
||||
// dance instead of existsSync), valid inside the container via RO mounts.
|
||||
const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md';
|
||||
@@ -106,6 +111,13 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Template persona (if any) — inline so it survives the prune below; imported
|
||||
// first (see the imports assembly) so it prepends the composed system prompt.
|
||||
const persona = readGroupPersona(groupDir);
|
||||
if (persona) {
|
||||
desired.set(PERSONA_FRAGMENT, { type: 'inline', content: persona });
|
||||
}
|
||||
|
||||
// Reconcile: drop stale, write desired.
|
||||
for (const existing of fs.readdirSync(fragmentsDir)) {
|
||||
if (!desired.has(existing)) {
|
||||
@@ -121,9 +133,14 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Composed entry — imports only.
|
||||
const imports = ['@./.claude-shared.md'];
|
||||
for (const name of [...desired.keys()].sort()) {
|
||||
// Composed entry — imports only. Persona first (top of the system prompt),
|
||||
// then the shared base, then the remaining fragments sorted.
|
||||
const imports: string[] = [];
|
||||
if (desired.has(PERSONA_FRAGMENT)) {
|
||||
imports.push(`@./.claude-fragments/${PERSONA_FRAGMENT}`);
|
||||
}
|
||||
imports.push('@./.claude-shared.md');
|
||||
for (const name of [...desired.keys()].filter((n) => n !== PERSONA_FRAGMENT).sort()) {
|
||||
imports.push(`@./.claude-fragments/${name}`);
|
||||
}
|
||||
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface ColumnDef {
|
||||
updatable?: boolean;
|
||||
/** Default value on create when not provided. */
|
||||
default?: unknown;
|
||||
/** Default to another column's resolved value on create when not provided. */
|
||||
defaultFrom?: string;
|
||||
/** Allowed values (shown in help). */
|
||||
enum?: string[];
|
||||
}
|
||||
@@ -150,6 +152,8 @@ function genericCreate(def: ResourceDef) {
|
||||
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
||||
} else if (col.default !== undefined) {
|
||||
values[col.name] = col.default;
|
||||
} else if (col.defaultFrom !== undefined && values[col.defaultFrom] !== undefined) {
|
||||
values[col.name] = values[col.defaultFrom];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import {
|
||||
ensureContainerConfig,
|
||||
getContainerConfig,
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
} from '../../db/container-configs.js';
|
||||
import type { ContainerConfigRow } from '../../types.js';
|
||||
import { createAgentFromTemplate } from '../../templates/create-agent.js';
|
||||
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
/** Deserialize JSON columns for display. */
|
||||
@@ -58,11 +63,42 @@ registerResource({
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
// `delete` is intentionally not in `operations` — the generic single-table
|
||||
// DELETE violates FK constraints (see #2525). The cascading handler is
|
||||
// provided as `customOperations.delete` below.
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
// `create` and `delete` are intentionally not in `operations` — create needs
|
||||
// a `--template` branch (below); the generic single-table DELETE violates FK
|
||||
// constraints (see #2525). Both are provided as `customOperations`.
|
||||
operations: { list: 'open', get: 'open', update: 'approval' },
|
||||
customOperations: {
|
||||
create: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Create an agent group. With --template <ref>, stamp from a local template under templates/ ' +
|
||||
'(MCP servers + instructions + skills); else insert a bare row (--name, --folder).',
|
||||
handler: async (args) => {
|
||||
if (args.template) {
|
||||
return createAgentFromTemplate(String(args.template), {
|
||||
name: args.name ? String(args.name) : undefined,
|
||||
});
|
||||
}
|
||||
const name = args.name ? String(args.name) : '';
|
||||
const folder = args.folder ? String(args.folder) : '';
|
||||
if (!name) throw new Error('--name is required');
|
||||
if (!folder) throw new Error('--folder is required');
|
||||
const group: AgentGroup = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createAgentGroup(group);
|
||||
// Seed the config row now so the group is created on the instance
|
||||
// default (ensureContainerConfig stamps it) and is spawnable without
|
||||
// waiting for the startup backfill. Per-group overrides via
|
||||
// `groups config update --provider` still win.
|
||||
ensureContainerConfig(group.id);
|
||||
return group;
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
access: 'approval',
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Regression test: `ncl messaging-groups create` must satisfy the NOT NULL
|
||||
* `instance` column without an operator-supplied `--instance`. The column has
|
||||
* no CLI flag at the operator's altitude (the default instance IS the channel
|
||||
* type), so the generic CRUD insert defaults it to `channel_type` — matching
|
||||
* `createMessagingGroup`'s `instance ?? channel_type` fallback on the router
|
||||
* path. Delete the `instance` column / `defaultFrom` wiring in
|
||||
* `messaging-groups.ts` and this goes red: the insert fails the NOT NULL.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-msggroups' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-msggroups';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `messaging-groups-create` command.
|
||||
import './messaging-groups.js';
|
||||
|
||||
describe('messaging-groups CLI create defaults instance to channel_type', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('create without --instance sets instance = channel_type', async () => {
|
||||
// caller: 'host' is the post-approval re-entry path for create (approval op).
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'messaging-groups-create',
|
||||
args: { channel_type: 'telegram', platform_id: '12345' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const row = getMessagingGroupByPlatform('telegram', '12345');
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.instance).toBe('telegram');
|
||||
});
|
||||
|
||||
it('create with an explicit --instance keeps that value', async () => {
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-2',
|
||||
command: 'messaging-groups-create',
|
||||
args: { channel_type: 'telegram', platform_id: '67890', instance: 'work' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('telegram', '67890', 'work')?.instance).toBe('work');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,14 @@ registerResource({
|
||||
'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'instance',
|
||||
type: 'string',
|
||||
description:
|
||||
'Adapter instance that owns this chat, when running N adapters of one channel type. Defaults to channel_type (the default instance) when omitted.',
|
||||
defaultFrom: 'channel_type',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
|
||||
+25
-1
@@ -6,9 +6,27 @@ import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']);
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'ONECLI_URL',
|
||||
'ONECLI_API_KEY',
|
||||
'TZ',
|
||||
'DEFAULT_AGENT_PROVIDER',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
|
||||
// Instance-wide default agent provider for newly created groups. `claude` (the
|
||||
// built-in provider) when unset, so existing installs are unaffected on upgrade.
|
||||
// Applied only at group-creation time (stamped onto the config row) — never in
|
||||
// provider resolution — so existing groups are never retroactively flipped.
|
||||
// Per-group `ncl groups config update --provider` still overrides it.
|
||||
export const DEFAULT_AGENT_PROVIDER = (
|
||||
process.env.DEFAULT_AGENT_PROVIDER ||
|
||||
envConfig.DEFAULT_AGENT_PROVIDER ||
|
||||
'claude'
|
||||
).toLowerCase();
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
|
||||
@@ -22,6 +40,12 @@ export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw',
|
||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||
// Local agent-template library. Committed but ships empty (+ README). Resolved
|
||||
// once at load. Override to another LOCAL path via NANOCLAW_TEMPLATES_DIR; never
|
||||
// a remote URL, never an ncl flag, never runtime-mutable.
|
||||
export const TEMPLATES_DIR = process.env.NANOCLAW_TEMPLATES_DIR
|
||||
? path.resolve(process.env.NANOCLAW_TEMPLATES_DIR)
|
||||
: path.resolve(PROJECT_ROOT, 'templates');
|
||||
|
||||
// Per-checkout image tag so two installs on the same host don't share
|
||||
// `nanoclaw-agent:latest` and clobber each other on rebuild.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* ensureContainerConfig provider stamping (global-default-provider feature).
|
||||
*
|
||||
* Two load-bearing guarantees:
|
||||
* 1. A fresh row is stamped with the given provider (claude → NULL), so a new
|
||||
* group is created on the instance default.
|
||||
* 2. An existing row is never overwritten (INSERT OR IGNORE), so enabling a
|
||||
* non-claude default never retroactively flips existing groups.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { initTestDb, closeDb } from './connection.js';
|
||||
import { runMigrations } from './migrations/index.js';
|
||||
import { createAgentGroup } from './agent-groups.js';
|
||||
import { ensureContainerConfig, getContainerConfig } from './container-configs.js';
|
||||
|
||||
function makeGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
describe('ensureContainerConfig provider stamping', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
it('stamps a non-default provider on a fresh row; claude is stored as NULL', () => {
|
||||
makeGroup('ag-codex');
|
||||
ensureContainerConfig('ag-codex', 'codex');
|
||||
expect(getContainerConfig('ag-codex')?.provider).toBe('codex');
|
||||
|
||||
makeGroup('ag-claude');
|
||||
ensureContainerConfig('ag-claude', 'claude');
|
||||
expect(getContainerConfig('ag-claude')?.provider).toBeNull();
|
||||
|
||||
// Casing is normalized to match what resolution lowercases to.
|
||||
makeGroup('ag-cased');
|
||||
ensureContainerConfig('ag-cased', 'Codex');
|
||||
expect(getContainerConfig('ag-cased')?.provider).toBe('codex');
|
||||
|
||||
makeGroup('ag-cased-claude');
|
||||
ensureContainerConfig('ag-cased-claude', 'Claude');
|
||||
expect(getContainerConfig('ag-cased-claude')?.provider).toBeNull();
|
||||
});
|
||||
|
||||
it('never overwrites an existing row — existing groups are not flipped', () => {
|
||||
makeGroup('ag-existing');
|
||||
ensureContainerConfig('ag-existing', 'codex'); // existing group already on codex
|
||||
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
|
||||
|
||||
// A later bare ensure (defensive re-init, or a changed instance default)
|
||||
// must NOT change it — INSERT OR IGNORE keeps the row frozen.
|
||||
ensureContainerConfig('ag-existing');
|
||||
expect(getContainerConfig('ag-existing')?.provider).toBe('codex');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_AGENT_PROVIDER } from '../config.js';
|
||||
import type { ContainerConfigRow } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
@@ -39,14 +40,36 @@ export function createContainerConfig(config: ContainerConfigRow): void {
|
||||
.run(config);
|
||||
}
|
||||
|
||||
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
|
||||
export function ensureContainerConfig(agentGroupId: string): void {
|
||||
/**
|
||||
* Create a config row if one doesn't exist, stamping the provider. Idempotent —
|
||||
* no-ops if the row already exists, so an existing group's provider is never
|
||||
* overwritten (load-bearing: this is how the global default stays "new groups
|
||||
* only" for groups that already have a row).
|
||||
*
|
||||
* An absent `provider` takes the instance default (`DEFAULT_AGENT_PROVIDER`);
|
||||
* `claude` and an absent value that resolves to claude are stored as NULL — the
|
||||
* column means "follows the built-in default", matching pre-feature rows.
|
||||
*/
|
||||
export function ensureContainerConfig(agentGroupId: string, provider?: string | null): void {
|
||||
// Single chokepoint for the instance default: a fresh row with no explicit
|
||||
// provider is stamped with DEFAULT_AGENT_PROVIDER, so every new-group creation
|
||||
// path inherits it without each having to remember. INSERT OR IGNORE keeps an
|
||||
// EXISTING row untouched — so this stays "new groups only" for any group that
|
||||
// already has a config row (backfillContainerConfigs seeds one for every group
|
||||
// at host startup; a non-claude default would only reach a row-less *legacy*
|
||||
// group if a creation script reused it before that first backfill ran). Callers
|
||||
// that know the provider (subagent → parent's, spawn → resolved) pass it
|
||||
// explicitly and override the default.
|
||||
// `claude` (the built-in default) and casing normalize to NULL/lowercase so the
|
||||
// column matches what resolution lowercases to.
|
||||
const normalized = (provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
|
||||
const stamped = normalized && normalized !== 'claude' ? normalized : null;
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
|
||||
VALUES (?, ?)`,
|
||||
`INSERT OR IGNORE INTO container_configs (agent_group_id, provider, updated_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
)
|
||||
.run(agentGroupId, new Date().toISOString());
|
||||
.run(agentGroupId, stamped, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
|
||||
|
||||
+17
-8
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
import { DATA_DIR, DEFAULT_AGENT_PROVIDER, GROUPS_DIR } from './config.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { log } from './log.js';
|
||||
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
|
||||
@@ -53,11 +53,18 @@ export function initGroupFilesystem(
|
||||
): void {
|
||||
const initialized: string[] = [];
|
||||
|
||||
// Default agent surfaces apply unless the group's provider declares (at
|
||||
// registration) that it provides its own. Callers that don't know the
|
||||
// provider omit it — unregistered/unknown names report no capabilities,
|
||||
// so the default surfaces are written, exactly as before this seam.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
|
||||
// `opts.provider` absent means "caller has no provider opinion" — for a
|
||||
// brand-new group that resolves to the instance default, so the scaffold and
|
||||
// the stamped config row both match it. A caller that knows the provider
|
||||
// (subagent → parent's, spawn → resolved, setup → operator's pick) passes it
|
||||
// explicitly — including `claude` — which pins the group and skips the
|
||||
// default. ensureContainerConfig is INSERT OR IGNORE, so this only stamps a
|
||||
// genuinely new group; existing rows are never touched.
|
||||
const providerHint = (opts?.provider ?? DEFAULT_AGENT_PROVIDER).toLowerCase();
|
||||
|
||||
// Default agent surfaces apply unless the provider declares (at registration)
|
||||
// that it provides its own.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(providerHint);
|
||||
|
||||
// 1. groups/<folder>/ — group memory + working dir
|
||||
const groupDir = path.resolve(GROUPS_DIR, group.folder);
|
||||
@@ -106,8 +113,10 @@ export function initGroupFilesystem(
|
||||
}
|
||||
|
||||
// Ensure container_configs row exists in the DB. Idempotent — no-op if
|
||||
// the row already exists (e.g. created by backfill or group creation).
|
||||
ensureContainerConfig(group.id);
|
||||
// the row already exists (e.g. created by backfill or group creation). On a
|
||||
// fresh row, stamp the resolved provider hint so a new group is created on
|
||||
// the instance default (or the caller's explicit pick).
|
||||
ensureContainerConfig(group.id, providerHint);
|
||||
initialized.push('container_configs');
|
||||
|
||||
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared containment guards for per-message inbox directories.
|
||||
*
|
||||
* Session dirs are mounted writable into agent containers, so a compromised
|
||||
* agent can pre-place a symlink inside its own session dir and wait for the
|
||||
* host to write through it — landing attacker-influenced bytes outside the
|
||||
* sandbox (CWE-59). Both inbound paths that materialise files into a session's
|
||||
* `inbox/<messageId>/` directory route through `ensureContainedInboxDir`:
|
||||
* - channel-inbound attachments (`extractAttachmentFiles` in session-manager)
|
||||
* - agent-to-agent forwarded files (`forwardAttachedFiles` in agent-route)
|
||||
*
|
||||
* Keeping the guard in one place means both paths defend identically; the fix
|
||||
* for GHSA #2828 originally lived only in the A2A path and the channel path had
|
||||
* the same gap (a symlinked `inbox` root was followed silently).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from './log.js';
|
||||
|
||||
/** True if `child` is `parent` itself or nested within it (no traversal/escape). */
|
||||
export function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and create `<inboxRoot>/<messageId>`, refusing pre-placed symlinks a
|
||||
* compromised container could use to redirect host writes outside the session.
|
||||
*
|
||||
* Guards, in order:
|
||||
* 1. lstat the inbox ROOT — reject if it is a symlink or a non-directory.
|
||||
* Without this, a symlinked `inbox` is silently followed by mkdir AND the
|
||||
* containment check in step 4 passes, because it compares against the
|
||||
* already-followed (escaped) root. This is the gap that affected the
|
||||
* channel-inbound path.
|
||||
* 2. lstat the per-message subdir — reject a pre-placed symlink/non-dir.
|
||||
* lstat does not follow the final path component, so it sees the link
|
||||
* itself even when the link target does not exist.
|
||||
* 3. mkdir the subdir (recursive).
|
||||
* 4. realpath containment — the resolved subdir must stay within the resolved
|
||||
* inbox root (defence in depth; symlinks are already ruled out above).
|
||||
*
|
||||
* Returns the resolved, contained subdir path (write into it with an exclusive
|
||||
* flag — `COPYFILE_EXCL` / `wx` — so a pre-existing symlinked *file* can't be
|
||||
* followed either), or `null` if any guard tripped. On `null` the caller logs
|
||||
* its own context and skips; `context` is merged into the warn logs here so
|
||||
* each call site stays diagnosable.
|
||||
*/
|
||||
export function ensureContainedInboxDir(
|
||||
inboxRoot: string,
|
||||
messageId: string,
|
||||
context: Record<string, unknown>,
|
||||
): string | null {
|
||||
const inboxDir = path.join(inboxRoot, messageId);
|
||||
|
||||
for (const dir of [inboxRoot, inboxDir]) {
|
||||
try {
|
||||
const st = fs.lstatSync(dir);
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) {
|
||||
log.warn('inbox-safety: rejecting unsafe inbox path', { ...context, dir });
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Does not exist yet — fine, mkdir below creates it.
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const realInboxDir = fs.realpathSync(inboxDir);
|
||||
const realInboxRoot = fs.realpathSync(inboxRoot);
|
||||
if (!isPathInside(realInboxRoot, realInboxDir)) {
|
||||
log.warn('inbox-safety: inbox dir escaped inbox root', { ...context, inboxDir });
|
||||
return null;
|
||||
}
|
||||
return realInboxDir;
|
||||
} catch (err) {
|
||||
log.warn('inbox-safety: failed to resolve inbox dir', { ...context, inboxDir, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
|
||||
import { forwardAttachedFiles, isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
|
||||
import { log } from '../../log.js';
|
||||
import { createDestination } from './db/agent-destinations.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession, updateSession } from '../../db/sessions.js';
|
||||
@@ -467,4 +468,129 @@ describe('routeAgentMessage return-path', () => {
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
// #2828 — target-side symlink containment. A compromised target agent can
|
||||
// write inside its own session dir; these tests prove it cannot redirect a
|
||||
// forwarded attachment outside the session sandbox via a pre-placed symlink.
|
||||
|
||||
it('file forwarding (#2828): skips a symlinked target inbox dir, writes nothing outside', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside-inbox');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
// Source has a real attachment to forward.
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-inbox');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'pwn.txt'), 'attacker-bytes');
|
||||
|
||||
// Target pre-places its whole `inbox` as a symlink pointing outside.
|
||||
const targetInbox = path.join(sessionDir(B, SB.id), 'inbox');
|
||||
fs.rmSync(targetInbox, { recursive: true, force: true });
|
||||
fs.symlinkSync(canaryDir, targetInbox);
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-evil-inbox',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['pwn.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
// Message still routes — just with no attachments.
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
expect(JSON.parse(bRows[0].content).attachments).toHaveLength(0);
|
||||
|
||||
// Nothing was written through the symlink to the canary location.
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828): skips a symlinked inbox/<msgId> subdir, writes nothing outside', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside-subdir');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-subdir');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'pwn.txt'), 'attacker-bytes');
|
||||
|
||||
// The forwarded a2a msg id generated inside routeAgentMessage is random, so
|
||||
// a symlink can't be pre-placed at inbox/<that-id>. Drive forwardAttachedFiles
|
||||
// directly with a fixed target message id and plant the symlink at that path.
|
||||
const targetMsgId = 'evil-subdir-msg';
|
||||
const realInbox = path.join(sessionDir(B, SB.id), 'inbox');
|
||||
fs.mkdirSync(realInbox, { recursive: true });
|
||||
fs.symlinkSync(canaryDir, path.join(realInbox, targetMsgId));
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{ agentGroupId: A, sessionId: S1.id, messageId: 'msg-evil-subdir', filenames: ['pwn.txt'] },
|
||||
{ agentGroupId: B, sessionId: SB.id, messageId: targetMsgId },
|
||||
);
|
||||
|
||||
expect(attachments).toHaveLength(0);
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828): refuses a pre-existing symlinked dst file (COPYFILE_EXCL)', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryFile = path.join(TEST_DIR, 'canary-dst-target.txt');
|
||||
fs.writeFileSync(canaryFile, 'original-canary');
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-dst');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'doc.txt'), 'attacker-bytes');
|
||||
|
||||
// inbox/<msgId>/ is a real dir, but contains a pre-placed symlink named
|
||||
// exactly like the incoming attachment, pointing at the canary file.
|
||||
// We can only do this once we know the a2a msg id, which is generated
|
||||
// inside routeAgentMessage. So we instead drive forwardAttachedFiles
|
||||
// directly with a fixed target message id.
|
||||
const targetMsgId = 'fixed-evil-dst';
|
||||
const realInboxSubdir = path.join(sessionDir(B, SB.id), 'inbox', targetMsgId);
|
||||
fs.mkdirSync(realInboxSubdir, { recursive: true });
|
||||
fs.symlinkSync(canaryFile, path.join(realInboxSubdir, 'doc.txt'));
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{ agentGroupId: A, sessionId: S1.id, messageId: 'msg-evil-dst', filenames: ['doc.txt'] },
|
||||
{ agentGroupId: B, sessionId: SB.id, messageId: targetMsgId },
|
||||
);
|
||||
|
||||
// The exclusive write failed → nothing forwarded.
|
||||
expect(attachments).toHaveLength(0);
|
||||
// Canary file untouched (symlink not followed/overwritten).
|
||||
expect(fs.readFileSync(canaryFile, 'utf-8')).toBe('original-canary');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828 regression): a normal forward still works end-to-end', async () => {
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-ok-file');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'ok.txt'), 'legit-bytes');
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-ok-file',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['ok.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(1);
|
||||
expect(parsed.attachments[0].name).toBe('ok.txt');
|
||||
const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath);
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('legit-bytes');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { isSafeAttachmentName } from '../../attachment-safety.js';
|
||||
import { ensureContainedInboxDir, isPathInside } from '../../inbox-safety.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
@@ -42,11 +43,6 @@ export interface ForwardedAttachment {
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
@@ -98,8 +94,20 @@ export function forwardAttachedFiles(
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
|
||||
fs.mkdirSync(targetInboxDir, { recursive: true });
|
||||
// Target-side containment — shared with the channel-inbound path. A
|
||||
// compromised target agent can write inside its own session dir, so it could
|
||||
// pre-place `inbox` (or `inbox/<future-msgId>`) as a symlink pointing
|
||||
// anywhere host-writable; ensureContainedInboxDir refuses the symlink before
|
||||
// any copy lands outside the sandbox (#2828, CWE-59).
|
||||
const inboxRoot = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox');
|
||||
const targetInboxDir = ensureContainedInboxDir(inboxRoot, target.messageId, {
|
||||
targetGroup: target.agentGroupId,
|
||||
targetSession: target.sessionId,
|
||||
targetMsgId: target.messageId,
|
||||
});
|
||||
if (!targetInboxDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments: ForwardedAttachment[] = [];
|
||||
for (const filename of source.filenames) {
|
||||
@@ -137,7 +145,20 @@ export function forwardAttachedFiles(
|
||||
continue;
|
||||
}
|
||||
const dst = path.join(targetInboxDir, filename);
|
||||
fs.copyFileSync(realSrc, dst);
|
||||
try {
|
||||
// COPYFILE_EXCL: fail with EEXIST rather than follow or overwrite a
|
||||
// pre-placed symlink / existing file at dst — the host is the sole
|
||||
// writer of these attachments.
|
||||
fs.copyFileSync(realSrc, dst, fs.constants.COPYFILE_EXCL);
|
||||
} catch (err) {
|
||||
log.warn('agent-route: refusing to write target inbox file', {
|
||||
sourceMsgId: source.messageId,
|
||||
targetMsgId: target.messageId,
|
||||
filename,
|
||||
err,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
attachments.push({
|
||||
name: filename,
|
||||
filename,
|
||||
|
||||
@@ -16,7 +16,6 @@ const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
const mockCreateAgentGroup = vi.fn();
|
||||
const mockInitGroupFilesystem = vi.fn();
|
||||
const mockUpdateScalars = vi.fn();
|
||||
const mockWriteDestinations = vi.fn();
|
||||
const mockNotifyWrite = vi.fn();
|
||||
|
||||
@@ -26,7 +25,6 @@ vi.mock('../approvals/index.js', () => ({
|
||||
vi.mock('../../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
|
||||
ensureContainerConfig: () => {},
|
||||
updateContainerConfigScalars: (...a: unknown[]) => mockUpdateScalars(...a),
|
||||
}));
|
||||
vi.mock('../../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (id: string) => ({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: '' }),
|
||||
@@ -80,8 +78,10 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
|
||||
it('child inherits the creator provider (codex parent → codex child)', async () => {
|
||||
// A subagent must run on the same authenticated runtime as its creator —
|
||||
// on a codex-only install a claude default would 401. Red-on-delete:
|
||||
// dropping the inheritance leaves the child provider-less (→ claude).
|
||||
// on a codex-only install a claude default would 401. The provider is
|
||||
// passed to initGroupFilesystem, which stamps the child's config row.
|
||||
// Red-on-delete: dropping the inheritance lets the child fall through to the
|
||||
// instance default instead of codex.
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global', provider: 'codex' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
@@ -90,15 +90,19 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider: 'codex' }),
|
||||
);
|
||||
expect(mockUpdateScalars).toHaveBeenCalledWith(expect.any(String), { provider: 'codex' });
|
||||
});
|
||||
|
||||
it('claude creator leaves the child provider unset (built-in default)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // no provider
|
||||
it('claude creator pins the child to claude, not the instance default', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // parent has no explicit provider
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
|
||||
expect(mockUpdateScalars).not.toHaveBeenCalled();
|
||||
// The child inherits the parent's EFFECTIVE provider (claude), passed
|
||||
// explicitly so it never falls through to a non-claude instance default.
|
||||
expect(mockInitGroupFilesystem).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider: 'claude' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('group scope (default): requires approval, does NOT create directly', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getContainerConfig, updateContainerConfigScalars } from '../../db/container-configs.js';
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
@@ -163,17 +163,15 @@ async function performCreateAgent(
|
||||
created_at: now,
|
||||
};
|
||||
createAgentGroup(newGroup);
|
||||
// A subagent inherits its creator's provider. Provider is a DB property; the
|
||||
// child is created provider-agnostic, then stamped with the parent's runtime
|
||||
// so a single-provider install (e.g. codex-only, where claude isn't
|
||||
// authenticated) doesn't spawn a child on a runtime it can't reach. The
|
||||
// Subagent path: a child inherits its creator's EFFECTIVE provider, NOT the
|
||||
// instance-wide default — so a child is never spawned on a runtime the parent
|
||||
// can't reach (e.g. a codex-only install where claude isn't authenticated).
|
||||
// Passing it explicitly to initGroupFilesystem pins the child's scaffold and
|
||||
// stamps its config row in one step (a NULL parent resolves to claude). The
|
||||
// operator can still flip a child later with `ncl groups config update
|
||||
// --provider`. claude (the built-in default) leaves the column unset.
|
||||
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? undefined;
|
||||
// --provider`.
|
||||
const parentProvider = getContainerConfig(sourceGroup.id)?.provider ?? 'claude';
|
||||
initGroupFilesystem(newGroup, { instructions: instructions ?? undefined, provider: parentProvider });
|
||||
if (parentProvider) {
|
||||
updateContainerConfigScalars(newGroup.id, { provider: parentProvider });
|
||||
}
|
||||
|
||||
// Insert bidirectional destination rows (= ACL grants).
|
||||
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
||||
|
||||
@@ -292,8 +292,10 @@ export function createNewAgentGroup(name: string): AgentGroup {
|
||||
});
|
||||
|
||||
const ag = getAgentGroup(agId)!;
|
||||
// Channel-approved groups get the built-in default provider (claude); the
|
||||
// operator flips a group with `ncl groups config update --provider`.
|
||||
// Channel-approved groups are created on the instance default provider
|
||||
// (DEFAULT_AGENT_PROVIDER, or claude when unset) — initGroupFilesystem stamps
|
||||
// it onto the fresh config row. The operator flips a group afterward with
|
||||
// `ncl groups config update --provider`.
|
||||
initGroupFilesystem(ag);
|
||||
return ag;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Security regression for the channel-inbound attachment path (#2828 sibling).
|
||||
*
|
||||
* `extractAttachmentFiles` (via `writeSessionMessage`) hardens the per-message
|
||||
* inbox subdir against pre-placed symlinks, but NOT the `inbox` root itself.
|
||||
* A compromised container can write inside its own session dir, so it can
|
||||
* replace `inbox` with a symlink pointing outside the session sandbox. The
|
||||
* existing guard then:
|
||||
* - skips the lstat branch (it only lstats `inbox/<msgId>`, not `inbox`),
|
||||
* - mkdirs `inbox/<msgId>` *through* the symlink,
|
||||
* - passes the containment check, because it compares against
|
||||
* `realpathSync(inboxRoot)` which has already followed the symlink, and
|
||||
* - writes a brand-new file (the `wx` flag only blocks an existing dst).
|
||||
*
|
||||
* Result: the host writes attacker-influenced bytes outside the session root —
|
||||
* the same class of bug fixed for the A2A path in forwardAttachedFiles (#2828).
|
||||
*
|
||||
* This test asserts the SECURE behaviour (nothing written outside). It FAILS
|
||||
* against the current code, demonstrating the gap.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-saveatt-gap' };
|
||||
});
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
||||
import { createSession } from './db/sessions.js';
|
||||
import { initSessionFolder, sessionDir, writeSessionMessage } from './session-manager.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-saveatt-gap';
|
||||
const AG = 'ag-saveatt';
|
||||
const SESS = 'sess-saveatt';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: AG, name: 'SaveAtt', folder: 'saveatt', agent_provider: null, created_at: now() });
|
||||
const sess: Session = {
|
||||
id: SESS,
|
||||
agent_group_id: AG,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
};
|
||||
createSession(sess);
|
||||
initSessionFolder(AG, SESS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('extractAttachmentFiles — inbox-root symlink containment (#2828 sibling)', () => {
|
||||
it('does not write an attachment outside the session root via a symlinked inbox root', () => {
|
||||
// Attacker-controlled location outside the session sandbox.
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
// Container pre-places its whole `inbox` as a symlink pointing outside.
|
||||
const inboxRoot = path.join(sessionDir(AG, SESS), 'inbox');
|
||||
fs.rmSync(inboxRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(canaryDir, inboxRoot);
|
||||
|
||||
const content = JSON.stringify({
|
||||
text: 'see attached',
|
||||
attachments: [{ name: 'pwn.txt', data: Buffer.from('attacker-bytes').toString('base64') }],
|
||||
});
|
||||
|
||||
writeSessionMessage(AG, SESS, {
|
||||
id: 'evil-inbox-root',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
platformId: 'whatsapp:123',
|
||||
channelType: 'whatsapp',
|
||||
threadId: null,
|
||||
content,
|
||||
});
|
||||
|
||||
// SECURE expectation: nothing was written through the symlink to the
|
||||
// attacker-controlled canary location.
|
||||
const escaped = path.join(canaryDir, 'evil-inbox-root', 'pwn.txt');
|
||||
expect(fs.existsSync(escaped)).toBe(false);
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+14
-30
@@ -18,6 +18,7 @@ import { deriveAttachmentName } from './attachment-naming.js';
|
||||
import { isSafeAttachmentName } from './attachment-safety.js';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { ensureContainedInboxDir, isPathInside } from './inbox-safety.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
@@ -38,11 +39,6 @@ import {
|
||||
import { log } from './log.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/** Root directory for all session data. */
|
||||
export function sessionsBaseDir(): string {
|
||||
return path.join(DATA_DIR, 'v2-sessions');
|
||||
@@ -288,6 +284,14 @@ function extractAttachmentFiles(
|
||||
return contentStr;
|
||||
}
|
||||
|
||||
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
|
||||
// Resolved lazily on the first attachment that actually carries bytes, so a
|
||||
// message whose attachments have no inline `data` never creates an inbox dir.
|
||||
// ensureContainedInboxDir refuses a pre-placed symlink at the inbox root or
|
||||
// the per-message subdir before any write lands outside the sandbox (#2828).
|
||||
let inboxDir: string | null = null;
|
||||
let inboxResolved = false;
|
||||
|
||||
let changed = false;
|
||||
for (const att of attachments) {
|
||||
if (typeof att.data !== 'string') continue;
|
||||
@@ -302,32 +306,12 @@ function extractAttachmentFiles(
|
||||
});
|
||||
}
|
||||
|
||||
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
|
||||
|
||||
// Refuse to mkdir through a symlink that the container may have pre placed
|
||||
// at inboxDir. With recursive:true, mkdirSync would silently no op on a
|
||||
// pre existing symlink and the subsequent writeFileSync would follow it.
|
||||
if (fs.existsSync(inboxDir)) {
|
||||
const stat = fs.lstatSync(inboxDir);
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
let realInboxDir: string;
|
||||
try {
|
||||
realInboxDir = fs.realpathSync(inboxDir);
|
||||
} catch (err) {
|
||||
log.warn('Failed to resolve inbox directory', { messageId, err });
|
||||
continue;
|
||||
}
|
||||
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
|
||||
if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) {
|
||||
log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir });
|
||||
continue;
|
||||
if (!inboxResolved) {
|
||||
inboxDir = ensureContainedInboxDir(inboxRoot, messageId, { messageId });
|
||||
inboxResolved = true;
|
||||
}
|
||||
// Unsafe inbox (symlink / escape) — no attachment can be written safely.
|
||||
if (!inboxDir) break;
|
||||
|
||||
const filePath = path.join(inboxDir, filename);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-create-agent-test';
|
||||
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
|
||||
const DATA_DIR = path.join(TEST_ROOT, 'data');
|
||||
const TEMPLATES_DIR = path.join(TEST_ROOT, 'templates');
|
||||
|
||||
vi.mock('../config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../config.js')>()),
|
||||
GROUPS_DIR: '/tmp/nanoclaw-create-agent-test/groups',
|
||||
DATA_DIR: '/tmp/nanoclaw-create-agent-test/data',
|
||||
TEMPLATES_DIR: '/tmp/nanoclaw-create-agent-test/templates',
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
import { closeDb, initTestDb, runMigrations } from '../db/index.js';
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
|
||||
import { createAgentFromTemplate } from './create-agent.js';
|
||||
|
||||
function writeTemplate(): void {
|
||||
const t = path.join(TEMPLATES_DIR, 'sales', 'sdr');
|
||||
fs.mkdirSync(path.join(t, 'context', 'additional_context'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t, 'context', 'instructions.md'), 'You are an SDR agent.\n');
|
||||
fs.writeFileSync(path.join(t, 'context', 'playbook.md'), '# Playbook\n');
|
||||
fs.writeFileSync(path.join(t, 'context', 'additional_context', 'faq.md'), '# FAQ\n');
|
||||
fs.writeFileSync(
|
||||
path.join(t, '.mcp.json'),
|
||||
JSON.stringify({ mcpServers: { hubspot: { command: 'npx', args: ['-y', '@hubspot/mcp-server'] } } }),
|
||||
);
|
||||
const skillDir = path.join(t, 'skills', 'widget');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: widget\n---\n');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
writeTemplate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('createAgentFromTemplate', () => {
|
||||
it('writes the persona prepend verbatim — no injected context refs, no .seed.md', () => {
|
||||
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Test' });
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, g.folder);
|
||||
const prepend = fs.readFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), 'utf-8');
|
||||
expect(prepend).toBe('You are an SDR agent.\n');
|
||||
expect(fs.existsSync(path.join(groupDir, '.seed.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('copies template skills into the group-private Claude-plane skills dir', () => {
|
||||
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Skills' });
|
||||
|
||||
const skill = path.join(DATA_DIR, 'v2-sessions', g.id, '.claude-shared', 'skills', 'widget', 'SKILL.md');
|
||||
expect(fs.existsSync(skill)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes MCP servers to the container config and context extras at their template-relative paths', () => {
|
||||
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Mcp' });
|
||||
|
||||
const cfg = getContainerConfig(g.id);
|
||||
expect(cfg).toBeTruthy();
|
||||
expect(JSON.parse(cfg!.mcp_servers)).toHaveProperty('hubspot');
|
||||
// Extras land relative to the group root, exactly as they sit relative to
|
||||
// instructions.md in the template — no context/ prefix in between.
|
||||
const groupDir = path.join(GROUPS_DIR, g.folder);
|
||||
expect(fs.existsSync(path.join(groupDir, 'playbook.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(groupDir, 'additional_context', 'faq.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(groupDir, 'context'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from '../config.js';
|
||||
import { createAgentGroup } from '../db/agent-groups.js';
|
||||
import { ensureContainerConfig, updateContainerConfigJson } from '../db/container-configs.js';
|
||||
import { assertValidGroupFolder, resolveGroupFolderPath } from '../group-folder.js';
|
||||
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
|
||||
import { normalizeName } from '../modules/agent-to-agent/db/agent-destinations.js';
|
||||
import type { AgentGroup } from '../types.js';
|
||||
import { resolveLocalTemplate } from './local-dir.js';
|
||||
import { parseTemplate } from './parse.js';
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a self-contained agent group from a LOCAL template ref under
|
||||
* TEMPLATES_DIR. The template carries MCP servers, instructions, optional
|
||||
* context extras, and optional skills — nothing else (no policy, no packages,
|
||||
* no provider).
|
||||
*
|
||||
* The template persona is written to the provider-neutral `instructions.prepend.md`
|
||||
* (see src/group-persona.ts). Each provider's project-doc composer inlines it at
|
||||
* the TOP of the doc it generates every spawn, so the persona is system-prompt
|
||||
* tier regardless of which provider the group ends up running. Because the file
|
||||
* is provider-agnostic, placement needs no provider knowledge at stamp time (the
|
||||
* provider is DB-resolved later, at first spawn).
|
||||
*
|
||||
* Returns the created group; the caller wires it to a channel as usual.
|
||||
*/
|
||||
export function createAgentFromTemplate(ref: string, opts?: CreateAgentOptions): AgentGroup {
|
||||
const dir = resolveLocalTemplate(ref);
|
||||
const tpl = parseTemplate(dir);
|
||||
|
||||
const id = randomUUID();
|
||||
const name = opts?.name ?? path.basename(dir);
|
||||
let folder = normalizeName(name);
|
||||
assertValidGroupFolder(folder);
|
||||
if (fs.existsSync(resolveGroupFolderPath(folder))) folder = `${folder}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const group: AgentGroup = { id, name, folder, agent_provider: null, created_at: new Date().toISOString() };
|
||||
createAgentGroup(group);
|
||||
ensureContainerConfig(id);
|
||||
|
||||
// group-init.ts owns the mkdir at first spawn, but it isn't called here — so we
|
||||
// create the dir ourselves to land instructions.prepend.md + context/.
|
||||
const groupDir = path.resolve(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
// Persona → provider-neutral prepend, inlined at the top of the group's
|
||||
// CLAUDE.md/AGENTS.md every spawn (system-prompt tier on any provider).
|
||||
fs.writeFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), tpl.instructions + '\n');
|
||||
|
||||
// Context extras keep their template-relative layout, placed next to the doc
|
||||
// the persona is inlined into — so a reference written in instructions.md
|
||||
// (e.g. `additional_context/faq.md`) resolves unchanged in the agent's
|
||||
// workspace. Nothing is injected into the persona; referencing each file from
|
||||
// instructions.md is the template author's job (docs/templates.md).
|
||||
for (const { name: file, content } of tpl.contextExtras) {
|
||||
const dest = path.join(groupDir, file);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, content);
|
||||
}
|
||||
|
||||
updateContainerConfigJson(id, 'mcp_servers', tpl.mcpServers);
|
||||
|
||||
// Per-group skills overlay — keyed by group id, never shared. cpSync creates
|
||||
// intermediate dirs, so .claude-shared/skills need not exist yet.
|
||||
const skillsDir = path.join(DATA_DIR, 'v2-sessions', id, '.claude-shared', 'skills');
|
||||
for (const { name: skill, srcDir } of tpl.skills) {
|
||||
fs.cpSync(srcDir, path.join(skillsDir, skill), { recursive: true });
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveLocalTemplate } from './local-dir.js';
|
||||
|
||||
let base: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-local-'));
|
||||
fs.mkdirSync(path.join(base, 'sales', 'sdr'), { recursive: true });
|
||||
fs.writeFileSync(path.join(base, 'afile.md'), 'not a directory');
|
||||
});
|
||||
afterEach(() => fs.rmSync(base, { recursive: true, force: true }));
|
||||
|
||||
describe('resolveLocalTemplate', () => {
|
||||
it('resolves a valid multi-segment relative ref under the base', () => {
|
||||
expect(resolveLocalTemplate('sales/sdr', base)).toBe(path.join(base, 'sales', 'sdr'));
|
||||
});
|
||||
|
||||
it('rejects a ref that escapes the base via ../', () => {
|
||||
expect(() => resolveLocalTemplate('../escape', base)).toThrow(/escapes/);
|
||||
});
|
||||
|
||||
it('rejects a multi-segment escape like sales/../../etc', () => {
|
||||
expect(() => resolveLocalTemplate('sales/../../etc', base)).toThrow(/escapes/);
|
||||
});
|
||||
|
||||
it('rejects an absolute ref', () => {
|
||||
expect(() => resolveLocalTemplate('/etc', base)).toThrow(/relative/);
|
||||
});
|
||||
|
||||
it('rejects a ~-prefixed ref', () => {
|
||||
expect(() => resolveLocalTemplate('~/x', base)).toThrow(/relative/);
|
||||
});
|
||||
|
||||
it('rejects empty and whitespace-only refs', () => {
|
||||
expect(() => resolveLocalTemplate('', base)).toThrow(/Invalid/);
|
||||
expect(() => resolveLocalTemplate(' ', base)).toThrow(/Invalid/);
|
||||
});
|
||||
|
||||
it('rejects an untrimmed ref', () => {
|
||||
expect(() => resolveLocalTemplate(' sales/sdr', base)).toThrow(/Invalid/);
|
||||
});
|
||||
|
||||
it('throws when the ref does not exist', () => {
|
||||
expect(() => resolveLocalTemplate('nope', base)).toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('throws when the ref is a file, not a directory', () => {
|
||||
expect(() => resolveLocalTemplate('afile.md', base)).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { TEMPLATES_DIR } from '../config.js';
|
||||
|
||||
/**
|
||||
* Resolve a LOCAL template ref to an absolute directory under `base`
|
||||
* (TEMPLATES_DIR by default). Lexical containment only — no realpathSync, no
|
||||
* symlink resolution (out of threat model). Mirrors ensureWithinBase() in
|
||||
* group-folder.ts. Refs are legitimately multi-segment (e.g. "sales/sdr"), so
|
||||
* this does NOT reuse isValidGroupFolder (which rejects "/").
|
||||
*
|
||||
* Rejects: empty / untrimmed refs, absolute paths, a leading "~", and any ref
|
||||
* that escapes `base` after resolution. Throws if the resolved path is missing
|
||||
* or not a directory.
|
||||
*/
|
||||
export function resolveLocalTemplate(ref: string, base: string = TEMPLATES_DIR): string {
|
||||
if (!ref || ref !== ref.trim()) {
|
||||
throw new Error(`Invalid template ref: "${ref}"`);
|
||||
}
|
||||
if (path.isAbsolute(ref) || ref.startsWith('~')) {
|
||||
throw new Error(`Template ref must be relative to the templates directory: "${ref}"`);
|
||||
}
|
||||
const candidate = path.resolve(base, ref);
|
||||
const rel = path.relative(base, candidate);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(`Template ref escapes the templates directory: "${ref}"`);
|
||||
}
|
||||
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) {
|
||||
throw new Error(`Template not found: "${ref}" (looked in ${base})`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseTemplate } from './parse.js';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-parse-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(rel: string, content: string): void {
|
||||
const full = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
describe('parseTemplate', () => {
|
||||
it('parses mcpServers, instructions, context extras, and skills', () => {
|
||||
write('.mcp.json', JSON.stringify({ mcpServers: { fs: { command: 'mcp-fs', args: ['/data'] } } }));
|
||||
write('context/instructions.md', 'Be helpful.\n\n');
|
||||
write('context/playbook.md', '# Playbook');
|
||||
write('context/additional_context/faq.md', '# FAQ');
|
||||
write('skills/research/SKILL.md', 'do research');
|
||||
fs.writeFileSync(path.join(dir, 'context', 'notes.txt'), 'ignored'); // non-.md is ignored
|
||||
|
||||
const tpl = parseTemplate(dir);
|
||||
|
||||
expect(tpl.mcpServers).toEqual({ fs: { command: 'mcp-fs', args: ['/data'] } });
|
||||
expect(tpl.instructions).toBe('Be helpful.'); // trimEnd, instructions.md excluded from extras
|
||||
// Nested extras keep their context/-relative path as the name.
|
||||
expect(tpl.contextExtras.map((c) => c.name).sort()).toEqual(['additional_context/faq.md', 'playbook.md']);
|
||||
expect(tpl.skills.map((s) => s.name)).toEqual(['research']);
|
||||
});
|
||||
|
||||
it('defaults the optionals when only instructions.md is present', () => {
|
||||
write('context/instructions.md', 'Only instructions.');
|
||||
const tpl = parseTemplate(dir);
|
||||
expect(tpl.mcpServers).toEqual({});
|
||||
expect(tpl.contextExtras).toEqual([]);
|
||||
expect(tpl.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when context/instructions.md is missing', () => {
|
||||
expect(() => parseTemplate(dir)).toThrow(/instructions\.md/);
|
||||
});
|
||||
|
||||
it('throws when the folder does not exist', () => {
|
||||
expect(() => parseTemplate(path.join(dir, 'nope'))).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** A parsed template folder. Pure data — no DB, no side effects. */
|
||||
export interface Template {
|
||||
mcpServers: Record<string, unknown>; // .mcp.json .mcpServers — name -> launch config
|
||||
instructions: string; // context/instructions.md (required)
|
||||
contextExtras: { name: string; content: string }[]; // context/**/*.md except instructions.md; name relative to context/
|
||||
skills: { name: string; srcDir: string }[]; // skills/<name>/ real folders
|
||||
}
|
||||
|
||||
function readJson(file: string): unknown {
|
||||
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf-8')) : undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and lightly validate a template folder into a typed object. Throws only
|
||||
* if the folder is missing or `context/instructions.md` (the one required file)
|
||||
* is absent. `unknown`-in / parsed-out at the .mcp.json boundary.
|
||||
*/
|
||||
export function parseTemplate(dir: string): Template {
|
||||
if (!fs.existsSync(dir)) throw new Error(`Template folder not found: ${dir}`);
|
||||
|
||||
const mcpServers = asRecord(asRecord(readJson(path.join(dir, '.mcp.json'))).mcpServers);
|
||||
|
||||
const instructionsFile = path.join(dir, 'context', 'instructions.md');
|
||||
if (!fs.existsSync(instructionsFile)) {
|
||||
throw new Error(`Template missing required context/instructions.md: ${dir}`);
|
||||
}
|
||||
const instructions = fs.readFileSync(instructionsFile, 'utf-8').trimEnd();
|
||||
|
||||
return {
|
||||
mcpServers,
|
||||
instructions,
|
||||
contextExtras: readContextExtras(path.join(dir, 'context')),
|
||||
skills: readSkills(path.join(dir, 'skills')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every context/**\/*.md except the top-level instructions.md, recursively.
|
||||
* `name` keeps the path relative to context/ so stamping can preserve the
|
||||
* layout — a reference like `additional_context/faq.md` written in
|
||||
* instructions.md resolves unchanged in the agent's workspace.
|
||||
*/
|
||||
function readContextExtras(contextDir: string): { name: string; content: string }[] {
|
||||
if (!fs.existsSync(contextDir)) return [];
|
||||
return (fs.readdirSync(contextDir, { recursive: true }) as string[])
|
||||
.filter((f) => f.endsWith('.md') && f !== 'instructions.md' && fs.statSync(path.join(contextDir, f)).isFile())
|
||||
.map((name) => ({ name, content: fs.readFileSync(path.join(contextDir, name), 'utf-8') }));
|
||||
}
|
||||
|
||||
/** Each immediate subdirectory of skills/ is a packaged skill. */
|
||||
function readSkills(skillsDir: string): { name: string; srcDir: string }[] {
|
||||
if (!fs.existsSync(skillsDir)) return [];
|
||||
return fs
|
||||
.readdirSync(skillsDir)
|
||||
.map((name) => ({ name, srcDir: path.join(skillsDir, name) }))
|
||||
.filter(({ srcDir }) => fs.statSync(srcDir).isDirectory());
|
||||
}
|
||||
@@ -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