mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f21fe7bd7 | |||
| 2938bbf94a | |||
| d1a2b04d32 | |||
| 455014e7b9 | |||
| 5032c431ae | |||
| ac8273c698 | |||
| b24bf2c6f7 | |||
| f4be00e6ed | |||
| c9aa69dea9 | |||
| b28c917997 | |||
| a00a5610bd | |||
| 05dc1b0a3c | |||
| a7b34bc872 | |||
| aecad864e6 | |||
| c87f2e55dc | |||
| 411f5e71df | |||
| cb6e3d117c | |||
| ed7e3f70da | |||
| 557e073c2f | |||
| 91ebc9def2 | |||
| 14c89e9716 | |||
| 549c424a38 | |||
| 186b9befcd | |||
| 863d413d32 | |||
| cf8478ffbb | |||
| 8be5be93ba | |||
| add3fc8f70 | |||
| 0d841bcd05 | |||
| dd1d0e5677 | |||
| 36afa40857 | |||
| 2afbd18233 | |||
| 953496dc37 | |||
| 797491d8b3 | |||
| 2df754459b | |||
| 0896d4089e | |||
| d153d91307 | |||
| ce55af12d5 | |||
| 545800a94e | |||
| bfb309bd0c | |||
| 38d9390eea | |||
| 8d3eca7027 | |||
| 1d6bba4d3f | |||
| 9bb69c0e50 | |||
| 520ec44aec | |||
| 8c6a243ffd | |||
| add6145f1c | |||
| 4e14d08173 | |||
| 8f2f788b6e | |||
| e96d7fd961 | |||
| 2ac7809385 | |||
| 15292ae76c | |||
| 055cf49bd5 | |||
| e8148bc0a7 | |||
| 625264ba4b | |||
| f34e590bcd | |||
| d208fd7bf5 | |||
| 886c65725b | |||
| 9977af68d7 | |||
| 8e44f07dd4 | |||
| 8c43f13d93 | |||
| 5cf4ff1bd2 | |||
| 6e475e5503 | |||
| 0f8499b141 | |||
| 82e1dc4ae8 | |||
| e70b021cde | |||
| ea90a12846 | |||
| 3b1f4501d6 | |||
| 385fb014fc | |||
| b2160a56aa | |||
| f72658bb50 | |||
| 3180f3f881 | |||
| b0bdc57b37 | |||
| 314b91efc0 | |||
| 53ed3b77c9 | |||
| 070714ec58 | |||
| e4907c2c33 | |||
| 3f39f57653 | |||
| 1b86950f10 | |||
| 8b435eb02d | |||
| 7e2004f945 | |||
| 63901d1bde | |||
| e5d96e348f | |||
| 439c24f1b7 | |||
| 2a144bb8d6 | |||
| 197faaaa14 | |||
| 3ffd6dde00 | |||
| 0516bea638 | |||
| 32f067f5bb |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -46,7 +46,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -50,7 +50,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -59,7 +59,7 @@ import './linear.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -60,7 +60,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: learn
|
||||
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
|
||||
---
|
||||
|
||||
# Learn — Distill a Skill from Anything
|
||||
|
||||
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
|
||||
|
||||
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
|
||||
|
||||
## When to use
|
||||
|
||||
Invoke when the user wants to *capture* a workflow as a reusable skill:
|
||||
|
||||
- `/learn <path>` — read a project/dir and build a skill for working with it
|
||||
- `/learn <url>` — read docs / an API page and build a usage skill
|
||||
- `/learn what we just did` — distill the current conversation's workflow
|
||||
- `/learn` + pasted notes — turn notes into a structured skill
|
||||
|
||||
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the source — and whether this is a new skill or a refine
|
||||
- A **path** → read the code/files.
|
||||
- A **URL** → fetch and read the page.
|
||||
- **"what we just did" / "this"** → use the current conversation as the source.
|
||||
- **Pasted text** → use it directly.
|
||||
|
||||
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
|
||||
|
||||
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
|
||||
|
||||
### 2. Gather the material
|
||||
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
|
||||
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
|
||||
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
|
||||
|
||||
### 3. Distill — find the reusable procedure
|
||||
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
|
||||
|
||||
- the trigger / when-to-use,
|
||||
- the step-by-step procedure (commands, file paths, decision points),
|
||||
- the non-obvious **gotchas** that were hit — usually the most valuable part,
|
||||
- any scripts or templates worth shipping alongside.
|
||||
|
||||
### 4. Author the SKILL.md
|
||||
|
||||
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
|
||||
- Keep what is still correct; weave the new learnings into the right sections.
|
||||
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
|
||||
- Correct anything the new source proves stale (a changed path, command, or flag).
|
||||
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
|
||||
|
||||
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
|
||||
|
||||
**Frontmatter (required):**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <kebab-case, matches the folder>
|
||||
description: "<what it does + when to use it + likely trigger phrases>"
|
||||
---
|
||||
```
|
||||
|
||||
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
|
||||
|
||||
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
|
||||
|
||||
**House authoring rules (from `docs/skill-guidelines.md`):**
|
||||
|
||||
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
|
||||
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
|
||||
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
|
||||
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
|
||||
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
|
||||
|
||||
### 5. Place and verify
|
||||
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
|
||||
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
|
||||
- Tell the user the skill exists and how to invoke it (`/<name>`).
|
||||
|
||||
## Example
|
||||
|
||||
`/learn what we just did` after a multi-step setup:
|
||||
|
||||
1. Re-read the conversation's commands and gotchas.
|
||||
2. Distill the repeatable procedure.
|
||||
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
|
||||
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
|
||||
- The most valuable content is the **gotchas**, not the happy path.
|
||||
- This skill is prose and safe to re-run — use it again to refine an existing skill.
|
||||
@@ -246,30 +246,40 @@ If one or more `[BREAKING]` lines are found:
|
||||
- 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).
|
||||
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
Updating your installed skills is **part of** updating NanoClaw, not an optional
|
||||
extra. Channel and provider code ships on long-lived branches (`channels`,
|
||||
`providers`) that the host merge above doesn't touch — so stopping here leaves
|
||||
that code on whatever version you installed, which is how an important upstream
|
||||
fix gets silently left behind. The default is to continue into `/update-skills`,
|
||||
which re-applies your installed channels/providers to pull their latest code.
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
Detect whether anything is installed: read `src/channels/index.ts` and
|
||||
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
|
||||
`cli`).
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
|
||||
- If nothing is installed: skip silently and proceed to Step 7.9.
|
||||
- If one or more are installed: continue into skill updates.
|
||||
|
||||
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
|
||||
- List the installed channels/providers.
|
||||
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
|
||||
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
|
||||
- "Skip — I'll update them later"
|
||||
- Set `multiSelect: true`
|
||||
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
|
||||
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
|
||||
Name the installed skills in the question so the choice is concrete:
|
||||
- Question: "Skill updates are part of this NanoClaw update — your installed
|
||||
channels/providers (<list the detected ones>) ride separate branches the host
|
||||
update didn't touch. Continue into `/update-skills` to bring them up to date?"
|
||||
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
|
||||
`/update-skills`, which re-applies your installed channels/providers to pull
|
||||
their latest upstream code. You pick which ones there."
|
||||
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
|
||||
installed skill code stays as-is and may be behind upstream."
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
Keep it to these two options — the per-skill selection lives inside
|
||||
`/update-skills`, not here.
|
||||
|
||||
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
|
||||
touches container code, `/update-skills` rebuilds the agent image itself — see
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ For each selected skill (process one at a time):
|
||||
After all selected skills are re-applied:
|
||||
- `pnpm run build`
|
||||
- `pnpm test` (do not fail the flow if tests are not configured)
|
||||
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
|
||||
|
||||
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
- [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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">docs</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
@@ -81,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
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">ドキュメント</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
<p align="center">
|
||||
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
에이전트를 각자의 컨테이너에서 안전하게 실행하는 AI 어시스턴트입니다. 가볍고, 쉽게 이해할 수 있으며, 여러분의 필요에 맞게 완전히 커스터마이즈할 수 있도록 만들어졌습니다.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">문서</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## NanoClaw를 만든 이유
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)는 인상적인 프로젝트지만, 제가 이해하지 못하는 복잡한 소프트웨어에 제 삶 전체에 대한 접근 권한을 줬다면 저는 잠을 이루지 못했을 것입니다. OpenClaw는 거의 50만 줄에 달하는 코드, 53개의 설정 파일, 70개 이상의 의존성을 가지고 있습니다. 보안은 진정한 OS 수준의 격리가 아니라 애플리케이션 수준(허용 목록, 페어링 코드)에 의존합니다. 모든 것이 메모리를 공유하는 하나의 Node 프로세스에서 실행됩니다.
|
||||
|
||||
NanoClaw는 그와 동일한 핵심 기능을 제공하지만, 이해할 수 있을 만큼 작은 코드베이스로 구현합니다. 하나의 프로세스와 몇 개의 파일뿐입니다. Claude 에이전트는 단순한 권한 검사 뒤가 아니라, 파일시스템이 격리된 각자의 Linux 컨테이너에서 실행됩니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
`nanoclaw.sh`는 갓 준비한 머신에서 시작해 메시지를 보낼 수 있는 이름 붙은 에이전트까지 안내합니다. 누락된 경우 Node, pnpm, Docker를 설치하고, Anthropic 자격 증명을 OneCLI에 등록하며, 에이전트 컨테이너를 빌드하고, 첫 채널(Telegram, Discord, WhatsApp 또는 로컬 CLI)을 페어링합니다. 어떤 단계가 실패하면 Claude Code가 자동으로 호출되어 원인을 진단하고 중단된 지점부터 재개합니다.
|
||||
|
||||
<details>
|
||||
<summary><strong>NanoClaw v1에서 마이그레이션하시나요?</strong></summary>
|
||||
|
||||
기존 v1 설치 옆에 새로운 v2 체크아웃을 만들어 실행하세요:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
`migrate-v2.sh`는 v1 설치(형제 디렉터리, 또는 `NANOCLAW_V1_PATH=/path/to/nanoclaw`)를 찾아 상태를 v2 체크아웃으로 마이그레이션한 다음, 판단이 필요한 부분(소유자 시딩, CLAUDE.local.md 정리, 포크 커스터마이징 재적용)을 마무리하기 위해 Claude Code로 `exec`합니다.
|
||||
|
||||
이 스크립트는 Claude 세션 내부가 아니라 직접 실행하세요. 결정론적인 부분에서 Node/pnpm 부트스트랩, Docker, OneCLI, 컨테이너 빌드를 위해 대화형 프롬프트와 실제 셸 I/O가 필요합니다.
|
||||
|
||||
**무엇을 하는가:** `.env`를 병합하고, `registered_groups`로부터 v2 DB를 시딩하며, 그룹 폴더 + 세션 데이터 + 예약 작업을 복사하고, 선택한 채널 어댑터를 설치하며, 채널 인증 상태(WhatsApp의 Baileys 키스토어 + LID 매핑 포함)를 복사하고, 에이전트 컨테이너를 빌드합니다.
|
||||
|
||||
**무엇을 하지 않는가:** 시스템 서비스를 전환하지 않습니다. 프롬프트에서 *"switch to v2"*를 선택하거나, 테스트 후 수동으로 전환하세요. 기존 v1 설치는 그대로 유지됩니다.
|
||||
|
||||
무엇이 달라졌는지는 [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md)를, 개발 노트는 [docs/migration-dev.md](docs/migration-dev.md)를 참고하세요.
|
||||
|
||||
</details>
|
||||
|
||||
## 철학
|
||||
|
||||
**이해할 수 있을 만큼 작게.** 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. NanoClaw 코드베이스 전체를 이해하고 싶다면 Claude Code에게 안내해 달라고 요청하기만 하면 됩니다.
|
||||
|
||||
**격리를 통한 보안.** 에이전트는 Linux 컨테이너에서 실행되며 명시적으로 마운트된 것만 볼 수 있습니다. 명령이 호스트가 아니라 컨테이너 안에서 실행되기 때문에 Bash 접근도 안전합니다.
|
||||
|
||||
**개별 사용자를 위해 설계.** NanoClaw는 거대한 단일 프레임워크가 아니라, 각 사용자의 정확한 필요에 맞는 소프트웨어입니다. 비대한 소프트웨어가 되는 대신, NanoClaw는 맞춤형이 되도록 설계되었습니다. 직접 포크를 만들고 Claude Code가 여러분의 필요에 맞게 수정하도록 합니다.
|
||||
|
||||
**커스터마이징 = 코드 변경.** 설정의 난립이 없습니다. 다른 동작을 원하시나요? 코드를 수정하세요. 코드베이스가 충분히 작아서 안전하게 변경할 수 있습니다.
|
||||
|
||||
**AI 네이티브, 설계상 하이브리드.** 설치와 온보딩 흐름은 최적화된 스크립트 경로로, 빠르고 결정론적입니다. 어떤 단계에 판단이 필요할 때 — 설치 실패, 안내가 필요한 결정, 커스터마이징 등 — 제어권이 Claude Code로 매끄럽게 넘어갑니다. 설정 이후에도 모니터링 대시보드나 디버깅 UI가 없습니다. 채팅으로 문제를 설명하면 Claude Code가 처리합니다.
|
||||
|
||||
**기능보다 스킬.** 트렁크는 특정 채널 어댑터나 대체 에이전트 프로바이더가 아니라 레지스트리와 인프라를 제공합니다. 채널(Discord, Slack, Telegram, WhatsApp, …)은 오래 유지되는 `channels` 브랜치에, 대체 프로바이더(OpenCode, Ollama)는 `providers` 브랜치에 있습니다. `/add-telegram`, `/add-opencode` 등을 실행하면 스킬이 여러분이 필요로 하는 모듈만 정확히 포크로 복사합니다. 요청하지 않은 기능은 없습니다.
|
||||
|
||||
**최고의 하니스, 최고의 모델.** NanoClaw는 Anthropic의 공식 Claude Agent SDK를 통해 Claude Code를 네이티브로 사용하므로, 최신 Claude 모델과 Claude Code의 전체 도구 세트를 누릴 수 있습니다. 여기에는 자신의 NanoClaw 포크를 직접 수정하고 확장하는 능력도 포함됩니다. 다른 프로바이더는 드롭인 옵션입니다. OpenAI의 Codex는 `/add-codex`(ChatGPT 구독 또는 API 키), OpenRouter·Google·DeepSeek 등은 OpenCode를 통한 `/add-opencode`, 로컬 오픈 웨이트 모델은 `/add-ollama-provider`로 추가합니다. 프로바이더는 에이전트 그룹별로 설정할 수 있습니다.
|
||||
|
||||
## 지원 기능
|
||||
|
||||
- **멀티 채널 메시징** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, 그리고 Resend를 통한 이메일. `/add-<channel>` 스킬로 필요할 때 설치합니다. 하나 또는 여러 개를 동시에 실행할 수 있습니다.
|
||||
- **유연한 격리** — 완전한 프라이버시를 위해 각 채널을 자체 에이전트에 연결하거나, 대화는 분리하되 메모리는 통합하기 위해 하나의 에이전트를 여러 채널에서 공유하거나, 여러 채널을 하나의 공유 세션으로 묶어 하나의 대화가 여러 채널에 걸쳐 이어지도록 할 수 있습니다. `/manage-channels`로 채널별로 선택하세요. [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
|
||||
- **에이전트별 작업 공간** — 각 에이전트 그룹은 자체 `CLAUDE.md`, 자체 메모리, 자체 컨테이너, 그리고 여러분이 허용한 마운트만 갖습니다. 직접 연결하지 않는 한 경계를 넘는 것은 아무것도 없습니다.
|
||||
- **예약 작업** — Claude를 실행하고 여러분에게 다시 메시지를 보낼 수 있는 반복 작업
|
||||
- **웹 접근** — 웹에서 검색하고 콘텐츠를 가져오기
|
||||
- **컨테이너 격리** — 에이전트는 Docker(macOS/Linux/WSL2)에서 샌드박스화되며, 선택적으로 [Docker Sandboxes](docs/docker-sandboxes.md) 마이크로 VM 격리나 macOS 네이티브 런타임인 Apple Container를 사용할 수 있습니다
|
||||
- **자격 증명 보안** — 에이전트는 원시 API 키를 절대 보유하지 않습니다. 아웃바운드 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 요청 시점에 자격 증명을 주입하고 에이전트별 정책과 속도 제한을 적용합니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
트리거 단어(기본값: `@Andy`)로 어시스턴트에게 말을 거세요:
|
||||
|
||||
```
|
||||
@Andy 매주 평일 오전 9시에 영업 파이프라인 개요를 보내줘 (내 Obsidian 보관함 폴더에 접근 가능)
|
||||
@Andy 매주 금요일에 지난 한 주간의 git 히스토리를 검토하고, 내용이 어긋나면 README를 업데이트해줘
|
||||
@Andy 매주 월요일 오전 8시에 Hacker News와 TechCrunch에서 AI 관련 소식을 모아 브리핑을 보내줘
|
||||
```
|
||||
|
||||
여러분이 소유하거나 관리하는 채널에서는 그룹과 작업을 관리할 수 있습니다:
|
||||
```
|
||||
@Andy 모든 그룹에 걸친 예약 작업을 전부 나열해줘
|
||||
@Andy 월요일 브리핑 작업을 일시 정지해줘
|
||||
@Andy Family Chat 그룹에 참여해줘
|
||||
```
|
||||
|
||||
## 커스터마이징
|
||||
|
||||
NanoClaw는 설정 파일을 사용하지 않습니다. 변경하려면 Claude Code에게 원하는 것을 말하기만 하면 됩니다:
|
||||
|
||||
- "트리거 단어를 @Bob으로 바꿔줘"
|
||||
- "앞으로는 응답을 더 짧고 직접적으로 하도록 기억해줘"
|
||||
- "내가 좋은 아침이라고 인사하면 맞춤 인사를 추가해줘"
|
||||
- "매주 대화 요약을 저장해줘"
|
||||
|
||||
또는 안내형 변경을 위해 `/customize`를 실행하세요.
|
||||
|
||||
코드베이스가 충분히 작아서 Claude가 안전하게 수정할 수 있습니다.
|
||||
|
||||
## 기여하기
|
||||
|
||||
**기능을 추가하지 마세요. 스킬을 추가하세요.**
|
||||
|
||||
새로운 채널이나 에이전트 프로바이더를 추가하고 싶다면 트렁크에 추가하지 마세요. 새 채널 어댑터는 `channels` 브랜치에, 새 에이전트 프로바이더는 `providers` 브랜치에 들어갑니다. 사용자는 `/add-<name>` 스킬로 자신의 포크에 설치하며, 이 스킬은 관련 모듈을 표준 경로로 복사하고, 등록을 연결하며, 의존성을 고정합니다.
|
||||
|
||||
이를 통해 트렁크는 순수한 레지스트리이자 인프라로 유지되고, 모든 포크는 가벼운 상태를 유지합니다. 사용자는 요청한 채널과 프로바이더만 얻고 그 외에는 아무것도 얻지 않습니다.
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
저희가 보고 싶은 스킬:
|
||||
|
||||
**커뮤니케이션 채널**
|
||||
- `/add-signal` — Signal을 채널로 추가
|
||||
|
||||
## 요구 사항
|
||||
|
||||
- macOS 또는 Linux (Windows는 WSL2 경유)
|
||||
- Node.js 20+ 및 pnpm 10+ (설치 프로그램이 누락 시 둘 다 설치합니다)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) 또는 Docker Engine (Linux)
|
||||
- `/customize`, `/debug`, 설정 중 오류 복구, 그리고 모든 `/add-<channel>` 스킬을 위한 [Claude Code](https://claude.ai/download)
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
메시징 앱 → 호스트 프로세스(라우터) → inbound.db → 컨테이너(Bun, Claude Agent SDK) → outbound.db → 호스트 프로세스(전송) → 메시징 앱
|
||||
```
|
||||
|
||||
하나의 Node 호스트가 세션별 에이전트 컨테이너를 오케스트레이션합니다. 메시지가 도착하면 호스트는 엔티티 모델(사용자 → 메시징 그룹 → 에이전트 그룹 → 세션)을 통해 라우팅하고, 세션의 `inbound.db`에 기록한 뒤 컨테이너를 깨웁니다. 컨테이너 내부의 에이전트 러너는 `inbound.db`를 폴링하고, Claude를 실행하며, 응답을 `outbound.db`에 기록합니다. 호스트는 `outbound.db`를 폴링하여 채널 어댑터를 통해 다시 전송합니다.
|
||||
|
||||
세션당 두 개의 SQLite 파일이 있으며 각각 정확히 하나의 작성자만 갖습니다. 교차 마운트 경합이 없고, IPC가 없으며, stdin 파이핑이 없습니다. 채널과 대체 프로바이더는 시작 시 자체 등록됩니다. 트렁크는 레지스트리와 Chat SDK 브리지를 제공하고, 어댑터 자체는 포크별로 스킬을 통해 설치됩니다.
|
||||
|
||||
전체 아키텍처 설명은 [docs/architecture.md](docs/architecture.md)를, 3단계 격리 모델은 [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
|
||||
|
||||
핵심 파일:
|
||||
- `src/index.ts` — 진입점: DB 초기화, 채널 어댑터, 전송 폴링, 스윕
|
||||
- `src/router.ts` — 인바운드 라우팅: 메시징 그룹 → 에이전트 그룹 → 세션 → `inbound.db`
|
||||
- `src/delivery.ts` — `outbound.db` 폴링, 어댑터를 통한 전송, 시스템 액션 처리
|
||||
- `src/host-sweep.ts` — 60초 스윕: 정체 감지, 예정 메시지 깨우기, 반복 처리
|
||||
- `src/session-manager.ts` — 세션 확인, `inbound.db` / `outbound.db` 열기
|
||||
- `src/container-runner.ts` — 에이전트 그룹별 컨테이너 생성, OneCLI 자격 증명 주입
|
||||
- `src/db/` — 중앙 DB (사용자, 역할, 에이전트 그룹, 메시징 그룹, 연결, 마이그레이션)
|
||||
- `src/channels/` — 채널 어댑터 인프라 (어댑터는 `/add-<channel>` 스킬로 설치)
|
||||
- `src/providers/` — 호스트 측 프로바이더 설정 (`claude`는 기본 내장, 그 외는 스킬 경유)
|
||||
- `container/agent-runner/` — Bun 에이전트 러너: 폴 루프, MCP 도구, 프로바이더 추상화
|
||||
- `groups/<folder>/` — 에이전트 그룹별 파일시스템 (`CLAUDE.md`, 스킬, 컨테이너 설정)
|
||||
|
||||
## FAQ
|
||||
|
||||
**왜 Docker인가요?**
|
||||
|
||||
Docker는 크로스 플랫폼 지원(macOS, Linux, 그리고 WSL2 경유 Windows)과 성숙한 생태계를 제공합니다. macOS에서는 더 가벼운 네이티브 런타임인 Apple Container도 지원됩니다. 추가 격리를 위해 [Docker Sandboxes](docs/docker-sandboxes.md)는 각 컨테이너를 마이크로 VM 안에서 실행합니다.
|
||||
|
||||
**Linux나 Windows에서 실행할 수 있나요?**
|
||||
|
||||
네. Docker가 기본 런타임이며 macOS, Linux, Windows(WSL2 경유)에서 작동합니다. `bash nanoclaw.sh`를 실행하기만 하면 됩니다.
|
||||
|
||||
**이것은 안전한가요?**
|
||||
|
||||
에이전트는 애플리케이션 수준의 권한 검사 뒤가 아니라 컨테이너에서 실행됩니다. 명시적으로 마운트된 디렉터리만 접근할 수 있습니다. 자격 증명은 컨테이너에 들어가지 않습니다. 아웃바운드 API 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 프록시 수준에서 인증을 주입하고 속도 제한과 접근 정책을 지원합니다. 여전히 실행하는 것을 검토해야 하지만, 코드베이스가 충분히 작아서 실제로 검토할 수 있습니다. 전체 보안 모델은 [보안 문서](https://docs.nanoclaw.dev/concepts/security)를 참고하세요.
|
||||
|
||||
**왜 설정 파일이 없나요?**
|
||||
|
||||
설정의 난립을 원하지 않습니다. 모든 사용자는 일반적인 시스템을 설정하는 대신, 코드가 정확히 원하는 대로 동작하도록 NanoClaw를 커스터마이즈해야 합니다. 설정 파일을 선호한다면 Claude에게 추가해 달라고 할 수 있습니다.
|
||||
|
||||
**서드파티나 오픈소스 모델을 사용할 수 있나요?**
|
||||
|
||||
네. 지원되는 경로는 `/add-opencode`(OpenCode 설정을 통한 OpenRouter, OpenAI, Google, DeepSeek 등) 또는 `/add-ollama-provider`(Ollama를 통한 로컬 오픈 웨이트 모델)입니다. 둘 다 에이전트 그룹별로 설정할 수 있으므로, 같은 설치 내에서 서로 다른 에이전트가 서로 다른 백엔드에서 실행될 수 있습니다.
|
||||
|
||||
일회성 실험의 경우, Claude API 호환 엔드포인트라면 `.env`를 통해서도 작동합니다:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
**문제를 어떻게 디버깅하나요?**
|
||||
|
||||
Claude Code에게 물어보세요. "스케줄러가 왜 실행되지 않지?" "최근 로그에 뭐가 있지?" "이 메시지는 왜 응답을 받지 못했지?" 그것이 NanoClaw의 바탕에 깔린 AI 네이티브 접근 방식입니다.
|
||||
|
||||
**설정이 왜 작동하지 않나요?**
|
||||
|
||||
어떤 단계가 실패하면 `nanoclaw.sh`는 진단하고 재개하기 위해 Claude Code로 넘깁니다. 그래도 해결되지 않으면 `claude`를 실행한 뒤 `/debug`를 실행하세요. Claude가 다른 사용자에게도 영향을 줄 만한 문제를 발견하면, 관련 설정 단계나 스킬에 대한 PR을 열어주세요.
|
||||
|
||||
**NanoClaw를 어떻게 제거하나요?**
|
||||
|
||||
```bash
|
||||
bash nanoclaw.sh --uninstall
|
||||
```
|
||||
|
||||
모든 설치는 체크아웃별 ID로 태깅되므로, 제거 프로그램은 해당 사본에 속한 것만 제거합니다: 백그라운드 서비스, 컨테이너와 이미지, 앱 데이터와 로그, 에이전트 파일, 그리고 이 사본의 OneCLI 볼트 에이전트입니다. 공유되는 것 — OneCLI 앱과 여러분의 자격 증명, 머신의 다른 NanoClaw 사본 — 은 그대로 둡니다. 무엇을 발견했는지 정확히 보여주고 그룹별로 확인을 요청합니다. 여러분이 동의하기 전까지는 아무것도 삭제되지 않습니다. 변경 없이 미리 보려면 `--dry-run`을, 프롬프트를 건너뛰려면 `--yes`를 사용하세요. `.env`는 제거 전에 백업됩니다. 마무리하려면 체크아웃 폴더 자체를 삭제하세요.
|
||||
|
||||
**어떤 변경이 코드베이스에 받아들여지나요?**
|
||||
|
||||
기본 구성에는 보안 수정, 버그 수정, 명확한 개선만 받아들여집니다. 그게 전부입니다.
|
||||
|
||||
그 외의 모든 것(새로운 기능, OS 호환성, 하드웨어 지원, 향상)은 스킬로 기여해야 합니다. 채널과 프로바이더 코드는 `channels`/`providers` 레지스트리 브랜치에, 그 외에는 자체 완결형 스킬로 기여합니다. [docs/customizing.md](docs/customizing.md)와 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
||||
|
||||
이를 통해 기본 시스템을 최소한으로 유지하고, 모든 사용자가 원하지 않는 기능을 떠안지 않으면서 자신의 설치를 커스터마이즈할 수 있습니다.
|
||||
|
||||
## 커뮤니티
|
||||
|
||||
질문이 있나요? 아이디어가 있나요? [Discord에 참여하세요](https://discord.gg/VDdww8qS42).
|
||||
|
||||
## 변경 이력
|
||||
|
||||
호환성을 깨는 변경 사항은 [CHANGELOG.md](CHANGELOG.md)를, 또는 문서 사이트의 [전체 릴리스 히스토리](https://docs.nanoclaw.dev/changelog)를 참고하세요.
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">文档</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -179,13 +179,61 @@ const preToolUseHook: HookCallback = async (input) => {
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */
|
||||
const postToolUseHook: HookCallback = async () => {
|
||||
/**
|
||||
* OneCLI reject-with-reason handoff: when an admin rejects a gateway request
|
||||
* with a reason, the host drops it at /workspace/onecli-rejection.json BEFORE
|
||||
* releasing the deny, so the failed tool call and the reason arrive as one
|
||||
* event. Consume the file (single use) and hand the reason to the model as
|
||||
* additional context on this tool result. Freshness-gated: a stale file from
|
||||
* an interrupted run must not annotate an unrelated failure.
|
||||
*/
|
||||
const ONECLI_REJECTION_FILE = '/workspace/onecli-rejection.json';
|
||||
const ONECLI_REJECTION_FRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
function consumeOneCLIRejection(input: unknown): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(ONECLI_REJECTION_FILE)) return null;
|
||||
const info = JSON.parse(fs.readFileSync(ONECLI_REJECTION_FILE, 'utf8')) as {
|
||||
rejectedAt?: string;
|
||||
reason?: string;
|
||||
host?: string | null;
|
||||
};
|
||||
// The rejection annotates a FAILED gateway call — require failure-shaped
|
||||
// output (or the target host) in this tool result before consuming.
|
||||
const resp = JSON.stringify((input as { tool_response?: unknown }).tool_response ?? '');
|
||||
const looksRelated =
|
||||
/denied|reject|403|approval|forbidden/i.test(resp) || (info.host ? resp.includes(info.host) : false);
|
||||
if (!looksRelated) return null;
|
||||
fs.unlinkSync(ONECLI_REJECTION_FILE);
|
||||
if (!info.reason || Date.now() - new Date(info.rejectedAt ?? 0).getTime() > ONECLI_REJECTION_FRESH_MS) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
`This request was rejected by the admin with the following reason: "${info.reason}". ` +
|
||||
'Do not retry the same request as-is — address the reason first (revise and re-attempt, or explain to the user).'
|
||||
);
|
||||
} catch (err) {
|
||||
log(`PostToolUse: rejection-file check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear in-flight tool on PostToolUse / PostToolUseFailure; inject a held
|
||||
* OneCLI rejection reason into the failed call's context when present. */
|
||||
const postToolUseHook: HookCallback = async (input) => {
|
||||
try {
|
||||
clearContainerToolInFlight();
|
||||
} catch (err) {
|
||||
log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
const rejection = consumeOneCLIRejection(input);
|
||||
if (rejection) {
|
||||
log('PostToolUse: injected OneCLI rejection reason into tool response context');
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: rejection },
|
||||
} as unknown as Awaited<ReturnType<HookCallback>>;
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './mock.js';
|
||||
|
||||
@@ -1,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 }
|
||||
]
|
||||
|
||||
@@ -341,6 +341,12 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:la
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps → `docker run --cpus/--memory`. Empty default =
|
||||
// no flag = unbounded (today's behavior). Opt in to bound a fleet sharing one
|
||||
// host: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g. Swap is a host concern
|
||||
// (run the host swapless to make --memory a hard cap); not managed here.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ flowchart TB
|
||||
subgraph Session["Per-Session Container (Docker / Apple Container)"]
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
|
||||
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Agent Templates
|
||||
|
||||
A **template** is a reusable folder you stamp into a working agent group: it
|
||||
carries the agent's standing instructions, its MCP tool servers, and its skills,
|
||||
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
|
||||
you get a configured agent in seconds; you choose the runtime/provider
|
||||
separately.
|
||||
|
||||
Templates are purely additive: no DB migration, no new dependency. **At runtime,
|
||||
templates are resolved only from a local directory**: `templates/` at the
|
||||
project root by default (committed but shipped empty), or whatever
|
||||
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
|
||||
discover templates from the public registry
|
||||
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
|
||||
and copy a chosen one into your local `templates/` before stamping.
|
||||
|
||||
## Using a template
|
||||
|
||||
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
|
||||
setup**, then either **NanoClaw template library** (clones the public registry,
|
||||
copies the template you pick into your local `templates/`) or **Local templates**
|
||||
(lists what's already in `templates/`). The normal auth step then picks the
|
||||
runtime, and the wizard stamps and wires your first agent.
|
||||
|
||||
**Anytime, via the CLI:**
|
||||
|
||||
```bash
|
||||
ncl groups create --template sales/sdr --name "SDR Agent"
|
||||
```
|
||||
|
||||
This stamps the group but does **not** wire it to a channel. Run
|
||||
`/manage-channels` (or `ncl wirings create`) afterward, exactly as for a
|
||||
hand-built group.
|
||||
|
||||
### The template ref
|
||||
|
||||
`--template <ref>` is a path **relative to the local templates directory**
|
||||
(`templates/` by default, or `NANOCLAW_TEMPLATES_DIR`). Refs are multi-segment,
|
||||
e.g. `sales/sdr` → `templates/sales/sdr`.
|
||||
|
||||
For safety the ref must stay inside the templates directory: absolute paths, a
|
||||
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
|
||||
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
|
||||
the setup wizard's library option), then stamp.
|
||||
|
||||
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
|
||||
is never a URL and never changes at runtime.
|
||||
|
||||
## What's in a template
|
||||
|
||||
The full authoring reference lives in the
|
||||
[templates repo README](https://github.com/nanocoai/nanoclaw-templates#anatomy-of-a-template).
|
||||
The short version: only `context/instructions.md` is required; everything else
|
||||
is optional and defaults sensibly:
|
||||
|
||||
```
|
||||
<template>/
|
||||
├── context/
|
||||
│ ├── instructions.md # REQUIRED: the agent's standing persona; marks the folder as a template
|
||||
│ └── additional_context/ # optional: extra .md files, referenced from instructions.md by relative path
|
||||
│ └── *.md
|
||||
├── .mcp.json # optional: MCP servers (command + args), NO secrets
|
||||
├── skills/<name>/ # optional: one folder per skill (SKILL.md + any references/), copied whole
|
||||
└── README.md # recommended: per-template docs
|
||||
```
|
||||
|
||||
| Path | Loaded as | Required |
|
||||
|------|-----------|----------|
|
||||
| `context/instructions.md` | The agent's persona, prepended to its `CLAUDE.md`/`AGENTS.md` every spawn (system-prompt tier, any provider) | **Yes** |
|
||||
| `context/**/*.md` (others) | Extra context, copied into the agent's workspace with the same layout relative to `instructions.md` | No |
|
||||
| `.mcp.json` → `mcpServers` | MCP tool servers (written verbatim to container config) | No |
|
||||
| `skills/<name>/` | A skill, auto-triggered by its `description` | No |
|
||||
|
||||
Notes:
|
||||
|
||||
- **No provider, model, effort, or packages in a template.** Those are set on
|
||||
the agent later via `ncl groups config update`. The runtime defaults to the
|
||||
install's configured provider.
|
||||
- **Keep `instructions.md` focused (under ~200 lines).** It's always in the
|
||||
agent's prompt, and some providers cap that doc (Codex ~32 KB), so an over-long
|
||||
persona gets truncated. Put bulk material in `skills/` or extra context files instead.
|
||||
- Skills are copied into the agent's own skills overlay, keyed to that group,
|
||||
never shared across groups.
|
||||
|
||||
### Referencing extra context files
|
||||
|
||||
Extra `.md` files under `context/` (by convention in an `additional_context/`
|
||||
subfolder) are copied into the agent's workspace preserving their position
|
||||
relative to `instructions.md` — a template file at
|
||||
`context/additional_context/pricing.md` is readable by the agent as
|
||||
`additional_context/pricing.md`, the same relative path you'd use from
|
||||
`instructions.md` itself. Nothing is injected automatically: the agent only
|
||||
reads an extra file if `instructions.md` points to it, so reference every file
|
||||
you ship.
|
||||
|
||||
```markdown
|
||||
Pricing rules live in `additional_context/pricing.md`. Read it before quoting a price.
|
||||
```
|
||||
|
||||
Context files are copied when you stamp, so files added to the template later
|
||||
won't reach an already-created agent. Re-stamp the same name to update it.
|
||||
|
||||
## MCP servers and credentials
|
||||
|
||||
**Templates declare MCP servers, not secrets.** `.mcp.json` carries `command` +
|
||||
`args` only:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hubspot": { "command": "npx", "args": ["-y", "@hubspot/mcp-server"] },
|
||||
"exa": { "command": "npx", "args": ["-y", "exa-mcp-server"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Credentials are held by the **credentials proxy** and injected into outbound
|
||||
HTTPS calls at the proxy boundary, matched by API host, at request time. The key
|
||||
never sits in `.mcp.json`, the container env, or chat context. See
|
||||
[the credentials proxy section in CLAUDE.md](../CLAUDE.md#secrets--credentials--onecli)
|
||||
for the model.
|
||||
|
||||
Two ways a credential gets connected:
|
||||
|
||||
1. **Up front.** Register the secret with the credentials proxy (its web UI or
|
||||
CLI), matched to the service's API host (e.g. `api.example.com`). Matching
|
||||
credentials are injected automatically, so usually nothing else is needed.
|
||||
2. **On demand (the common path).** Don't set anything up first. The first time
|
||||
the agent calls a service with no credential, the API returns **401/403** and
|
||||
the agent replies with a prefilled connect link for that host. The user opens
|
||||
it, pastes the key, and asks the agent to retry. The key lands in the
|
||||
credentials proxy, which injects it on every later call.
|
||||
|
||||
### MCP servers that require an env var to boot
|
||||
|
||||
Some MCP servers refuse to start unless an env var is *present*, even though the
|
||||
real credential should come from the credentials proxy, not the env. Because `.mcp.json`'s `env`
|
||||
block passes through verbatim to the agent's container config, put a **placeholder
|
||||
value** there to satisfy the boot check:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"acme": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@acme/mcp-server"],
|
||||
"env": { "ACME_API_KEY": "placeholder" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server starts; its real outbound calls are still authenticated by the
|
||||
credentials proxy. **Never put a real key in `env`**: a placeholder only, and only when
|
||||
the server won't boot without one.
|
||||
|
||||
### Approval-gating sensitive actions
|
||||
|
||||
The credentials proxy can *hold* a credentialed outbound request and require a
|
||||
human to approve it before it leaves the proxy: enforcement the agent can't talk
|
||||
around. This is matched on the outbound HTTP request (host + method + path),
|
||||
configured on the credentials proxy, and answered by NanoClaw (it DMs an approver). The host side is
|
||||
already wired; see
|
||||
[the credentialed-approval flow in CLAUDE.md](../CLAUDE.md#requiring-approval-for-credential-use)
|
||||
and the [`sales/sdr` template README](https://github.com/nanocoai/nanoclaw-templates/blob/main/sales/sdr/README.md)
|
||||
for a worked example.
|
||||
|
||||
## Contributing a template
|
||||
|
||||
Templates ship in the separate
|
||||
[`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates)
|
||||
repo, not this one. To add one: fork that repo, drop a folder at
|
||||
`<category>/<template>/` with at least `context/instructions.md`, test it end to
|
||||
end (copy it under `templates/` and run
|
||||
`ncl groups create --template <category>/<template> --name Test`), confirm
|
||||
no secrets are committed, and open a PR. The repo's README has the full anatomy,
|
||||
category conventions, and checklist.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Main
|
||||
|
||||
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
|
||||
|
||||
## What You Can Do
|
||||
|
||||
- Answer questions and have conversations
|
||||
- Search the web and fetch content from URLs
|
||||
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
|
||||
- Read and write files in your workspace
|
||||
- Run bash commands in your sandbox
|
||||
- Schedule tasks to run later or on a recurring basis
|
||||
- Send messages back to the chat
|
||||
|
||||
## Communication
|
||||
|
||||
Be concise — every message costs the reader's attention.
|
||||
|
||||
### Destinations
|
||||
|
||||
Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `<message to="name">...</message>` block:
|
||||
|
||||
```
|
||||
<message to="family">On my way home, 15 minutes</message>
|
||||
<message to="worker-1">kick off the pipeline</message>
|
||||
```
|
||||
|
||||
Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name.
|
||||
|
||||
### Mid-turn updates
|
||||
|
||||
Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
|
||||
|
||||
- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response.
|
||||
- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message.
|
||||
- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
|
||||
|
||||
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
|
||||
|
||||
**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did.
|
||||
|
||||
### Internal thoughts
|
||||
|
||||
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `<message>` blocks is also treated as scratchpad. With a single destination, only explicit `<internal>` tags are scratchpad; the rest of your response is sent.
|
||||
|
||||
```
|
||||
<internal>Compiled all three reports, ready to summarize.</internal>
|
||||
|
||||
Here are the key findings from the research…
|
||||
```
|
||||
|
||||
### Sub-agents and teammates
|
||||
|
||||
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
|
||||
|
||||
## Your Workspace
|
||||
|
||||
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
|
||||
|
||||
## Memory
|
||||
|
||||
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
|
||||
|
||||
When you learn something important:
|
||||
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
|
||||
- Split files larger than 500 lines into folders
|
||||
- Keep an index in your memory for the files you create
|
||||
|
||||
## Message Formatting
|
||||
|
||||
Format messages based on the channel you're responding to. Check your group folder name:
|
||||
|
||||
### Slack channels (folder starts with `slack_`)
|
||||
|
||||
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
|
||||
- `*bold*` (single asterisks)
|
||||
- `_italic_` (underscores)
|
||||
- `<https://url|link text>` for links (NOT `[text](url)`)
|
||||
- `•` bullets (no numbered lists)
|
||||
- `:emoji:` shortcodes
|
||||
- `>` for block quotes
|
||||
- No `##` headings — use `*Bold text*` instead
|
||||
|
||||
### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`)
|
||||
|
||||
- `*bold*` (single asterisks, NEVER **double**)
|
||||
- `_italic_` (underscores)
|
||||
- `•` bullet points
|
||||
- ` ``` ` code blocks
|
||||
|
||||
No `##` headings. No `[links](url)`. No `**double stars**`.
|
||||
|
||||
### Discord channels (folder starts with `discord_`)
|
||||
|
||||
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
|
||||
|
||||
---
|
||||
|
||||
## Installing Packages & Tools
|
||||
|
||||
Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools:
|
||||
|
||||
1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval.
|
||||
2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes.
|
||||
|
||||
Example flow:
|
||||
```
|
||||
install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" })
|
||||
# → Admin gets an approval card → approves
|
||||
request_rebuild({ reason: "Apply ffmpeg + transformers" })
|
||||
# → Admin approves → image rebuilt with the packages
|
||||
```
|
||||
|
||||
**When to use this vs workspace pnpm install:**
|
||||
- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies
|
||||
- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH
|
||||
|
||||
### MCP Servers
|
||||
|
||||
Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.:
|
||||
|
||||
```
|
||||
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
|
||||
request_rebuild({ reason: "Add memory MCP server" })
|
||||
```
|
||||
|
||||
## Task Scripts
|
||||
|
||||
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`.
|
||||
|
||||
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows.
|
||||
|
||||
Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
|
||||
|
||||
### How it works
|
||||
|
||||
1. You provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first (30-second timeout)
|
||||
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script in your sandbox to verify it works:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
|
||||
|
||||
- Explain that each wake-up uses API credits and risks rate limits
|
||||
- Suggest restructuring with a script that checks the condition first
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
@@ -1,312 +0,0 @@
|
||||
@./.claude-global.md
|
||||
# Main
|
||||
|
||||
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
|
||||
|
||||
## What You Can Do
|
||||
|
||||
- Answer questions and have conversations
|
||||
- Search the web and fetch content from URLs
|
||||
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
|
||||
- Read and write files in your workspace
|
||||
- Run bash commands in your sandbox
|
||||
- Schedule tasks to run later or on a recurring basis
|
||||
- Send messages back to the chat
|
||||
|
||||
## Communication
|
||||
|
||||
Your output is sent to the user or group.
|
||||
|
||||
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
|
||||
|
||||
### Internal thoughts
|
||||
|
||||
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
|
||||
|
||||
```
|
||||
<internal>Compiled all three reports, ready to summarize.</internal>
|
||||
|
||||
Here are the key findings from the research...
|
||||
```
|
||||
|
||||
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
|
||||
|
||||
### Sub-agents and teammates
|
||||
|
||||
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
|
||||
|
||||
## Memory
|
||||
|
||||
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
|
||||
|
||||
When you learn something important:
|
||||
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
|
||||
- Split files larger than 500 lines into folders
|
||||
- Keep an index in your memory for the files you create
|
||||
|
||||
## Message Formatting
|
||||
|
||||
Format messages based on the channel. Check the group folder name prefix:
|
||||
|
||||
### Slack channels (folder starts with `slack_`)
|
||||
|
||||
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
|
||||
- `*bold*` (single asterisks)
|
||||
- `_italic_` (underscores)
|
||||
- `<https://url|link text>` for links (NOT `[text](url)`)
|
||||
- `•` bullets (no numbered lists)
|
||||
- `:emoji:` shortcodes like `:white_check_mark:`, `:rocket:`
|
||||
- `>` for block quotes
|
||||
- No `##` headings — use `*Bold text*` instead
|
||||
|
||||
### WhatsApp/Telegram (folder starts with `whatsapp_` or `telegram_`)
|
||||
|
||||
- `*bold*` (single asterisks, NEVER **double**)
|
||||
- `_italic_` (underscores)
|
||||
- `•` bullet points
|
||||
- ` ``` ` code blocks
|
||||
|
||||
No `##` headings. No `[links](url)`. No `**double stars**`.
|
||||
|
||||
### Discord (folder starts with `discord_`)
|
||||
|
||||
Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
|
||||
|
||||
---
|
||||
|
||||
## Admin Context
|
||||
|
||||
This is the **main channel**, which has elevated privileges.
|
||||
|
||||
## Authentication
|
||||
|
||||
Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
|
||||
|
||||
## Container Mounts
|
||||
|
||||
Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder:
|
||||
|
||||
| Container Path | Host Path | Access |
|
||||
|----------------|-----------|--------|
|
||||
| `/workspace/project` | Project root | read-only |
|
||||
| `/workspace/project/store` | `store/` | read-write |
|
||||
| `/workspace/group` | `groups/main/` | read-write |
|
||||
|
||||
Key paths inside the container:
|
||||
- `/workspace/project/store/messages.db` - SQLite database (read-write)
|
||||
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
|
||||
- `/workspace/project/groups/` - All group folders
|
||||
|
||||
---
|
||||
|
||||
## Managing Groups
|
||||
|
||||
### Finding Available Groups
|
||||
|
||||
Available groups are provided in `/workspace/ipc/available_groups.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"jid": "120363336345536173@g.us",
|
||||
"name": "Family Chat",
|
||||
"lastActivity": "2026-01-31T12:00:00.000Z",
|
||||
"isRegistered": false
|
||||
}
|
||||
],
|
||||
"lastSync": "2026-01-31T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
|
||||
|
||||
If a group the user mentions isn't in the list, request a fresh sync:
|
||||
|
||||
```bash
|
||||
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
|
||||
```
|
||||
|
||||
Then wait a moment and re-read `available_groups.json`.
|
||||
|
||||
**Fallback**: Query the SQLite database directly:
|
||||
|
||||
```bash
|
||||
sqlite3 /workspace/project/store/messages.db "
|
||||
SELECT jid, name, last_message_time
|
||||
FROM chats
|
||||
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
|
||||
ORDER BY last_message_time DESC
|
||||
LIMIT 10;
|
||||
"
|
||||
```
|
||||
|
||||
### Registered Groups Config
|
||||
|
||||
Groups are registered in the SQLite `registered_groups` table:
|
||||
|
||||
```json
|
||||
{
|
||||
"1234567890-1234567890@g.us": {
|
||||
"name": "Family Chat",
|
||||
"folder": "whatsapp_family-chat",
|
||||
"trigger": "@Andy",
|
||||
"added_at": "2024-01-31T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
|
||||
- **name**: Display name for the group
|
||||
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
|
||||
- **trigger**: The trigger word (usually same as global, but could differ)
|
||||
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
|
||||
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
|
||||
- **added_at**: ISO timestamp when registered
|
||||
|
||||
### Trigger Behavior
|
||||
|
||||
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
|
||||
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
|
||||
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
|
||||
|
||||
### Adding a Group
|
||||
|
||||
1. Query the database to find the group's JID
|
||||
2. Ask the user whether the group should require a trigger word before registering
|
||||
3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting
|
||||
4. Optionally include `containerConfig` for additional mounts
|
||||
5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
|
||||
6. Optionally create an initial `CLAUDE.md` for the group
|
||||
|
||||
Folder naming convention — channel prefix with underscore separator:
|
||||
- WhatsApp "Family Chat" → `whatsapp_family-chat`
|
||||
- Telegram "Dev Team" → `telegram_dev-team`
|
||||
- Discord "General" → `discord_general`
|
||||
- Slack "Engineering" → `slack_engineering`
|
||||
- Use lowercase, hyphens for the group name part
|
||||
|
||||
#### Adding Additional Directories for a Group
|
||||
|
||||
Groups can have extra directories mounted. Add `containerConfig` to their entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"1234567890@g.us": {
|
||||
"name": "Dev Team",
|
||||
"folder": "dev-team",
|
||||
"trigger": "@Andy",
|
||||
"added_at": "2026-01-31T12:00:00Z",
|
||||
"containerConfig": {
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "~/projects/webapp",
|
||||
"containerPath": "webapp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The directory will appear at `/workspace/extra/webapp` in that group's container.
|
||||
|
||||
#### Sender Allowlist
|
||||
|
||||
After registering a group, explain the sender allowlist feature to the user:
|
||||
|
||||
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
|
||||
>
|
||||
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
|
||||
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
|
||||
>
|
||||
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
|
||||
|
||||
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
|
||||
|
||||
```json
|
||||
{
|
||||
"default": { "allow": "*", "mode": "trigger" },
|
||||
"chats": {
|
||||
"<chat-jid>": {
|
||||
"allow": ["sender-id-1", "sender-id-2"],
|
||||
"mode": "trigger"
|
||||
}
|
||||
},
|
||||
"logDenied": true
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
|
||||
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
|
||||
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
|
||||
|
||||
### Removing a Group
|
||||
|
||||
1. Read `/workspace/project/data/registered_groups.json`
|
||||
2. Remove the entry for that group
|
||||
3. Write the updated JSON back
|
||||
4. The group folder and its files remain (don't delete them)
|
||||
|
||||
### Listing Groups
|
||||
|
||||
Read `/workspace/project/data/registered_groups.json` and format it nicely.
|
||||
|
||||
---
|
||||
|
||||
## Global Memory
|
||||
|
||||
You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
|
||||
|
||||
---
|
||||
|
||||
## Scheduling for Other Groups
|
||||
|
||||
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
|
||||
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
|
||||
|
||||
The task will run in that group's context with access to their files and memory.
|
||||
|
||||
---
|
||||
|
||||
## Task Scripts
|
||||
|
||||
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
|
||||
|
||||
Use `list_tasks` to see existing tasks (one row per series with the stable id), and `update_task` / `cancel_task` / `pause_task` / `resume_task` to modify them. Prefer `update_task` over cancel + reschedule when adjusting an existing task.
|
||||
|
||||
### How it works
|
||||
|
||||
1. You provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first (30-second timeout)
|
||||
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script in your sandbox to verify it works:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
|
||||
|
||||
- Explain that each wake-up uses API credits and risks rate limits
|
||||
- Suggest restructuring with a script that checks the condition first
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.17",
|
||||
"version": "2.1.26",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "2.2.1",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"chat": "4.29.0",
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
|
||||
Generated
+14
-5
@@ -21,8 +21,8 @@ importers:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
chat:
|
||||
specifier: ^4.24.0
|
||||
version: 4.26.0
|
||||
specifier: 4.29.0
|
||||
version: 4.29.0
|
||||
cron-parser:
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
@@ -609,8 +609,17 @@ packages:
|
||||
character-entities@2.0.2:
|
||||
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||
|
||||
chat@4.26.0:
|
||||
resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==}
|
||||
chat@4.29.0:
|
||||
resolution: {integrity: sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
ai: ^6.0.182
|
||||
zod: ^3.0.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
ai:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
@@ -1963,7 +1972,7 @@ snapshots:
|
||||
|
||||
character-entities@2.0.2: {}
|
||||
|
||||
chat@4.26.0:
|
||||
chat@4.29.0:
|
||||
dependencies:
|
||||
'@workflow/serde': 4.1.0-beta.2
|
||||
mdast-util-to-string: 4.0.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="196k tokens, 98% of context window">
|
||||
<title>196k tokens, 98% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="207k tokens, 104% of context window">
|
||||
<title>207k tokens, 104% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">196k</text>
|
||||
<text x="71" y="14">196k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
|
||||
<text x="71" y="14">207k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
+14
-6
@@ -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
|
||||
@@ -15,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the
|
||||
* channel flow). The CLI scratch agent is always
|
||||
* "Terminal Agent".
|
||||
* NANOCLAW_AGENT_PROVIDER preselect the setup provider and skip the picker
|
||||
* (for packaged flows). Example: claude.
|
||||
* NANOCLAW_SKIP comma-separated step names to skip
|
||||
* (environment|container|onecli|auth|mounts|
|
||||
* service|cli-agent|timezone|channel|
|
||||
@@ -816,6 +818,15 @@ async function askAgentProviderChoice(): Promise<string> {
|
||||
...installed.map(({ value, label, hint }) => ({ value, label, hint })),
|
||||
...available.map((prov) => ({ value: prov.value, label: prov.label, hint: `${prov.hint} — installs now` })),
|
||||
];
|
||||
const preset = process.env.NANOCLAW_AGENT_PROVIDER?.trim().toLowerCase();
|
||||
if (preset) {
|
||||
if (!options.some((option) => option.value === preset)) {
|
||||
throw new Error(`NANOCLAW_AGENT_PROVIDER=${preset} is not available in this NanoClaw install`);
|
||||
}
|
||||
setupLog.userInput('agent_provider', preset);
|
||||
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).
|
||||
|
||||
+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(
|
||||
[
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './github.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -86,7 +86,7 @@ if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './teams.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -63,7 +63,7 @@ if ! grep -q "'pair-telegram':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractClaudeOAuthToken } from './captured-token.js';
|
||||
|
||||
// A syntactically valid token: sk-ant-oat + 93 token chars + AA.
|
||||
const TOKEN = `sk-ant-oat01-${'a'.repeat(90)}AA`;
|
||||
|
||||
describe('extractClaudeOAuthToken', () => {
|
||||
it('extracts the token from clean single-line output (normal terminal)', () => {
|
||||
const raw = `Login successful.\nYour token:\n${TOKEN}\n`;
|
||||
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
// The actual sbx failure shape: the real token wrapped across two lines AND
|
||||
// the `export CLAUDE_CODE_OAUTH_TOKEN=<token>` placeholder in the same
|
||||
// capture. The old parser returned null (matched only the first fragment);
|
||||
// the normalizer must un-wrap the real token and never mistake the
|
||||
// placeholder for it.
|
||||
it('extracts the real wrapped token from sbx capture and ignores the placeholder export', () => {
|
||||
const head = TOKEN.slice(0, 72);
|
||||
const tail = TOKEN.slice(72);
|
||||
const raw = `
|
||||
\x1b[?2026h✓ Long-lived authentication token created successfully!
|
||||
|
||||
Your OAuth token (valid for 1 year):
|
||||
|
||||
${head}
|
||||
${tail}
|
||||
|
||||
Store this token securely. You won't be able to see it again.
|
||||
|
||||
Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token>
|
||||
`;
|
||||
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('returns null for the placeholder env-var line, not a real token', () => {
|
||||
expect(extractClaudeOAuthToken('export CLAUDE_CODE_OAUTH_TOKEN=<token>\n')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no token is present', () => {
|
||||
expect(extractClaudeOAuthToken('claude: authentication cancelled\n')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Parse a provider auth token out of interactive CLI output captured through
|
||||
* a PTY (`script(1)`).
|
||||
*
|
||||
* Secret this module hides: the menagerie of PTY-capture artifacts that
|
||||
* corrupt an otherwise whitespace-free secret. A real terminal wraps long
|
||||
* lines, pads with spaces, and interleaves ANSI/control sequences, so a token
|
||||
* the CLI printed as one string lands in the capture split across lines with
|
||||
* escape codes embedded. Provider login itself succeeds — only our parse of
|
||||
* the human-oriented output fails.
|
||||
*
|
||||
* A normalize step strips the capture artifacts; the extractor matches the
|
||||
* token shape against the clean string. A future provider adds its own
|
||||
* extractor here rather than regexing raw `script(1)` output.
|
||||
*
|
||||
* Runnable as a CLI for the bash callers that can't import TS:
|
||||
* tsx setup/lib/captured-token.ts claude <capture-file>
|
||||
* Prints the token and exits 0, or exits 1 with nothing on stdout.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
/* eslint-disable no-control-regex -- these patterns exist precisely to match
|
||||
the ESC/control bytes a PTY capture is full of. */
|
||||
// CSI sequences (colors, cursor moves): ESC [ , optional private '?' /
|
||||
// parameter bytes, optional intermediate bytes, one final byte. Stripped
|
||||
// explicitly because a colour reset mid-token (sk…\x1b[0m…AA) would otherwise
|
||||
// leave a `[` that breaks the token's character run.
|
||||
const CSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
||||
// Everything <= space (control bytes incl. any stray ESC, CR/LF, tabs, and the
|
||||
// wrap-padding spaces inserted mid-token) plus DEL. Tokens contain none of these.
|
||||
const CONTROL_AND_SPACE = /[\x00-\x20\x7f]/g;
|
||||
/* eslint-enable no-control-regex */
|
||||
|
||||
/**
|
||||
* Collapse PTY-capture artifacts so a whitespace-free secret printed across
|
||||
* wrapped lines becomes a single contiguous string. Drops ALL whitespace by
|
||||
* design — these captures exist only to recover a token, never prose.
|
||||
*/
|
||||
function normalizeCapturedTerminalOutput(raw: string): string {
|
||||
return raw.replace(CSI, '').replace(CONTROL_AND_SPACE, '');
|
||||
}
|
||||
|
||||
// Claude subscription OAuth tokens: sk-ant-oat<base64url>AA. Bounded length
|
||||
// keeps a greedy match from running off the end of the token.
|
||||
const CLAUDE_OAUTH_TOKEN = /sk-ant-oat[A-Za-z0-9_-]{80,500}AA/g;
|
||||
|
||||
/**
|
||||
* Extract the Claude OAuth token from a PTY capture of `claude setup-token`,
|
||||
* or `null` if none is present. Returns the LAST match — setup-token can echo
|
||||
* partial/intermediate output before the final token. Placeholder strings like
|
||||
* `<token>` never match (they lack the `sk-ant-oat` prefix).
|
||||
*/
|
||||
export function extractClaudeOAuthToken(raw: string): string | null {
|
||||
const matches = normalizeCapturedTerminalOutput(raw).match(CLAUDE_OAUTH_TOKEN);
|
||||
return matches ? matches[matches.length - 1] : null;
|
||||
}
|
||||
|
||||
function runCli(argv: string[]): number {
|
||||
const [provider, file] = argv;
|
||||
if (provider !== 'claude' || !file) {
|
||||
process.stderr.write('usage: captured-token.ts claude <capture-file>\n');
|
||||
return 2;
|
||||
}
|
||||
const token = extractClaudeOAuthToken(fs.readFileSync(file, 'utf-8'));
|
||||
if (!token) return 1;
|
||||
process.stdout.write(token);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
process.exit(runCli(process.argv.slice(2)));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import path from 'path';
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { extractClaudeOAuthToken } from './captured-token.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
|
||||
|
||||
@@ -207,16 +208,11 @@ export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||
});
|
||||
|
||||
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
|
||||
const raw = fs.readFileSync(tmpfile, 'utf-8');
|
||||
const stripped = raw
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/[\n\r]/g, '');
|
||||
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
|
||||
if (matches) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
|
||||
}
|
||||
const token = extractClaudeOAuthToken(fs.readFileSync(tmpfile, 'utf-8'));
|
||||
if (token) process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
||||
}
|
||||
} finally {
|
||||
// eslint-disable-next-line no-empty -- best-effort temp cleanup
|
||||
try { fs.unlinkSync(tmpfile); } catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,14 @@ export const CONFIG: Entry[] = [
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'agentProvider',
|
||||
envVar: 'NANOCLAW_AGENT_PROVIDER',
|
||||
label: 'Agent provider',
|
||||
help: 'Preselect the setup provider and skip the provider picker.',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'assistMode',
|
||||
envVar: 'NANOCLAW_SETUP_ASSIST_MODE',
|
||||
|
||||
@@ -43,7 +43,6 @@ interface V1Group {
|
||||
folder: string;
|
||||
trigger_pattern: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -65,7 +64,7 @@ async function main(): Promise<void> {
|
||||
// v1 schema varies — channel_name was a late addition. Query only the
|
||||
// columns we know exist in all v1 installs.
|
||||
const v1Groups = v1Db
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main FROM registered_groups')
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger FROM registered_groups')
|
||||
.all() as V1Group[];
|
||||
v1Db.close();
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
|
||||
|
||||
// The reaper deletes config files from ~/Library/LaunchAgents (or the systemd
|
||||
// user dir). We point HOME at a throwaway temp dir so real registrations are
|
||||
// never touched, and force os.platform() so the launchd/systemd branch runs
|
||||
// regardless of the host running the suite. The best-effort unload inside the
|
||||
// reaper (launchctl/systemctl) is swallowed when the binary is absent, so these
|
||||
// tests are deterministic on both macOS and Linux CI.
|
||||
|
||||
function tempHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'peer-cleanup-'));
|
||||
}
|
||||
|
||||
function writePlist(filePath: string, target: string): void {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>/usr/bin/node</string><string>${target}</string></array>
|
||||
</dict></plist>`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeUnit(filePath: string, target: string): void {
|
||||
fs.writeFileSync(filePath, `[Service]\nExecStart=/usr/bin/node ${target}\n`);
|
||||
}
|
||||
|
||||
const created: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of created.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('cleanupUnhealthyPeers — dead launchd registrations', () => {
|
||||
function setup(): { home: string; agentsDir: string; projectRoot: string } {
|
||||
const home = tempHome();
|
||||
created.push(home);
|
||||
const agentsDir = path.join(home, 'Library', 'LaunchAgents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
return { home, agentsDir, projectRoot: path.join(home, 'install') };
|
||||
}
|
||||
|
||||
it('removes a plist whose target binary is gone', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const dead = path.join(agentsDir, 'com.nanoclaw-v2-dead.plist');
|
||||
writePlist(dead, path.join(agentsDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(dead)).toBe(false);
|
||||
expect(result.removed.map((r) => r.label)).toContain('com.nanoclaw-v2-dead');
|
||||
});
|
||||
|
||||
it('leaves a plist whose target still exists', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const liveTarget = path.join(agentsDir, 'live', 'dist', 'index.js');
|
||||
fs.mkdirSync(path.dirname(liveTarget), { recursive: true });
|
||||
fs.writeFileSync(liveTarget, '// host entry');
|
||||
const live = path.join(agentsDir, 'com.nanoclaw-v2-live.plist');
|
||||
writePlist(live, liveTarget);
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(live)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never reaps this install's own plist, even with a missing target", () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const own = path.join(agentsDir, `${ownLabel}.plist`);
|
||||
writePlist(own, path.join(agentsDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(own)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores an unrecognized plist (no dist/index.js target)', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const weird = path.join(agentsDir, 'com.nanoclaw-v2-weird.plist');
|
||||
fs.writeFileSync(weird, '<plist><dict></dict></plist>');
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(weird)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupUnhealthyPeers — dead systemd registrations', () => {
|
||||
function setup(): { unitDir: string; projectRoot: string } {
|
||||
const home = tempHome();
|
||||
created.push(home);
|
||||
const unitDir = path.join(home, '.config', 'systemd', 'user');
|
||||
fs.mkdirSync(unitDir, { recursive: true });
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
return { unitDir, projectRoot: path.join(home, 'install') };
|
||||
}
|
||||
|
||||
it('removes a unit whose target binary is gone', () => {
|
||||
const { unitDir, projectRoot } = setup();
|
||||
const dead = path.join(unitDir, 'nanoclaw-v2-dead.service');
|
||||
writeUnit(dead, path.join(unitDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(dead)).toBe(false);
|
||||
expect(result.removed.map((r) => r.label)).toContain('nanoclaw-v2-dead');
|
||||
});
|
||||
|
||||
it("never reaps this install's own unit", () => {
|
||||
const { unitDir, projectRoot } = setup();
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const own = path.join(unitDir, `${ownUnit}.service`);
|
||||
writeUnit(own, path.join(unitDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(own)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+112
-3
@@ -11,6 +11,14 @@
|
||||
* - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD`
|
||||
* - systemd: unit is in `failed` state, OR `activating` with many restarts
|
||||
*
|
||||
* Separately, a peer registration is "dead" when the program it launches no
|
||||
* longer exists on disk — almost always a deleted test checkout or worktree.
|
||||
* The service manager keeps retrying the missing binary forever, and the
|
||||
* health probes can't see it because an unloaded/inactive job doesn't report
|
||||
* via `launchctl print` / `systemctl show`. Deleting an install's folder
|
||||
* without running the uninstaller leaves these behind, so they accumulate. We
|
||||
* unload and delete the orphaned config file outright.
|
||||
*
|
||||
* Healthy peers are left alone — multiple installs can coexist fine now that
|
||||
* container-reaper is label-scoped.
|
||||
*/
|
||||
@@ -35,6 +43,7 @@ export interface PeerStatus {
|
||||
export interface PeerCleanupResult {
|
||||
checked: PeerStatus[];
|
||||
unloaded: PeerStatus[];
|
||||
removed: Array<{ label: string; configPath: string }>;
|
||||
failures: Array<{ label: string; err: string }>;
|
||||
}
|
||||
|
||||
@@ -50,7 +59,39 @@ export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): Peer
|
||||
if (platform === 'linux') {
|
||||
return cleanupSystemdPeers(projectRoot);
|
||||
}
|
||||
return { checked: [], unloaded: [], failures: [] };
|
||||
return { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a dead peer's job (best-effort) and delete its orphaned config file.
|
||||
* `unload` runs first and may throw harmlessly when the job isn't loaded or the
|
||||
* service-manager binary is absent (e.g. exercising launchd cleanup on Linux).
|
||||
*/
|
||||
function reapDeadPeer(
|
||||
result: PeerCleanupResult,
|
||||
peer: { label: string; configPath: string },
|
||||
unload: () => void,
|
||||
kind: string,
|
||||
missingTarget: string,
|
||||
): void {
|
||||
try {
|
||||
unload();
|
||||
} catch {
|
||||
/* job not loaded — nothing to unload */
|
||||
}
|
||||
try {
|
||||
fs.rmSync(peer.configPath, { force: true });
|
||||
log.info(`Removed dead peer ${kind}`, {
|
||||
label: peer.label,
|
||||
configPath: peer.configPath,
|
||||
missingTarget,
|
||||
});
|
||||
result.removed.push(peer);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`Failed to remove dead peer ${kind}`, { label: peer.label, err: message });
|
||||
result.failures.push({ label: peer.label, err: message });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- launchd (macOS) --------------------------------------------------------
|
||||
@@ -58,7 +99,7 @@ export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): Peer
|
||||
function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
|
||||
let plists: string[];
|
||||
try {
|
||||
@@ -76,6 +117,20 @@ function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const label = path.basename(plistPath, '.plist');
|
||||
if (label === ownLabel) continue;
|
||||
|
||||
const missingTarget = deadLaunchdTarget(plistPath);
|
||||
if (missingTarget) {
|
||||
reapDeadPeer(
|
||||
result,
|
||||
{ label, configPath: plistPath },
|
||||
// Best-effort unload in case launchd still has it registered; throwing
|
||||
// (not loaded, or launchctl absent off-macOS) is expected and ignored.
|
||||
() => execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' }),
|
||||
'launchd plist',
|
||||
missingTarget,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = probeLaunchdPeer(label, plistPath, uid);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
@@ -121,12 +176,32 @@ function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerSt
|
||||
return { label, configPath: plistPath, state, runs, unhealthy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program path a launchd plist launches when that program no longer
|
||||
* exists on disk (a dead registration), or undefined when the plist is
|
||||
* unreadable, has an unrecognized shape, or its target still exists — in which
|
||||
* case the plist must not be touched.
|
||||
*/
|
||||
function deadLaunchdTarget(plistPath: string): string | undefined {
|
||||
let xml: string;
|
||||
try {
|
||||
xml = fs.readFileSync(plistPath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// ProgramArguments is [nodePath, "<projectRoot>/dist/index.js"]; the host
|
||||
// entry point is the stable marker to match on.
|
||||
const target = /<string>([^<]*\/dist\/index\.js)<\/string>/.exec(xml)?.[1];
|
||||
if (!target) return undefined;
|
||||
return fs.existsSync(target) ? undefined : target;
|
||||
}
|
||||
|
||||
// ---- systemd (Linux) --------------------------------------------------------
|
||||
|
||||
function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
|
||||
let units: string[];
|
||||
try {
|
||||
@@ -141,6 +216,22 @@ function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
for (const unit of units) {
|
||||
if (unit === ownUnit) continue;
|
||||
|
||||
const unitPath = path.join(unitDir, `${unit}.service`);
|
||||
const missingTarget = deadSystemdTarget(unitPath);
|
||||
if (missingTarget) {
|
||||
reapDeadPeer(
|
||||
result,
|
||||
{ label: unit, configPath: unitPath },
|
||||
() => {
|
||||
execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' });
|
||||
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'pipe' });
|
||||
},
|
||||
'systemd unit',
|
||||
missingTarget,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = probeSystemdPeer(unit);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
@@ -184,3 +275,21 @@ function probeSystemdPeer(unit: string): PeerStatus | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program path a systemd unit launches when that program no longer
|
||||
* exists on disk (a dead registration), or undefined when the unit is
|
||||
* unreadable, has an unrecognized shape, or its target still exists.
|
||||
*/
|
||||
function deadSystemdTarget(unitPath: string): string | undefined {
|
||||
let unit: string;
|
||||
try {
|
||||
unit = fs.readFileSync(unitPath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// ExecStart=<nodePath> <projectRoot>/dist/index.js
|
||||
const target = /^ExecStart=\S+\s+(\S+\/dist\/index\.js)\s*$/m.exec(unit)?.[1];
|
||||
if (!target) return undefined;
|
||||
return fs.existsSync(target) ? undefined : target;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,17 @@ describe('setup flow consumes the registry (structural)', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'auto.ts'), 'utf-8');
|
||||
expect(src).toContain('listSetupProviders()');
|
||||
expect(src).toContain("import './providers/index.js'");
|
||||
expect(src).toContain('NANOCLAW_AGENT_PROVIDER');
|
||||
// The capability-keyed branch — a provider's own auth runs iff it declares one.
|
||||
expect(src).toMatch(/providerEntry\?\.runAuth/);
|
||||
});
|
||||
|
||||
it('the provider preset is exposed as an env setup knob', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'lib', 'setup-config.ts'), 'utf-8');
|
||||
expect(src).toContain('NANOCLAW_AGENT_PROVIDER');
|
||||
expect(src).toContain("key: 'agentProvider'");
|
||||
});
|
||||
|
||||
it('the standalone provider-auth step is reachable from the STEPS map', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'index.ts'), 'utf-8');
|
||||
expect(src).toContain("'provider-auth'");
|
||||
|
||||
@@ -9,7 +9,8 @@ set -euo pipefail
|
||||
# Flow:
|
||||
# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser
|
||||
# OAuth dance works and its token is captured into a tempfile.
|
||||
# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture.
|
||||
# 2. Parse the sk-ant-oat…AA token out of the capture via the shared
|
||||
# PTY-capture parser (setup/lib/captured-token.ts).
|
||||
# 3. Register it with OneCLI.
|
||||
#
|
||||
# Env overrides:
|
||||
@@ -99,12 +100,11 @@ else
|
||||
script -q "$tmpfile" $cmd
|
||||
fi
|
||||
|
||||
# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match
|
||||
# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255.
|
||||
token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \
|
||||
| tr -d '\n\r' \
|
||||
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
|
||||
| tail -1 || true)
|
||||
# Extract the token via the shared PTY-capture parser (setup/lib/captured-token.ts),
|
||||
# so this script and setup/lib/claude-assist.ts stay in lockstep on the
|
||||
# normalization rules (ANSI/control stripping, un-wrapping the token).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
token=$(pnpm exec tsx "$SCRIPT_DIR/lib/captured-token.ts" claude "$tmpfile" || true)
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
keep=$(mktemp -t claude-setup-token-log.XXXXXX)
|
||||
|
||||
@@ -72,6 +72,12 @@ export async function run(_args: string[]): Promise<void> {
|
||||
labels: peerReport.unloaded.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
if (peerReport.removed.length > 0) {
|
||||
log.warn('Removed dead peer NanoClaw registrations (target binary missing)', {
|
||||
count: peerReport.removed.length,
|
||||
labels: peerReport.removed.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'macos') {
|
||||
setupLaunchd(projectRoot, nodePath, homeDir);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-claude-md-compose-test';
|
||||
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
|
||||
|
||||
vi.mock('./config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config.js')>()),
|
||||
GROUPS_DIR: '/tmp/nanoclaw-claude-md-compose-test/groups',
|
||||
}));
|
||||
|
||||
vi.mock('./log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { PERSONA_PREPEND_FILE } from './group-persona.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
function group(id: string, folder: string): AgentGroup {
|
||||
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
|
||||
}
|
||||
|
||||
function seed(ag: AgentGroup): void {
|
||||
createAgentGroup(ag);
|
||||
ensureContainerConfig(ag.id);
|
||||
}
|
||||
|
||||
function writePersona(folder: string, text: string): void {
|
||||
const dir = path.join(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, PERSONA_PREPEND_FILE), text);
|
||||
}
|
||||
|
||||
function importsOf(folder: string): string[] {
|
||||
const md = fs.readFileSync(path.join(GROUPS_DIR, folder, 'CLAUDE.md'), 'utf-8');
|
||||
return md.split('\n').filter((line) => line.startsWith('@'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('composeGroupClaudeMd persona prepend', () => {
|
||||
it('imports the persona fragment FIRST, before the shared base', () => {
|
||||
const ag = group('ag-persona', 'persona-group');
|
||||
seed(ag);
|
||||
writePersona(ag.folder, 'You are an SDR agent.\n');
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports[0]).toBe('@./.claude-fragments/persona.md');
|
||||
expect(imports[1]).toBe('@./.claude-shared.md');
|
||||
expect(fs.readFileSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'), 'utf-8')).toBe(
|
||||
'You are an SDR agent.',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the persona across a second compose (not pruned)', () => {
|
||||
const ag = group('ag-persona-2', 'persona-group-2');
|
||||
seed(ag);
|
||||
writePersona(ag.folder, 'persona body');
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(true);
|
||||
expect(importsOf(ag.folder)[0]).toBe('@./.claude-fragments/persona.md');
|
||||
});
|
||||
|
||||
it('is inert when no persona file is present (non-template groups)', () => {
|
||||
const ag = group('ag-no-persona', 'no-persona-group');
|
||||
seed(ag);
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports[0]).toBe('@./.claude-shared.md');
|
||||
expect(imports).not.toContain('@./.claude-fragments/persona.md');
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,14 @@ import path from 'path';
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import type { McpServerConfig } from './container-config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { readGroupPersona } from './group-persona.js';
|
||||
import { log } from './log.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
// Fragment holding a template's persona prepend. Imported FIRST (before the
|
||||
// shared base) so the persona is the top of the composed system prompt.
|
||||
const PERSONA_FRAGMENT = 'persona.md';
|
||||
|
||||
// Symlink targets are container paths — dangling on host (hence the readlink
|
||||
// dance instead of existsSync), valid inside the container via RO mounts.
|
||||
const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md';
|
||||
@@ -106,6 +111,13 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Template persona (if any) — inline so it survives the prune below; imported
|
||||
// first (see the imports assembly) so it prepends the composed system prompt.
|
||||
const persona = readGroupPersona(groupDir);
|
||||
if (persona) {
|
||||
desired.set(PERSONA_FRAGMENT, { type: 'inline', content: persona });
|
||||
}
|
||||
|
||||
// Reconcile: drop stale, write desired.
|
||||
for (const existing of fs.readdirSync(fragmentsDir)) {
|
||||
if (!desired.has(existing)) {
|
||||
@@ -121,9 +133,14 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Composed entry — imports only.
|
||||
const imports = ['@./.claude-shared.md'];
|
||||
for (const name of [...desired.keys()].sort()) {
|
||||
// Composed entry — imports only. Persona first (top of the system prompt),
|
||||
// then the shared base, then the remaining fragments sorted.
|
||||
const imports: string[] = [];
|
||||
if (desired.has(PERSONA_FRAGMENT)) {
|
||||
imports.push(`@./.claude-fragments/${PERSONA_FRAGMENT}`);
|
||||
}
|
||||
imports.push('@./.claude-shared.md');
|
||||
for (const name of [...desired.keys()].filter((n) => n !== PERSONA_FRAGMENT).sort()) {
|
||||
imports.push(`@./.claude-fragments/${name}`);
|
||||
}
|
||||
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface ColumnDef {
|
||||
updatable?: boolean;
|
||||
/** Default value on create when not provided. */
|
||||
default?: unknown;
|
||||
/** Default to another column's resolved value on create when not provided. */
|
||||
defaultFrom?: string;
|
||||
/** Allowed values (shown in help). */
|
||||
enum?: string[];
|
||||
}
|
||||
@@ -150,6 +152,8 @@ function genericCreate(def: ResourceDef) {
|
||||
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
||||
} else if (col.default !== undefined) {
|
||||
values[col.name] = col.default;
|
||||
} else if (col.defaultFrom !== undefined && values[col.defaultFrom] !== undefined) {
|
||||
values[col.name] = values[col.defaultFrom];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const approvalState = vi.hoisted(() => ({
|
||||
requestApproval: vi.fn(),
|
||||
approvalHandler: null as
|
||||
| null
|
||||
| ((args: {
|
||||
session: unknown;
|
||||
payload: Record<string, unknown>;
|
||||
userId: string;
|
||||
notify: (text: string) => void;
|
||||
}) => Promise<void>),
|
||||
registerApprovalHandler: vi.fn(
|
||||
(
|
||||
action: string,
|
||||
handler: (args: {
|
||||
session: unknown;
|
||||
payload: Record<string, unknown>;
|
||||
userId: string;
|
||||
notify: (text: string) => void;
|
||||
}) => Promise<void>,
|
||||
) => {
|
||||
if (action === 'cli_command') approvalState.approvalHandler = handler;
|
||||
},
|
||||
),
|
||||
observedContexts: [] as CallerContext[],
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
@@ -29,8 +55,8 @@ vi.mock('./crud.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../modules/approvals/index.js', () => ({
|
||||
registerApprovalHandler: vi.fn(),
|
||||
requestApproval: vi.fn(),
|
||||
registerApprovalHandler: approvalState.registerApprovalHandler,
|
||||
requestApproval: approvalState.requestApproval,
|
||||
}));
|
||||
|
||||
// Register a test command so dispatch has something to find
|
||||
@@ -98,6 +124,18 @@ register({
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'approval-context-command',
|
||||
description: 'approval command that records caller context',
|
||||
resource: 'groups',
|
||||
access: 'approval',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (_args, ctx) => {
|
||||
approvalState.observedContexts.push(ctx);
|
||||
return { caller: ctx.caller };
|
||||
},
|
||||
});
|
||||
|
||||
// Commands that return data shaped like real resources (for post-handler filtering tests)
|
||||
register({
|
||||
name: 'groups-list-data',
|
||||
@@ -152,6 +190,7 @@ import type { CallerContext } from './frame.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
approvalState.observedContexts.length = 0;
|
||||
// Default: the four CLI-whitelisted resources with their real scopeFields.
|
||||
const scopeFields: Record<string, string> = {
|
||||
groups: 'id',
|
||||
@@ -391,6 +430,39 @@ describe('CLI scope enforcement', () => {
|
||||
expect(mockGetContainerConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('approval replay preserves the original agent caller context', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
|
||||
|
||||
const ctx = agentCtx();
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
|
||||
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
|
||||
expect(approval.payload).toEqual({
|
||||
frame: {
|
||||
id: '1',
|
||||
command: 'approval-context-command',
|
||||
args: { agent_group_id: 'g1', group: 'g1', id: 'g1' },
|
||||
},
|
||||
callerContext: ctx,
|
||||
});
|
||||
|
||||
expect(approvalState.approvalHandler).toBeTypeOf('function');
|
||||
await approvalState.approvalHandler!({
|
||||
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
|
||||
payload: approval.payload,
|
||||
userId: 'telegram:admin',
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
expect(approvalState.observedContexts).toEqual([ctx]);
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- Post-handler filtering ---
|
||||
|
||||
it('group: groups list filters out other groups', async () => {
|
||||
|
||||
+35
-5
@@ -14,7 +14,16 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr
|
||||
import { getResource } from './crud.js';
|
||||
import { lookup } from './registry.js';
|
||||
|
||||
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
|
||||
type DispatchOptions = {
|
||||
/** True when a command is being replayed after approval. */
|
||||
approved?: boolean;
|
||||
};
|
||||
|
||||
export async function dispatch(
|
||||
req: RequestFrame,
|
||||
ctx: CallerContext,
|
||||
opts: DispatchOptions = {},
|
||||
): Promise<ResponseFrame> {
|
||||
let cmd = lookup(req.command);
|
||||
|
||||
// Fallback: if the full command isn't registered, trim the last
|
||||
@@ -101,7 +110,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval') {
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
|
||||
const session = getSession(ctx.sessionId);
|
||||
if (!session) {
|
||||
return err(req.id, 'handler-error', 'Session not found.');
|
||||
@@ -117,7 +126,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
session,
|
||||
agentName,
|
||||
action: 'cli_command',
|
||||
payload: { frame: { id: req.id, command: req.command, args: req.args } },
|
||||
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
|
||||
title: `CLI: ${req.command}`,
|
||||
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
|
||||
});
|
||||
@@ -178,9 +187,10 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
}
|
||||
}
|
||||
|
||||
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
|
||||
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
|
||||
const frame = payload.frame as RequestFrame;
|
||||
const response = await dispatch(frame, { caller: 'host' });
|
||||
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
|
||||
const response = await dispatch(frame, callerContext, { approved: true });
|
||||
|
||||
if (response.ok) {
|
||||
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
|
||||
@@ -190,6 +200,26 @@ registerApprovalHandler('cli_command', async ({ session, payload, userId, notify
|
||||
}
|
||||
});
|
||||
|
||||
function parseCallerContext(value: unknown): CallerContext | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.caller === 'host') return { caller: 'host' };
|
||||
if (
|
||||
record.caller === 'agent' &&
|
||||
typeof record.sessionId === 'string' &&
|
||||
typeof record.agentGroupId === 'string' &&
|
||||
typeof record.messagingGroupId === 'string'
|
||||
) {
|
||||
return {
|
||||
caller: 'agent',
|
||||
sessionId: record.sessionId,
|
||||
agentGroupId: record.agentGroupId,
|
||||
messagingGroupId: record.messagingGroupId,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
|
||||
return { id, ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
@@ -9,7 +12,8 @@ import {
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
} from '../../db/container-configs.js';
|
||||
import type { ContainerConfigRow } from '../../types.js';
|
||||
import { createAgentFromTemplate } from '../../templates/create-agent.js';
|
||||
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
/** Deserialize JSON columns for display. */
|
||||
@@ -58,11 +62,37 @@ registerResource({
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
// `delete` is intentionally not in `operations` — the generic single-table
|
||||
// DELETE violates FK constraints (see #2525). The cascading handler is
|
||||
// provided as `customOperations.delete` below.
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
// `create` and `delete` are intentionally not in `operations` — create needs
|
||||
// a `--template` branch (below); the generic single-table DELETE violates FK
|
||||
// constraints (see #2525). Both are provided as `customOperations`.
|
||||
operations: { list: 'open', get: 'open', update: 'approval' },
|
||||
customOperations: {
|
||||
create: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Create an agent group. With --template <ref>, stamp from a local template under templates/ ' +
|
||||
'(MCP servers + instructions + skills); else insert a bare row (--name, --folder).',
|
||||
handler: async (args) => {
|
||||
if (args.template) {
|
||||
return createAgentFromTemplate(String(args.template), {
|
||||
name: args.name ? String(args.name) : undefined,
|
||||
});
|
||||
}
|
||||
const name = args.name ? String(args.name) : '';
|
||||
const folder = args.folder ? String(args.folder) : '';
|
||||
if (!name) throw new Error('--name is required');
|
||||
if (!folder) throw new Error('--folder is required');
|
||||
const group: AgentGroup = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createAgentGroup(group);
|
||||
return group;
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
access: 'approval',
|
||||
description:
|
||||
|
||||
@@ -9,6 +9,7 @@ import './users.js';
|
||||
import './roles.js';
|
||||
import './members.js';
|
||||
import './destinations.js';
|
||||
import './policies.js';
|
||||
import './user-dms.js';
|
||||
import './dropped-messages.js';
|
||||
import './approvals.js';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { removeMessagePolicy, setMessagePolicy } from '../../modules/agent-to-agent/db/agent-message-policies.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'policy',
|
||||
plural: 'policies',
|
||||
table: 'agent_message_policies',
|
||||
description:
|
||||
'Agent-to-agent approval policy. A row requires every message from one agent to another to be approved by a human before delivery — without un-wiring the connection. No row = free flow. Directed and per-pair: gate both directions with two policies. Operator-only (agents cannot manage their own gates).',
|
||||
idColumn: 'from_agent_group_id',
|
||||
columns: [
|
||||
{ name: 'from_agent_group_id', type: 'string', description: 'Source agent group. References agent_groups.id.' },
|
||||
{ name: 'to_agent_group_id', type: 'string', description: 'Target agent group. References agent_groups.id.' },
|
||||
{
|
||||
name: 'approver',
|
||||
type: 'string',
|
||||
description: 'User-id who approves each gated message (required). Only this user (or an owner) can approve.',
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
||||
],
|
||||
operations: { list: 'open' },
|
||||
customOperations: {
|
||||
set: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Require approval for messages from one agent to another. Use --from <agent-group-id> --to <agent-group-id> --approver <user-id>. Only the named approver (or an owner) can approve.',
|
||||
handler: async (args) => {
|
||||
const from = args.from as string;
|
||||
const to = args.to as string;
|
||||
const approver = args.approver as string;
|
||||
if (!from) throw new Error('--from is required');
|
||||
if (!to) throw new Error('--to is required');
|
||||
if (!approver) throw new Error('--approver is required');
|
||||
if (from === to) throw new Error('--from and --to must differ (self-messages are never gated)');
|
||||
if (!getAgentGroup(from)) throw new Error(`source agent group not found: ${from}`);
|
||||
if (!getAgentGroup(to)) throw new Error(`target agent group not found: ${to}`);
|
||||
|
||||
setMessagePolicy(from, to, approver, new Date().toISOString());
|
||||
return { from_agent_group_id: from, to_agent_group_id: to, approver };
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
access: 'approval',
|
||||
description: 'Remove an approval policy (back to free flow). Use --from <agent-group-id> --to <agent-group-id>.',
|
||||
handler: async (args) => {
|
||||
const from = args.from as string;
|
||||
const to = args.to as string;
|
||||
if (!from) throw new Error('--from is required');
|
||||
if (!to) throw new Error('--to is required');
|
||||
if (!removeMessagePolicy(from, to)) throw new Error('policy not found');
|
||||
return { removed: { from_agent_group_id: from, to_agent_group_id: to } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,12 @@ export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw',
|
||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||
// Local agent-template library. Committed but ships empty (+ README). Resolved
|
||||
// once at load. Override to another LOCAL path via NANOCLAW_TEMPLATES_DIR; never
|
||||
// a remote URL, never an ncl flag, never runtime-mutable.
|
||||
export const TEMPLATES_DIR = process.env.NANOCLAW_TEMPLATES_DIR
|
||||
? path.resolve(process.env.NANOCLAW_TEMPLATES_DIR)
|
||||
: path.resolve(PROJECT_ROOT, 'templates');
|
||||
|
||||
// Per-checkout image tag so two installs on the same host don't share
|
||||
// `nanoclaw-agent:latest` and clobber each other on rebuild.
|
||||
@@ -38,6 +44,11 @@ export const ONECLI_API_KEY = process.env.ONECLI_API_KEY || envConfig.ONECLI_API
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps, passed through to `docker run`. Default empty =
|
||||
// no flag added = today's unbounded behavior (don't OOM existing OSS workloads).
|
||||
// Operators opt in: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
@@ -47,6 +47,37 @@ describe('buildContainerArgs ordering invariant (structural)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-container resource limits (structural)', () => {
|
||||
// CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT pass through to `docker run` as
|
||||
// --cpus / --memory, but only when set. The default is empty string → no flag →
|
||||
// today's unbounded behavior (don't OOM existing OSS workloads). Swap is not
|
||||
// managed here (a swapless host makes --memory a hard cap). buildContainerArgs
|
||||
// needs a live gateway to drive, so guard the wiring structurally: the flags
|
||||
// must be pushed, and each must be guarded by its env knob so empty emits nothing.
|
||||
it('reads both limit knobs from config', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toContain('CONTAINER_CPU_LIMIT');
|
||||
expect(src).toContain('CONTAINER_MEMORY_LIMIT');
|
||||
});
|
||||
|
||||
it('guards --cpus behind a truthy CONTAINER_CPU_LIMIT', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toMatch(/if \(CONTAINER_CPU_LIMIT\)[\s\S]*?args\.push\('--cpus', CONTAINER_CPU_LIMIT\)/);
|
||||
});
|
||||
|
||||
it('guards --memory behind a truthy CONTAINER_MEMORY_LIMIT (and sets no swap flag)', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toMatch(/if \(CONTAINER_MEMORY_LIMIT\) args\.push\('--memory', CONTAINER_MEMORY_LIMIT\)/);
|
||||
expect(src).not.toContain('--memory-swap');
|
||||
});
|
||||
|
||||
it('defaults both knobs to empty string in config (no flag = unbounded)', () => {
|
||||
const cfg = fs.readFileSync(path.join(process.cwd(), 'src', 'config.ts'), 'utf-8');
|
||||
expect(cfg).toContain("CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || ''");
|
||||
expect(cfg).toContain("CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || ''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('container boot-failure tripwire (structural)', () => {
|
||||
// A container that dies at boot (unknown provider, missing CLI binary, bad
|
||||
// config) explains itself only on stderr — which logs at debug, below the
|
||||
|
||||
@@ -10,9 +10,11 @@ import path from 'path';
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import {
|
||||
CONTAINER_CPU_LIMIT,
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_IMAGE_BASE,
|
||||
CONTAINER_INSTALL_LABEL,
|
||||
CONTAINER_MEMORY_LIMIT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
ONECLI_API_KEY,
|
||||
@@ -318,12 +320,6 @@ export function buildMounts(
|
||||
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
|
||||
}
|
||||
|
||||
// Global memory directory — always read-only.
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
|
||||
}
|
||||
|
||||
// Shared CLAUDE.md — read-only, imported by the composed entry point via
|
||||
// the `.claude-shared.md` symlink inside the group dir.
|
||||
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
|
||||
@@ -434,6 +430,13 @@ async function buildContainerArgs(
|
||||
): Promise<string[]> {
|
||||
const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL];
|
||||
|
||||
// Per-container resource caps (opt-in; empty = unbounded, today's behavior).
|
||||
// Only --memory is set. Whether that's a hard cap depends on the host having no
|
||||
// swap (a deployment concern) — on a swapless host --memory is hard and a runaway
|
||||
// is OOM-killed; we don't manage swap from here.
|
||||
if (CONTAINER_CPU_LIMIT) args.push('--cpus', CONTAINER_CPU_LIMIT);
|
||||
if (CONTAINER_MEMORY_LIMIT) args.push('--memory', CONTAINER_MEMORY_LIMIT);
|
||||
|
||||
// Environment — only vars read by code we don't own.
|
||||
// Everything NanoClaw-specific is in container.json (read by runner at startup).
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
/** Per-message approval gate on an agent-to-agent connection; no row = free flow. */
|
||||
export const migration017: Migration = {
|
||||
version: 17,
|
||||
name: 'agent-message-policies',
|
||||
up(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE agent_message_policies (
|
||||
from_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
to_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
approver TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (from_agent_group_id, to_agent_group_id)
|
||||
);
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
/**
|
||||
* `approver_user_id` on `pending_approvals`: when an approval names a specific
|
||||
* approver (an a2a message-gate policy's approver), only that exact user may
|
||||
* resolve it. NULL keeps the existing group/owner authorization path.
|
||||
*/
|
||||
export const migration018: Migration = {
|
||||
version: 18,
|
||||
name: 'approvals-approver-user-id',
|
||||
up(db) {
|
||||
db.exec(`ALTER TABLE pending_approvals ADD COLUMN approver_user_id TEXT;`);
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
|
||||
import { migration001 } from './001-initial.js';
|
||||
import { migration002 } from './002-chat-sdk-state.js';
|
||||
import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinations.js';
|
||||
import { migration017 } from './017-agent-message-policies.js';
|
||||
import { migration008 } from './008-dropped-messages.js';
|
||||
import { migration009 } from './009-drop-pending-credentials.js';
|
||||
import { migration010 } from './010-engage-modes.js';
|
||||
@@ -15,6 +16,7 @@ import { migration015 } from './015-cli-scope.js';
|
||||
import { migration016 } from './016-messaging-group-instance.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
import { migration018 } from './018-approvals-approver-user-id.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -36,7 +38,9 @@ export const migrations: Migration[] = [
|
||||
migration002,
|
||||
moduleApprovalsPendingApprovals,
|
||||
moduleAgentToAgentDestinations,
|
||||
migration017,
|
||||
moduleApprovalsTitleOptions,
|
||||
migration018,
|
||||
migration008,
|
||||
migration009,
|
||||
migration010,
|
||||
|
||||
+25
-2
@@ -155,11 +155,11 @@ export function createPendingApproval(
|
||||
`INSERT OR IGNORE INTO pending_approvals
|
||||
(approval_id, session_id, request_id, action, payload, created_at,
|
||||
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
|
||||
title, options_json)
|
||||
title, options_json, approver_user_id)
|
||||
VALUES
|
||||
(@approval_id, @session_id, @request_id, @action, @payload, @created_at,
|
||||
@agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status,
|
||||
@title, @options_json)`,
|
||||
@title, @options_json, @approver_user_id)`,
|
||||
)
|
||||
.run({
|
||||
session_id: null,
|
||||
@@ -169,6 +169,7 @@ export function createPendingApproval(
|
||||
platform_message_id: null,
|
||||
expires_at: null,
|
||||
status: 'pending',
|
||||
approver_user_id: null,
|
||||
...pa,
|
||||
});
|
||||
return result.changes > 0;
|
||||
@@ -184,6 +185,28 @@ export function updatePendingApprovalStatus(approvalId: string, status: PendingA
|
||||
getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Park an approval in the "rejected, awaiting reason" hold: the admin clicked
|
||||
* "Reject with reason…" and we're waiting for their one-line reply. `expiresAt`
|
||||
* is the deadline after which the host sweep finalizes a plain reject (so a
|
||||
* ghosted hold never strands the requesting agent). Reuses the otherwise-unused
|
||||
* `expires_at` column on module-initiated rows.
|
||||
*/
|
||||
export function markApprovalAwaitingReason(approvalId: string, expiresAt: string): void {
|
||||
getDb()
|
||||
.prepare("UPDATE pending_approvals SET status = 'awaiting_reason', expires_at = ? WHERE approval_id = ?")
|
||||
.run(expiresAt, approvalId);
|
||||
}
|
||||
|
||||
/** Awaiting-reason approvals whose reply window has elapsed — the sweep's ghost set. */
|
||||
export function getExpiredAwaitingReasonApprovals(nowIso: string): PendingApproval[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM pending_approvals WHERE status = 'awaiting_reason' AND expires_at IS NOT NULL AND expires_at <= ?",
|
||||
)
|
||||
.all(nowIso) as PendingApproval[];
|
||||
}
|
||||
|
||||
export function deletePendingApproval(approvalId: string): void {
|
||||
getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { isValidGroupFolder, resolveGroupFolderPath } from './group-folder.js';
|
||||
|
||||
describe('group folder validation', () => {
|
||||
it('accepts normal group folder names', () => {
|
||||
@@ -23,13 +23,7 @@ describe('group folder validation', () => {
|
||||
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves safe paths under data ipc directory', () => {
|
||||
const resolved = resolveGroupIpcPath('family-chat');
|
||||
expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws for unsafe folder names', () => {
|
||||
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
|
||||
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-9
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
|
||||
const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
const RESERVED_FOLDERS = new Set(['global']);
|
||||
@@ -34,11 +34,3 @@ export function resolveGroupFolderPath(folder: string): string {
|
||||
ensureWithinBase(GROUPS_DIR, groupPath);
|
||||
return groupPath;
|
||||
}
|
||||
|
||||
export function resolveGroupIpcPath(folder: string): string {
|
||||
assertValidGroupFolder(folder);
|
||||
const ipcBaseDir = path.resolve(DATA_DIR, 'ipc');
|
||||
const ipcPath = path.resolve(ipcBaseDir, folder);
|
||||
ensureWithinBase(ipcBaseDir, ipcPath);
|
||||
return ipcPath;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { PERSONA_PREPEND_FILE, readGroupPersona } from './group-persona.js';
|
||||
|
||||
const TMP = '/tmp/nanoclaw-group-persona-test';
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
fs.mkdirSync(TMP, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('readGroupPersona', () => {
|
||||
it('returns null when the prepend file is absent', () => {
|
||||
expect(readGroupPersona(TMP)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty / whitespace-only file', () => {
|
||||
fs.writeFileSync(path.join(TMP, PERSONA_PREPEND_FILE), ' \n\n');
|
||||
expect(readGroupPersona(TMP)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the trimmed content when present', () => {
|
||||
fs.writeFileSync(path.join(TMP, PERSONA_PREPEND_FILE), '\nYou are an SDR agent.\n\n');
|
||||
expect(readGroupPersona(TMP)).toBe('You are an SDR agent.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Provider-neutral per-group persona ("instructions prepend").
|
||||
*
|
||||
* A template stamps its standing instructions here (src/templates/create-agent.ts).
|
||||
* Each provider's project-doc composer inlines this content at the TOP of the
|
||||
* doc it generates every spawn — `CLAUDE.md` (Claude, src/claude-md-compose.ts)
|
||||
* or `AGENTS.md` (Codex, src/providers/codex-agents-md.ts on the providers
|
||||
* branch) — so a template persona lands at system-prompt tier on every provider
|
||||
* rather than in a recall-tier memory file.
|
||||
*
|
||||
* This module is the single owner of the filename + read semantics so the two
|
||||
* composers (one on main, one on the providers donor branch) never hardcode the
|
||||
* path independently. Absent file ⇒ null ⇒ no-op for non-template groups.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** Per-group host file holding the persona prepend. Never regenerated — persistent. */
|
||||
export const PERSONA_PREPEND_FILE = 'instructions.prepend.md';
|
||||
|
||||
/**
|
||||
* Read a group's persona prepend from its host dir, or null if absent/empty.
|
||||
* `groupDir` is the per-group host directory (`GROUPS_DIR/<folder>`).
|
||||
*/
|
||||
export function readGroupPersona(groupDir: string): string | null {
|
||||
const file = path.join(groupDir, PERSONA_PREPEND_FILE);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const content = fs.readFileSync(file, 'utf-8').trim();
|
||||
return content.length > 0 ? content : null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-group-skills-test';
|
||||
const DATA_DIR = path.join(TEST_ROOT, 'data');
|
||||
|
||||
vi.mock('./config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config.js')>()),
|
||||
DATA_DIR: '/tmp/nanoclaw-group-skills-test/data',
|
||||
}));
|
||||
|
||||
import { materializeTemplateSkills } from './group-skills.js';
|
||||
|
||||
function templateSkill(groupId: string, name: string, file: string, content: string): void {
|
||||
const dir = path.join(DATA_DIR, 'v2-sessions', groupId, '.claude-shared', 'skills', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), content);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('materializeTemplateSkills', () => {
|
||||
it('copies real template-skill dirs into the provider skills dir', () => {
|
||||
templateSkill('g1', 'widget', 'SKILL.md', 'body');
|
||||
const dest = path.join(TEST_ROOT, 'grp1', '.agents', 'skills');
|
||||
|
||||
materializeTemplateSkills('g1', dest);
|
||||
|
||||
expect(fs.readFileSync(path.join(dest, 'widget', 'SKILL.md'), 'utf-8')).toBe('body');
|
||||
expect(fs.lstatSync(path.join(dest, 'widget')).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op when the group has no template skills', () => {
|
||||
const dest = path.join(TEST_ROOT, 'grp2', '.agents', 'skills');
|
||||
materializeTemplateSkills('g2', dest);
|
||||
expect(fs.existsSync(dest)).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites its own skill dirs but leaves other destination entries intact', () => {
|
||||
templateSkill('g3', 'widget', 'SKILL.md', 'new');
|
||||
const dest = path.join(TEST_ROOT, 'grp3', '.agents', 'skills');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
// Stale copy of the same skill (should be refreshed) + a coexisting
|
||||
// shared-skill symlink (must NOT be touched — it is provider-owned).
|
||||
fs.mkdirSync(path.join(dest, 'widget'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'widget', 'SKILL.md'), 'old');
|
||||
fs.symlinkSync('/app/skills/shared', path.join(dest, 'shared'));
|
||||
|
||||
materializeTemplateSkills('g3', dest);
|
||||
|
||||
expect(fs.readFileSync(path.join(dest, 'widget', 'SKILL.md'), 'utf-8')).toBe('new');
|
||||
expect(fs.lstatSync(path.join(dest, 'shared')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not destroy skills when dest equals the source (Claude reads source directly)', () => {
|
||||
templateSkill('g4', 'widget', 'SKILL.md', 'body');
|
||||
const src = path.join(DATA_DIR, 'v2-sessions', 'g4', '.claude-shared', 'skills');
|
||||
|
||||
materializeTemplateSkills('g4', src);
|
||||
|
||||
expect(fs.existsSync(path.join(src, 'widget', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Provider-agnostic template-skill materialization.
|
||||
*
|
||||
* A template stamps its skills as REAL directories into the group-private store
|
||||
* `data/v2-sessions/<group-id>/.claude-shared/skills/<name>` (src/templates/create-agent.ts).
|
||||
* Claude reads that store directly — it is mounted at `~/.claude/skills`, and
|
||||
* real dirs survive the symlink-only skill-link prune. Every OTHER surfaces-owning
|
||||
* provider (codex, opencode, pi, …) reads a DIFFERENT per-group skills directory,
|
||||
* often READ-ONLY-mounted, so the skills must be copied there host-side, before
|
||||
* the container starts.
|
||||
*
|
||||
* This is the single shared spot that does that copy. Each provider's host-side
|
||||
* container contribution calls it once with its own skills dir (codex →
|
||||
* `.agents/skills`; a future provider → whatever it reads). Adding a provider
|
||||
* therefore adds one call, not a new mirror implementation. The copied dirs are
|
||||
* real (not symlinks), so they survive providers' symlink-only prunes and persist
|
||||
* across respawns.
|
||||
*
|
||||
* This module is a main-owned seam that provider payloads (on the `providers`
|
||||
* donor branch) import — mirrors src/group-persona.ts.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
|
||||
/** The group-private store templates stamp skills into (Claude's read plane). */
|
||||
function templateSkillsSource(agentGroupId: string): string {
|
||||
return path.join(DATA_DIR, 'v2-sessions', agentGroupId, '.claude-shared', 'skills');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a group's template skills into a provider's per-group skills directory.
|
||||
* No-op if the group has no template skills, or if `destSkillsDir` IS the source
|
||||
* (Claude, which reads the source directly — copying onto itself would delete it).
|
||||
* Idempotent: overwrites each template skill so edits propagate on respawn. It
|
||||
* manages only its own skill dirs — other entries in the destination (e.g. a
|
||||
* provider's shared-skill symlinks) are left untouched.
|
||||
*/
|
||||
export function materializeTemplateSkills(agentGroupId: string, destSkillsDir: string): void {
|
||||
const src = templateSkillsSource(agentGroupId);
|
||||
if (!fs.existsSync(src)) return;
|
||||
if (path.resolve(src) === path.resolve(destSkillsDir)) return;
|
||||
|
||||
fs.mkdirSync(destSkillsDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(src)) {
|
||||
if (!fs.statSync(path.join(src, name)).isDirectory()) continue;
|
||||
const dest = path.join(destSkillsDir, name);
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(src, name), dest, { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,18 @@ async function sweep(): Promise<void> {
|
||||
log.error('Host sweep error', { err });
|
||||
}
|
||||
|
||||
// Finalize any "Reject with reason…" holds whose reply window elapsed (admin
|
||||
// ghosted, or the host restarted mid-capture). Central-DB scan, once per tick
|
||||
// — not per session.
|
||||
// MODULE-HOOK:approvals-reason-sweep:start
|
||||
try {
|
||||
const { sweepAwaitingReasonRejects } = await import('./modules/approvals/index.js');
|
||||
await sweepAwaitingReasonRejects();
|
||||
} catch (err) {
|
||||
log.error('Reject-with-reason sweep failed', { err });
|
||||
}
|
||||
// MODULE-HOOK:approvals-reason-sweep:end
|
||||
|
||||
setTimeout(sweep, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared containment guards for per-message inbox directories.
|
||||
*
|
||||
* Session dirs are mounted writable into agent containers, so a compromised
|
||||
* agent can pre-place a symlink inside its own session dir and wait for the
|
||||
* host to write through it — landing attacker-influenced bytes outside the
|
||||
* sandbox (CWE-59). Both inbound paths that materialise files into a session's
|
||||
* `inbox/<messageId>/` directory route through `ensureContainedInboxDir`:
|
||||
* - channel-inbound attachments (`extractAttachmentFiles` in session-manager)
|
||||
* - agent-to-agent forwarded files (`forwardAttachedFiles` in agent-route)
|
||||
*
|
||||
* Keeping the guard in one place means both paths defend identically; the fix
|
||||
* for GHSA #2828 originally lived only in the A2A path and the channel path had
|
||||
* the same gap (a symlinked `inbox` root was followed silently).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from './log.js';
|
||||
|
||||
/** True if `child` is `parent` itself or nested within it (no traversal/escape). */
|
||||
export function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and create `<inboxRoot>/<messageId>`, refusing pre-placed symlinks a
|
||||
* compromised container could use to redirect host writes outside the session.
|
||||
*
|
||||
* Guards, in order:
|
||||
* 1. lstat the inbox ROOT — reject if it is a symlink or a non-directory.
|
||||
* Without this, a symlinked `inbox` is silently followed by mkdir AND the
|
||||
* containment check in step 4 passes, because it compares against the
|
||||
* already-followed (escaped) root. This is the gap that affected the
|
||||
* channel-inbound path.
|
||||
* 2. lstat the per-message subdir — reject a pre-placed symlink/non-dir.
|
||||
* lstat does not follow the final path component, so it sees the link
|
||||
* itself even when the link target does not exist.
|
||||
* 3. mkdir the subdir (recursive).
|
||||
* 4. realpath containment — the resolved subdir must stay within the resolved
|
||||
* inbox root (defence in depth; symlinks are already ruled out above).
|
||||
*
|
||||
* Returns the resolved, contained subdir path (write into it with an exclusive
|
||||
* flag — `COPYFILE_EXCL` / `wx` — so a pre-existing symlinked *file* can't be
|
||||
* followed either), or `null` if any guard tripped. On `null` the caller logs
|
||||
* its own context and skips; `context` is merged into the warn logs here so
|
||||
* each call site stays diagnosable.
|
||||
*/
|
||||
export function ensureContainedInboxDir(
|
||||
inboxRoot: string,
|
||||
messageId: string,
|
||||
context: Record<string, unknown>,
|
||||
): string | null {
|
||||
const inboxDir = path.join(inboxRoot, messageId);
|
||||
|
||||
for (const dir of [inboxRoot, inboxDir]) {
|
||||
try {
|
||||
const st = fs.lstatSync(dir);
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) {
|
||||
log.warn('inbox-safety: rejecting unsafe inbox path', { ...context, dir });
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Does not exist yet — fine, mkdir below creates it.
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const realInboxDir = fs.realpathSync(inboxDir);
|
||||
const realInboxRoot = fs.realpathSync(inboxRoot);
|
||||
if (!isPathInside(realInboxRoot, realInboxDir)) {
|
||||
log.warn('inbox-safety: inbox dir escaped inbox root', { ...context, inboxDir });
|
||||
return null;
|
||||
}
|
||||
return realInboxDir;
|
||||
} catch (err) {
|
||||
log.warn('inbox-safety: failed to resolve inbox dir', { ...context, inboxDir, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
|
||||
import { forwardAttachedFiles, isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
|
||||
import { log } from '../../log.js';
|
||||
import { createDestination } from './db/agent-destinations.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession, updateSession } from '../../db/sessions.js';
|
||||
@@ -467,4 +468,129 @@ describe('routeAgentMessage return-path', () => {
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
// #2828 — target-side symlink containment. A compromised target agent can
|
||||
// write inside its own session dir; these tests prove it cannot redirect a
|
||||
// forwarded attachment outside the session sandbox via a pre-placed symlink.
|
||||
|
||||
it('file forwarding (#2828): skips a symlinked target inbox dir, writes nothing outside', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside-inbox');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
// Source has a real attachment to forward.
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-inbox');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'pwn.txt'), 'attacker-bytes');
|
||||
|
||||
// Target pre-places its whole `inbox` as a symlink pointing outside.
|
||||
const targetInbox = path.join(sessionDir(B, SB.id), 'inbox');
|
||||
fs.rmSync(targetInbox, { recursive: true, force: true });
|
||||
fs.symlinkSync(canaryDir, targetInbox);
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-evil-inbox',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['pwn.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
// Message still routes — just with no attachments.
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
expect(JSON.parse(bRows[0].content).attachments).toHaveLength(0);
|
||||
|
||||
// Nothing was written through the symlink to the canary location.
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828): skips a symlinked inbox/<msgId> subdir, writes nothing outside', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryDir = path.join(TEST_DIR, 'canary-outside-subdir');
|
||||
fs.mkdirSync(canaryDir, { recursive: true });
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-subdir');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'pwn.txt'), 'attacker-bytes');
|
||||
|
||||
// The forwarded a2a msg id generated inside routeAgentMessage is random, so
|
||||
// a symlink can't be pre-placed at inbox/<that-id>. Drive forwardAttachedFiles
|
||||
// directly with a fixed target message id and plant the symlink at that path.
|
||||
const targetMsgId = 'evil-subdir-msg';
|
||||
const realInbox = path.join(sessionDir(B, SB.id), 'inbox');
|
||||
fs.mkdirSync(realInbox, { recursive: true });
|
||||
fs.symlinkSync(canaryDir, path.join(realInbox, targetMsgId));
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{ agentGroupId: A, sessionId: S1.id, messageId: 'msg-evil-subdir', filenames: ['pwn.txt'] },
|
||||
{ agentGroupId: B, sessionId: SB.id, messageId: targetMsgId },
|
||||
);
|
||||
|
||||
expect(attachments).toHaveLength(0);
|
||||
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828): refuses a pre-existing symlinked dst file (COPYFILE_EXCL)', async () => {
|
||||
const warnSpy = vi.spyOn(log, 'warn');
|
||||
const canaryFile = path.join(TEST_DIR, 'canary-dst-target.txt');
|
||||
fs.writeFileSync(canaryFile, 'original-canary');
|
||||
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-evil-dst');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'doc.txt'), 'attacker-bytes');
|
||||
|
||||
// inbox/<msgId>/ is a real dir, but contains a pre-placed symlink named
|
||||
// exactly like the incoming attachment, pointing at the canary file.
|
||||
// We can only do this once we know the a2a msg id, which is generated
|
||||
// inside routeAgentMessage. So we instead drive forwardAttachedFiles
|
||||
// directly with a fixed target message id.
|
||||
const targetMsgId = 'fixed-evil-dst';
|
||||
const realInboxSubdir = path.join(sessionDir(B, SB.id), 'inbox', targetMsgId);
|
||||
fs.mkdirSync(realInboxSubdir, { recursive: true });
|
||||
fs.symlinkSync(canaryFile, path.join(realInboxSubdir, 'doc.txt'));
|
||||
|
||||
const attachments = forwardAttachedFiles(
|
||||
{ agentGroupId: A, sessionId: S1.id, messageId: 'msg-evil-dst', filenames: ['doc.txt'] },
|
||||
{ agentGroupId: B, sessionId: SB.id, messageId: targetMsgId },
|
||||
);
|
||||
|
||||
// The exclusive write failed → nothing forwarded.
|
||||
expect(attachments).toHaveLength(0);
|
||||
// Canary file untouched (symlink not followed/overwritten).
|
||||
expect(fs.readFileSync(canaryFile, 'utf-8')).toBe('original-canary');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('file forwarding (#2828 regression): a normal forward still works end-to-end', async () => {
|
||||
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-ok-file');
|
||||
fs.mkdirSync(outboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outboxDir, 'ok.txt'), 'legit-bytes');
|
||||
|
||||
await routeAgentMessage(
|
||||
{
|
||||
id: 'msg-ok-file',
|
||||
platform_id: B,
|
||||
content: JSON.stringify({ text: 'see attached', files: ['ok.txt'] }),
|
||||
in_reply_to: null,
|
||||
},
|
||||
S1,
|
||||
);
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
const parsed = JSON.parse(bRows[0].content);
|
||||
expect(parsed.attachments).toHaveLength(1);
|
||||
expect(parsed.attachments[0].name).toBe('ok.txt');
|
||||
const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath);
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('legit-bytes');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { isSafeAttachmentName } from '../../attachment-safety.js';
|
||||
import { ensureContainedInboxDir, isPathInside } from '../../inbox-safety.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
@@ -29,7 +30,9 @@ import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { requestApproval } from '../approvals/index.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
import { getMessagePolicy } from './db/agent-message-policies.js';
|
||||
|
||||
export { isSafeAttachmentName };
|
||||
|
||||
@@ -40,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
|
||||
@@ -96,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) {
|
||||
@@ -135,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,
|
||||
@@ -208,21 +231,90 @@ function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session,
|
||||
}
|
||||
|
||||
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
|
||||
const sourceAgentGroupId = session.agent_group_id;
|
||||
const targetAgentGroupId = msg.platform_id;
|
||||
if (!targetAgentGroupId) {
|
||||
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
|
||||
}
|
||||
if (
|
||||
targetAgentGroupId !== session.agent_group_id &&
|
||||
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
|
||||
) {
|
||||
throw new Error(
|
||||
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
|
||||
);
|
||||
const isSelf = targetAgentGroupId === sourceAgentGroupId;
|
||||
if (!isSelf && !hasDestination(sourceAgentGroupId, 'agent', targetAgentGroupId)) {
|
||||
throw new Error(`unauthorized agent-to-agent: ${sourceAgentGroupId} has no destination for ${targetAgentGroupId}`);
|
||||
}
|
||||
if (!getAgentGroup(targetAgentGroupId)) {
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
}
|
||||
|
||||
// Gated edge: hold the message and return (not throw) so the delivery loop
|
||||
// consumes the outbound row; `applyA2aMessageGate` re-routes it on approve.
|
||||
if (!isSelf) {
|
||||
const policy = getMessagePolicy(sourceAgentGroupId, targetAgentGroupId);
|
||||
if (policy) {
|
||||
const { approver } = policy;
|
||||
const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId;
|
||||
const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId;
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: sourceName,
|
||||
action: A2A_MESSAGE_GATE_ACTION,
|
||||
approverUserId: approver,
|
||||
title: 'Message approval',
|
||||
question: buildGateQuestion(sourceName, targetName, msg.content),
|
||||
payload: {
|
||||
id: msg.id,
|
||||
platform_id: targetAgentGroupId,
|
||||
content: msg.content,
|
||||
in_reply_to: msg.in_reply_to,
|
||||
},
|
||||
});
|
||||
log.info('Agent message held for approval', {
|
||||
from: sourceAgentGroupId,
|
||||
to: targetAgentGroupId,
|
||||
msgId: msg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await performAgentRoute(msg, session, targetAgentGroupId);
|
||||
}
|
||||
|
||||
export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
|
||||
|
||||
const GATE_CARD_BODY_MAX = 1500;
|
||||
|
||||
function parseMessageContent(contentStr: string): { text: string; files: string[] } {
|
||||
try {
|
||||
const parsed = JSON.parse(contentStr) as { text?: unknown; files?: unknown };
|
||||
return {
|
||||
text: typeof parsed.text === 'string' ? parsed.text : '',
|
||||
files: Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string') : [],
|
||||
};
|
||||
} catch {
|
||||
return { text: contentStr, files: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function buildGateQuestion(sourceName: string, targetName: string, contentStr: string): string {
|
||||
const { text, files } = parseMessageContent(contentStr);
|
||||
const body = text.length > GATE_CARD_BODY_MAX ? `${text.slice(0, GATE_CARD_BODY_MAX)}… (truncated)` : text;
|
||||
const lines = [`Agent "${sourceName}" wants to send a message to "${targetName}":`, '', body];
|
||||
if (files.length > 0) lines.push('', `Attachments: ${files.join(', ')}`);
|
||||
lines.push(
|
||||
'',
|
||||
`Approve, Reject, or "Reject with reason…" to decline and then type a short reason I'll relay to "${sourceName}".`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-session route: pick the target session, forward files, write to its
|
||||
* inbound DB, wake it. Authorization is the caller's responsibility.
|
||||
*/
|
||||
export async function performAgentRoute(
|
||||
msg: RoutableAgentMessage,
|
||||
session: Session,
|
||||
targetAgentGroupId: string,
|
||||
): Promise<void> {
|
||||
const targetSession = resolveTargetSession(msg, session, targetAgentGroupId);
|
||||
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
*/
|
||||
import type { AgentDestination } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
import { deletePoliciesTouching, removeMessagePolicy } from './agent-message-policies.js';
|
||||
|
||||
/**
|
||||
* ⚠️ Caller responsibility: after this returns, call
|
||||
@@ -89,9 +90,16 @@ export function hasDestination(agentGroupId: string, targetType: 'channel' | 'ag
|
||||
* so the deletion propagates to the running container's inbound.db.
|
||||
*/
|
||||
export function deleteDestination(agentGroupId: string, localName: string): void {
|
||||
// Resolve the target first so we can drop a matching policy for this edge (no ghost gate on re-wire).
|
||||
const row = getDb()
|
||||
.prepare('SELECT target_type, target_id FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||
.get(agentGroupId, localName) as { target_type: string; target_id: string } | undefined;
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||
.run(agentGroupId, localName);
|
||||
if (row?.target_type === 'agent') {
|
||||
removeMessagePolicy(agentGroupId, row.target_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +116,7 @@ export function deleteAllDestinationsTouching(agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? OR (target_type = ? AND target_id = ?)')
|
||||
.run(agentGroupId, 'agent', agentGroupId);
|
||||
deletePoliciesTouching(agentGroupId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Per-message approval policies for agent-to-agent connections; no row = free flow. */
|
||||
import type { AgentMessagePolicy } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
export function getMessagePolicy(fromAgentGroupId: string, toAgentGroupId: string): AgentMessagePolicy | undefined {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM agent_message_policies WHERE from_agent_group_id = ? AND to_agent_group_id = ?')
|
||||
.get(fromAgentGroupId, toAgentGroupId) as AgentMessagePolicy | undefined;
|
||||
}
|
||||
|
||||
export function setMessagePolicy(
|
||||
fromAgentGroupId: string,
|
||||
toAgentGroupId: string,
|
||||
approver: string,
|
||||
createdAt: string,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_message_policies (from_agent_group_id, to_agent_group_id, approver, created_at)
|
||||
VALUES (@from_agent_group_id, @to_agent_group_id, @approver, @created_at)
|
||||
ON CONFLICT (from_agent_group_id, to_agent_group_id) DO UPDATE SET approver = excluded.approver`,
|
||||
)
|
||||
.run({ from_agent_group_id: fromAgentGroupId, to_agent_group_id: toAgentGroupId, approver, created_at: createdAt });
|
||||
}
|
||||
|
||||
export function removeMessagePolicy(fromAgentGroupId: string, toAgentGroupId: string): boolean {
|
||||
const info = getDb()
|
||||
.prepare('DELETE FROM agent_message_policies WHERE from_agent_group_id = ? AND to_agent_group_id = ?')
|
||||
.run(fromAgentGroupId, toAgentGroupId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/** Delete every policy touching this agent group, so none outlives its connection. */
|
||||
export function deletePoliciesTouching(agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_message_policies WHERE from_agent_group_id = ? OR to_agent_group_id = ?')
|
||||
.run(agentGroupId, agentGroupId);
|
||||
}
|
||||
@@ -22,7 +22,11 @@
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { A2A_MESSAGE_GATE_ACTION } from './agent-route.js';
|
||||
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
|
||||
import { applyA2aMessageGate } from './message-gate.js';
|
||||
|
||||
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||
registerApprovalHandler('create_agent', applyCreateAgent);
|
||||
|
||||
registerApprovalHandler(A2A_MESSAGE_GATE_ACTION, applyA2aMessageGate);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { routeAgentMessage } from './agent-route.js';
|
||||
import { createDestination, deleteDestination, deleteAllDestinationsTouching } from './db/agent-destinations.js';
|
||||
import { getMessagePolicy, removeMessagePolicy, setMessagePolicy } from './db/agent-message-policies.js';
|
||||
import { applyA2aMessageGate } from './message-gate.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { getDb } from '../../db/connection.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { requestApproval } from '../approvals/index.js';
|
||||
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
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('../approvals/index.js', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../approvals/index.js')>();
|
||||
return { ...actual, requestApproval: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-a2a-gate' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-a2a-gate';
|
||||
const A = 'ag-A';
|
||||
const B = 'ag-B';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function policyCount(): number {
|
||||
return (getDb().prepare('SELECT COUNT(*) AS n FROM agent_message_policies').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
function readInbound(agentGroupId: string, sessionId: string) {
|
||||
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
|
||||
const rows = db.prepare('SELECT id, platform_id, content FROM messages_in ORDER BY seq').all() as Array<{
|
||||
id: string;
|
||||
platform_id: string | null;
|
||||
content: string;
|
||||
}>;
|
||||
db.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function makeSession(id: string, agentGroupId: string): Session {
|
||||
return {
|
||||
id,
|
||||
agent_group_id: agentGroupId,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent message policies', () => {
|
||||
let SA: Session;
|
||||
let SB: Session;
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
vi.mocked(requestApproval).mockClear();
|
||||
|
||||
createAgentGroup({ id: A, name: 'A', folder: 'a', agent_provider: null, created_at: now() });
|
||||
createAgentGroup({ id: B, name: 'B', folder: 'b', agent_provider: null, created_at: now() });
|
||||
SA = makeSession('sess-A', A);
|
||||
SB = makeSession('sess-B', B);
|
||||
createSession(SA);
|
||||
createSession(SB);
|
||||
initSessionFolder(A, SA.id);
|
||||
initSessionFolder(B, SB.id);
|
||||
// A→B connection wired.
|
||||
createDestination({ agent_group_id: A, local_name: 'b', target_type: 'agent', target_id: B, created_at: now() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
// ── policy table round-trip ──
|
||||
|
||||
it('set / get / remove round-trip, incl. approver', () => {
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
|
||||
setMessagePolicy(A, B, 'telegram:sam', now());
|
||||
expect(getMessagePolicy(A, B)).toMatchObject({
|
||||
from_agent_group_id: A,
|
||||
to_agent_group_id: B,
|
||||
approver: 'telegram:sam',
|
||||
});
|
||||
expect(policyCount()).toBe(1);
|
||||
|
||||
// Upsert updates the approver without inserting a duplicate row.
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
expect(getMessagePolicy(A, B)!.approver).toBe('telegram:dana');
|
||||
expect(policyCount()).toBe(1);
|
||||
|
||||
expect(removeMessagePolicy(A, B)).toBe(true);
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
expect(removeMessagePolicy(A, B)).toBe(false);
|
||||
});
|
||||
|
||||
// ── gate behavior in routeAgentMessage ──
|
||||
|
||||
it('no policy → routes normally, no approval requested', async () => {
|
||||
await routeAgentMessage(
|
||||
{ id: 'm1', platform_id: B, content: JSON.stringify({ text: 'hi B' }), in_reply_to: null },
|
||||
SA,
|
||||
);
|
||||
expect(readInbound(B, SB.id)).toHaveLength(1);
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('policy present → holds the message and requests approval from the policy approver scoped to the target', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
|
||||
await routeAgentMessage(
|
||||
{ id: 'm2', platform_id: B, content: JSON.stringify({ text: 'sensitive' }), in_reply_to: null },
|
||||
SA,
|
||||
);
|
||||
|
||||
// Held: nothing routed to B.
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
// One approval requested, to the policy's approver, scoped to the target group.
|
||||
expect(requestApproval).toHaveBeenCalledTimes(1);
|
||||
const opts = vi.mocked(requestApproval).mock.calls[0][0];
|
||||
expect(opts.action).toBe('a2a_message_gate');
|
||||
expect(opts.approverUserId).toBe('telegram:dana');
|
||||
expect(opts.payload).toMatchObject({ id: 'm2', platform_id: B });
|
||||
expect(JSON.parse(String(opts.payload.content)).text).toBe('sensitive');
|
||||
});
|
||||
|
||||
it('self-message is never gated even if a policy row somehow exists', async () => {
|
||||
setMessagePolicy(A, A, 'telegram:dana', now()); // pathological, but must be ignored
|
||||
await routeAgentMessage(
|
||||
{ id: 'self', platform_id: A, content: JSON.stringify({ text: 'note' }), in_reply_to: null },
|
||||
SA,
|
||||
);
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
expect(readInbound(A, SA.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── approve handler re-routes the held message ──
|
||||
|
||||
it('applyA2aMessageGate delivers the held message to the target', async () => {
|
||||
const notify = vi.fn();
|
||||
await applyA2aMessageGate({
|
||||
session: SA,
|
||||
userId: 'slack:dana',
|
||||
notify,
|
||||
payload: { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null },
|
||||
});
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
expect(JSON.parse(bRows[0].content).text).toBe('approved!');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── ghost-gate cleanup ──
|
||||
|
||||
it('deleting the connection drops its policy', () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
deleteDestination(A, 'b'); // removes the A→B agent destination
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deleteAllDestinationsTouching drops policies on both sides', () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
setMessagePolicy(B, A, 'telegram:dana', now());
|
||||
deleteAllDestinationsTouching(A);
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
expect(getMessagePolicy(B, A)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Approve handler for a held a2a message. (Reject is handled by the generic response-handler path.) */
|
||||
import { log } from '../../log.js';
|
||||
import type { ApprovalHandler } from '../approvals/index.js';
|
||||
import { performAgentRoute, type RoutableAgentMessage } from './agent-route.js';
|
||||
|
||||
export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, notify }) => {
|
||||
const { id, platform_id, content, in_reply_to } = payload;
|
||||
if (typeof platform_id !== 'string' || !platform_id) {
|
||||
notify('Message approved but the target agent group was missing from the request.');
|
||||
log.warn('a2a_message_gate apply: missing target', { sessionId: session.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg: RoutableAgentMessage = {
|
||||
id: typeof id === 'string' ? id : `a2a-gate-${Date.now()}`,
|
||||
platform_id,
|
||||
content: typeof content === 'string' ? content : '',
|
||||
in_reply_to: typeof in_reply_to === 'string' ? in_reply_to : null,
|
||||
};
|
||||
|
||||
await performAgentRoute(msg, session, platform_id);
|
||||
log.info('Held agent message delivered after approval', {
|
||||
from: session.agent_group_id,
|
||||
to: platform_id,
|
||||
msgId: msg.id,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Shared "finalize a rejected approval" path.
|
||||
*
|
||||
* Three entry points land here so they relay one message and clean up
|
||||
* identically:
|
||||
* 1. The instant Reject button (response-handler.ts)
|
||||
* 2. A captured Reject-with-reason reply (reason-capture.ts)
|
||||
* 3. The host-sweep ghost finalizer (reason-capture.ts, via host-sweep)
|
||||
*
|
||||
* Kept in its own leaf file so both response-handler.ts and reason-capture.ts
|
||||
* can import it without an import cycle (finalize → primitive only).
|
||||
*/
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { deletePendingApproval } from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import { notifyApprovalResolved } from './primitive.js';
|
||||
|
||||
/**
|
||||
* Notify the requesting agent that its action was rejected, drop the pending
|
||||
* row, fire approval-resolved callbacks, and wake the container.
|
||||
*
|
||||
* When `reason` is provided it's appended to the agent-facing note with generic
|
||||
* attribution — the why, not the who (the rejecting admin may belong to a
|
||||
* different owner than the requesting agent). Callers are responsible for
|
||||
* clamping the reason length before passing it in.
|
||||
*/
|
||||
export async function finalizeReject(
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
// 'onecli_credential' is an internal action key — describe the actual
|
||||
// request (host from the persisted payload) to the agent instead.
|
||||
let what = `${approval.action} request`;
|
||||
if (approval.action === 'onecli_credential') {
|
||||
let host: string | undefined;
|
||||
try {
|
||||
host = (JSON.parse(approval.payload ?? '{}') as { host?: string }).host;
|
||||
} catch {
|
||||
/* keep generic */
|
||||
}
|
||||
what = host ? `credential request to ${host}` : 'credential request';
|
||||
}
|
||||
const text = reason ? `Your ${what} was rejected by admin: "${reason}"` : `Your ${what} was rejected by admin.`;
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
|
||||
log.info('Approval rejected', {
|
||||
approvalId: approval.approval_id,
|
||||
action: approval.action,
|
||||
userId,
|
||||
withReason: reason !== undefined,
|
||||
});
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
await wakeContainer(session);
|
||||
}
|
||||
@@ -8,10 +8,16 @@
|
||||
* - A response handler that claims pending_approvals rows and dispatches
|
||||
* to whatever module registered for the row's `action` string. Also
|
||||
* resolves in-memory OneCLI credential approvals.
|
||||
* - A message-interceptor (via ./reason-capture.js) that captures an admin's
|
||||
* one-line reply after they click "Reject with reason…".
|
||||
* - An adapter-ready callback that starts the OneCLI manual-approval handler
|
||||
* once the delivery adapter is set.
|
||||
* - A shutdown callback that stops the OneCLI handler cleanly.
|
||||
*
|
||||
* Exposes `sweepAwaitingReasonRejects` for the host sweep to finalize ghosted
|
||||
* reject-with-reason holds (re-exported here, which also loads reason-capture
|
||||
* so its interceptor registers).
|
||||
*
|
||||
* Self-mod flows (install_packages, add_mcp_server) moved out to
|
||||
* `src/modules/self-mod/` in PR #7 — they now register delivery actions
|
||||
* + approval handlers via this module's public API.
|
||||
@@ -24,6 +30,9 @@ import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-
|
||||
// Public API re-exports so consumers import from the module root.
|
||||
export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js';
|
||||
export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } from './primitive.js';
|
||||
// Host-sweep hook for ghosted "Reject with reason…" holds. The re-export also
|
||||
// loads reason-capture.js, registering its message-interceptor on import.
|
||||
export { sweepAwaitingReasonRejects } from './reason-capture.js';
|
||||
|
||||
registerResponseHandler(handleApprovalsResponse);
|
||||
|
||||
|
||||
@@ -17,9 +17,20 @@
|
||||
* Startup sweep edits any leftover cards from a previous process to
|
||||
* "Expired (host restarted)" and drops the rows.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
|
||||
|
||||
import { pickApprovalDelivery, pickApprover } from './primitive.js';
|
||||
import { finalizeReject } from './finalize.js';
|
||||
import {
|
||||
notifyApprovalResolved,
|
||||
pickApprovalDelivery,
|
||||
pickApprover,
|
||||
registerApprovalResolvedHandler,
|
||||
registerReasonRejectFinalizer,
|
||||
REJECT_WITH_REASON_VALUE,
|
||||
} from './primitive.js';
|
||||
import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import {
|
||||
@@ -30,7 +41,8 @@ import {
|
||||
} from '../../db/sessions.js';
|
||||
import type { ChannelDeliveryAdapter } from '../../delivery.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
import { sessionDir } from '../../session-manager.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
|
||||
export const ONECLI_ACTION = 'onecli_credential';
|
||||
|
||||
@@ -41,6 +53,9 @@ const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY });
|
||||
interface PendingState {
|
||||
resolve: (decision: Decision) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
/** Reject-with-reason: the gateway decision is held open until the reason
|
||||
* is captured and relayed (or the gateway TTL forces the deny out). */
|
||||
heldForReason?: boolean;
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingState>();
|
||||
@@ -82,10 +97,101 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject-with-reason: keep the gateway decision UNRESOLVED while the reason is
|
||||
* being captured, so the agent's held HTTP call fails only after the reason is
|
||||
* available to it. The row stays for reason-capture; the TTL timer stays armed
|
||||
* as the safety valve — if the approver types slower than the gateway TTL, the
|
||||
* deny goes out then and a late reason still relays through the
|
||||
* awaiting_reason row.
|
||||
*/
|
||||
export function holdOneCLIApprovalForReason(approvalId: string): boolean {
|
||||
const state = pending.get(approvalId);
|
||||
if (!state) return false;
|
||||
state.heldForReason = true;
|
||||
log.info('OneCLI approval held awaiting rejection reason', { approvalId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom reason delivery for OneCLI rejects (registered with reason-capture's
|
||||
* finalizer registry): drop the reason where the agent-runner's PostToolUse
|
||||
* hook can inject it into the FAILED TOOL CALL itself — the session workspace,
|
||||
* mounted at /workspace in the container — then release the held deny. The
|
||||
* agent experiences one coherent event: its gmail call fails carrying the
|
||||
* admin's reason, instead of a bare denial plus a separate chat message.
|
||||
*
|
||||
* Falls back to the chat-message relay when the decision is no longer held
|
||||
* (gateway TTL beat the approver, or a restart lost the resolver) — the tool
|
||||
* call already failed bare, so a session message is the only remaining path.
|
||||
*/
|
||||
async function finalizeOneCLIReasonReject(
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
const state = pending.get(approval.approval_id);
|
||||
if (!state || !reason) {
|
||||
await finalizeReject(approval, session, userId, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
let requestMeta: { host?: string; method?: string; path?: string } = {};
|
||||
try {
|
||||
requestMeta = JSON.parse(approval.payload ?? '{}') as typeof requestMeta;
|
||||
} catch {
|
||||
/* best-effort metadata */
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(sessionDir(session.agent_group_id, session.id), 'onecli-rejection.json'),
|
||||
JSON.stringify({
|
||||
rejectedAt: new Date().toISOString(),
|
||||
reason,
|
||||
host: requestMeta.host ?? null,
|
||||
method: requestMeta.method ?? null,
|
||||
path: requestMeta.path ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
pending.delete(approval.approval_id);
|
||||
clearTimeout(state.timer);
|
||||
updatePendingApprovalStatus(approval.approval_id, 'rejected');
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
// Release the deny LAST — the reason file is already in place when the
|
||||
// gateway fails the agent's held HTTP call.
|
||||
state.resolve('deny');
|
||||
log.info('OneCLI approval denied with reason injected into tool response', {
|
||||
approvalId: approval.approval_id,
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
|
||||
registerReasonRejectFinalizer(ONECLI_ACTION, finalizeOneCLIReasonReject);
|
||||
|
||||
let resolvedHandlerRegistered = false;
|
||||
|
||||
export function startOneCLIApprovalHandler(deliveryAdapter: ChannelDeliveryAdapter): void {
|
||||
if (handle) return;
|
||||
adapterRef = deliveryAdapter;
|
||||
|
||||
// Releases a held reject-with-reason decision once finalizeReject has
|
||||
// relayed the reason (or the sweep gave up on the approver) — the reason
|
||||
// reaches the agent's session BEFORE its held HTTP call fails.
|
||||
if (!resolvedHandlerRegistered) {
|
||||
resolvedHandlerRegistered = true;
|
||||
registerApprovalResolvedHandler(async (event) => {
|
||||
if (event.approval.action !== ONECLI_ACTION) return;
|
||||
const state = pending.get(event.approval.approval_id);
|
||||
if (!state) return; // TTL already released the deny — nothing held
|
||||
pending.delete(event.approval.approval_id);
|
||||
clearTimeout(state.timer);
|
||||
state.resolve('deny');
|
||||
log.info('OneCLI approval denied after reason relay', { approvalId: event.approval.approval_id });
|
||||
});
|
||||
}
|
||||
|
||||
// Sweep any rows left over from a previous process.
|
||||
sweepStaleApprovals().catch((err) => log.error('OneCLI approval sweep failed', { err }));
|
||||
|
||||
@@ -149,6 +255,7 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
||||
const onecliOptions = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected — reason requested', value: REJECT_WITH_REASON_VALUE },
|
||||
];
|
||||
let platformMessageId: string | undefined;
|
||||
try {
|
||||
@@ -202,8 +309,17 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
||||
|
||||
return new Promise<Decision>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!pending.has(approvalId)) return;
|
||||
const state = pending.get(approvalId);
|
||||
if (!state) return;
|
||||
pending.delete(approvalId);
|
||||
if (state.heldForReason) {
|
||||
// Gateway TTL caught up while the approver was still typing the
|
||||
// reason. Release the deny; keep the awaiting_reason row so the late
|
||||
// reason (or the ghost sweep) still relays to the agent.
|
||||
log.info('OneCLI approval TTL reached while awaiting reason — denying now', { approvalId });
|
||||
resolve('deny');
|
||||
return;
|
||||
}
|
||||
expireApproval(approvalId, 'no response').catch((err) =>
|
||||
log.error('Failed to mark OneCLI approval expired', { approvalId, err }),
|
||||
);
|
||||
|
||||
@@ -32,10 +32,23 @@ import type { MessagingGroup, PendingApproval, Session } from '../../types.js';
|
||||
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
|
||||
/** Two-button approval UI — the only options the primitive supports today. */
|
||||
/**
|
||||
* Card value for the "Reject with reason…" button. Selecting it doesn't
|
||||
* finalize the reject — it holds the row and captures the approver's next DM
|
||||
* as a one-line reason relayed to the requesting agent. See reason-capture.ts.
|
||||
*/
|
||||
export const REJECT_WITH_REASON_VALUE = 'reject_with_reason';
|
||||
|
||||
/**
|
||||
* Three-button approval UI. Plain Reject is the instant fast path; "Reject with
|
||||
* reason…" opts into the reason-capture flow. Shared by every module approval
|
||||
* (create_agent, install_packages, add_mcp_server); OneCLI credential cards
|
||||
* keep their own two-button set in onecli-approvals.ts.
|
||||
*/
|
||||
const APPROVAL_OPTIONS: RawOption[] = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected (awaiting reason)', value: REJECT_WITH_REASON_VALUE },
|
||||
];
|
||||
|
||||
// ── Approval handler registry ──
|
||||
@@ -94,6 +107,29 @@ export function registerApprovalResolvedHandler(handler: ApprovalResolvedHandler
|
||||
approvalResolvedHandlers.push(handler);
|
||||
}
|
||||
|
||||
// ── Reason-reject finalizer overrides ──
|
||||
// By default a captured rejection reason is relayed as a session chat message
|
||||
// (finalizeReject). An action can register a custom finalizer to deliver the
|
||||
// reason differently — e.g. OneCLI credential rejects inject it into the
|
||||
// agent's failed tool call instead of a separate message.
|
||||
|
||||
export type ReasonRejectFinalizer = (
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
) => Promise<void>;
|
||||
|
||||
const reasonRejectFinalizers = new Map<string, ReasonRejectFinalizer>();
|
||||
|
||||
export function registerReasonRejectFinalizer(action: string, finalizer: ReasonRejectFinalizer): void {
|
||||
reasonRejectFinalizers.set(action, finalizer);
|
||||
}
|
||||
|
||||
export function getReasonRejectFinalizer(action: string): ReasonRejectFinalizer | undefined {
|
||||
return reasonRejectFinalizers.get(action);
|
||||
}
|
||||
|
||||
/** Fire every registered approval-resolved callback. Called by the response handler. */
|
||||
export async function notifyApprovalResolved(event: ApprovalResolvedEvent): Promise<void> {
|
||||
for (const handler of approvalResolvedHandlers) {
|
||||
@@ -197,6 +233,8 @@ export interface RequestApprovalOptions {
|
||||
title: string;
|
||||
/** Card body shown to the admin. */
|
||||
question: string;
|
||||
/** Deliver the card to this specific user instead of all of the session group's admins. */
|
||||
approverUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,9 +244,9 @@ export interface RequestApprovalOptions {
|
||||
* approval handler for this action via the response dispatcher.
|
||||
*/
|
||||
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
|
||||
const { session, action, payload, title, question, agentName } = opts;
|
||||
const { session, action, payload, title, question, agentName, approverUserId } = opts;
|
||||
|
||||
const approvers = pickApprover(session.agent_group_id);
|
||||
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
|
||||
if (approvers.length === 0) {
|
||||
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
|
||||
return;
|
||||
@@ -235,6 +273,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<voi
|
||||
created_at: new Date().toISOString(),
|
||||
title,
|
||||
options_json: JSON.stringify(normalizedOptions),
|
||||
approver_user_id: approverUserId ?? null,
|
||||
});
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* "Reject with reason…" capture flow.
|
||||
*
|
||||
* Covers the three entry points end to end against the real central DB:
|
||||
* - arming (handleApprovalsResponse with the third option) holds the row and
|
||||
* prompts the admin instead of finalizing;
|
||||
* - the captured reply relays one combined message, clamped to 280 chars;
|
||||
* - the host sweep finalizes a ghosted hold as a plain reject.
|
||||
*
|
||||
* writeSessionMessage is mocked so the relayed agent-facing text can be read
|
||||
* back directly; the delivery adapter is a fake that records prompt sends.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
createPendingApproval,
|
||||
deletePendingApproval,
|
||||
getPendingApproval,
|
||||
markApprovalAwaitingReason,
|
||||
} from '../../db/sessions.js';
|
||||
import { setDeliveryAdapter, type ChannelDeliveryAdapter } from '../../delivery.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { upsertUserDm } from '../permissions/db/user-dms.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
import { REJECT_WITH_REASON_VALUE } from './primitive.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-reject-reason' };
|
||||
});
|
||||
|
||||
vi.mock('../../session-manager.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../session-manager.js')>('../../session-manager.js');
|
||||
return { ...actual, writeSessionMessage: vi.fn() };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-reject-reason';
|
||||
const DM_CHANNEL = 'slack';
|
||||
const DM_PLATFORM = 'D-admin-1';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
let delivered: Array<{ channelType: string; platformId: string; content: string }>;
|
||||
|
||||
const fakeAdapter: ChannelDeliveryAdapter = {
|
||||
async deliver(channelType, platformId, _threadId, _kind, content) {
|
||||
delivered.push({ channelType, platformId, content });
|
||||
return 'pm-1';
|
||||
},
|
||||
};
|
||||
|
||||
function seedApproval(approvalId: string, action = 'create_agent'): void {
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: 'sess-1',
|
||||
request_id: approvalId,
|
||||
action,
|
||||
payload: JSON.stringify({ name: 'child' }),
|
||||
created_at: now(),
|
||||
title: 'Approval',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
function dmReply(text?: string): InboundEvent {
|
||||
const content: Record<string, unknown> = { sender: 'admin-1', senderId: 'admin-1' };
|
||||
if (text !== undefined) content.text = text;
|
||||
return {
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: DM_PLATFORM,
|
||||
threadId: null,
|
||||
message: { id: 'm-1', kind: 'chat', content: JSON.stringify(content), timestamp: now() },
|
||||
};
|
||||
}
|
||||
|
||||
/** Click the "Reject with reason…" button as the seeded admin. */
|
||||
async function clickRejectWithReason(approvalId: string): Promise<void> {
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
await handleApprovalsResponse({
|
||||
questionId: approvalId,
|
||||
value: REJECT_WITH_REASON_VALUE,
|
||||
userId: 'admin-1',
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: '', // not surfaced by the click payload — resolved via ensureUserDm
|
||||
threadId: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** The text of the most recent agent-facing note written via writeSessionMessage. */
|
||||
function lastRelayedText(): string | undefined {
|
||||
const call = vi.mocked(writeSessionMessage).mock.calls.at(-1);
|
||||
if (!call) return undefined;
|
||||
return (JSON.parse(call[2].content) as { text: string }).text;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
delivered = [];
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
// Authorized approver + a cached DM so ensureUserDm resolves without a
|
||||
// platform openDM call.
|
||||
upsertUser({ id: 'slack:admin-1', kind: 'slack', display_name: 'Admin', created_at: now() });
|
||||
grantRole({ user_id: 'slack:admin-1', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
createMessagingGroup({
|
||||
id: 'mg-dm-1',
|
||||
channel_type: DM_CHANNEL,
|
||||
platform_id: DM_PLATFORM,
|
||||
name: 'Admin DM',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
upsertUserDm({
|
||||
user_id: 'slack:admin-1',
|
||||
channel_type: DM_CHANNEL,
|
||||
messaging_group_id: 'mg-dm-1',
|
||||
resolved_at: now(),
|
||||
});
|
||||
|
||||
setDeliveryAdapter(fakeAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('reject with reason', () => {
|
||||
it('holds the row and prompts the admin instead of finalizing', async () => {
|
||||
seedApproval('appr-1');
|
||||
await clickRejectWithReason('appr-1');
|
||||
|
||||
const row = getPendingApproval('appr-1');
|
||||
expect(row?.status).toBe('awaiting_reason');
|
||||
expect(row?.expires_at).toBeTruthy();
|
||||
|
||||
// Prompt went to the admin's resolved DM, not the (empty) click platformId.
|
||||
expect(delivered).toHaveLength(1);
|
||||
expect(delivered[0].channelType).toBe(DM_CHANNEL);
|
||||
expect(delivered[0].platformId).toBe(DM_PLATFORM);
|
||||
expect((JSON.parse(delivered[0].content) as { text: string }).text).toMatch(/reason/i);
|
||||
|
||||
// Agent is not notified yet — the hold is still open.
|
||||
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('relays the captured reason as one combined message and clears the row', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-2', 'install_packages');
|
||||
await clickRejectWithReason('appr-2');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply('too risky for prod'));
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(getPendingApproval('appr-2')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin: "too risky for prod"');
|
||||
});
|
||||
|
||||
it('truncates an over-long reason to 280 chars with an ellipsis', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-3');
|
||||
await clickRejectWithReason('appr-3');
|
||||
|
||||
await captureReasonReply(dmReply('x'.repeat(400)));
|
||||
|
||||
const reason = lastRelayedText()!.match(/: "(.*)"$/)![1];
|
||||
expect(reason).toHaveLength(280);
|
||||
expect(reason.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('finalizes a plain reject when the captured reply carries no text', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-4');
|
||||
await clickRejectWithReason('appr-4');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply(undefined));
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(getPendingApproval('appr-4')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your create_agent request was rejected by admin.');
|
||||
});
|
||||
|
||||
it('does not swallow a later DM once the hold was already finalized', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-5');
|
||||
await clickRejectWithReason('appr-5');
|
||||
// Simulate the sweep (or any other path) finalizing first.
|
||||
deletePendingApproval('appr-5');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply('late reason'));
|
||||
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores DMs on channels with no armed reason capture', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
const consumed = await captureReasonReply({
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: 'D-someone-else',
|
||||
threadId: null,
|
||||
message: { id: 'm', kind: 'chat', content: JSON.stringify({ text: 'hi' }), timestamp: now() },
|
||||
});
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject-with-reason host sweep', () => {
|
||||
it('finalizes a hold whose window elapsed as a plain reject', async () => {
|
||||
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
|
||||
seedApproval('appr-ghost', 'add_mcp_server');
|
||||
markApprovalAwaitingReason('appr-ghost', new Date(Date.now() - 1000).toISOString());
|
||||
|
||||
await sweepAwaitingReasonRejects();
|
||||
|
||||
expect(getPendingApproval('appr-ghost')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your add_mcp_server request was rejected by admin.');
|
||||
});
|
||||
|
||||
it('leaves a still-open hold untouched', async () => {
|
||||
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
|
||||
seedApproval('appr-open');
|
||||
markApprovalAwaitingReason('appr-open', new Date(Date.now() + 60_000).toISOString());
|
||||
|
||||
await sweepAwaitingReasonRejects();
|
||||
|
||||
expect(getPendingApproval('appr-open')?.status).toBe('awaiting_reason');
|
||||
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain reject (regression)', () => {
|
||||
it('finalizes immediately with no reason and no DM prompt', async () => {
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
seedApproval('appr-plain', 'install_packages');
|
||||
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-plain',
|
||||
value: 'reject',
|
||||
userId: 'admin-1',
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: '',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(getPendingApproval('appr-plain')).toBeUndefined();
|
||||
expect(delivered).toHaveLength(0);
|
||||
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* "Reject with reason…" capture flow.
|
||||
*
|
||||
* When an admin clicks the third approval button, the reject is held instead of
|
||||
* finalized: the row is parked at status='awaiting_reason' and the admin is
|
||||
* prompted in their DM for a one-line reason. Their next DM (≤ 280 chars) is
|
||||
* captured by a router message-interceptor and relayed to the requesting agent
|
||||
* as one combined message — `Your <action> request was rejected by admin:
|
||||
* "<reason>"`. A plain Reject never arms this, so an unrelated DM is never
|
||||
* swallowed.
|
||||
*
|
||||
* Restart-safety: arming lives in an in-memory map (lost on restart, like the
|
||||
* agent-naming capture it mirrors), but the hold is a durable DB row. If the
|
||||
* admin never replies — or the host restarts mid-capture — the host sweep
|
||||
* (sweepAwaitingReasonRejects, run each tick) finalizes a plain reject once the
|
||||
* row's window elapses, so the requesting agent is never stranded.
|
||||
*
|
||||
* Reuses, not reinvents: the agent-naming prompt-then-capture pattern
|
||||
* (in-memory map + next-DM interceptor) and the shared finalizeReject path.
|
||||
*/
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import {
|
||||
deletePendingApproval,
|
||||
findSessionByAgentGroup,
|
||||
getExpiredAwaitingReasonApprovals,
|
||||
getPendingApproval,
|
||||
getSession,
|
||||
markApprovalAwaitingReason,
|
||||
} from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { registerMessageInterceptor } from '../../router.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
import { finalizeReject } from './finalize.js';
|
||||
import { getReasonRejectFinalizer } from './primitive.js';
|
||||
|
||||
/** How long an awaiting-reason hold waits for the admin's reply before the sweep finalizes a plain reject. */
|
||||
const REASON_CAPTURE_WINDOW_MS = 5 * 60 * 1000;
|
||||
/** Cap on the relayed reason — one cheap guardrail against a wall of text landing in another team's agent context. */
|
||||
const MAX_REASON_LEN = 280;
|
||||
|
||||
const PROMPT_TEXT =
|
||||
"Reply with a one-line reason for the rejection — I'll relay it to the agent. " +
|
||||
'No reply within ~5 min declines it without a reason.';
|
||||
|
||||
interface ReasonArming {
|
||||
approvalId: string;
|
||||
/** Namespaced id of the admin who clicked, for resolution attribution. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approvers waiting to type a rejection reason, keyed by their DM channel
|
||||
* (`<channelType>:<dmPlatformId>`). A DM's platform id is unique per user, so
|
||||
* the inbound reply matches by channel alone — no sender re-parsing needed, and
|
||||
* a group message can never collide with an armed DM. Cleared on receipt,
|
||||
* staleness, or restart.
|
||||
*/
|
||||
const awaitingReason = new Map<string, ReasonArming>();
|
||||
|
||||
function dmKey(channelType: string, platformId: string): string {
|
||||
return `${channelType}:${platformId}`;
|
||||
}
|
||||
|
||||
function clampReason(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length <= MAX_REASON_LEN) return trimmed;
|
||||
return trimmed.slice(0, MAX_REASON_LEN - 1) + '…';
|
||||
}
|
||||
|
||||
function extractText(event: InboundEvent): string {
|
||||
try {
|
||||
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
return typeof parsed.text === 'string' ? parsed.text : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin the reject-with-reason hold for an approval the admin chose not to
|
||||
* finalize outright. Prompts the admin's DM, then parks the row and arms
|
||||
* capture. If we can't reach the admin (no DM, no adapter, delivery throws) we
|
||||
* finalize a plain reject immediately rather than strand the requesting agent.
|
||||
*/
|
||||
export async function armReasonCapture(approval: PendingApproval, session: Session, userId: string): Promise<void> {
|
||||
const dm = userId ? await ensureUserDm(userId) : null;
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!dm || !adapter) {
|
||||
log.warn('reject-with-reason: cannot reach approver, finalizing plain reject', {
|
||||
approvalId: approval.approval_id,
|
||||
userId,
|
||||
hasDm: Boolean(dm),
|
||||
hasAdapter: Boolean(adapter),
|
||||
});
|
||||
await finalizeReject(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.deliver(dm.channel_type, dm.platform_id, null, 'chat-sdk', JSON.stringify({ text: PROMPT_TEXT }));
|
||||
} catch (err) {
|
||||
log.error('reject-with-reason: reason prompt delivery failed, finalizing plain reject', {
|
||||
approvalId: approval.approval_id,
|
||||
err,
|
||||
});
|
||||
await finalizeReject(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt is out — now hold the row and arm capture. Order matters: a reply
|
||||
// can't arrive before the prompt is read, so there's no lost-message window.
|
||||
const expiresAt = new Date(Date.now() + REASON_CAPTURE_WINDOW_MS).toISOString();
|
||||
markApprovalAwaitingReason(approval.approval_id, expiresAt);
|
||||
awaitingReason.set(dmKey(dm.channel_type, dm.platform_id), { approvalId: approval.approval_id, userId });
|
||||
log.info('reject-with-reason: awaiting reason reply', { approvalId: approval.approval_id, userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Router message-interceptor: capture the next DM from an admin who armed a
|
||||
* reason. Returns true (consume the message) when this DM is an armed reason
|
||||
* channel and still holds a live row; false otherwise so normal routing runs.
|
||||
*
|
||||
* Exported for tests; registered as the interceptor below.
|
||||
*/
|
||||
export async function captureReasonReply(event: InboundEvent): Promise<boolean> {
|
||||
const arming = awaitingReason.get(dmKey(event.channelType, event.platformId));
|
||||
if (!arming) return false;
|
||||
|
||||
// This DM is an armed reason channel — disarm regardless of outcome.
|
||||
awaitingReason.delete(dmKey(event.channelType, event.platformId));
|
||||
|
||||
const approval = getPendingApproval(arming.approvalId);
|
||||
if (!approval || approval.status !== 'awaiting_reason') {
|
||||
// Already finalized (e.g. ghosted by the sweep). The reply is no longer a
|
||||
// reason — let it route normally instead of swallowing it.
|
||||
return false;
|
||||
}
|
||||
|
||||
// OneCLI credential rows carry no session_id — fall back to the originating
|
||||
// agent group's active session so the reason still reaches the agent.
|
||||
const session =
|
||||
(approval.session_id ? getSession(approval.session_id) : null) ??
|
||||
(approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : null);
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const reason = clampReason(extractText(event));
|
||||
// Action-specific delivery (e.g. OneCLI injects the reason into the failed
|
||||
// tool call); default is the chat-message relay.
|
||||
const finalizer = getReasonRejectFinalizer(approval.action) ?? finalizeReject;
|
||||
await finalizer(approval, session, arming.userId, reason || undefined);
|
||||
log.info('reject-with-reason: reason captured and relayed', {
|
||||
approvalId: approval.approval_id,
|
||||
hasReason: reason.length > 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
registerMessageInterceptor(captureReasonReply);
|
||||
|
||||
/**
|
||||
* Host-sweep finalizer: any reject-with-reason hold whose window elapsed (admin
|
||||
* ghosted, or the host restarted mid-capture and lost the in-memory arming) is
|
||||
* finalized as a plain reject. Restart-safe — the hold is a durable row, so the
|
||||
* requesting agent always gets its decision. Called once per sweep tick.
|
||||
*/
|
||||
export async function sweepAwaitingReasonRejects(): Promise<void> {
|
||||
const rows = getExpiredAwaitingReasonApprovals(new Date().toISOString());
|
||||
for (const approval of rows) {
|
||||
const session =
|
||||
(approval.session_id ? getSession(approval.session_id) : null) ??
|
||||
(approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : null);
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
continue;
|
||||
}
|
||||
// Plain reject, unknown resolver — the admin opted in but never typed.
|
||||
await finalizeReject(approval, session, '');
|
||||
log.info('reject-with-reason: window elapsed, finalized as plain reject', { approvalId: approval.approval_id });
|
||||
}
|
||||
}
|
||||
@@ -161,4 +161,47 @@ describe('approval response authorization', () => {
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(getPendingApproval('appr-3')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('an approval with approver_user_id is resolvable by that user, not a non-assignee', async () => {
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('assigned_approver_action', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-4',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-4',
|
||||
action: 'assigned_approver_action',
|
||||
payload: JSON.stringify({}),
|
||||
created_at: now(),
|
||||
title: 'Assigned approval',
|
||||
options_json: JSON.stringify([]),
|
||||
approver_user_id: 'telegram:dana',
|
||||
});
|
||||
|
||||
// A non-assignee (no global/owner role) cannot resolve it.
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-4',
|
||||
value: 'approve',
|
||||
userId: 'stranger',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-stranger',
|
||||
threadId: null,
|
||||
});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(getPendingApproval('appr-4')).toBeDefined();
|
||||
|
||||
// The named approver resolves it.
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-4',
|
||||
value: 'approve',
|
||||
userId: 'dana',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-dana',
|
||||
threadId: null,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(getPendingApproval('appr-4')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* 1. Module-initiated actions — the module called `requestApproval()` with
|
||||
* some free-form `action` string and registered a handler via
|
||||
* `registerApprovalHandler(action, handler)`. On approve, we look up the
|
||||
* handler and call it; on reject, we notify the agent and move on.
|
||||
* handler and call it; on plain reject we relay a decline to the agent; on
|
||||
* "Reject with reason…" we hold the row and capture the admin's next DM as
|
||||
* a one-line reason (see reason-capture.ts). Reject finalization is shared
|
||||
* via finalizeReject.
|
||||
* 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved
|
||||
* via an in-memory Promise — see onecli-approvals.ts.
|
||||
*
|
||||
@@ -13,14 +16,16 @@
|
||||
* core iterates handlers and the first one to return `true` claims the response.
|
||||
*/
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
|
||||
import { deletePendingApproval, findSessionByAgentGroup, getPendingApproval, getSession } from '../../db/sessions.js';
|
||||
import type { ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
|
||||
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { getApprovalHandler, notifyApprovalResolved } from './primitive.js';
|
||||
import { finalizeReject } from './finalize.js';
|
||||
import { holdOneCLIApprovalForReason, ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { getApprovalHandler, notifyApprovalResolved, REJECT_WITH_REASON_VALUE } from './primitive.js';
|
||||
import { armReasonCapture } from './reason-capture.js';
|
||||
|
||||
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const approval = getPendingApproval(payload.questionId);
|
||||
@@ -37,6 +42,28 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
|
||||
}
|
||||
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
// "Reject with reason…" — the gateway decision is HELD open while the
|
||||
// reason is captured, so the agent's pending HTTP call fails only after
|
||||
// the reason is already relayed into its session (finalizeReject relays,
|
||||
// then the approval-resolved handler in onecli-approvals releases the
|
||||
// deny; the gateway TTL is the safety valve). OneCLI rows carry no
|
||||
// session_id; resolve the session via the originating agent group.
|
||||
if (payload.value === REJECT_WITH_REASON_VALUE) {
|
||||
const session = approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : undefined;
|
||||
if (!session) {
|
||||
log.warn('OneCLI reject-with-reason: no active session to relay to — plain reject', {
|
||||
approvalId: approval.approval_id,
|
||||
agentGroupId: approval.agent_group_id,
|
||||
});
|
||||
if (!resolveOneCLIApproval(payload.questionId, 'reject')) deletePendingApproval(payload.questionId);
|
||||
return true;
|
||||
}
|
||||
// false ⇒ the resolver died with a previous process (the gateway side is
|
||||
// already settled) — the reason relay is still worth arming.
|
||||
holdOneCLIApprovalForReason(payload.questionId);
|
||||
await armReasonCapture(approval, session, namespacedUserId(payload) ?? '');
|
||||
return true;
|
||||
}
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
@@ -65,6 +92,21 @@ async function handleRegisteredApproval(
|
||||
return;
|
||||
}
|
||||
|
||||
// "Reject with reason…" — hold the row and capture the admin's next DM
|
||||
// instead of finalizing now. The agent is notified exactly once: after the
|
||||
// reason arrives, or after the sweep's timeout if the admin ghosts.
|
||||
if (selectedOption === REJECT_WITH_REASON_VALUE) {
|
||||
await armReasonCapture(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain Reject (or any other non-approve value) — instant fast path.
|
||||
if (selectedOption !== 'approve') {
|
||||
await finalizeReject(approval, session, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved — dispatch to the module that registered for this action.
|
||||
const notify = (text: string): void => {
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -77,16 +119,6 @@ async function handleRegisteredApproval(
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedOption !== 'approve') {
|
||||
notify(`Your ${approval.action} request was rejected by admin.`);
|
||||
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
await wakeContainer(session);
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved — dispatch to the module that registered for this action.
|
||||
const handler = getApprovalHandler(approval.action);
|
||||
if (!handler) {
|
||||
log.warn('No approval handler registered — row dropped', {
|
||||
@@ -125,6 +157,11 @@ function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponseP
|
||||
const userId = namespacedUserId(payload);
|
||||
if (!userId) return false;
|
||||
|
||||
// An approval may name a specific approver; only that exact user may resolve it.
|
||||
if (approval.approver_user_id) {
|
||||
return userId === approval.approver_user_id;
|
||||
}
|
||||
|
||||
const agentGroupId =
|
||||
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
routeInbound,
|
||||
setAccessGate,
|
||||
setChannelRequestGate,
|
||||
setMessageInterceptor,
|
||||
registerMessageInterceptor,
|
||||
setSenderResolver,
|
||||
setSenderScopeGate,
|
||||
type AccessGateResult,
|
||||
@@ -521,7 +521,7 @@ registerResponseHandler(handleChannelApprovalResponse);
|
||||
// Captures the next DM from an approver who clicked "Create new agent",
|
||||
// creates the agent immediately, wires the channel, and replays.
|
||||
|
||||
setMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return false;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Host-side provider container-config barrel.
|
||||
// Providers that need host-side container setup (extra mounts, env passthrough,
|
||||
// per-session directories) self-register on import. Providers with no host
|
||||
// needs (claude, mock) don't appear here.
|
||||
// needs (claude) don't appear here.
|
||||
//
|
||||
// Skills add a new provider by appending one import line below.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* the registered config fn, and merges the returned mounts/env into the spawn
|
||||
* args.
|
||||
*
|
||||
* Providers without host-side needs (e.g. `claude`, `mock`) don't appear in
|
||||
* Providers without host-side needs (e.g. `claude`) don't appear in
|
||||
* this registry at all — the lookup returns `undefined` and the spawn path
|
||||
* proceeds with only the default mounts and env.
|
||||
*
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user