mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-30 18:40:32 +08:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d841bcd05 | |||
| 2afbd18233 | |||
| 953496dc37 | |||
| 797491d8b3 | |||
| 2df754459b | |||
| 0896d4089e | |||
| d153d91307 | |||
| ce55af12d5 | |||
| 545800a94e | |||
| bfb309bd0c | |||
| 38d9390eea | |||
| 8d3eca7027 | |||
| 1d6bba4d3f | |||
| 9bb69c0e50 | |||
| 520ec44aec | |||
| 8c6a243ffd | |||
| add6145f1c | |||
| 4e14d08173 | |||
| 8f2f788b6e | |||
| e96d7fd961 | |||
| 2ac7809385 | |||
| 15292ae76c | |||
| 055cf49bd5 | |||
| e8148bc0a7 | |||
| 625264ba4b | |||
| f34e590bcd | |||
| d208fd7bf5 | |||
| 886c65725b | |||
| 9977af68d7 | |||
| 8e44f07dd4 | |||
| 8c43f13d93 | |||
| 5cf4ff1bd2 | |||
| 6e475e5503 | |||
| 0f8499b141 | |||
| 82e1dc4ae8 | |||
| e70b021cde | |||
| ea90a12846 | |||
| 3b1f4501d6 | |||
| 385fb014fc | |||
| b2160a56aa | |||
| f72658bb50 | |||
| 3180f3f881 | |||
| b0bdc57b37 | |||
| 314b91efc0 | |||
| 53ed3b77c9 | |||
| 070714ec58 |
@@ -46,7 +46,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -50,7 +50,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -59,7 +59,7 @@ import './linear.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -60,7 +60,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: learn
|
||||
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
|
||||
---
|
||||
|
||||
# Learn — Distill a Skill from Anything
|
||||
|
||||
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
|
||||
|
||||
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
|
||||
|
||||
## When to use
|
||||
|
||||
Invoke when the user wants to *capture* a workflow as a reusable skill:
|
||||
|
||||
- `/learn <path>` — read a project/dir and build a skill for working with it
|
||||
- `/learn <url>` — read docs / an API page and build a usage skill
|
||||
- `/learn what we just did` — distill the current conversation's workflow
|
||||
- `/learn` + pasted notes — turn notes into a structured skill
|
||||
|
||||
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the source — and whether this is a new skill or a refine
|
||||
- A **path** → read the code/files.
|
||||
- A **URL** → fetch and read the page.
|
||||
- **"what we just did" / "this"** → use the current conversation as the source.
|
||||
- **Pasted text** → use it directly.
|
||||
|
||||
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
|
||||
|
||||
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
|
||||
|
||||
### 2. Gather the material
|
||||
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
|
||||
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
|
||||
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
|
||||
|
||||
### 3. Distill — find the reusable procedure
|
||||
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
|
||||
|
||||
- the trigger / when-to-use,
|
||||
- the step-by-step procedure (commands, file paths, decision points),
|
||||
- the non-obvious **gotchas** that were hit — usually the most valuable part,
|
||||
- any scripts or templates worth shipping alongside.
|
||||
|
||||
### 4. Author the SKILL.md
|
||||
|
||||
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
|
||||
- Keep what is still correct; weave the new learnings into the right sections.
|
||||
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
|
||||
- Correct anything the new source proves stale (a changed path, command, or flag).
|
||||
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
|
||||
|
||||
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
|
||||
|
||||
**Frontmatter (required):**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <kebab-case, matches the folder>
|
||||
description: "<what it does + when to use it + likely trigger phrases>"
|
||||
---
|
||||
```
|
||||
|
||||
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
|
||||
|
||||
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
|
||||
|
||||
**House authoring rules (from `docs/skill-guidelines.md`):**
|
||||
|
||||
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
|
||||
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
|
||||
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
|
||||
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
|
||||
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
|
||||
|
||||
### 5. Place and verify
|
||||
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
|
||||
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
|
||||
- Tell the user the skill exists and how to invoke it (`/<name>`).
|
||||
|
||||
## Example
|
||||
|
||||
`/learn what we just did` after a multi-step setup:
|
||||
|
||||
1. Re-read the conversation's commands and gotchas.
|
||||
2. Distill the repeatable procedure.
|
||||
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
|
||||
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
|
||||
- The most valuable content is the **gotchas**, not the happy path.
|
||||
- This skill is prose and safe to re-run — use it again to refine an existing skill.
|
||||
@@ -246,30 +246,40 @@ If one or more `[BREAKING]` lines are found:
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
Updating your installed skills is **part of** updating NanoClaw, not an optional
|
||||
extra. Channel and provider code ships on long-lived branches (`channels`,
|
||||
`providers`) that the host merge above doesn't touch — so stopping here leaves
|
||||
that code on whatever version you installed, which is how an important upstream
|
||||
fix gets silently left behind. The default is to continue into `/update-skills`,
|
||||
which re-applies your installed channels/providers to pull their latest code.
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
Detect whether anything is installed: read `src/channels/index.ts` and
|
||||
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
|
||||
`cli`).
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
|
||||
- If nothing is installed: skip silently and proceed to Step 7.9.
|
||||
- If one or more are installed: continue into skill updates.
|
||||
|
||||
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
|
||||
- List the installed channels/providers.
|
||||
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
|
||||
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
|
||||
- "Skip — I'll update them later"
|
||||
- Set `multiSelect: true`
|
||||
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
|
||||
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
|
||||
Name the installed skills in the question so the choice is concrete:
|
||||
- Question: "Skill updates are part of this NanoClaw update — your installed
|
||||
channels/providers (<list the detected ones>) ride separate branches the host
|
||||
update didn't touch. Continue into `/update-skills` to bring them up to date?"
|
||||
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
|
||||
`/update-skills`, which re-applies your installed channels/providers to pull
|
||||
their latest upstream code. You pick which ones there."
|
||||
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
|
||||
installed skill code stays as-is and may be behind upstream."
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
Keep it to these two options — the per-skill selection lives inside
|
||||
`/update-skills`, not here.
|
||||
|
||||
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
|
||||
touches container code, `/update-skills` rebuilds the agent image itself — see
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ For each selected skill (process one at a time):
|
||||
After all selected skills are re-applied:
|
||||
- `pnpm run build`
|
||||
- `pnpm test` (do not fail the flow if tests are not configured)
|
||||
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
|
||||
|
||||
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||
|
||||
@@ -341,6 +341,12 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:la
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps → `docker run --cpus/--memory`. Empty default =
|
||||
// no flag = unbounded (today's behavior). Opt in to bound a fleet sharing one
|
||||
// host: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g. Swap is a host concern
|
||||
// (run the host swapless to make --memory a hard cap); not managed here.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||
```
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.18",
|
||||
"version": "2.1.21",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "2.2.1",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"chat": "4.29.0",
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
|
||||
Generated
+14
-5
@@ -21,8 +21,8 @@ importers:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
chat:
|
||||
specifier: ^4.24.0
|
||||
version: 4.26.0
|
||||
specifier: 4.29.0
|
||||
version: 4.29.0
|
||||
cron-parser:
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
@@ -609,8 +609,17 @@ packages:
|
||||
character-entities@2.0.2:
|
||||
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||
|
||||
chat@4.26.0:
|
||||
resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==}
|
||||
chat@4.29.0:
|
||||
resolution: {integrity: sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
ai: ^6.0.182
|
||||
zod: ^3.0.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
ai:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
@@ -1963,7 +1972,7 @@ snapshots:
|
||||
|
||||
character-entities@2.0.2: {}
|
||||
|
||||
chat@4.26.0:
|
||||
chat@4.29.0:
|
||||
dependencies:
|
||||
'@workflow/serde': 4.1.0-beta.2
|
||||
mdast-util-to-string: 4.0.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="196k tokens, 98% of context window">
|
||||
<title>196k tokens, 98% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="199k tokens, 100% of context window">
|
||||
<title>199k tokens, 100% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">196k</text>
|
||||
<text x="71" y="14">196k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">199k</text>
|
||||
<text x="71" y="14">199k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the
|
||||
* channel flow). The CLI scratch agent is always
|
||||
* "Terminal Agent".
|
||||
* NANOCLAW_AGENT_PROVIDER preselect the setup provider and skip the picker
|
||||
* (for packaged flows). Example: claude.
|
||||
* NANOCLAW_SKIP comma-separated step names to skip
|
||||
* (environment|container|onecli|auth|mounts|
|
||||
* service|cli-agent|timezone|channel|
|
||||
@@ -816,6 +818,15 @@ async function askAgentProviderChoice(): Promise<string> {
|
||||
...installed.map(({ value, label, hint }) => ({ value, label, hint })),
|
||||
...available.map((prov) => ({ value: prov.value, label: prov.label, hint: `${prov.hint} — installs now` })),
|
||||
];
|
||||
const preset = process.env.NANOCLAW_AGENT_PROVIDER?.trim().toLowerCase();
|
||||
if (preset) {
|
||||
if (!options.some((option) => option.value === preset)) {
|
||||
throw new Error(`NANOCLAW_AGENT_PROVIDER=${preset} is not available in this NanoClaw install`);
|
||||
}
|
||||
setupLog.userInput('agent_provider', preset);
|
||||
phEmit('agent_provider_chosen', { provider: preset, preset: true });
|
||||
return preset;
|
||||
}
|
||||
// The pick installs and authenticates a runtime — it is not an
|
||||
// install-wide default, so re-runs safely Enter-through on claude (its
|
||||
// auth flow short-circuits when the secret already exists).
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './github.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -86,7 +86,7 @@ if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './teams.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -63,7 +63,7 @@ if ! grep -q "'pair-telegram':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -123,6 +123,14 @@ export const CONFIG: Entry[] = [
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'agentProvider',
|
||||
envVar: 'NANOCLAW_AGENT_PROVIDER',
|
||||
label: 'Agent provider',
|
||||
help: 'Preselect the setup provider and skip the provider picker.',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'assistMode',
|
||||
envVar: 'NANOCLAW_SETUP_ASSIST_MODE',
|
||||
|
||||
@@ -43,7 +43,6 @@ interface V1Group {
|
||||
folder: string;
|
||||
trigger_pattern: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -65,7 +64,7 @@ async function main(): Promise<void> {
|
||||
// v1 schema varies — channel_name was a late addition. Query only the
|
||||
// columns we know exist in all v1 installs.
|
||||
const v1Groups = v1Db
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main FROM registered_groups')
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger FROM registered_groups')
|
||||
.all() as V1Group[];
|
||||
v1Db.close();
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
|
||||
|
||||
// The reaper deletes config files from ~/Library/LaunchAgents (or the systemd
|
||||
// user dir). We point HOME at a throwaway temp dir so real registrations are
|
||||
// never touched, and force os.platform() so the launchd/systemd branch runs
|
||||
// regardless of the host running the suite. The best-effort unload inside the
|
||||
// reaper (launchctl/systemctl) is swallowed when the binary is absent, so these
|
||||
// tests are deterministic on both macOS and Linux CI.
|
||||
|
||||
function tempHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'peer-cleanup-'));
|
||||
}
|
||||
|
||||
function writePlist(filePath: string, target: string): void {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>/usr/bin/node</string><string>${target}</string></array>
|
||||
</dict></plist>`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeUnit(filePath: string, target: string): void {
|
||||
fs.writeFileSync(filePath, `[Service]\nExecStart=/usr/bin/node ${target}\n`);
|
||||
}
|
||||
|
||||
const created: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of created.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('cleanupUnhealthyPeers — dead launchd registrations', () => {
|
||||
function setup(): { home: string; agentsDir: string; projectRoot: string } {
|
||||
const home = tempHome();
|
||||
created.push(home);
|
||||
const agentsDir = path.join(home, 'Library', 'LaunchAgents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
return { home, agentsDir, projectRoot: path.join(home, 'install') };
|
||||
}
|
||||
|
||||
it('removes a plist whose target binary is gone', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const dead = path.join(agentsDir, 'com.nanoclaw-v2-dead.plist');
|
||||
writePlist(dead, path.join(agentsDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(dead)).toBe(false);
|
||||
expect(result.removed.map((r) => r.label)).toContain('com.nanoclaw-v2-dead');
|
||||
});
|
||||
|
||||
it('leaves a plist whose target still exists', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const liveTarget = path.join(agentsDir, 'live', 'dist', 'index.js');
|
||||
fs.mkdirSync(path.dirname(liveTarget), { recursive: true });
|
||||
fs.writeFileSync(liveTarget, '// host entry');
|
||||
const live = path.join(agentsDir, 'com.nanoclaw-v2-live.plist');
|
||||
writePlist(live, liveTarget);
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(live)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never reaps this install's own plist, even with a missing target", () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const own = path.join(agentsDir, `${ownLabel}.plist`);
|
||||
writePlist(own, path.join(agentsDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(own)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores an unrecognized plist (no dist/index.js target)', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const weird = path.join(agentsDir, 'com.nanoclaw-v2-weird.plist');
|
||||
fs.writeFileSync(weird, '<plist><dict></dict></plist>');
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(weird)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupUnhealthyPeers — dead systemd registrations', () => {
|
||||
function setup(): { unitDir: string; projectRoot: string } {
|
||||
const home = tempHome();
|
||||
created.push(home);
|
||||
const unitDir = path.join(home, '.config', 'systemd', 'user');
|
||||
fs.mkdirSync(unitDir, { recursive: true });
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
return { unitDir, projectRoot: path.join(home, 'install') };
|
||||
}
|
||||
|
||||
it('removes a unit whose target binary is gone', () => {
|
||||
const { unitDir, projectRoot } = setup();
|
||||
const dead = path.join(unitDir, 'nanoclaw-v2-dead.service');
|
||||
writeUnit(dead, path.join(unitDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(dead)).toBe(false);
|
||||
expect(result.removed.map((r) => r.label)).toContain('nanoclaw-v2-dead');
|
||||
});
|
||||
|
||||
it("never reaps this install's own unit", () => {
|
||||
const { unitDir, projectRoot } = setup();
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const own = path.join(unitDir, `${ownUnit}.service`);
|
||||
writeUnit(own, path.join(unitDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(own)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+112
-3
@@ -11,6 +11,14 @@
|
||||
* - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD`
|
||||
* - systemd: unit is in `failed` state, OR `activating` with many restarts
|
||||
*
|
||||
* Separately, a peer registration is "dead" when the program it launches no
|
||||
* longer exists on disk — almost always a deleted test checkout or worktree.
|
||||
* The service manager keeps retrying the missing binary forever, and the
|
||||
* health probes can't see it because an unloaded/inactive job doesn't report
|
||||
* via `launchctl print` / `systemctl show`. Deleting an install's folder
|
||||
* without running the uninstaller leaves these behind, so they accumulate. We
|
||||
* unload and delete the orphaned config file outright.
|
||||
*
|
||||
* Healthy peers are left alone — multiple installs can coexist fine now that
|
||||
* container-reaper is label-scoped.
|
||||
*/
|
||||
@@ -35,6 +43,7 @@ export interface PeerStatus {
|
||||
export interface PeerCleanupResult {
|
||||
checked: PeerStatus[];
|
||||
unloaded: PeerStatus[];
|
||||
removed: Array<{ label: string; configPath: string }>;
|
||||
failures: Array<{ label: string; err: string }>;
|
||||
}
|
||||
|
||||
@@ -50,7 +59,39 @@ export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): Peer
|
||||
if (platform === 'linux') {
|
||||
return cleanupSystemdPeers(projectRoot);
|
||||
}
|
||||
return { checked: [], unloaded: [], failures: [] };
|
||||
return { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a dead peer's job (best-effort) and delete its orphaned config file.
|
||||
* `unload` runs first and may throw harmlessly when the job isn't loaded or the
|
||||
* service-manager binary is absent (e.g. exercising launchd cleanup on Linux).
|
||||
*/
|
||||
function reapDeadPeer(
|
||||
result: PeerCleanupResult,
|
||||
peer: { label: string; configPath: string },
|
||||
unload: () => void,
|
||||
kind: string,
|
||||
missingTarget: string,
|
||||
): void {
|
||||
try {
|
||||
unload();
|
||||
} catch {
|
||||
/* job not loaded — nothing to unload */
|
||||
}
|
||||
try {
|
||||
fs.rmSync(peer.configPath, { force: true });
|
||||
log.info(`Removed dead peer ${kind}`, {
|
||||
label: peer.label,
|
||||
configPath: peer.configPath,
|
||||
missingTarget,
|
||||
});
|
||||
result.removed.push(peer);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`Failed to remove dead peer ${kind}`, { label: peer.label, err: message });
|
||||
result.failures.push({ label: peer.label, err: message });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- launchd (macOS) --------------------------------------------------------
|
||||
@@ -58,7 +99,7 @@ export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): Peer
|
||||
function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
|
||||
let plists: string[];
|
||||
try {
|
||||
@@ -76,6 +117,20 @@ function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const label = path.basename(plistPath, '.plist');
|
||||
if (label === ownLabel) continue;
|
||||
|
||||
const missingTarget = deadLaunchdTarget(plistPath);
|
||||
if (missingTarget) {
|
||||
reapDeadPeer(
|
||||
result,
|
||||
{ label, configPath: plistPath },
|
||||
// Best-effort unload in case launchd still has it registered; throwing
|
||||
// (not loaded, or launchctl absent off-macOS) is expected and ignored.
|
||||
() => execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' }),
|
||||
'launchd plist',
|
||||
missingTarget,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = probeLaunchdPeer(label, plistPath, uid);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
@@ -121,12 +176,32 @@ function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerSt
|
||||
return { label, configPath: plistPath, state, runs, unhealthy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program path a launchd plist launches when that program no longer
|
||||
* exists on disk (a dead registration), or undefined when the plist is
|
||||
* unreadable, has an unrecognized shape, or its target still exists — in which
|
||||
* case the plist must not be touched.
|
||||
*/
|
||||
function deadLaunchdTarget(plistPath: string): string | undefined {
|
||||
let xml: string;
|
||||
try {
|
||||
xml = fs.readFileSync(plistPath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// ProgramArguments is [nodePath, "<projectRoot>/dist/index.js"]; the host
|
||||
// entry point is the stable marker to match on.
|
||||
const target = /<string>([^<]*\/dist\/index\.js)<\/string>/.exec(xml)?.[1];
|
||||
if (!target) return undefined;
|
||||
return fs.existsSync(target) ? undefined : target;
|
||||
}
|
||||
|
||||
// ---- systemd (Linux) --------------------------------------------------------
|
||||
|
||||
function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
|
||||
let units: string[];
|
||||
try {
|
||||
@@ -141,6 +216,22 @@ function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
for (const unit of units) {
|
||||
if (unit === ownUnit) continue;
|
||||
|
||||
const unitPath = path.join(unitDir, `${unit}.service`);
|
||||
const missingTarget = deadSystemdTarget(unitPath);
|
||||
if (missingTarget) {
|
||||
reapDeadPeer(
|
||||
result,
|
||||
{ label: unit, configPath: unitPath },
|
||||
() => {
|
||||
execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' });
|
||||
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'pipe' });
|
||||
},
|
||||
'systemd unit',
|
||||
missingTarget,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = probeSystemdPeer(unit);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
@@ -184,3 +275,21 @@ function probeSystemdPeer(unit: string): PeerStatus | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program path a systemd unit launches when that program no longer
|
||||
* exists on disk (a dead registration), or undefined when the unit is
|
||||
* unreadable, has an unrecognized shape, or its target still exists.
|
||||
*/
|
||||
function deadSystemdTarget(unitPath: string): string | undefined {
|
||||
let unit: string;
|
||||
try {
|
||||
unit = fs.readFileSync(unitPath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// ExecStart=<nodePath> <projectRoot>/dist/index.js
|
||||
const target = /^ExecStart=\S+\s+(\S+\/dist\/index\.js)\s*$/m.exec(unit)?.[1];
|
||||
if (!target) return undefined;
|
||||
return fs.existsSync(target) ? undefined : target;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,17 @@ describe('setup flow consumes the registry (structural)', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'auto.ts'), 'utf-8');
|
||||
expect(src).toContain('listSetupProviders()');
|
||||
expect(src).toContain("import './providers/index.js'");
|
||||
expect(src).toContain('NANOCLAW_AGENT_PROVIDER');
|
||||
// The capability-keyed branch — a provider's own auth runs iff it declares one.
|
||||
expect(src).toMatch(/providerEntry\?\.runAuth/);
|
||||
});
|
||||
|
||||
it('the provider preset is exposed as an env setup knob', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'lib', 'setup-config.ts'), 'utf-8');
|
||||
expect(src).toContain('NANOCLAW_AGENT_PROVIDER');
|
||||
expect(src).toContain("key: 'agentProvider'");
|
||||
});
|
||||
|
||||
it('the standalone provider-auth step is reachable from the STEPS map', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'index.ts'), 'utf-8');
|
||||
expect(src).toContain("'provider-auth'");
|
||||
|
||||
@@ -72,6 +72,12 @@ export async function run(_args: string[]): Promise<void> {
|
||||
labels: peerReport.unloaded.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
if (peerReport.removed.length > 0) {
|
||||
log.warn('Removed dead peer NanoClaw registrations (target binary missing)', {
|
||||
count: peerReport.removed.length,
|
||||
labels: peerReport.removed.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'macos') {
|
||||
setupLaunchd(projectRoot, nodePath, homeDir);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import './users.js';
|
||||
import './roles.js';
|
||||
import './members.js';
|
||||
import './destinations.js';
|
||||
import './policies.js';
|
||||
import './user-dms.js';
|
||||
import './dropped-messages.js';
|
||||
import './approvals.js';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Regression test: `ncl messaging-groups create` must satisfy the NOT NULL
|
||||
* `instance` column without an operator-supplied `--instance`. The column has
|
||||
* no CLI flag at the operator's altitude (the default instance IS the channel
|
||||
* type), so the generic CRUD insert defaults it to `channel_type` — matching
|
||||
* `createMessagingGroup`'s `instance ?? channel_type` fallback on the router
|
||||
* path. Delete the `instance` column / `defaultFrom` wiring in
|
||||
* `messaging-groups.ts` and this goes red: the insert fails the NOT NULL.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-msggroups' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-msggroups';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `messaging-groups-create` command.
|
||||
import './messaging-groups.js';
|
||||
|
||||
describe('messaging-groups CLI create defaults instance to channel_type', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('create without --instance sets instance = channel_type', async () => {
|
||||
// caller: 'host' is the post-approval re-entry path for create (approval op).
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'messaging-groups-create',
|
||||
args: { channel_type: 'telegram', platform_id: '12345' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const row = getMessagingGroupByPlatform('telegram', '12345');
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.instance).toBe('telegram');
|
||||
});
|
||||
|
||||
it('create with an explicit --instance keeps that value', async () => {
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-2',
|
||||
command: 'messaging-groups-create',
|
||||
args: { channel_type: 'telegram', platform_id: '67890', instance: 'work' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(getMessagingGroupByPlatform('telegram', '67890', 'work')?.instance).toBe('work');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,14 @@ registerResource({
|
||||
'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'instance',
|
||||
type: 'string',
|
||||
description:
|
||||
'Adapter instance that owns this chat, when running N adapters of one channel type. Defaults to channel_type (the default instance) when omitted.',
|
||||
defaultFrom: 'channel_type',
|
||||
updatable: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { removeMessagePolicy, setMessagePolicy } from '../../modules/agent-to-agent/db/agent-message-policies.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
registerResource({
|
||||
name: 'policy',
|
||||
plural: 'policies',
|
||||
table: 'agent_message_policies',
|
||||
description:
|
||||
'Agent-to-agent approval policy. A row requires every message from one agent to another to be approved by a human before delivery — without un-wiring the connection. No row = free flow. Directed and per-pair: gate both directions with two policies. Operator-only (agents cannot manage their own gates).',
|
||||
idColumn: 'from_agent_group_id',
|
||||
columns: [
|
||||
{ name: 'from_agent_group_id', type: 'string', description: 'Source agent group. References agent_groups.id.' },
|
||||
{ name: 'to_agent_group_id', type: 'string', description: 'Target agent group. References agent_groups.id.' },
|
||||
{
|
||||
name: 'approver',
|
||||
type: 'string',
|
||||
description: 'User-id who approves each gated message (required). Only this user (or an owner) can approve.',
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
||||
],
|
||||
operations: { list: 'open' },
|
||||
customOperations: {
|
||||
set: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Require approval for messages from one agent to another. Use --from <agent-group-id> --to <agent-group-id> --approver <user-id>. Only the named approver (or an owner) can approve.',
|
||||
handler: async (args) => {
|
||||
const from = args.from as string;
|
||||
const to = args.to as string;
|
||||
const approver = args.approver as string;
|
||||
if (!from) throw new Error('--from is required');
|
||||
if (!to) throw new Error('--to is required');
|
||||
if (!approver) throw new Error('--approver is required');
|
||||
if (from === to) throw new Error('--from and --to must differ (self-messages are never gated)');
|
||||
if (!getAgentGroup(from)) throw new Error(`source agent group not found: ${from}`);
|
||||
if (!getAgentGroup(to)) throw new Error(`target agent group not found: ${to}`);
|
||||
|
||||
setMessagePolicy(from, to, approver, new Date().toISOString());
|
||||
return { from_agent_group_id: from, to_agent_group_id: to, approver };
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
access: 'approval',
|
||||
description: 'Remove an approval policy (back to free flow). Use --from <agent-group-id> --to <agent-group-id>.',
|
||||
handler: async (args) => {
|
||||
const from = args.from as string;
|
||||
const to = args.to as string;
|
||||
if (!from) throw new Error('--from is required');
|
||||
if (!to) throw new Error('--to is required');
|
||||
if (!removeMessagePolicy(from, to)) throw new Error('policy not found');
|
||||
return { removed: { from_agent_group_id: from, to_agent_group_id: to } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -38,6 +38,11 @@ export const ONECLI_API_KEY = process.env.ONECLI_API_KEY || envConfig.ONECLI_API
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps, passed through to `docker run`. Default empty =
|
||||
// no flag added = today's unbounded behavior (don't OOM existing OSS workloads).
|
||||
// Operators opt in: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
@@ -47,6 +47,37 @@ describe('buildContainerArgs ordering invariant (structural)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-container resource limits (structural)', () => {
|
||||
// CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT pass through to `docker run` as
|
||||
// --cpus / --memory, but only when set. The default is empty string → no flag →
|
||||
// today's unbounded behavior (don't OOM existing OSS workloads). Swap is not
|
||||
// managed here (a swapless host makes --memory a hard cap). buildContainerArgs
|
||||
// needs a live gateway to drive, so guard the wiring structurally: the flags
|
||||
// must be pushed, and each must be guarded by its env knob so empty emits nothing.
|
||||
it('reads both limit knobs from config', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toContain('CONTAINER_CPU_LIMIT');
|
||||
expect(src).toContain('CONTAINER_MEMORY_LIMIT');
|
||||
});
|
||||
|
||||
it('guards --cpus behind a truthy CONTAINER_CPU_LIMIT', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toMatch(/if \(CONTAINER_CPU_LIMIT\)[\s\S]*?args\.push\('--cpus', CONTAINER_CPU_LIMIT\)/);
|
||||
});
|
||||
|
||||
it('guards --memory behind a truthy CONTAINER_MEMORY_LIMIT (and sets no swap flag)', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toMatch(/if \(CONTAINER_MEMORY_LIMIT\) args\.push\('--memory', CONTAINER_MEMORY_LIMIT\)/);
|
||||
expect(src).not.toContain('--memory-swap');
|
||||
});
|
||||
|
||||
it('defaults both knobs to empty string in config (no flag = unbounded)', () => {
|
||||
const cfg = fs.readFileSync(path.join(process.cwd(), 'src', 'config.ts'), 'utf-8');
|
||||
expect(cfg).toContain("CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || ''");
|
||||
expect(cfg).toContain("CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || ''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('container boot-failure tripwire (structural)', () => {
|
||||
// A container that dies at boot (unknown provider, missing CLI binary, bad
|
||||
// config) explains itself only on stderr — which logs at debug, below the
|
||||
|
||||
@@ -10,9 +10,11 @@ import path from 'path';
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import {
|
||||
CONTAINER_CPU_LIMIT,
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_IMAGE_BASE,
|
||||
CONTAINER_INSTALL_LABEL,
|
||||
CONTAINER_MEMORY_LIMIT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
ONECLI_API_KEY,
|
||||
@@ -434,6 +436,13 @@ async function buildContainerArgs(
|
||||
): Promise<string[]> {
|
||||
const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL];
|
||||
|
||||
// Per-container resource caps (opt-in; empty = unbounded, today's behavior).
|
||||
// Only --memory is set. Whether that's a hard cap depends on the host having no
|
||||
// swap (a deployment concern) — on a swapless host --memory is hard and a runaway
|
||||
// is OOM-killed; we don't manage swap from here.
|
||||
if (CONTAINER_CPU_LIMIT) args.push('--cpus', CONTAINER_CPU_LIMIT);
|
||||
if (CONTAINER_MEMORY_LIMIT) args.push('--memory', CONTAINER_MEMORY_LIMIT);
|
||||
|
||||
// Environment — only vars read by code we don't own.
|
||||
// Everything NanoClaw-specific is in container.json (read by runner at startup).
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
/** Per-message approval gate on an agent-to-agent connection; no row = free flow. */
|
||||
export const migration017: Migration = {
|
||||
version: 17,
|
||||
name: 'agent-message-policies',
|
||||
up(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE agent_message_policies (
|
||||
from_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
to_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
approver TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (from_agent_group_id, to_agent_group_id)
|
||||
);
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
/**
|
||||
* `approver_user_id` on `pending_approvals`: when an approval names a specific
|
||||
* approver (an a2a message-gate policy's approver), only that exact user may
|
||||
* resolve it. NULL keeps the existing group/owner authorization path.
|
||||
*/
|
||||
export const migration018: Migration = {
|
||||
version: 18,
|
||||
name: 'approvals-approver-user-id',
|
||||
up(db) {
|
||||
db.exec(`ALTER TABLE pending_approvals ADD COLUMN approver_user_id TEXT;`);
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
|
||||
import { migration001 } from './001-initial.js';
|
||||
import { migration002 } from './002-chat-sdk-state.js';
|
||||
import { moduleAgentToAgentDestinations } from './module-agent-to-agent-destinations.js';
|
||||
import { migration017 } from './017-agent-message-policies.js';
|
||||
import { migration008 } from './008-dropped-messages.js';
|
||||
import { migration009 } from './009-drop-pending-credentials.js';
|
||||
import { migration010 } from './010-engage-modes.js';
|
||||
@@ -15,6 +16,7 @@ import { migration015 } from './015-cli-scope.js';
|
||||
import { migration016 } from './016-messaging-group-instance.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
import { migration018 } from './018-approvals-approver-user-id.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -36,7 +38,9 @@ export const migrations: Migration[] = [
|
||||
migration002,
|
||||
moduleApprovalsPendingApprovals,
|
||||
moduleAgentToAgentDestinations,
|
||||
migration017,
|
||||
moduleApprovalsTitleOptions,
|
||||
migration018,
|
||||
migration008,
|
||||
migration009,
|
||||
migration010,
|
||||
|
||||
+25
-2
@@ -155,11 +155,11 @@ export function createPendingApproval(
|
||||
`INSERT OR IGNORE INTO pending_approvals
|
||||
(approval_id, session_id, request_id, action, payload, created_at,
|
||||
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
|
||||
title, options_json)
|
||||
title, options_json, approver_user_id)
|
||||
VALUES
|
||||
(@approval_id, @session_id, @request_id, @action, @payload, @created_at,
|
||||
@agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status,
|
||||
@title, @options_json)`,
|
||||
@title, @options_json, @approver_user_id)`,
|
||||
)
|
||||
.run({
|
||||
session_id: null,
|
||||
@@ -169,6 +169,7 @@ export function createPendingApproval(
|
||||
platform_message_id: null,
|
||||
expires_at: null,
|
||||
status: 'pending',
|
||||
approver_user_id: null,
|
||||
...pa,
|
||||
});
|
||||
return result.changes > 0;
|
||||
@@ -184,6 +185,28 @@ export function updatePendingApprovalStatus(approvalId: string, status: PendingA
|
||||
getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Park an approval in the "rejected, awaiting reason" hold: the admin clicked
|
||||
* "Reject with reason…" and we're waiting for their one-line reply. `expiresAt`
|
||||
* is the deadline after which the host sweep finalizes a plain reject (so a
|
||||
* ghosted hold never strands the requesting agent). Reuses the otherwise-unused
|
||||
* `expires_at` column on module-initiated rows.
|
||||
*/
|
||||
export function markApprovalAwaitingReason(approvalId: string, expiresAt: string): void {
|
||||
getDb()
|
||||
.prepare("UPDATE pending_approvals SET status = 'awaiting_reason', expires_at = ? WHERE approval_id = ?")
|
||||
.run(expiresAt, approvalId);
|
||||
}
|
||||
|
||||
/** Awaiting-reason approvals whose reply window has elapsed — the sweep's ghost set. */
|
||||
export function getExpiredAwaitingReasonApprovals(nowIso: string): PendingApproval[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM pending_approvals WHERE status = 'awaiting_reason' AND expires_at IS NOT NULL AND expires_at <= ?",
|
||||
)
|
||||
.all(nowIso) as PendingApproval[];
|
||||
}
|
||||
|
||||
export function deletePendingApproval(approvalId: string): void {
|
||||
getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ import { wakeContainer } from '../../container-runner.js';
|
||||
import { log } from '../../log.js';
|
||||
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { requestApproval } from '../approvals/index.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
import { getMessagePolicy } from './db/agent-message-policies.js';
|
||||
|
||||
export { isSafeAttachmentName };
|
||||
|
||||
@@ -208,21 +210,90 @@ function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session,
|
||||
}
|
||||
|
||||
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
|
||||
const sourceAgentGroupId = session.agent_group_id;
|
||||
const targetAgentGroupId = msg.platform_id;
|
||||
if (!targetAgentGroupId) {
|
||||
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
|
||||
}
|
||||
if (
|
||||
targetAgentGroupId !== session.agent_group_id &&
|
||||
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
|
||||
) {
|
||||
throw new Error(
|
||||
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
|
||||
);
|
||||
const isSelf = targetAgentGroupId === sourceAgentGroupId;
|
||||
if (!isSelf && !hasDestination(sourceAgentGroupId, 'agent', targetAgentGroupId)) {
|
||||
throw new Error(`unauthorized agent-to-agent: ${sourceAgentGroupId} has no destination for ${targetAgentGroupId}`);
|
||||
}
|
||||
if (!getAgentGroup(targetAgentGroupId)) {
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
}
|
||||
|
||||
// Gated edge: hold the message and return (not throw) so the delivery loop
|
||||
// consumes the outbound row; `applyA2aMessageGate` re-routes it on approve.
|
||||
if (!isSelf) {
|
||||
const policy = getMessagePolicy(sourceAgentGroupId, targetAgentGroupId);
|
||||
if (policy) {
|
||||
const { approver } = policy;
|
||||
const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId;
|
||||
const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId;
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: sourceName,
|
||||
action: A2A_MESSAGE_GATE_ACTION,
|
||||
approverUserId: approver,
|
||||
title: 'Message approval',
|
||||
question: buildGateQuestion(sourceName, targetName, msg.content),
|
||||
payload: {
|
||||
id: msg.id,
|
||||
platform_id: targetAgentGroupId,
|
||||
content: msg.content,
|
||||
in_reply_to: msg.in_reply_to,
|
||||
},
|
||||
});
|
||||
log.info('Agent message held for approval', {
|
||||
from: sourceAgentGroupId,
|
||||
to: targetAgentGroupId,
|
||||
msgId: msg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await performAgentRoute(msg, session, targetAgentGroupId);
|
||||
}
|
||||
|
||||
export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
|
||||
|
||||
const GATE_CARD_BODY_MAX = 1500;
|
||||
|
||||
function parseMessageContent(contentStr: string): { text: string; files: string[] } {
|
||||
try {
|
||||
const parsed = JSON.parse(contentStr) as { text?: unknown; files?: unknown };
|
||||
return {
|
||||
text: typeof parsed.text === 'string' ? parsed.text : '',
|
||||
files: Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string') : [],
|
||||
};
|
||||
} catch {
|
||||
return { text: contentStr, files: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function buildGateQuestion(sourceName: string, targetName: string, contentStr: string): string {
|
||||
const { text, files } = parseMessageContent(contentStr);
|
||||
const body = text.length > GATE_CARD_BODY_MAX ? `${text.slice(0, GATE_CARD_BODY_MAX)}… (truncated)` : text;
|
||||
const lines = [`Agent "${sourceName}" wants to send a message to "${targetName}":`, '', body];
|
||||
if (files.length > 0) lines.push('', `Attachments: ${files.join(', ')}`);
|
||||
lines.push(
|
||||
'',
|
||||
`Approve, Reject, or "Reject with reason…" to decline and then type a short reason I'll relay to "${sourceName}".`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-session route: pick the target session, forward files, write to its
|
||||
* inbound DB, wake it. Authorization is the caller's responsibility.
|
||||
*/
|
||||
export async function performAgentRoute(
|
||||
msg: RoutableAgentMessage,
|
||||
session: Session,
|
||||
targetAgentGroupId: string,
|
||||
): Promise<void> {
|
||||
const targetSession = resolveTargetSession(msg, session, targetAgentGroupId);
|
||||
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
*/
|
||||
import type { AgentDestination } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
import { deletePoliciesTouching, removeMessagePolicy } from './agent-message-policies.js';
|
||||
|
||||
/**
|
||||
* ⚠️ Caller responsibility: after this returns, call
|
||||
@@ -89,9 +90,16 @@ export function hasDestination(agentGroupId: string, targetType: 'channel' | 'ag
|
||||
* so the deletion propagates to the running container's inbound.db.
|
||||
*/
|
||||
export function deleteDestination(agentGroupId: string, localName: string): void {
|
||||
// Resolve the target first so we can drop a matching policy for this edge (no ghost gate on re-wire).
|
||||
const row = getDb()
|
||||
.prepare('SELECT target_type, target_id FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||
.get(agentGroupId, localName) as { target_type: string; target_id: string } | undefined;
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||
.run(agentGroupId, localName);
|
||||
if (row?.target_type === 'agent') {
|
||||
removeMessagePolicy(agentGroupId, row.target_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +116,7 @@ export function deleteAllDestinationsTouching(agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? OR (target_type = ? AND target_id = ?)')
|
||||
.run(agentGroupId, 'agent', agentGroupId);
|
||||
deletePoliciesTouching(agentGroupId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Per-message approval policies for agent-to-agent connections; no row = free flow. */
|
||||
import type { AgentMessagePolicy } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
export function getMessagePolicy(fromAgentGroupId: string, toAgentGroupId: string): AgentMessagePolicy | undefined {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM agent_message_policies WHERE from_agent_group_id = ? AND to_agent_group_id = ?')
|
||||
.get(fromAgentGroupId, toAgentGroupId) as AgentMessagePolicy | undefined;
|
||||
}
|
||||
|
||||
export function setMessagePolicy(
|
||||
fromAgentGroupId: string,
|
||||
toAgentGroupId: string,
|
||||
approver: string,
|
||||
createdAt: string,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_message_policies (from_agent_group_id, to_agent_group_id, approver, created_at)
|
||||
VALUES (@from_agent_group_id, @to_agent_group_id, @approver, @created_at)
|
||||
ON CONFLICT (from_agent_group_id, to_agent_group_id) DO UPDATE SET approver = excluded.approver`,
|
||||
)
|
||||
.run({ from_agent_group_id: fromAgentGroupId, to_agent_group_id: toAgentGroupId, approver, created_at: createdAt });
|
||||
}
|
||||
|
||||
export function removeMessagePolicy(fromAgentGroupId: string, toAgentGroupId: string): boolean {
|
||||
const info = getDb()
|
||||
.prepare('DELETE FROM agent_message_policies WHERE from_agent_group_id = ? AND to_agent_group_id = ?')
|
||||
.run(fromAgentGroupId, toAgentGroupId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/** Delete every policy touching this agent group, so none outlives its connection. */
|
||||
export function deletePoliciesTouching(agentGroupId: string): void {
|
||||
getDb()
|
||||
.prepare('DELETE FROM agent_message_policies WHERE from_agent_group_id = ? OR to_agent_group_id = ?')
|
||||
.run(agentGroupId, agentGroupId);
|
||||
}
|
||||
@@ -22,7 +22,11 @@
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { A2A_MESSAGE_GATE_ACTION } from './agent-route.js';
|
||||
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
|
||||
import { applyA2aMessageGate } from './message-gate.js';
|
||||
|
||||
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||
registerApprovalHandler('create_agent', applyCreateAgent);
|
||||
|
||||
registerApprovalHandler(A2A_MESSAGE_GATE_ACTION, applyA2aMessageGate);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { routeAgentMessage } from './agent-route.js';
|
||||
import { createDestination, deleteDestination, deleteAllDestinationsTouching } from './db/agent-destinations.js';
|
||||
import { getMessagePolicy, removeMessagePolicy, setMessagePolicy } from './db/agent-message-policies.js';
|
||||
import { applyA2aMessageGate } from './message-gate.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { getDb } from '../../db/connection.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { requestApproval } from '../approvals/index.js';
|
||||
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../approvals/index.js', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../approvals/index.js')>();
|
||||
return { ...actual, requestApproval: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-a2a-gate' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-a2a-gate';
|
||||
const A = 'ag-A';
|
||||
const B = 'ag-B';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function policyCount(): number {
|
||||
return (getDb().prepare('SELECT COUNT(*) AS n FROM agent_message_policies').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
function readInbound(agentGroupId: string, sessionId: string) {
|
||||
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
|
||||
const rows = db.prepare('SELECT id, platform_id, content FROM messages_in ORDER BY seq').all() as Array<{
|
||||
id: string;
|
||||
platform_id: string | null;
|
||||
content: string;
|
||||
}>;
|
||||
db.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function makeSession(id: string, agentGroupId: string): Session {
|
||||
return {
|
||||
id,
|
||||
agent_group_id: agentGroupId,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent message policies', () => {
|
||||
let SA: Session;
|
||||
let SB: Session;
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
vi.mocked(requestApproval).mockClear();
|
||||
|
||||
createAgentGroup({ id: A, name: 'A', folder: 'a', agent_provider: null, created_at: now() });
|
||||
createAgentGroup({ id: B, name: 'B', folder: 'b', agent_provider: null, created_at: now() });
|
||||
SA = makeSession('sess-A', A);
|
||||
SB = makeSession('sess-B', B);
|
||||
createSession(SA);
|
||||
createSession(SB);
|
||||
initSessionFolder(A, SA.id);
|
||||
initSessionFolder(B, SB.id);
|
||||
// A→B connection wired.
|
||||
createDestination({ agent_group_id: A, local_name: 'b', target_type: 'agent', target_id: B, created_at: now() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
// ── policy table round-trip ──
|
||||
|
||||
it('set / get / remove round-trip, incl. approver', () => {
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
|
||||
setMessagePolicy(A, B, 'telegram:sam', now());
|
||||
expect(getMessagePolicy(A, B)).toMatchObject({
|
||||
from_agent_group_id: A,
|
||||
to_agent_group_id: B,
|
||||
approver: 'telegram:sam',
|
||||
});
|
||||
expect(policyCount()).toBe(1);
|
||||
|
||||
// Upsert updates the approver without inserting a duplicate row.
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
expect(getMessagePolicy(A, B)!.approver).toBe('telegram:dana');
|
||||
expect(policyCount()).toBe(1);
|
||||
|
||||
expect(removeMessagePolicy(A, B)).toBe(true);
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
expect(removeMessagePolicy(A, B)).toBe(false);
|
||||
});
|
||||
|
||||
// ── gate behavior in routeAgentMessage ──
|
||||
|
||||
it('no policy → routes normally, no approval requested', async () => {
|
||||
await routeAgentMessage(
|
||||
{ id: 'm1', platform_id: B, content: JSON.stringify({ text: 'hi B' }), in_reply_to: null },
|
||||
SA,
|
||||
);
|
||||
expect(readInbound(B, SB.id)).toHaveLength(1);
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('policy present → holds the message and requests approval from the policy approver scoped to the target', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
|
||||
await routeAgentMessage(
|
||||
{ id: 'm2', platform_id: B, content: JSON.stringify({ text: 'sensitive' }), in_reply_to: null },
|
||||
SA,
|
||||
);
|
||||
|
||||
// Held: nothing routed to B.
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
// One approval requested, to the policy's approver, scoped to the target group.
|
||||
expect(requestApproval).toHaveBeenCalledTimes(1);
|
||||
const opts = vi.mocked(requestApproval).mock.calls[0][0];
|
||||
expect(opts.action).toBe('a2a_message_gate');
|
||||
expect(opts.approverUserId).toBe('telegram:dana');
|
||||
expect(opts.payload).toMatchObject({ id: 'm2', platform_id: B });
|
||||
expect(JSON.parse(String(opts.payload.content)).text).toBe('sensitive');
|
||||
});
|
||||
|
||||
it('self-message is never gated even if a policy row somehow exists', async () => {
|
||||
setMessagePolicy(A, A, 'telegram:dana', now()); // pathological, but must be ignored
|
||||
await routeAgentMessage(
|
||||
{ id: 'self', platform_id: A, content: JSON.stringify({ text: 'note' }), in_reply_to: null },
|
||||
SA,
|
||||
);
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
expect(readInbound(A, SA.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── approve handler re-routes the held message ──
|
||||
|
||||
it('applyA2aMessageGate delivers the held message to the target', async () => {
|
||||
const notify = vi.fn();
|
||||
await applyA2aMessageGate({
|
||||
session: SA,
|
||||
userId: 'slack:dana',
|
||||
notify,
|
||||
payload: { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null },
|
||||
});
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
expect(JSON.parse(bRows[0].content).text).toBe('approved!');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── ghost-gate cleanup ──
|
||||
|
||||
it('deleting the connection drops its policy', () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
deleteDestination(A, 'b'); // removes the A→B agent destination
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deleteAllDestinationsTouching drops policies on both sides', () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
setMessagePolicy(B, A, 'telegram:dana', now());
|
||||
deleteAllDestinationsTouching(A);
|
||||
expect(getMessagePolicy(A, B)).toBeUndefined();
|
||||
expect(getMessagePolicy(B, A)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Approve handler for a held a2a message. (Reject is handled by the generic response-handler path.) */
|
||||
import { log } from '../../log.js';
|
||||
import type { ApprovalHandler } from '../approvals/index.js';
|
||||
import { performAgentRoute, type RoutableAgentMessage } from './agent-route.js';
|
||||
|
||||
export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, notify }) => {
|
||||
const { id, platform_id, content, in_reply_to } = payload;
|
||||
if (typeof platform_id !== 'string' || !platform_id) {
|
||||
notify('Message approved but the target agent group was missing from the request.');
|
||||
log.warn('a2a_message_gate apply: missing target', { sessionId: session.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg: RoutableAgentMessage = {
|
||||
id: typeof id === 'string' ? id : `a2a-gate-${Date.now()}`,
|
||||
platform_id,
|
||||
content: typeof content === 'string' ? content : '',
|
||||
in_reply_to: typeof in_reply_to === 'string' ? in_reply_to : null,
|
||||
};
|
||||
|
||||
await performAgentRoute(msg, session, platform_id);
|
||||
log.info('Held agent message delivered after approval', {
|
||||
from: session.agent_group_id,
|
||||
to: platform_id,
|
||||
msgId: msg.id,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Shared "finalize a rejected approval" path.
|
||||
*
|
||||
* Three entry points land here so they relay one message and clean up
|
||||
* identically:
|
||||
* 1. The instant Reject button (response-handler.ts)
|
||||
* 2. A captured Reject-with-reason reply (reason-capture.ts)
|
||||
* 3. The host-sweep ghost finalizer (reason-capture.ts, via host-sweep)
|
||||
*
|
||||
* Kept in its own leaf file so both response-handler.ts and reason-capture.ts
|
||||
* can import it without an import cycle (finalize → primitive only).
|
||||
*/
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { deletePendingApproval } from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import { notifyApprovalResolved } from './primitive.js';
|
||||
|
||||
/**
|
||||
* Notify the requesting agent that its action was rejected, drop the pending
|
||||
* row, fire approval-resolved callbacks, and wake the container.
|
||||
*
|
||||
* When `reason` is provided it's appended to the agent-facing note with generic
|
||||
* attribution — the why, not the who (the rejecting admin may belong to a
|
||||
* different owner than the requesting agent). Callers are responsible for
|
||||
* clamping the reason length before passing it in.
|
||||
*/
|
||||
export async function finalizeReject(
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
const text = reason
|
||||
? `Your ${approval.action} request was rejected by admin: "${reason}"`
|
||||
: `Your ${approval.action} request was rejected by admin.`;
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
|
||||
log.info('Approval rejected', {
|
||||
approvalId: approval.approval_id,
|
||||
action: approval.action,
|
||||
userId,
|
||||
withReason: reason !== undefined,
|
||||
});
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
await wakeContainer(session);
|
||||
}
|
||||
@@ -8,10 +8,16 @@
|
||||
* - A response handler that claims pending_approvals rows and dispatches
|
||||
* to whatever module registered for the row's `action` string. Also
|
||||
* resolves in-memory OneCLI credential approvals.
|
||||
* - A message-interceptor (via ./reason-capture.js) that captures an admin's
|
||||
* one-line reply after they click "Reject with reason…".
|
||||
* - An adapter-ready callback that starts the OneCLI manual-approval handler
|
||||
* once the delivery adapter is set.
|
||||
* - A shutdown callback that stops the OneCLI handler cleanly.
|
||||
*
|
||||
* Exposes `sweepAwaitingReasonRejects` for the host sweep to finalize ghosted
|
||||
* reject-with-reason holds (re-exported here, which also loads reason-capture
|
||||
* so its interceptor registers).
|
||||
*
|
||||
* Self-mod flows (install_packages, add_mcp_server) moved out to
|
||||
* `src/modules/self-mod/` in PR #7 — they now register delivery actions
|
||||
* + approval handlers via this module's public API.
|
||||
@@ -24,6 +30,9 @@ import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-
|
||||
// Public API re-exports so consumers import from the module root.
|
||||
export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js';
|
||||
export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } from './primitive.js';
|
||||
// Host-sweep hook for ghosted "Reject with reason…" holds. The re-export also
|
||||
// loads reason-capture.js, registering its message-interceptor on import.
|
||||
export { sweepAwaitingReasonRejects } from './reason-capture.js';
|
||||
|
||||
registerResponseHandler(handleApprovalsResponse);
|
||||
|
||||
|
||||
@@ -32,10 +32,23 @@ import type { MessagingGroup, PendingApproval, Session } from '../../types.js';
|
||||
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
|
||||
/** Two-button approval UI — the only options the primitive supports today. */
|
||||
/**
|
||||
* Card value for the "Reject with reason…" button. Selecting it doesn't
|
||||
* finalize the reject — it holds the row and captures the approver's next DM
|
||||
* as a one-line reason relayed to the requesting agent. See reason-capture.ts.
|
||||
*/
|
||||
export const REJECT_WITH_REASON_VALUE = 'reject_with_reason';
|
||||
|
||||
/**
|
||||
* Three-button approval UI. Plain Reject is the instant fast path; "Reject with
|
||||
* reason…" opts into the reason-capture flow. Shared by every module approval
|
||||
* (create_agent, install_packages, add_mcp_server); OneCLI credential cards
|
||||
* keep their own two-button set in onecli-approvals.ts.
|
||||
*/
|
||||
const APPROVAL_OPTIONS: RawOption[] = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected (awaiting reason)', value: REJECT_WITH_REASON_VALUE },
|
||||
];
|
||||
|
||||
// ── Approval handler registry ──
|
||||
@@ -197,6 +210,8 @@ export interface RequestApprovalOptions {
|
||||
title: string;
|
||||
/** Card body shown to the admin. */
|
||||
question: string;
|
||||
/** Deliver the card to this specific user instead of all of the session group's admins. */
|
||||
approverUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,9 +221,9 @@ export interface RequestApprovalOptions {
|
||||
* approval handler for this action via the response dispatcher.
|
||||
*/
|
||||
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
|
||||
const { session, action, payload, title, question, agentName } = opts;
|
||||
const { session, action, payload, title, question, agentName, approverUserId } = opts;
|
||||
|
||||
const approvers = pickApprover(session.agent_group_id);
|
||||
const approvers = approverUserId ? [approverUserId] : pickApprover(session.agent_group_id);
|
||||
if (approvers.length === 0) {
|
||||
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
|
||||
return;
|
||||
@@ -235,6 +250,7 @@ export async function requestApproval(opts: RequestApprovalOptions): Promise<voi
|
||||
created_at: new Date().toISOString(),
|
||||
title,
|
||||
options_json: JSON.stringify(normalizedOptions),
|
||||
approver_user_id: approverUserId ?? null,
|
||||
});
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* "Reject with reason…" capture flow.
|
||||
*
|
||||
* Covers the three entry points end to end against the real central DB:
|
||||
* - arming (handleApprovalsResponse with the third option) holds the row and
|
||||
* prompts the admin instead of finalizing;
|
||||
* - the captured reply relays one combined message, clamped to 280 chars;
|
||||
* - the host sweep finalizes a ghosted hold as a plain reject.
|
||||
*
|
||||
* writeSessionMessage is mocked so the relayed agent-facing text can be read
|
||||
* back directly; the delivery adapter is a fake that records prompt sends.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
createPendingApproval,
|
||||
deletePendingApproval,
|
||||
getPendingApproval,
|
||||
markApprovalAwaitingReason,
|
||||
} from '../../db/sessions.js';
|
||||
import { setDeliveryAdapter, type ChannelDeliveryAdapter } from '../../delivery.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import { upsertUser } from '../permissions/db/users.js';
|
||||
import { upsertUserDm } from '../permissions/db/user-dms.js';
|
||||
import { grantRole } from '../permissions/db/user-roles.js';
|
||||
import { REJECT_WITH_REASON_VALUE } from './primitive.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-reject-reason' };
|
||||
});
|
||||
|
||||
vi.mock('../../session-manager.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../session-manager.js')>('../../session-manager.js');
|
||||
return { ...actual, writeSessionMessage: vi.fn() };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-reject-reason';
|
||||
const DM_CHANNEL = 'slack';
|
||||
const DM_PLATFORM = 'D-admin-1';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
let delivered: Array<{ channelType: string; platformId: string; content: string }>;
|
||||
|
||||
const fakeAdapter: ChannelDeliveryAdapter = {
|
||||
async deliver(channelType, platformId, _threadId, _kind, content) {
|
||||
delivered.push({ channelType, platformId, content });
|
||||
return 'pm-1';
|
||||
},
|
||||
};
|
||||
|
||||
function seedApproval(approvalId: string, action = 'create_agent'): void {
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: 'sess-1',
|
||||
request_id: approvalId,
|
||||
action,
|
||||
payload: JSON.stringify({ name: 'child' }),
|
||||
created_at: now(),
|
||||
title: 'Approval',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
function dmReply(text?: string): InboundEvent {
|
||||
const content: Record<string, unknown> = { sender: 'admin-1', senderId: 'admin-1' };
|
||||
if (text !== undefined) content.text = text;
|
||||
return {
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: DM_PLATFORM,
|
||||
threadId: null,
|
||||
message: { id: 'm-1', kind: 'chat', content: JSON.stringify(content), timestamp: now() },
|
||||
};
|
||||
}
|
||||
|
||||
/** Click the "Reject with reason…" button as the seeded admin. */
|
||||
async function clickRejectWithReason(approvalId: string): Promise<void> {
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
await handleApprovalsResponse({
|
||||
questionId: approvalId,
|
||||
value: REJECT_WITH_REASON_VALUE,
|
||||
userId: 'admin-1',
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: '', // not surfaced by the click payload — resolved via ensureUserDm
|
||||
threadId: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** The text of the most recent agent-facing note written via writeSessionMessage. */
|
||||
function lastRelayedText(): string | undefined {
|
||||
const call = vi.mocked(writeSessionMessage).mock.calls.at(-1);
|
||||
if (!call) return undefined;
|
||||
return (JSON.parse(call[2].content) as { text: string }).text;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
delivered = [];
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
// Authorized approver + a cached DM so ensureUserDm resolves without a
|
||||
// platform openDM call.
|
||||
upsertUser({ id: 'slack:admin-1', kind: 'slack', display_name: 'Admin', created_at: now() });
|
||||
grantRole({ user_id: 'slack:admin-1', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
createMessagingGroup({
|
||||
id: 'mg-dm-1',
|
||||
channel_type: DM_CHANNEL,
|
||||
platform_id: DM_PLATFORM,
|
||||
name: 'Admin DM',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now(),
|
||||
});
|
||||
upsertUserDm({
|
||||
user_id: 'slack:admin-1',
|
||||
channel_type: DM_CHANNEL,
|
||||
messaging_group_id: 'mg-dm-1',
|
||||
resolved_at: now(),
|
||||
});
|
||||
|
||||
setDeliveryAdapter(fakeAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('reject with reason', () => {
|
||||
it('holds the row and prompts the admin instead of finalizing', async () => {
|
||||
seedApproval('appr-1');
|
||||
await clickRejectWithReason('appr-1');
|
||||
|
||||
const row = getPendingApproval('appr-1');
|
||||
expect(row?.status).toBe('awaiting_reason');
|
||||
expect(row?.expires_at).toBeTruthy();
|
||||
|
||||
// Prompt went to the admin's resolved DM, not the (empty) click platformId.
|
||||
expect(delivered).toHaveLength(1);
|
||||
expect(delivered[0].channelType).toBe(DM_CHANNEL);
|
||||
expect(delivered[0].platformId).toBe(DM_PLATFORM);
|
||||
expect((JSON.parse(delivered[0].content) as { text: string }).text).toMatch(/reason/i);
|
||||
|
||||
// Agent is not notified yet — the hold is still open.
|
||||
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('relays the captured reason as one combined message and clears the row', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-2', 'install_packages');
|
||||
await clickRejectWithReason('appr-2');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply('too risky for prod'));
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(getPendingApproval('appr-2')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin: "too risky for prod"');
|
||||
});
|
||||
|
||||
it('truncates an over-long reason to 280 chars with an ellipsis', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-3');
|
||||
await clickRejectWithReason('appr-3');
|
||||
|
||||
await captureReasonReply(dmReply('x'.repeat(400)));
|
||||
|
||||
const reason = lastRelayedText()!.match(/: "(.*)"$/)![1];
|
||||
expect(reason).toHaveLength(280);
|
||||
expect(reason.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('finalizes a plain reject when the captured reply carries no text', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-4');
|
||||
await clickRejectWithReason('appr-4');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply(undefined));
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(getPendingApproval('appr-4')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your create_agent request was rejected by admin.');
|
||||
});
|
||||
|
||||
it('does not swallow a later DM once the hold was already finalized', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
seedApproval('appr-5');
|
||||
await clickRejectWithReason('appr-5');
|
||||
// Simulate the sweep (or any other path) finalizing first.
|
||||
deletePendingApproval('appr-5');
|
||||
|
||||
const consumed = await captureReasonReply(dmReply('late reason'));
|
||||
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores DMs on channels with no armed reason capture', async () => {
|
||||
const { captureReasonReply } = await import('./reason-capture.js');
|
||||
const consumed = await captureReasonReply({
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: 'D-someone-else',
|
||||
threadId: null,
|
||||
message: { id: 'm', kind: 'chat', content: JSON.stringify({ text: 'hi' }), timestamp: now() },
|
||||
});
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject-with-reason host sweep', () => {
|
||||
it('finalizes a hold whose window elapsed as a plain reject', async () => {
|
||||
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
|
||||
seedApproval('appr-ghost', 'add_mcp_server');
|
||||
markApprovalAwaitingReason('appr-ghost', new Date(Date.now() - 1000).toISOString());
|
||||
|
||||
await sweepAwaitingReasonRejects();
|
||||
|
||||
expect(getPendingApproval('appr-ghost')).toBeUndefined();
|
||||
expect(lastRelayedText()).toBe('Your add_mcp_server request was rejected by admin.');
|
||||
});
|
||||
|
||||
it('leaves a still-open hold untouched', async () => {
|
||||
const { sweepAwaitingReasonRejects } = await import('./reason-capture.js');
|
||||
seedApproval('appr-open');
|
||||
markApprovalAwaitingReason('appr-open', new Date(Date.now() + 60_000).toISOString());
|
||||
|
||||
await sweepAwaitingReasonRejects();
|
||||
|
||||
expect(getPendingApproval('appr-open')?.status).toBe('awaiting_reason');
|
||||
expect(vi.mocked(writeSessionMessage)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain reject (regression)', () => {
|
||||
it('finalizes immediately with no reason and no DM prompt', async () => {
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
seedApproval('appr-plain', 'install_packages');
|
||||
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-plain',
|
||||
value: 'reject',
|
||||
userId: 'admin-1',
|
||||
channelType: DM_CHANNEL,
|
||||
platformId: '',
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
expect(getPendingApproval('appr-plain')).toBeUndefined();
|
||||
expect(delivered).toHaveLength(0);
|
||||
expect(lastRelayedText()).toBe('Your install_packages request was rejected by admin.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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 });
|
||||
}
|
||||
}
|
||||
@@ -161,4 +161,47 @@ describe('approval response authorization', () => {
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(getPendingApproval('appr-3')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('an approval with approver_user_id is resolvable by that user, not a non-assignee', async () => {
|
||||
const { registerApprovalHandler } = await import('./primitive.js');
|
||||
const { handleApprovalsResponse } = await import('./response-handler.js');
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
registerApprovalHandler('assigned_approver_action', handler);
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: 'appr-4',
|
||||
session_id: 'sess-1',
|
||||
request_id: 'appr-4',
|
||||
action: 'assigned_approver_action',
|
||||
payload: JSON.stringify({}),
|
||||
created_at: now(),
|
||||
title: 'Assigned approval',
|
||||
options_json: JSON.stringify([]),
|
||||
approver_user_id: 'telegram:dana',
|
||||
});
|
||||
|
||||
// A non-assignee (no global/owner role) cannot resolve it.
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-4',
|
||||
value: 'approve',
|
||||
userId: 'stranger',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-stranger',
|
||||
threadId: null,
|
||||
});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(getPendingApproval('appr-4')).toBeDefined();
|
||||
|
||||
// The named approver resolves it.
|
||||
await handleApprovalsResponse({
|
||||
questionId: 'appr-4',
|
||||
value: 'approve',
|
||||
userId: 'dana',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-dana',
|
||||
threadId: null,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(getPendingApproval('appr-4')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* 1. Module-initiated actions — the module called `requestApproval()` with
|
||||
* some free-form `action` string and registered a handler via
|
||||
* `registerApprovalHandler(action, handler)`. On approve, we look up the
|
||||
* handler and call it; on reject, we notify the agent and move on.
|
||||
* handler and call it; on plain reject we relay a decline to the agent; on
|
||||
* "Reject with reason…" we hold the row and capture the admin's next DM as
|
||||
* a one-line reason (see reason-capture.ts). Reject finalization is shared
|
||||
* via finalizeReject.
|
||||
* 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved
|
||||
* via an in-memory Promise — see onecli-approvals.ts.
|
||||
*
|
||||
@@ -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', {
|
||||
@@ -125,6 +135,11 @@ function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponseP
|
||||
const userId = namespacedUserId(payload);
|
||||
if (!userId) return false;
|
||||
|
||||
// An approval may name a specific approver; only that exact user may resolve it.
|
||||
if (approval.approver_user_id) {
|
||||
return userId === approval.approver_user_id;
|
||||
}
|
||||
|
||||
const agentGroupId =
|
||||
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
routeInbound,
|
||||
setAccessGate,
|
||||
setChannelRequestGate,
|
||||
setMessageInterceptor,
|
||||
registerMessageInterceptor,
|
||||
setSenderResolver,
|
||||
setSenderScopeGate,
|
||||
type AccessGateResult,
|
||||
@@ -521,7 +521,7 @@ registerResponseHandler(handleChannelApprovalResponse);
|
||||
// Captures the next DM from an approver who clicked "Create new agent",
|
||||
// creates the agent immediately, wires the channel, and replays.
|
||||
|
||||
setMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return false;
|
||||
|
||||
|
||||
+17
-9
@@ -110,16 +110,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
||||
|
||||
/**
|
||||
* Message-interceptor hook. Runs at the very top of routeInbound, before
|
||||
* messaging-group resolution. When the interceptor returns true the message
|
||||
* is consumed and routing stops. Used by the permissions module to capture
|
||||
* free-text replies during multi-step approval flows (e.g. agent naming).
|
||||
* messaging-group resolution. When an interceptor returns true the message is
|
||||
* consumed and routing stops. Multiple interceptors may register; they run in
|
||||
* registration order and the first to claim the message (return true) wins.
|
||||
*
|
||||
* Used by modules to capture free-text DM replies during multi-step approval
|
||||
* flows — the permissions module (agent naming during channel registration)
|
||||
* and the approvals module (reject-with-reason capture).
|
||||
*/
|
||||
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||
|
||||
let messageInterceptor: MessageInterceptorFn | null = null;
|
||||
const messageInterceptors: MessageInterceptorFn[] = [];
|
||||
|
||||
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptor = fn;
|
||||
export function registerMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptors.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,9 +160,13 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
|
||||
* Creates messaging group + session if they don't exist yet.
|
||||
*/
|
||||
export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// Pre-route interceptor — lets modules consume messages before any routing
|
||||
// (e.g. free-text replies during multi-step approval flows).
|
||||
if (messageInterceptor && (await messageInterceptor(event))) return;
|
||||
// Pre-route interceptors — let modules consume messages before any routing
|
||||
// (e.g. free-text DM replies during multi-step approval flows). They run in
|
||||
// registration order; the first to claim the message stops routing. The
|
||||
// sequential await is intentional — first-to-claim is order-dependent.
|
||||
for (const intercept of messageInterceptors) {
|
||||
if (await intercept(event)) return;
|
||||
}
|
||||
|
||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel. Resolved
|
||||
|
||||
+15
-1
@@ -200,10 +200,17 @@ 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. */
|
||||
approver_user_id: string | null;
|
||||
}
|
||||
|
||||
// ── Agent destinations (central DB) ──
|
||||
@@ -215,3 +222,10 @@ export interface AgentDestination {
|
||||
target_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AgentMessagePolicy {
|
||||
from_agent_group_id: string;
|
||||
to_agent_group_id: string;
|
||||
approver: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user