mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-27 18:34:58 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d3eca7027 | |||
| 1d6bba4d3f | |||
| 9bb69c0e50 | |||
| add6145f1c | |||
| 4e14d08173 | |||
| 8f2f788b6e | |||
| e96d7fd961 | |||
| 15292ae76c | |||
| 055cf49bd5 | |||
| 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
|
||||
|
||||
@@ -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.19",
|
||||
"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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
+3
-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;
|
||||
|
||||
@@ -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,87 @@ 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 delivery?');
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -197,6 +197,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 +208,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 +237,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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,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);
|
||||
|
||||
|
||||
@@ -204,6 +204,8 @@ export interface PendingApproval {
|
||||
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
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 +217,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