Compare commits

...

47 Commits

Author SHA1 Message Date
gavrielc 2ba2555032 Merge branch 'main' into feat/onecli-approval-card-summary 2026-07-04 16:28:01 +03:00
github-actions[bot] 687d7d13ac chore: bump version to 2.1.27 2026-07-04 13:26:56 +00:00
gavrielc 2938bbf94a Merge pull request #2928 from nanocoai/cleanup/remove-dead-global-mount
Remove the dead /workspace/global mount and untrack v1 group seed files
2026-07-04 16:26:42 +03:00
gavrielc d1a2b04d32 Merge branch 'main' into cleanup/remove-dead-global-mount 2026-07-04 16:26:10 +03:00
github-actions[bot] 455014e7b9 chore: bump version to 2.1.26 2026-07-04 13:25:56 +00:00
gavrielc 5032c431ae Merge pull request #2927 from nanocoai/cleanup/unregister-mock-provider
Unregister the mock provider from the production container barrel
2026-07-04 16:25:40 +03:00
gavrielc ac8273c698 Merge branch 'main' into cleanup/unregister-mock-provider 2026-07-04 16:24:37 +03:00
gavrielc f4be00e6ed Remove dead /workspace/global mount and untrack v1 group seed files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:14 +03:00
gavrielc c9aa69dea9 Unregister the mock provider from the production container barrel
The container self-registration barrel imported mock.js, so every container
registered a 'mock' provider returning canned text. A typo'd --provider mock
would silently 'work' instead of failing loudly. Drop the import (mock.ts stays
for direct test use) and tidy the now-stale host comments and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:09 +03:00
gavrielc 3731e26769 feat(approvals): render OneCLI approval requests from the gateway's structured summary
The hosted OneCLI gateway (api.onecli.sh) sends a structured summary field
on ApprovalRequest — { action, details: [{label, value}] } — that the SDK's
TypeScript type doesn't declare yet. When present, render it as the approval
card body (*Action:* plus labeled fields, fencing multi-line values) instead
of the raw METHOD host/path + bodyPreview, so approvers see what the agent
is doing (e.g. To / Subject / Body of an email send) rather than an HTTP
trace.

Rendering is defensive: non-string values are coerced via JSON.stringify (a
render throw would turn into a deny via handleRequest's catch), each value is
capped at 900 chars, and a 2600-char running budget keeps the card under
Slack's 3000-char section block limit — overflow is reported with an
explicit "…N field(s) omitted for length" line instead of a failed
delivery. Without a summary, the old bodyPreview fallback remains, now
capped and with the method/host/path as a footnote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:08 +03:00
github-actions[bot] b28c917997 docs: update token count to 207k tokens · 104% of context window 2026-07-04 08:08:53 +00:00
github-actions[bot] a00a5610bd chore: bump version to 2.1.25 2026-07-04 08:08:50 +00:00
gavrielc 05dc1b0a3c Merge pull request #2611 from Hinotoi-agent/fix/approval-cli-caller-context
[security] fix(cli): preserve caller context after approval
2026-07-04 11:08:39 +03:00
glifocat a7b34bc872 Merge branch 'main' into fix/approval-cli-caller-context 2026-07-04 09:11:27 +02:00
github-actions[bot] aecad864e6 chore: bump version to 2.1.24 2026-07-02 11:57:00 +00:00
gavrielc c87f2e55dc Merge pull request #2890 from amit-shafnir/worktree-nanoclaw-templates
feat(templates): local template loader, ncl --template, and docs
2026-07-02 14:56:45 +03:00
Amit Shafnir 411f5e71df feat(templates): local template loader, ncl --template, provider-agnostic persona and skills seams
Agent templates: folder-only templates under templates/ (context/instructions.md +
optional context extras, .mcp.json, skills/). Stamping via ncl groups create
--template writes the provider-neutral instructions.prepend.md (inlined at the top
of CLAUDE.md/AGENTS.md every spawn), copies context extras preserving their
template-relative layout, writes MCP servers to container config, and installs the
per-group skills overlay. Includes docs (docs/templates.md, templates/README.md).

Setup-wizard wiring ships separately on top of this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:36:33 +03:00
gavrielc cb6e3d117c Merge pull request #2885 from PartridgeNet/fix/slack-setup-socket-mode
fix(setup): offer Slack Socket Mode in the guided setup flow
2026-06-30 23:05:45 +03:00
gavrielc ed7e3f70da Merge branch 'main' into fix/slack-setup-socket-mode 2026-06-30 23:04:38 +03:00
github-actions[bot] 557e073c2f chore: bump version to 2.1.23 2026-06-30 19:28:09 +00:00
gavrielc 91ebc9def2 chore(container): bump claude-code, agent SDK to latest
- @anthropic-ai/claude-code (cli-tools.json): 2.1.170 → 2.1.197
- @anthropic-ai/claude-agent-sdk: ^0.3.170 → ^0.3.197
- @anthropic-ai/sdk: ^0.100.0 → ^0.108.0

Typecheck and all 112 agent-runner tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:27:46 +03:00
github-actions[bot] 14c89e9716 docs: update token count to 204k tokens · 102% of context window 2026-06-30 15:49:15 +00:00
github-actions[bot] 549c424a38 chore: bump version to 2.1.22 2026-06-30 15:49:12 +00:00
gavrielc 186b9befcd Merge pull request #2880 from johnmathews/fix/2828-inbox-symlink-containment-upstream
fix(security): contain inbox symlink escapes in attachment writes (#2828)
2026-06-30 18:48:59 +03:00
John Mathews 863d413d32 Merge branch 'main' into fix/2828-inbox-symlink-containment-upstream 2026-06-29 18:27:07 +02:00
Rob Stevenson cf8478ffbb fix(setup): offer Slack Socket Mode in the guided setup flow
PR #2837 added Slack Socket Mode end-to-end ("adapter + guided setup") but
was merged into the `channels` branch, not `main`. As a result the
`setup:auto` Slack flow on main is webhook-only: it always collects a
signing secret and pushes the user toward a public Request URL, with no
Socket Mode option — even though setup/verify.ts already recognizes
SLACK_APP_TOKEN and the channels-branch adapter supports it.

Forward-port the setup-side of #2837 onto current main, re-authored on top
of main's current flow (back-nav, inline agent wiring, operator-role prompt,
welcome DM all preserved):

- setup/channels/slack.ts: add a mode picker (askSlackMode), mode-specific
  app-creation steps, collectAppToken() for the xapp- app-level token,
  conditional credential collection + env, and a mode-aware post-install
  checklist (Socket Mode skips the public-URL guidance).
- setup/add-slack.sh: require/persist either SLACK_APP_TOKEN (Socket Mode)
  or SLACK_SIGNING_SECRET (webhook) instead of mandating the signing secret.

Scope is setup-side only: the adapter's socket support already lives on
`channels` (#2837/#2839) and reaches users via /add-slack; the add-slack
SKILL.md doc is handled by #2700.
2026-06-29 14:45:31 +01:00
github-actions[bot] 8be5be93ba docs: update token count to 203k tokens · 101% of context window 2026-06-29 05:58:05 +00:00
omri-maya add3fc8f70 Merge pull request #2882 from nanocoai/fix/ncl-messaging-group-instance
fix(ncl): default messaging-groups create instance to channel_type
2026-06-29 08:57:53 +03:00
Omri Maya 0d841bcd05 fix(ncl): default messaging-groups create instance to channel_type
`ncl messaging-groups create` failed with a NOT NULL violation on the
`instance` column (migration 016). The generic CRUD insert builds its
column list from the resource definition, and `instance` wasn't declared
there — so the INSERT omitted the column entirely. The router path never
hit this because it goes through `createMessagingGroup`, which has its own
`instance ?? channel_type` fallback.

There is no operator-facing reason to require `--instance`: the default
instance IS the channel type (migration 016, `createMessagingGroup`, and
the default-instance resolver all encode this). So rather than force a
flag, default it.

- crud: add `defaultFrom` to ColumnDef — default a column to another
  already-resolved column's value on create. Generic, reusable.
- messaging-groups: declare `instance` with `defaultFrom: 'channel_type'`
  (placed after channel_type so it resolves first), still overridable via
  `--instance` for multi-instance setups.
- test: drive the real dispatch('messaging-groups-create') path; asserts
  omitted -> channel_type and explicit --instance preserved. Goes red if
  the column/defaultFrom wiring is deleted (insert fails NOT NULL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:38:22 +03:00
John Mathews dd1d0e5677 fix(security): contain channel-inbound attachments via shared inbox guard (#2828)
extractAttachmentFiles (the channel-inbound attachment path) hardened only
the per-message inbox subdir, not the `inbox` root itself. A compromised
container can write inside its own session dir, so it could replace `inbox`
with a symlink: mkdirSync({recursive}) then followed it, and the realpath
containment check passed because it compared against realpathSync(inboxRoot)
— which had already followed the symlink. A brand-new attachment file (the
`wx` flag only blocks an existing dst) therefore landed outside the session
sandbox. This is the same symlink-follow class fixed for the A2A path in
#2828 (CWE-59), but reachable from ordinary inbound messages.

Extract the guard both inbound paths need into src/inbox-safety.ts
(ensureContainedInboxDir + isPathInside): lstat-reject a pre-placed symlink
or non-dir at the inbox root AND the per-message subdir before mkdir, then a
realpath containment check. forwardAttachedFiles and extractAttachmentFiles
now share it, removing the duplicated guard logic. Callers still write with
an exclusive flag (COPYFILE_EXCL / wx) and skip-with-warn on failure.

Adds src/session-manager.attachments.test.ts (red before this change, green
after) covering the symlinked inbox-root vector on the channel path.
2026-06-29 00:27:58 +02:00
John Mathews 36afa40857 fix(agent-to-agent): containment-check target inbox in forwardAttachedFiles (#2828)
forwardAttachedFiles hardened only the source side of A2A attachment
forwarding; the target side called fs.mkdirSync({recursive}) and
fs.copyFileSync without any symlink or containment checks. A compromised
target agent that can write inside its own session dir could pre-place
`inbox` (or `inbox/<future-msgId>`) as a symlink pointing anywhere
host-writable — mkdirSync silently follows it and copyFileSync lands
attacker-influenced bytes outside the sandbox (CWE-59, GHSA #2828). This
mirrors the existing defensive pattern in src/session-manager.ts
saveAttachments(): lstat-reject a pre-existing symlink/non-dir at the
inbox root and the per-message subdir before mkdir, realpath + isPathInside
containment check, and an exclusive (COPYFILE_EXCL) copy that refuses to
follow or overwrite a pre-placed symlinked destination. Failures log.warn
with structured context and skip rather than throw, so one bad attachment
never kills a batch. Tests cover a symlinked inbox dir, a symlinked
inbox/<msgId> subdir, a pre-existing symlinked destination file, and a
normal end-to-end forward regression.
2026-06-29 00:27:58 +02:00
gavrielc 2afbd18233 Merge pull request #2859 from cben0ist/fix/migrate-v2-is-main
fix(migrate-v2): don't SELECT is_main from v1 registered_groups
2026-06-26 13:38:42 +03:00
gavrielc 953496dc37 Merge branch 'main' into fix/migrate-v2-is-main 2026-06-26 13:38:27 +03:00
Christophe Benoist 797491d8b3 fix(migrate-v2): don't SELECT is_main from v1 registered_groups
The v2 DB seed queried `is_main` from the v1 `registered_groups` table, but
that column was a later v1 addition — older v1 installs (e.g. 1.1.0) don't have
it, so the migration's `1b-db` step crashes with `no such column: is_main` and
v2.db is never created, cascading into the sessions and tasks steps failing.

`is_main` was selected into the V1Group interface but never read anywhere, so
this just drops it from the SELECT and the interface. The accompanying comment
already states the intent ("Query only the columns we know exist in all v1
installs") — the code now matches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:43:19 -04:00
github-actions[bot] 2df754459b chore: bump version to 2.1.21 2026-06-25 18:59:54 +00:00
gavrielc 0896d4089e Merge pull request #2832 from nanocoai/feat/reject-with-reason
feat(approvals): reject with reason
2026-06-25 21:59:42 +03:00
gavrielc d153d91307 Merge branch 'main' into feat/reject-with-reason 2026-06-25 21:45:12 +03:00
gavrielc ce55af12d5 Merge pull request #2843 from robbyczgw-cla/feat/learn-skill
feat: add /learn skill — distill or refine a reusable skill from anything
2026-06-25 21:41:23 +03:00
gavrielc 545800a94e Merge branch 'main' into feat/learn-skill 2026-06-25 21:40:55 +03:00
github-actions[bot] bfb309bd0c chore: bump version to 2.1.20 2026-06-25 18:34:34 +00:00
gavrielc 38d9390eea Merge pull request #2856 from nanocoai/container-limits
feat(container): per-container CPU/memory limits (opt-in)
2026-06-25 21:34:16 +03:00
robbyczgw-cla 520ec44aec feat: add /learn skill — distill or refine a reusable skill from anything
Instruction-only skill that distills a reusable skill from any source
(directory, URL, pasted notes, or the current conversation) or refines an
existing skill in place. Uses existing agent tools (Read/Grep/Glob/WebFetch/
Write) and injects the project's skill-authoring guidelines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:49 +02:00
gavrielc 8c6a243ffd Merge branch 'main' into feat/reject-with-reason 2026-06-23 15:46:02 +03:00
Moshe Krupper 2ac7809385 feat(agent-to-agent): clarify the a2a gate approval prompt
Replace the terse "Approve delivery?" with a one-line legend that names all
three buttons and notes that "Reject with reason…" prompts the approver to
type a reason relayed back to the sender. The longer line also widens the
card bubble, easing the three-button-row truncation on platforms that size
buttons to the message width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:16:03 +03:00
Moshe Krupper e8148bc0a7 feat(approvals): reject-with-reason — relay an optional decline reason to the agent
Add a third "Reject with reason…" button to module approval cards. Plain
Reject stays the instant fast path; the new option holds the row
(status='awaiting_reason'), DM-prompts the approver, and captures their
next DM (≤280 chars, truncated) as a one-line reason relayed to the
requesting agent as a single combined message. A ghosted hold is
finalized as a plain reject by the host sweep after ~5 min — restart-safe
via the durable DB row.

- Generalize the router message-interceptor to a list
  (registerMessageInterceptor) so approvals can capture replies alongside
  the permissions agent-naming flow.
- Share reject finalization across the instant, captured, and swept paths
  via finalizeReject.
- Scope: all module approvals (create_agent, install_packages,
  add_mcp_server); OneCLI credential cards are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:55:01 +03:00
Hinotobi 0516bea638 Merge branch 'main' into fix/approval-cli-caller-context 2026-06-11 11:29:11 +08:00
hinotoi-agent 32f067f5bb fix(cli): preserve caller context after approval 2026-05-25 19:51:54 +08:00
57 changed files with 2408 additions and 625 deletions
+97
View File
@@ -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.
+1
View File
@@ -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
+4
View File
@@ -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.
+1
View File
@@ -82,6 +82,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
## Usage
+12 -12
View File
@@ -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=="],
+2 -2
View File
@@ -9,8 +9,8 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
@@ -3,4 +3,3 @@
// level. Skills add a new provider by appending one import line below.
import './claude.js';
import './mock.js';
+1 -1
View File
@@ -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 }
]
+1 -1
View File
@@ -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")]
+177
View File
@@ -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.
-166
View File
@@ -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
-312
View File
@@ -1,312 +0,0 @@
@./.claude-global.md
# Main
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
Format messages based on the channel. Check the group folder name prefix:
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes like `:white_check_mark:`, `:rocket:`
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord (folder starts with `discord_`)
Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Admin Context
This is the **main channel**, which has elevated privileges.
## Authentication
Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
## Container Mounts
Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder:
| Container Path | Host Path | Access |
|----------------|-----------|--------|
| `/workspace/project` | Project root | read-only |
| `/workspace/project/store` | `store/` | read-write |
| `/workspace/group` | `groups/main/` | read-write |
Key paths inside the container:
- `/workspace/project/store/messages.db` - SQLite database (read-write)
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
- `/workspace/project/groups/` - All group folders
---
## Managing Groups
### Finding Available Groups
Available groups are provided in `/workspace/ipc/available_groups.json`:
```json
{
"groups": [
{
"jid": "120363336345536173@g.us",
"name": "Family Chat",
"lastActivity": "2026-01-31T12:00:00.000Z",
"isRegistered": false
}
],
"lastSync": "2026-01-31T12:00:00.000Z"
}
```
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
If a group the user mentions isn't in the list, request a fresh sync:
```bash
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
```
Then wait a moment and re-read `available_groups.json`.
**Fallback**: Query the SQLite database directly:
```bash
sqlite3 /workspace/project/store/messages.db "
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
ORDER BY last_message_time DESC
LIMIT 10;
"
```
### Registered Groups Config
Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
"folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
}
```
Fields:
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
2. Ask the user whether the group should require a trigger word before registering
3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting
4. Optionally include `containerConfig` for additional mounts
5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
6. Optionally create an initial `CLAUDE.md` for the group
Folder naming convention — channel prefix with underscore separator:
- WhatsApp "Family Chat" → `whatsapp_family-chat`
- Telegram "Dev Team" → `telegram_dev-team`
- Discord "General" → `discord_general`
- Slack "Engineering" → `slack_engineering`
- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
Groups can have extra directories mounted. Add `containerConfig` to their entry:
```json
{
"1234567890@g.us": {
"name": "Dev Team",
"folder": "dev-team",
"trigger": "@Andy",
"added_at": "2026-01-31T12:00:00Z",
"containerConfig": {
"additionalMounts": [
{
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}
]
}
}
}
```
The directory will appear at `/workspace/extra/webapp` in that group's container.
#### Sender Allowlist
After registering a group, explain the sender allowlist feature to the user:
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
>
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
>
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
```json
{
"default": { "allow": "*", "mode": "trigger" },
"chats": {
"<chat-jid>": {
"allow": ["sender-id-1", "sender-id-2"],
"mode": "trigger"
}
},
"logDenied": true
}
```
Notes:
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
### Removing a Group
1. Read `/workspace/project/data/registered_groups.json`
2. Remove the entry for that group
3. Write the updated JSON back
4. The group folder and its files remain (don't delete them)
### Listing Groups
Read `/workspace/project/data/registered_groups.json` and format it nicely.
---
## Global Memory
You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
---
## Scheduling for Other Groups
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory.
---
## Task Scripts
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
Use `list_tasks` to see existing tasks (one row per series with the stable id), and `update_task` / `cancel_task` / `pause_task` / `resume_task` to modify them. Prefer `update_task` over cancel + reschedule when adjusting an existing task.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.19",
"version": "2.1.27",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="199k tokens, 100% of context window">
<title>199k tokens, 100% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="207k tokens, 104% of context window">
<title>207k tokens, 104% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">199k</text>
<text x="71" y="14">199k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
<text x="71" y="14">207k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+13 -5
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
# .env + data/env/env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
#
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
@@ -41,8 +42,10 @@ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
emit_status failed "SLACK_BOT_TOKEN env var not set"
exit 1
fi
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "SLACK_SIGNING_SECRET env var not set"
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
# SLACK_SIGNING_SECRET. Require at least one.
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
exit 1
fi
@@ -98,7 +101,12 @@ upsert_env() {
fi
}
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
fi
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
+124 -21
View File
@@ -4,15 +4,18 @@
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
*
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test → resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
* URL) or a public webhook
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* events, and the mode-specific credential (app-level token for
* Socket Mode, signing secret for webhook)
* 3. Paste the bot token + that credential (clack password prompts)
* 4. Validate via auth.test → resolves workspace + bot identity
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
* 6. Ask for the operator's Slack user ID
* 7. conversations.open to get the DM channel ID
* 8. Ask for the messaging-agent name (defaulting to "Nano")
* 9. Wire the agent via scripts/init-first-agent.ts
*
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
@@ -45,14 +48,26 @@ interface WorkspaceInfo {
botUserId: string;
}
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
// credential to collect and which post-install guidance to show.
type SlackMode = 'socket' | 'webhook';
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
const intro = await walkThroughAppCreation();
const mode = await askSlackMode();
const intro = await walkThroughAppCreation(mode);
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
const token = await collectBotToken();
const signingSecret = await collectSigningSecret();
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
const info = await validateSlackToken(token);
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
if (appToken) env.SLACK_APP_TOKEN = appToken;
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
const install = await runQuietChild(
'slack-install',
'bash',
@@ -62,11 +77,9 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
done: 'Slack adapter installed.',
},
{
env: {
SLACK_BOT_TOKEN: token,
SLACK_SIGNING_SECRET: signingSecret,
},
env,
extraFields: {
MODE: mode,
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
@@ -122,10 +135,45 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
);
}
showPostInstallChecklist(info);
showPostInstallChecklist(info, mode);
}
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
async function askSlackMode(): Promise<SlackMode> {
const choice = ensureAnswer(
await brightSelect<SlackMode>({
message: 'How should Slack deliver events to NanoClaw?',
initialValue: 'socket',
options: [
{
value: 'socket',
label: 'Socket Mode',
hint: 'no public URL — recommended for local or behind NAT',
},
{
value: 'webhook',
label: 'Public webhook',
hint: 'needs a public HTTPS Request URL',
},
],
}),
);
setupLog.userInput('slack_mode', String(choice));
return choice;
}
async function walkThroughAppCreation(mode: SlackMode): Promise<'continue' | 'back'> {
const credSteps =
mode === 'socket'
? [
' 4. Basic Information → App-Level Tokens → "Generate Token and',
' Scopes" → add the connections:write scope → copy it (xapp-…)',
' 5. Socket Mode → toggle "Enable Socket Mode" on',
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
]
: [
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
];
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
const linkBlock = isHeadless()
@@ -149,8 +197,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
' • files:read, files:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
...credSteps,
].join('\n'),
'Create a Slack app',
);
@@ -171,7 +218,10 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
ensureAnswer(
await p.confirm({
message: 'Got your bot token and signing secret?',
message:
mode === 'socket'
? 'Got your bot token and app-level token?'
: 'Got your bot token and signing secret?',
initialValue: true,
}),
);
@@ -249,6 +299,40 @@ async function collectSigningSecret(): Promise<string> {
return secret;
}
async function collectAppToken(): Promise<string> {
const existing = readEnvKey('SLACK_APP_TOKEN');
if (existing && existing.startsWith('xapp-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack app-level token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_app_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack app-level token (Socket Mode)',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'App-level token is required for Socket Mode';
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
if (t.length < 24) return "That's shorter than a real Slack app-level token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_app_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
@@ -416,7 +500,26 @@ async function resolveAgentName(): Promise<string> {
return value;
}
function showPostInstallChecklist(info: WorkspaceInfo): void {
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
if (mode === 'socket') {
note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`Socket Mode is on — ${info.teamName} reaches NanoClaw over an outbound`,
`WebSocket, so there's no public URL to configure.`,
'',
' • Just DM @' + info.botName + ' from Slack — replies flow straight away.',
'',
' • Keep the NanoClaw host running to hold the socket open —',
' Slack does not retry delivery while it is down.',
].join('\n'),
6,
),
'Finish setting up Slack',
);
return;
}
note(
wrapForGutter(
[
+1 -2
View File
@@ -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();
+93
View File
@@ -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 -3
View File
@@ -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');
+4
View File
@@ -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];
}
}
+74 -2
View File
@@ -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
View File
@@ -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 } };
}
+35 -5
View File
@@ -1,6 +1,9 @@
import { randomUUID } from 'crypto';
import type { McpServerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
import { restartAgentGroupContainers } from '../../container-restart.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { getDb, hasTable } from '../../db/connection.js';
import { getSession } from '../../db/sessions.js';
import { writeSessionMessage } from '../../session-manager.js';
@@ -9,7 +12,8 @@ import {
updateContainerConfigScalars,
updateContainerConfigJson,
} from '../../db/container-configs.js';
import type { ContainerConfigRow } from '../../types.js';
import { createAgentFromTemplate } from '../../templates/create-agent.js';
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
/** Deserialize JSON columns for display. */
@@ -58,11 +62,37 @@ registerResource({
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
// `delete` is intentionally not in `operations` — the generic single-table
// DELETE violates FK constraints (see #2525). The cascading handler is
// provided as `customOperations.delete` below.
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
// `create` and `delete` are intentionally not in `operations` — create needs
// a `--template` branch (below); the generic single-table DELETE violates FK
// constraints (see #2525). Both are provided as `customOperations`.
operations: { list: 'open', get: 'open', update: 'approval' },
customOperations: {
create: {
access: 'approval',
description:
'Create an agent group. With --template <ref>, stamp from a local template under templates/ ' +
'(MCP servers + instructions + skills); else insert a bare row (--name, --folder).',
handler: async (args) => {
if (args.template) {
return createAgentFromTemplate(String(args.template), {
name: args.name ? String(args.name) : undefined,
});
}
const name = args.name ? String(args.name) : '';
const folder = args.folder ? String(args.folder) : '';
if (!name) throw new Error('--name is required');
if (!folder) throw new Error('--folder is required');
const group: AgentGroup = {
id: randomUUID(),
name,
folder,
agent_provider: null,
created_at: new Date().toISOString(),
};
createAgentGroup(group);
return group;
},
},
delete: {
access: 'approval',
description:
@@ -0,0 +1,75 @@
/**
* Regression test: `ncl messaging-groups create` must satisfy the NOT NULL
* `instance` column without an operator-supplied `--instance`. The column has
* no CLI flag at the operator's altitude (the default instance IS the channel
* type), so the generic CRUD insert defaults it to `channel_type` matching
* `createMessagingGroup`'s `instance ?? channel_type` fallback on the router
* path. Delete the `instance` column / `defaultFrom` wiring in
* `messaging-groups.ts` and this goes red: the insert fails the NOT NULL.
*/
import fs from 'fs';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-msggroups' };
});
const TEST_DIR = '/tmp/nanoclaw-test-cli-msggroups';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `messaging-groups-create` command.
import './messaging-groups.js';
describe('messaging-groups CLI create defaults instance to channel_type', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('create without --instance sets instance = channel_type', async () => {
// caller: 'host' is the post-approval re-entry path for create (approval op).
const resp = await dispatch(
{
id: 'req-1',
command: 'messaging-groups-create',
args: { channel_type: 'telegram', platform_id: '12345' },
},
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
const row = getMessagingGroupByPlatform('telegram', '12345');
expect(row).toBeDefined();
expect(row?.instance).toBe('telegram');
});
it('create with an explicit --instance keeps that value', async () => {
const resp = await dispatch(
{
id: 'req-2',
command: 'messaging-groups-create',
args: { channel_type: 'telegram', platform_id: '67890', instance: 'work' },
},
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
expect(getMessagingGroupByPlatform('telegram', '67890', 'work')?.instance).toBe('work');
});
});
+8
View File
@@ -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',
+6
View File
@@ -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.
-6
View File
@@ -320,12 +320,6 @@ export function buildMounts(
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
// Global memory directory — always read-only.
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
}
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
+22
View File
@@ -185,6 +185,28 @@ export function updatePendingApprovalStatus(approvalId: string, status: PendingA
getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId);
}
/**
* Park an approval in the "rejected, awaiting reason" hold: the admin clicked
* "Reject with reason…" and we're waiting for their one-line reply. `expiresAt`
* is the deadline after which the host sweep finalizes a plain reject (so a
* ghosted hold never strands the requesting agent). Reuses the otherwise-unused
* `expires_at` column on module-initiated rows.
*/
export function markApprovalAwaitingReason(approvalId: string, expiresAt: string): void {
getDb()
.prepare("UPDATE pending_approvals SET status = 'awaiting_reason', expires_at = ? WHERE approval_id = ?")
.run(expiresAt, approvalId);
}
/** Awaiting-reason approvals whose reply window has elapsed — the sweep's ghost set. */
export function getExpiredAwaitingReasonApprovals(nowIso: string): PendingApproval[] {
return getDb()
.prepare(
"SELECT * FROM pending_approvals WHERE status = 'awaiting_reason' AND expires_at IS NOT NULL AND expires_at <= ?",
)
.all(nowIso) as PendingApproval[];
}
export function deletePendingApproval(approvalId: string): void {
getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId);
}
+32
View File
@@ -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.');
});
});
+30
View File
@@ -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;
}
+71
View File
@@ -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);
});
});
+52
View File
@@ -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 });
}
}
+12
View File
@@ -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);
}
+83
View File
@@ -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;
}
}
+127 -1
View File
@@ -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');
});
});
+33 -9
View File
@@ -22,6 +22,7 @@ import fs from 'fs';
import path from 'path';
import { isSafeAttachmentName } from '../../attachment-safety.js';
import { ensureContainedInboxDir, isPathInside } from '../../inbox-safety.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
import { getSession } from '../../db/sessions.js';
@@ -42,11 +43,6 @@ export interface ForwardedAttachment {
localPath: string;
}
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/**
* Copy file attachments from the source agent's outbox into the target
* agent's inbox. Returns attachments using the formatter's existing
@@ -98,8 +94,20 @@ export function forwardAttachedFiles(
return [];
}
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
fs.mkdirSync(targetInboxDir, { recursive: true });
// Target-side containment — shared with the channel-inbound path. A
// compromised target agent can write inside its own session dir, so it could
// pre-place `inbox` (or `inbox/<future-msgId>`) as a symlink pointing
// anywhere host-writable; ensureContainedInboxDir refuses the symlink before
// any copy lands outside the sandbox (#2828, CWE-59).
const inboxRoot = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox');
const targetInboxDir = ensureContainedInboxDir(inboxRoot, target.messageId, {
targetGroup: target.agentGroupId,
targetSession: target.sessionId,
targetMsgId: target.messageId,
});
if (!targetInboxDir) {
return [];
}
const attachments: ForwardedAttachment[] = [];
for (const filename of source.filenames) {
@@ -137,7 +145,20 @@ export function forwardAttachedFiles(
continue;
}
const dst = path.join(targetInboxDir, filename);
fs.copyFileSync(realSrc, dst);
try {
// COPYFILE_EXCL: fail with EEXIST rather than follow or overwrite a
// pre-placed symlink / existing file at dst — the host is the sole
// writer of these attachments.
fs.copyFileSync(realSrc, dst, fs.constants.COPYFILE_EXCL);
} catch (err) {
log.warn('agent-route: refusing to write target inbox file', {
sourceMsgId: source.messageId,
targetMsgId: target.messageId,
filename,
err,
});
continue;
}
attachments.push({
name: filename,
filename,
@@ -278,7 +299,10 @@ function buildGateQuestion(sourceName: string, targetName: string, contentStr: s
const body = text.length > GATE_CARD_BODY_MAX ? `${text.slice(0, GATE_CARD_BODY_MAX)}… (truncated)` : text;
const lines = [`Agent "${sourceName}" wants to send a message to "${targetName}":`, '', body];
if (files.length > 0) lines.push('', `Attachments: ${files.join(', ')}`);
lines.push('', 'Approve delivery?');
lines.push(
'',
`Approve, Reject, or "Reject with reason…" to decline and then type a short reason I'll relay to "${sourceName}".`,
);
return lines.join('\n');
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Shared "finalize a rejected approval" path.
*
* Three entry points land here so they relay one message and clean up
* identically:
* 1. The instant Reject button (response-handler.ts)
* 2. A captured Reject-with-reason reply (reason-capture.ts)
* 3. The host-sweep ghost finalizer (reason-capture.ts, via host-sweep)
*
* Kept in its own leaf file so both response-handler.ts and reason-capture.ts
* can import it without an import cycle (finalize primitive only).
*/
import { wakeContainer } from '../../container-runner.js';
import { deletePendingApproval } from '../../db/sessions.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { PendingApproval, Session } from '../../types.js';
import { notifyApprovalResolved } from './primitive.js';
/**
* Notify the requesting agent that its action was rejected, drop the pending
* row, fire approval-resolved callbacks, and wake the container.
*
* When `reason` is provided it's appended to the agent-facing note with generic
* attribution the why, not the who (the rejecting admin may belong to a
* different owner than the requesting agent). Callers are responsible for
* clamping the reason length before passing it in.
*/
export async function finalizeReject(
approval: PendingApproval,
session: Session,
userId: string,
reason?: string,
): Promise<void> {
const text = reason
? `Your ${approval.action} request was rejected by admin: "${reason}"`
: `Your ${approval.action} request was rejected by admin.`;
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
log.info('Approval rejected', {
approvalId: approval.approval_id,
action: approval.action,
userId,
withReason: reason !== undefined,
});
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
await wakeContainer(session);
}
+9
View File
@@ -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);
+39 -9
View File
@@ -254,16 +254,46 @@ async function sweepStaleApprovals(): Promise<void> {
}
}
/** The hosted gateway's structured request summary not yet in the SDK's
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
* performed plus labeled fields (To / Subject / Body for email sends). */
interface ApprovalSummary {
action?: string;
details?: { label: string; value: string }[];
}
const SUMMARY_VALUE_EXCERPT_CHARS = 900;
function buildQuestion(request: ApprovalRequest, agentName: string): string {
const lines = [
'Credential access request',
`Agent: ${agentName}`,
'```',
`${request.method} ${request.host}${request.path}`,
'```',
];
if (request.bodyPreview) {
lines.push('Body:', '```', request.bodyPreview, '```');
const lines = [`*Agent:* ${agentName}`];
const summary = (request as ApprovalRequest & { summary?: ApprovalSummary }).summary;
if (summary?.details?.length) {
if (summary.action) lines.push(`*Action:* ${summary.action}`);
// A render bug here must never decide the request: handleRequest's catch
// returns 'deny', so stay defensive — coerce non-string values instead of
// assuming the gateway's shape, and keep the card under Slack's 3000-char
// section limit or delivery itself fails.
let budget = 2600;
for (const { label, value } of summary.details) {
const raw = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
const cap = Math.min(SUMMARY_VALUE_EXCERPT_CHARS, Math.max(0, budget));
if (cap === 0) {
lines.push(`_…${summary.details.length} field(s) omitted for length — see the audit payload._`);
break;
}
const v = raw.length > cap ? `${raw.slice(0, cap)}` : raw;
budget -= v.length + String(label).length + 8;
// Multi-line values (message bodies) read better fenced; short labeled
// fields (To, Subject) inline.
if (v.includes('\n')) lines.push(`*${label}:*`, '```', v, '```');
else lines.push(`*${label}:* ${v}`);
}
} else if (request.bodyPreview) {
lines.push('```', request.bodyPreview.slice(0, SUMMARY_VALUE_EXCERPT_CHARS * 2), '```');
lines.push(`_${request.method} ${request.host}${request.path}_`);
} else {
lines.push(`_${request.method} ${request.host}${request.path}_`);
}
return lines.join('\n');
}
+14 -1
View File
@@ -32,10 +32,23 @@ import type { MessagingGroup, PendingApproval, Session } from '../../types.js';
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
import { ensureUserDm } from '../permissions/user-dm.js';
/** Two-button approval UI — the only options the primitive supports today. */
/**
* Card value for the "Reject with reason…" button. Selecting it doesn't
* finalize the reject it holds the row and captures the approver's next DM
* as a one-line reason relayed to the requesting agent. See reason-capture.ts.
*/
export const REJECT_WITH_REASON_VALUE = 'reject_with_reason';
/**
* Three-button approval UI. Plain Reject is the instant fast path; "Reject with
* reason" opts into the reason-capture flow. Shared by every module approval
* (create_agent, install_packages, add_mcp_server); OneCLI credential cards
* keep their own two-button set in onecli-approvals.ts.
*/
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected (awaiting reason)', value: REJECT_WITH_REASON_VALUE },
];
// ── Approval handler registry ──
@@ -0,0 +1,279 @@
/**
* "Reject with reason…" capture flow.
*
* Covers the three entry points end to end against the real central DB:
* - arming (handleApprovalsResponse with the third option) holds the row and
* prompts the admin instead of finalizing;
* - the captured reply relays one combined message, clamped to 280 chars;
* - the host sweep finalizes a ghosted hold as a plain reject.
*
* writeSessionMessage is mocked so the relayed agent-facing text can be read
* back directly; the delivery adapter is a fake that records prompt sends.
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboundEvent } from '../../channels/adapter.js';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createMessagingGroup } from '../../db/messaging-groups.js';
import {
createSession,
createPendingApproval,
deletePendingApproval,
getPendingApproval,
markApprovalAwaitingReason,
} from '../../db/sessions.js';
import { setDeliveryAdapter, type ChannelDeliveryAdapter } from '../../delivery.js';
import { writeSessionMessage } from '../../session-manager.js';
import { upsertUser } from '../permissions/db/users.js';
import { upsertUserDm } from '../permissions/db/user-dms.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { REJECT_WITH_REASON_VALUE } from './primitive.js';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-reject-reason' };
});
vi.mock('../../session-manager.js', async () => {
const actual = await vi.importActual<typeof import('../../session-manager.js')>('../../session-manager.js');
return { ...actual, writeSessionMessage: vi.fn() };
});
const TEST_DIR = '/tmp/nanoclaw-test-reject-reason';
const DM_CHANNEL = 'slack';
const DM_PLATFORM = 'D-admin-1';
function now(): string {
return new Date().toISOString();
}
let delivered: Array<{ channelType: string; platformId: string; content: string }>;
const fakeAdapter: ChannelDeliveryAdapter = {
async deliver(channelType, platformId, _threadId, _kind, content) {
delivered.push({ channelType, platformId, content });
return 'pm-1';
},
};
function seedApproval(approvalId: string, action = 'create_agent'): void {
createPendingApproval({
approval_id: approvalId,
session_id: 'sess-1',
request_id: approvalId,
action,
payload: JSON.stringify({ name: 'child' }),
created_at: now(),
title: 'Approval',
options_json: JSON.stringify([]),
});
}
function dmReply(text?: string): InboundEvent {
const content: Record<string, unknown> = { sender: 'admin-1', senderId: 'admin-1' };
if (text !== undefined) content.text = text;
return {
channelType: DM_CHANNEL,
platformId: DM_PLATFORM,
threadId: null,
message: { id: 'm-1', kind: 'chat', content: JSON.stringify(content), timestamp: now() },
};
}
/** Click the "Reject with reason…" button as the seeded admin. */
async function clickRejectWithReason(approvalId: string): Promise<void> {
const { handleApprovalsResponse } = await import('./response-handler.js');
await handleApprovalsResponse({
questionId: approvalId,
value: REJECT_WITH_REASON_VALUE,
userId: 'admin-1',
channelType: DM_CHANNEL,
platformId: '', // not surfaced by the click payload — resolved via ensureUserDm
threadId: null,
});
}
/** The text of the most recent agent-facing note written via writeSessionMessage. */
function lastRelayedText(): string | undefined {
const call = vi.mocked(writeSessionMessage).mock.calls.at(-1);
if (!call) return undefined;
return (JSON.parse(call[2].content) as { text: string }).text;
}
beforeEach(() => {
vi.clearAllMocks();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
delivered = [];
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
createSession({
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
});
// Authorized approver + a cached DM so ensureUserDm resolves without a
// platform openDM call.
upsertUser({ id: 'slack:admin-1', kind: 'slack', display_name: 'Admin', created_at: now() });
grantRole({ user_id: 'slack:admin-1', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
createMessagingGroup({
id: 'mg-dm-1',
channel_type: DM_CHANNEL,
platform_id: DM_PLATFORM,
name: 'Admin DM',
is_group: 0,
unknown_sender_policy: 'strict',
created_at: now(),
});
upsertUserDm({
user_id: 'slack:admin-1',
channel_type: DM_CHANNEL,
messaging_group_id: 'mg-dm-1',
resolved_at: now(),
});
setDeliveryAdapter(fakeAdapter);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
describe('reject with reason', () => {
it('holds the row and prompts the admin instead of finalizing', async () => {
seedApproval('appr-1');
await clickRejectWithReason('appr-1');
const row = getPendingApproval('appr-1');
expect(row?.status).toBe('awaiting_reason');
expect(row?.expires_at).toBeTruthy();
// Prompt went to the admin's resolved DM, not the (empty) click platformId.
expect(delivered).toHaveLength(1);
expect(delivered[0].channelType).toBe(DM_CHANNEL);
expect(delivered[0].platformId).toBe(DM_PLATFORM);
expect((JSON.parse(delivered[0].content) as { text: string }).text).toMatch(/reason/i);
// Agent is not notified yet — the hold is still open.
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
});
it('relays the captured reason as one combined message and clears the row', async () => {
const { captureReasonReply } = await import('./reason-capture.js');
seedApproval('appr-2', 'install_packages');
await clickRejectWithReason('appr-2');
const consumed = await captureReasonReply(dmReply('too risky for prod'));
expect(consumed).toBe(true);
expect(getPendingApproval('appr-2')).toBeUndefined();
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin: "too risky for prod"');
});
it('truncates an over-long reason to 280 chars with an ellipsis', async () => {
const { captureReasonReply } = await import('./reason-capture.js');
seedApproval('appr-3');
await clickRejectWithReason('appr-3');
await captureReasonReply(dmReply('x'.repeat(400)));
const reason = lastRelayedText()!.match(/: "(.*)"$/)![1];
expect(reason).toHaveLength(280);
expect(reason.endsWith('…')).toBe(true);
});
it('finalizes a plain reject when the captured reply carries no text', async () => {
const { captureReasonReply } = await import('./reason-capture.js');
seedApproval('appr-4');
await clickRejectWithReason('appr-4');
const consumed = await captureReasonReply(dmReply(undefined));
expect(consumed).toBe(true);
expect(getPendingApproval('appr-4')).toBeUndefined();
expect(lastRelayedText()).toBe('Your create_agent request was rejected by admin.');
});
it('does not swallow a later DM once the hold was already finalized', async () => {
const { captureReasonReply } = await import('./reason-capture.js');
seedApproval('appr-5');
await clickRejectWithReason('appr-5');
// Simulate the sweep (or any other path) finalizing first.
deletePendingApproval('appr-5');
const consumed = await captureReasonReply(dmReply('late reason'));
expect(consumed).toBe(false);
});
it('ignores DMs on channels with no armed reason capture', async () => {
const { captureReasonReply } = await import('./reason-capture.js');
const consumed = await captureReasonReply({
channelType: DM_CHANNEL,
platformId: 'D-someone-else',
threadId: null,
message: { id: 'm', kind: 'chat', content: JSON.stringify({ text: 'hi' }), timestamp: now() },
});
expect(consumed).toBe(false);
});
});
describe('reject-with-reason host sweep', () => {
it('finalizes a hold whose window elapsed as a plain reject', async () => {
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
seedApproval('appr-ghost', 'add_mcp_server');
markApprovalAwaitingReason('appr-ghost', new Date(Date.now() - 1000).toISOString());
await sweepAwaitingReasonRejects();
expect(getPendingApproval('appr-ghost')).toBeUndefined();
expect(lastRelayedText()).toBe('Your add_mcp_server request was rejected by admin.');
});
it('leaves a still-open hold untouched', async () => {
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
seedApproval('appr-open');
markApprovalAwaitingReason('appr-open', new Date(Date.now() + 60_000).toISOString());
await sweepAwaitingReasonRejects();
expect(getPendingApproval('appr-open')?.status).toBe('awaiting_reason');
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
});
});
describe('plain reject (regression)', () => {
it('finalizes immediately with no reason and no DM prompt', async () => {
const { handleApprovalsResponse } = await import('./response-handler.js');
seedApproval('appr-plain', 'install_packages');
await handleApprovalsResponse({
questionId: 'appr-plain',
value: 'reject',
userId: 'admin-1',
channelType: DM_CHANNEL,
platformId: '',
threadId: null,
});
expect(getPendingApproval('appr-plain')).toBeUndefined();
expect(delivered).toHaveLength(0);
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin.');
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* "Reject with reason…" capture flow.
*
* When an admin clicks the third approval button, the reject is held instead of
* finalized: the row is parked at status='awaiting_reason' and the admin is
* prompted in their DM for a one-line reason. Their next DM ( 280 chars) is
* captured by a router message-interceptor and relayed to the requesting agent
* as one combined message `Your <action> request was rejected by admin:
* "<reason>"`. A plain Reject never arms this, so an unrelated DM is never
* swallowed.
*
* Restart-safety: arming lives in an in-memory map (lost on restart, like the
* agent-naming capture it mirrors), but the hold is a durable DB row. If the
* admin never replies or the host restarts mid-capture the host sweep
* (sweepAwaitingReasonRejects, run each tick) finalizes a plain reject once the
* row's window elapses, so the requesting agent is never stranded.
*
* Reuses, not reinvents: the agent-naming prompt-then-capture pattern
* (in-memory map + next-DM interceptor) and the shared finalizeReject path.
*/
import type { InboundEvent } from '../../channels/adapter.js';
import { getDeliveryAdapter } from '../../delivery.js';
import {
deletePendingApproval,
getExpiredAwaitingReasonApprovals,
getPendingApproval,
getSession,
markApprovalAwaitingReason,
} from '../../db/sessions.js';
import { log } from '../../log.js';
import { registerMessageInterceptor } from '../../router.js';
import type { PendingApproval, Session } from '../../types.js';
import { ensureUserDm } from '../permissions/user-dm.js';
import { finalizeReject } from './finalize.js';
/** How long an awaiting-reason hold waits for the admin's reply before the sweep finalizes a plain reject. */
const REASON_CAPTURE_WINDOW_MS = 5 * 60 * 1000;
/** Cap on the relayed reason — one cheap guardrail against a wall of text landing in another team's agent context. */
const MAX_REASON_LEN = 280;
const PROMPT_TEXT =
"Reply with a one-line reason for the rejection — I'll relay it to the agent. " +
'No reply within ~5 min declines it without a reason.';
interface ReasonArming {
approvalId: string;
/** Namespaced id of the admin who clicked, for resolution attribution. */
userId: string;
}
/**
* Approvers waiting to type a rejection reason, keyed by their DM channel
* (`<channelType>:<dmPlatformId>`). A DM's platform id is unique per user, so
* the inbound reply matches by channel alone no sender re-parsing needed, and
* a group message can never collide with an armed DM. Cleared on receipt,
* staleness, or restart.
*/
const awaitingReason = new Map<string, ReasonArming>();
function dmKey(channelType: string, platformId: string): string {
return `${channelType}:${platformId}`;
}
function clampReason(raw: string): string {
const trimmed = raw.trim();
if (trimmed.length <= MAX_REASON_LEN) return trimmed;
return trimmed.slice(0, MAX_REASON_LEN - 1) + '…';
}
function extractText(event: InboundEvent): string {
try {
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
return typeof parsed.text === 'string' ? parsed.text : '';
} catch {
return '';
}
}
/**
* Begin the reject-with-reason hold for an approval the admin chose not to
* finalize outright. Prompts the admin's DM, then parks the row and arms
* capture. If we can't reach the admin (no DM, no adapter, delivery throws) we
* finalize a plain reject immediately rather than strand the requesting agent.
*/
export async function armReasonCapture(approval: PendingApproval, session: Session, userId: string): Promise<void> {
const dm = userId ? await ensureUserDm(userId) : null;
const adapter = getDeliveryAdapter();
if (!dm || !adapter) {
log.warn('reject-with-reason: cannot reach approver, finalizing plain reject', {
approvalId: approval.approval_id,
userId,
hasDm: Boolean(dm),
hasAdapter: Boolean(adapter),
});
await finalizeReject(approval, session, userId);
return;
}
try {
await adapter.deliver(dm.channel_type, dm.platform_id, null, 'chat-sdk', JSON.stringify({ text: PROMPT_TEXT }));
} catch (err) {
log.error('reject-with-reason: reason prompt delivery failed, finalizing plain reject', {
approvalId: approval.approval_id,
err,
});
await finalizeReject(approval, session, userId);
return;
}
// Prompt is out — now hold the row and arm capture. Order matters: a reply
// can't arrive before the prompt is read, so there's no lost-message window.
const expiresAt = new Date(Date.now() + REASON_CAPTURE_WINDOW_MS).toISOString();
markApprovalAwaitingReason(approval.approval_id, expiresAt);
awaitingReason.set(dmKey(dm.channel_type, dm.platform_id), { approvalId: approval.approval_id, userId });
log.info('reject-with-reason: awaiting reason reply', { approvalId: approval.approval_id, userId });
}
/**
* Router message-interceptor: capture the next DM from an admin who armed a
* reason. Returns true (consume the message) when this DM is an armed reason
* channel and still holds a live row; false otherwise so normal routing runs.
*
* Exported for tests; registered as the interceptor below.
*/
export async function captureReasonReply(event: InboundEvent): Promise<boolean> {
const arming = awaitingReason.get(dmKey(event.channelType, event.platformId));
if (!arming) return false;
// This DM is an armed reason channel — disarm regardless of outcome.
awaitingReason.delete(dmKey(event.channelType, event.platformId));
const approval = getPendingApproval(arming.approvalId);
if (!approval || approval.status !== 'awaiting_reason') {
// Already finalized (e.g. ghosted by the sweep). The reply is no longer a
// reason — let it route normally instead of swallowing it.
return false;
}
const session = approval.session_id ? getSession(approval.session_id) : null;
if (!session) {
deletePendingApproval(approval.approval_id);
return true;
}
const reason = clampReason(extractText(event));
await finalizeReject(approval, session, arming.userId, reason || undefined);
log.info('reject-with-reason: reason captured and relayed', {
approvalId: approval.approval_id,
hasReason: reason.length > 0,
});
return true;
}
registerMessageInterceptor(captureReasonReply);
/**
* Host-sweep finalizer: any reject-with-reason hold whose window elapsed (admin
* ghosted, or the host restarted mid-capture and lost the in-memory arming) is
* finalized as a plain reject. Restart-safe the hold is a durable row, so the
* requesting agent always gets its decision. Called once per sweep tick.
*/
export async function sweepAwaitingReasonRejects(): Promise<void> {
const rows = getExpiredAwaitingReasonApprovals(new Date().toISOString());
for (const approval of rows) {
const session = approval.session_id ? getSession(approval.session_id) : null;
if (!session) {
deletePendingApproval(approval.approval_id);
continue;
}
// Plain reject, unknown resolver — the admin opted in but never typed.
await finalizeReject(approval, session, '');
log.info('reject-with-reason: window elapsed, finalized as plain reject', { approvalId: approval.approval_id });
}
}
+22 -12
View File
@@ -5,7 +5,10 @@
* 1. Module-initiated actions the module called `requestApproval()` with
* some free-form `action` string and registered a handler via
* `registerApprovalHandler(action, handler)`. On approve, we look up the
* handler and call it; on reject, we notify the agent and move on.
* handler and call it; on plain reject we relay a decline to the agent; on
* "Reject with reason…" we hold the row and capture the admin's next DM as
* a one-line reason (see reason-capture.ts). Reject finalization is shared
* via finalizeReject.
* 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved
* via an in-memory Promise see onecli-approvals.ts.
*
@@ -19,8 +22,10 @@ import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { PendingApproval } from '../../types.js';
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
import { finalizeReject } from './finalize.js';
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
import { getApprovalHandler, notifyApprovalResolved } from './primitive.js';
import { getApprovalHandler, notifyApprovalResolved, REJECT_WITH_REASON_VALUE } from './primitive.js';
import { armReasonCapture } from './reason-capture.js';
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
const approval = getPendingApproval(payload.questionId);
@@ -65,6 +70,21 @@ async function handleRegisteredApproval(
return;
}
// "Reject with reason…" — hold the row and capture the admin's next DM
// instead of finalizing now. The agent is notified exactly once: after the
// reason arrives, or after the sweep's timeout if the admin ghosts.
if (selectedOption === REJECT_WITH_REASON_VALUE) {
await armReasonCapture(approval, session, userId);
return;
}
// Plain Reject (or any other non-approve value) — instant fast path.
if (selectedOption !== 'approve') {
await finalizeReject(approval, session, userId);
return;
}
// Approved — dispatch to the module that registered for this action.
const notify = (text: string): void => {
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -77,16 +97,6 @@ async function handleRegisteredApproval(
});
};
if (selectedOption !== 'approve') {
notify(`Your ${approval.action} request was rejected by admin.`);
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
await wakeContainer(session);
return;
}
// Approved — dispatch to the module that registered for this action.
const handler = getApprovalHandler(approval.action);
if (!handler) {
log.warn('No approval handler registered — row dropped', {
+2 -2
View File
@@ -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 -1
View File
@@ -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.
+1 -1
View File
@@ -7,7 +7,7 @@
* the registered config fn, and merges the returned mounts/env into the spawn
* args.
*
* Providers without host-side needs (e.g. `claude`, `mock`) don't appear in
* Providers without host-side needs (e.g. `claude`) don't appear in
* this registry at all the lookup returns `undefined` and the spawn path
* proceeds with only the default mounts and env.
*
+17 -9
View File
@@ -110,16 +110,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
/**
* Message-interceptor hook. Runs at the very top of routeInbound, before
* messaging-group resolution. When the interceptor returns true the message
* is consumed and routing stops. Used by the permissions module to capture
* free-text replies during multi-step approval flows (e.g. agent naming).
* messaging-group resolution. When an interceptor returns true the message is
* consumed and routing stops. Multiple interceptors may register; they run in
* registration order and the first to claim the message (return true) wins.
*
* Used by modules to capture free-text DM replies during multi-step approval
* flows the permissions module (agent naming during channel registration)
* and the approvals module (reject-with-reason capture).
*/
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
let messageInterceptor: MessageInterceptorFn | null = null;
const messageInterceptors: MessageInterceptorFn[] = [];
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
messageInterceptor = fn;
export function registerMessageInterceptor(fn: MessageInterceptorFn): void {
messageInterceptors.push(fn);
}
/**
@@ -156,9 +160,13 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
* Creates messaging group + session if they don't exist yet.
*/
export async function routeInbound(event: InboundEvent): Promise<void> {
// Pre-route interceptor — lets modules consume messages before any routing
// (e.g. free-text replies during multi-step approval flows).
if (messageInterceptor && (await messageInterceptor(event))) return;
// Pre-route interceptors — let modules consume messages before any routing
// (e.g. free-text DM replies during multi-step approval flows). They run in
// registration order; the first to claim the message stops routing. The
// sequential await is intentional — first-to-claim is order-dependent.
for (const intercept of messageInterceptors) {
if (await intercept(event)) return;
}
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
// WhatsApp, iMessage, email) collapse threads to the channel. Resolved
+103
View File
@@ -0,0 +1,103 @@
/**
* Security regression for the channel-inbound attachment path (#2828 sibling).
*
* `extractAttachmentFiles` (via `writeSessionMessage`) hardens the per-message
* inbox subdir against pre-placed symlinks, but NOT the `inbox` root itself.
* A compromised container can write inside its own session dir, so it can
* replace `inbox` with a symlink pointing outside the session sandbox. The
* existing guard then:
* - skips the lstat branch (it only lstats `inbox/<msgId>`, not `inbox`),
* - mkdirs `inbox/<msgId>` *through* the symlink,
* - passes the containment check, because it compares against
* `realpathSync(inboxRoot)` which has already followed the symlink, and
* - writes a brand-new file (the `wx` flag only blocks an existing dst).
*
* Result: the host writes attacker-influenced bytes outside the session root
* the same class of bug fixed for the A2A path in forwardAttachedFiles (#2828).
*
* This test asserts the SECURE behaviour (nothing written outside). It FAILS
* against the current code, demonstrating the gap.
*/
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-saveatt-gap' };
});
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
import { createSession } from './db/sessions.js';
import { initSessionFolder, sessionDir, writeSessionMessage } from './session-manager.js';
import type { Session } from './types.js';
const TEST_DIR = '/tmp/nanoclaw-test-saveatt-gap';
const AG = 'ag-saveatt';
const SESS = 'sess-saveatt';
function now(): string {
return new Date().toISOString();
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: AG, name: 'SaveAtt', folder: 'saveatt', agent_provider: null, created_at: now() });
const sess: Session = {
id: SESS,
agent_group_id: AG,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: now(),
};
createSession(sess);
initSessionFolder(AG, SESS);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
describe('extractAttachmentFiles — inbox-root symlink containment (#2828 sibling)', () => {
it('does not write an attachment outside the session root via a symlinked inbox root', () => {
// Attacker-controlled location outside the session sandbox.
const canaryDir = path.join(TEST_DIR, 'canary-outside');
fs.mkdirSync(canaryDir, { recursive: true });
// Container pre-places its whole `inbox` as a symlink pointing outside.
const inboxRoot = path.join(sessionDir(AG, SESS), 'inbox');
fs.rmSync(inboxRoot, { recursive: true, force: true });
fs.symlinkSync(canaryDir, inboxRoot);
const content = JSON.stringify({
text: 'see attached',
attachments: [{ name: 'pwn.txt', data: Buffer.from('attacker-bytes').toString('base64') }],
});
writeSessionMessage(AG, SESS, {
id: 'evil-inbox-root',
kind: 'chat',
timestamp: now(),
platformId: 'whatsapp:123',
channelType: 'whatsapp',
threadId: null,
content,
});
// SECURE expectation: nothing was written through the symlink to the
// attacker-controlled canary location.
const escaped = path.join(canaryDir, 'evil-inbox-root', 'pwn.txt');
expect(fs.existsSync(escaped)).toBe(false);
expect(fs.readdirSync(canaryDir)).toHaveLength(0);
});
});
+14 -30
View File
@@ -18,6 +18,7 @@ import { deriveAttachmentName } from './attachment-naming.js';
import { isSafeAttachmentName } from './attachment-safety.js';
import type { OutboundFile } from './channels/adapter.js';
import { DATA_DIR } from './config.js';
import { ensureContainedInboxDir, isPathInside } from './inbox-safety.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import {
createSession,
@@ -38,11 +39,6 @@ import {
import { log } from './log.js';
import type { Session } from './types.js';
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/** Root directory for all session data. */
export function sessionsBaseDir(): string {
return path.join(DATA_DIR, 'v2-sessions');
@@ -288,6 +284,14 @@ function extractAttachmentFiles(
return contentStr;
}
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
// Resolved lazily on the first attachment that actually carries bytes, so a
// message whose attachments have no inline `data` never creates an inbox dir.
// ensureContainedInboxDir refuses a pre-placed symlink at the inbox root or
// the per-message subdir before any write lands outside the sandbox (#2828).
let inboxDir: string | null = null;
let inboxResolved = false;
let changed = false;
for (const att of attachments) {
if (typeof att.data !== 'string') continue;
@@ -302,32 +306,12 @@ function extractAttachmentFiles(
});
}
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
// Refuse to mkdir through a symlink that the container may have pre placed
// at inboxDir. With recursive:true, mkdirSync would silently no op on a
// pre existing symlink and the subsequent writeFileSync would follow it.
if (fs.existsSync(inboxDir)) {
const stat = fs.lstatSync(inboxDir);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir });
continue;
}
}
fs.mkdirSync(inboxDir, { recursive: true });
let realInboxDir: string;
try {
realInboxDir = fs.realpathSync(inboxDir);
} catch (err) {
log.warn('Failed to resolve inbox directory', { messageId, err });
continue;
}
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) {
log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir });
continue;
if (!inboxResolved) {
inboxDir = ensureContainedInboxDir(inboxRoot, messageId, { messageId });
inboxResolved = true;
}
// Unsafe inbox (symlink / escape) — no attachment can be written safely.
if (!inboxDir) break;
const filePath = path.join(inboxDir, filename);
try {
+83
View File
@@ -0,0 +1,83 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-create-agent-test';
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
const DATA_DIR = path.join(TEST_ROOT, 'data');
const TEMPLATES_DIR = path.join(TEST_ROOT, 'templates');
vi.mock('../config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../config.js')>()),
GROUPS_DIR: '/tmp/nanoclaw-create-agent-test/groups',
DATA_DIR: '/tmp/nanoclaw-create-agent-test/data',
TEMPLATES_DIR: '/tmp/nanoclaw-create-agent-test/templates',
}));
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
import { closeDb, initTestDb, runMigrations } from '../db/index.js';
import { getContainerConfig } from '../db/container-configs.js';
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
import { createAgentFromTemplate } from './create-agent.js';
function writeTemplate(): void {
const t = path.join(TEMPLATES_DIR, 'sales', 'sdr');
fs.mkdirSync(path.join(t, 'context', 'additional_context'), { recursive: true });
fs.writeFileSync(path.join(t, 'context', 'instructions.md'), 'You are an SDR agent.\n');
fs.writeFileSync(path.join(t, 'context', 'playbook.md'), '# Playbook\n');
fs.writeFileSync(path.join(t, 'context', 'additional_context', 'faq.md'), '# FAQ\n');
fs.writeFileSync(
path.join(t, '.mcp.json'),
JSON.stringify({ mcpServers: { hubspot: { command: 'npx', args: ['-y', '@hubspot/mcp-server'] } } }),
);
const skillDir = path.join(t, 'skills', 'widget');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: widget\n---\n');
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
writeTemplate();
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('createAgentFromTemplate', () => {
it('writes the persona prepend verbatim — no injected context refs, no .seed.md', () => {
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Test' });
const groupDir = path.join(GROUPS_DIR, g.folder);
const prepend = fs.readFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), 'utf-8');
expect(prepend).toBe('You are an SDR agent.\n');
expect(fs.existsSync(path.join(groupDir, '.seed.md'))).toBe(false);
});
it('copies template skills into the group-private Claude-plane skills dir', () => {
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Skills' });
const skill = path.join(DATA_DIR, 'v2-sessions', g.id, '.claude-shared', 'skills', 'widget', 'SKILL.md');
expect(fs.existsSync(skill)).toBe(true);
});
it('writes MCP servers to the container config and context extras at their template-relative paths', () => {
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Mcp' });
const cfg = getContainerConfig(g.id);
expect(cfg).toBeTruthy();
expect(JSON.parse(cfg!.mcp_servers)).toHaveProperty('hubspot');
// Extras land relative to the group root, exactly as they sit relative to
// instructions.md in the template — no context/ prefix in between.
const groupDir = path.join(GROUPS_DIR, g.folder);
expect(fs.existsSync(path.join(groupDir, 'playbook.md'))).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'additional_context', 'faq.md'))).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'context'))).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { randomUUID } from 'crypto';
import fs from 'fs';
import path from 'path';
import { DATA_DIR, GROUPS_DIR } from '../config.js';
import { createAgentGroup } from '../db/agent-groups.js';
import { ensureContainerConfig, updateContainerConfigJson } from '../db/container-configs.js';
import { assertValidGroupFolder, resolveGroupFolderPath } from '../group-folder.js';
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
import { normalizeName } from '../modules/agent-to-agent/db/agent-destinations.js';
import type { AgentGroup } from '../types.js';
import { resolveLocalTemplate } from './local-dir.js';
import { parseTemplate } from './parse.js';
export interface CreateAgentOptions {
name?: string;
}
/**
* Stamp a self-contained agent group from a LOCAL template ref under
* TEMPLATES_DIR. The template carries MCP servers, instructions, optional
* context extras, and optional skills nothing else (no policy, no packages,
* no provider).
*
* The template persona is written to the provider-neutral `instructions.prepend.md`
* (see src/group-persona.ts). Each provider's project-doc composer inlines it at
* the TOP of the doc it generates every spawn, so the persona is system-prompt
* tier regardless of which provider the group ends up running. Because the file
* is provider-agnostic, placement needs no provider knowledge at stamp time (the
* provider is DB-resolved later, at first spawn).
*
* Returns the created group; the caller wires it to a channel as usual.
*/
export function createAgentFromTemplate(ref: string, opts?: CreateAgentOptions): AgentGroup {
const dir = resolveLocalTemplate(ref);
const tpl = parseTemplate(dir);
const id = randomUUID();
const name = opts?.name ?? path.basename(dir);
let folder = normalizeName(name);
assertValidGroupFolder(folder);
if (fs.existsSync(resolveGroupFolderPath(folder))) folder = `${folder}-${randomUUID().slice(0, 8)}`;
const group: AgentGroup = { id, name, folder, agent_provider: null, created_at: new Date().toISOString() };
createAgentGroup(group);
ensureContainerConfig(id);
// group-init.ts owns the mkdir at first spawn, but it isn't called here — so we
// create the dir ourselves to land instructions.prepend.md + context/.
const groupDir = path.resolve(GROUPS_DIR, folder);
fs.mkdirSync(groupDir, { recursive: true });
// Persona → provider-neutral prepend, inlined at the top of the group's
// CLAUDE.md/AGENTS.md every spawn (system-prompt tier on any provider).
fs.writeFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), tpl.instructions + '\n');
// Context extras keep their template-relative layout, placed next to the doc
// the persona is inlined into — so a reference written in instructions.md
// (e.g. `additional_context/faq.md`) resolves unchanged in the agent's
// workspace. Nothing is injected into the persona; referencing each file from
// instructions.md is the template author's job (docs/templates.md).
for (const { name: file, content } of tpl.contextExtras) {
const dest = path.join(groupDir, file);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, content);
}
updateContainerConfigJson(id, 'mcp_servers', tpl.mcpServers);
// Per-group skills overlay — keyed by group id, never shared. cpSync creates
// intermediate dirs, so .claude-shared/skills need not exist yet.
const skillsDir = path.join(DATA_DIR, 'v2-sessions', id, '.claude-shared', 'skills');
for (const { name: skill, srcDir } of tpl.skills) {
fs.cpSync(srcDir, path.join(skillsDir, skill), { recursive: true });
}
return group;
}
+55
View File
@@ -0,0 +1,55 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resolveLocalTemplate } from './local-dir.js';
let base: string;
beforeEach(() => {
base = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-local-'));
fs.mkdirSync(path.join(base, 'sales', 'sdr'), { recursive: true });
fs.writeFileSync(path.join(base, 'afile.md'), 'not a directory');
});
afterEach(() => fs.rmSync(base, { recursive: true, force: true }));
describe('resolveLocalTemplate', () => {
it('resolves a valid multi-segment relative ref under the base', () => {
expect(resolveLocalTemplate('sales/sdr', base)).toBe(path.join(base, 'sales', 'sdr'));
});
it('rejects a ref that escapes the base via ../', () => {
expect(() => resolveLocalTemplate('../escape', base)).toThrow(/escapes/);
});
it('rejects a multi-segment escape like sales/../../etc', () => {
expect(() => resolveLocalTemplate('sales/../../etc', base)).toThrow(/escapes/);
});
it('rejects an absolute ref', () => {
expect(() => resolveLocalTemplate('/etc', base)).toThrow(/relative/);
});
it('rejects a ~-prefixed ref', () => {
expect(() => resolveLocalTemplate('~/x', base)).toThrow(/relative/);
});
it('rejects empty and whitespace-only refs', () => {
expect(() => resolveLocalTemplate('', base)).toThrow(/Invalid/);
expect(() => resolveLocalTemplate(' ', base)).toThrow(/Invalid/);
});
it('rejects an untrimmed ref', () => {
expect(() => resolveLocalTemplate(' sales/sdr', base)).toThrow(/Invalid/);
});
it('throws when the ref does not exist', () => {
expect(() => resolveLocalTemplate('nope', base)).toThrow(/not found/i);
});
it('throws when the ref is a file, not a directory', () => {
expect(() => resolveLocalTemplate('afile.md', base)).toThrow(/not found/i);
});
});
+33
View File
@@ -0,0 +1,33 @@
import fs from 'fs';
import path from 'path';
import { TEMPLATES_DIR } from '../config.js';
/**
* Resolve a LOCAL template ref to an absolute directory under `base`
* (TEMPLATES_DIR by default). Lexical containment only no realpathSync, no
* symlink resolution (out of threat model). Mirrors ensureWithinBase() in
* group-folder.ts. Refs are legitimately multi-segment (e.g. "sales/sdr"), so
* this does NOT reuse isValidGroupFolder (which rejects "/").
*
* Rejects: empty / untrimmed refs, absolute paths, a leading "~", and any ref
* that escapes `base` after resolution. Throws if the resolved path is missing
* or not a directory.
*/
export function resolveLocalTemplate(ref: string, base: string = TEMPLATES_DIR): string {
if (!ref || ref !== ref.trim()) {
throw new Error(`Invalid template ref: "${ref}"`);
}
if (path.isAbsolute(ref) || ref.startsWith('~')) {
throw new Error(`Template ref must be relative to the templates directory: "${ref}"`);
}
const candidate = path.resolve(base, ref);
const rel = path.relative(base, candidate);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Template ref escapes the templates directory: "${ref}"`);
}
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) {
throw new Error(`Template not found: "${ref}" (looked in ${base})`);
}
return candidate;
}
+56
View File
@@ -0,0 +1,56 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseTemplate } from './parse.js';
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-parse-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
function write(rel: string, content: string): void {
const full = path.join(dir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
describe('parseTemplate', () => {
it('parses mcpServers, instructions, context extras, and skills', () => {
write('.mcp.json', JSON.stringify({ mcpServers: { fs: { command: 'mcp-fs', args: ['/data'] } } }));
write('context/instructions.md', 'Be helpful.\n\n');
write('context/playbook.md', '# Playbook');
write('context/additional_context/faq.md', '# FAQ');
write('skills/research/SKILL.md', 'do research');
fs.writeFileSync(path.join(dir, 'context', 'notes.txt'), 'ignored'); // non-.md is ignored
const tpl = parseTemplate(dir);
expect(tpl.mcpServers).toEqual({ fs: { command: 'mcp-fs', args: ['/data'] } });
expect(tpl.instructions).toBe('Be helpful.'); // trimEnd, instructions.md excluded from extras
// Nested extras keep their context/-relative path as the name.
expect(tpl.contextExtras.map((c) => c.name).sort()).toEqual(['additional_context/faq.md', 'playbook.md']);
expect(tpl.skills.map((s) => s.name)).toEqual(['research']);
});
it('defaults the optionals when only instructions.md is present', () => {
write('context/instructions.md', 'Only instructions.');
const tpl = parseTemplate(dir);
expect(tpl.mcpServers).toEqual({});
expect(tpl.contextExtras).toEqual([]);
expect(tpl.skills).toEqual([]);
});
it('throws when context/instructions.md is missing', () => {
expect(() => parseTemplate(dir)).toThrow(/instructions\.md/);
});
it('throws when the folder does not exist', () => {
expect(() => parseTemplate(path.join(dir, 'nope'))).toThrow(/not found/i);
});
});
+64
View File
@@ -0,0 +1,64 @@
import fs from 'fs';
import path from 'path';
/** A parsed template folder. Pure data — no DB, no side effects. */
export interface Template {
mcpServers: Record<string, unknown>; // .mcp.json .mcpServers — name -> launch config
instructions: string; // context/instructions.md (required)
contextExtras: { name: string; content: string }[]; // context/**/*.md except instructions.md; name relative to context/
skills: { name: string; srcDir: string }[]; // skills/<name>/ real folders
}
function readJson(file: string): unknown {
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf-8')) : undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
/**
* Read and lightly validate a template folder into a typed object. Throws only
* if the folder is missing or `context/instructions.md` (the one required file)
* is absent. `unknown`-in / parsed-out at the .mcp.json boundary.
*/
export function parseTemplate(dir: string): Template {
if (!fs.existsSync(dir)) throw new Error(`Template folder not found: ${dir}`);
const mcpServers = asRecord(asRecord(readJson(path.join(dir, '.mcp.json'))).mcpServers);
const instructionsFile = path.join(dir, 'context', 'instructions.md');
if (!fs.existsSync(instructionsFile)) {
throw new Error(`Template missing required context/instructions.md: ${dir}`);
}
const instructions = fs.readFileSync(instructionsFile, 'utf-8').trimEnd();
return {
mcpServers,
instructions,
contextExtras: readContextExtras(path.join(dir, 'context')),
skills: readSkills(path.join(dir, 'skills')),
};
}
/**
* Every context/**\/*.md except the top-level instructions.md, recursively.
* `name` keeps the path relative to context/ so stamping can preserve the
* layout a reference like `additional_context/faq.md` written in
* instructions.md resolves unchanged in the agent's workspace.
*/
function readContextExtras(contextDir: string): { name: string; content: string }[] {
if (!fs.existsSync(contextDir)) return [];
return (fs.readdirSync(contextDir, { recursive: true }) as string[])
.filter((f) => f.endsWith('.md') && f !== 'instructions.md' && fs.statSync(path.join(contextDir, f)).isFile())
.map((name) => ({ name, content: fs.readFileSync(path.join(contextDir, name), 'utf-8') }));
}
/** Each immediate subdirectory of skills/ is a packaged skill. */
function readSkills(skillsDir: string): { name: string; srcDir: string }[] {
if (!fs.existsSync(skillsDir)) return [];
return fs
.readdirSync(skillsDir)
.map((name) => ({ name, srcDir: path.join(skillsDir, name) }))
.filter(({ srcDir }) => fs.statSync(srcDir).isDirectory());
}
+6 -1
View File
@@ -200,8 +200,13 @@ export interface PendingApproval {
channel_type: string | null;
platform_id: string | null;
platform_message_id: string | null;
/**
* For OneCLI credential rows, the gateway's request TTL. For a module
* approval held by "Reject with reason…", the deadline after which the
* host sweep finalizes a plain reject (set by markApprovalAwaitingReason).
*/
expires_at: string | null;
status: 'pending' | 'approved' | 'rejected' | 'expired';
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'awaiting_reason';
title: string;
options_json: string;
/** When set, only this exact user may resolve the approval. */
+48
View File
@@ -0,0 +1,48 @@
# Templates
Local agent-template library for this NanoClaw install. **This folder ships
empty.** Anything you drop here is a template you can stamp into an agent:
```bash
ncl groups create --template <relative-ref> --name "My Agent"
```
`<relative-ref>` is a path *relative to this folder* (e.g. `sales/sdr`). Refs
must stay inside this directory — absolute paths, `~`, and `../` escapes are
rejected. Override the location with `NANOCLAW_TEMPLATES_DIR=/another/local/path`
(a local path only — never a URL).
The setup wizard's **Template setup → NanoClaw template library** option clones
the public registry and copies your chosen template *into this folder*, after
which it stamps from the local copy. **Local templates** lists whatever is here.
## Anatomy of a template
Only `context/instructions.md` is required; it both supplies the agent's
standing brief and marks the folder as a template.
```
<template>/
├── context/
│ ├── instructions.md # REQUIRED: the agent's standing persona, prepended to its
│ │ # CLAUDE.md/AGENTS.md every spawn
│ └── additional_context/ # optional: extra .md files
│ └── *.md
├── .mcp.json # optional: { "mcpServers": { ... } } — command + args, NO secrets
├── skills/<name>/ # optional: one folder per skill (SKILL.md + references/), copied whole
└── README.md # recommended: per-template docs
```
Notes:
- **Extra context is copied preserving its layout relative to `instructions.md`**
(`context/additional_context/faq.md``additional_context/faq.md` in the
agent's workspace). Nothing is referenced automatically — `instructions.md`
must point to each file (e.g. "Pricing rules live in
`additional_context/pricing.md`").
- **No provider, no model, no packages.** A template is instructions + MCP
servers + skills. The agent's runtime/provider is chosen separately
(`ncl groups config update --provider …` or during setup).
- **No secrets.** `.mcp.json` carries launch config only; credentials are
injected by the credentials proxy at request time. If an MCP server refuses
to boot without an env var, use a placeholder value — never a real key.
- Skills are copied into the agent's own per-group overlay, never shared.