Compare commits

...

31 Commits

Author SHA1 Message Date
gavrielc 8d3eca7027 Merge branch 'main' into container-limits 2026-06-25 21:34:00 +03:00
Omri Maya 1d6bba4d3f feat(container): per-container CPU/memory limits (opt-in)
Pass CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT through to `docker run`
as --cpus / --memory in buildContainerArgs. Both default to empty, so spawn
args are byte-identical to today unless an operator opts in — no risk of
OOM-ing existing workloads. Caps an agent container's CPU/memory so one agent
can't monopolize the host. Swap is a deployment concern (--memory is a hard
cap on a swapless host); not managed here.

Structural tests assert each flag is pushed and guarded by its env knob,
matching the existing buildContainerArgs structural-test convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:39:16 +03:00
amit-shafnir 9bb69c0e50 Merge pull request #2830 from amit-shafnir/fix/peer-dead-plist-reaper
fix(setup): reap dead peer service registrations whose binary is gone
2026-06-25 11:27:54 +03:00
gavrielc add6145f1c Merge pull request #2826 from nanocoai/fix/skill-updates-nudge-and-container-rebuild
fix(update-skills): nudge into skill updates, rebuild container on re-apply
2026-06-23 15:41:05 +03:00
gavrielc 4e14d08173 Merge pull request #2834 from nanocoai/chore/bump-chat-sdk-4.29.0
chore(deps): move chat SDK + channel-adapter pins to 4.29.0
2026-06-23 15:26:50 +03:00
Gabi Simons 8f2f788b6e chore(deps): bump channel adapter install pins to 4.29.0 (skills + setup)
The prior commit moves `chat` to 4.29.0, but main's own install pins were left
behind — and were inconsistent: the 8 /add-<channel> SKILL.md steps pinned
@chat-adapter/*@4.27.0 while the 12 setup/*.sh scripts pinned @4.26.0. Unify all
to @4.29.0 so `/add-<channel>` (and setup:auto) on a main install fetch an
adapter whose ChatInstance matches the bridge.

20 files, version-string only. Shell scripts pass `bash -n`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:47:31 +03:00
Gabi Simons e96d7fd961 chore(deps): pin chat SDK to 4.29.0
`chat` and the `@chat-adapter/*` channel adapters are version-locked — the
adapter's ChatInstance must match the bridge's, so the pair must move together.
Pin `chat` exactly to 4.29.0 (was 4.26.0 via `^4.24.0`); a caret range floats to
4.31.0 and reintroduces the skew.

Host build + full test suite green at 4.29.0 (chat is consumed only as type
imports by the Chat SDK bridge). The channels-branch adapters bump to 4.29.0 in
lockstep; CHANGELOG notes the reinstall migration for existing channel installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:03:48 +03:00
Amit Shafnir 15292ae76c fix(setup): reap dead peer service registrations whose binary is gone
The setup preflight unloads *crash-looping* peers but ignores a more common
leftover: a launchd plist (or systemd unit) whose program no longer exists,
left behind when a NanoClaw checkout is deleted without running the
uninstaller. The health probe can't see these because an unloaded/inactive job
doesn't report via `launchctl print` / `systemctl show`, so they accumulate —
the OS keeps retrying a missing binary forever.

Detect a registration as dead when its `dist/index.js` target is absent on
disk, then unload (best-effort) and delete the orphaned config file. Own-label
and still-valid registrations are never touched.

Adds peer-cleanup.test.ts (the file previously had no tests) covering both
platforms: dead target removed, live target kept, own registration spared,
unrecognized config ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:58:51 +03:00
Koshkoshinsk 055cf49bd5 fix(update-skills): nudge into skill updates, rebuild container on re-apply
/update-nanoclaw Step 7 framed skill updates as an optional, "safe to skip"
extra, so an important channel/provider fix — shipped on the channels/providers
branches the host merge never touches — could be silently missed. Reframe it as
part of the update: default into /update-skills, name the installed skills, and
leave one minimal opt-out.

Move the container image rebuild into /update-skills Step 4: when a re-apply
changes files under container/ (e.g. a provider's runtime), rebuild so new
sessions actually run the new code. Living in update-skills covers both the
standalone and via-update-nanoclaw paths; the update-nanoclaw Step 7.5 that
briefly owned this is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHaa6bp25E62AuUJyW1V5J
2026-06-21 17:08:51 +03:00
amit-shafnir 625264ba4b Merge pull request #2811 from amit-shafnir/setup-agent-provider-flag
fix(setup): allow env-selected agent provider
2026-06-18 22:08:37 +03:00
github-actions[bot] f34e590bcd docs: update token count to 199k tokens · 100% of context window 2026-06-18 15:19:05 +00:00
github-actions[bot] d208fd7bf5 chore: bump version to 2.1.19 2026-06-18 15:18:56 +00:00
Moshe Krupper 886c65725b Merge pull request #2793 from nanocoai/feat/a2a-approval-policies
feat(agent-to-agent): per-message approval policies on connected agents
2026-06-18 18:18:43 +03:00
Moshe Krupper 9977af68d7 chore(migrations): number the new migration files (017, 018)
Rename the two new migration files to the numbered convention used by the core
migrations (001–016), with matching migrationNNN exports, instead of the
module- prefix. Versions (17, 18) and stable migration `name`s are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8e44f07dd4 refactor(approvals): carry approver on a pending_approvals column, not the payload
Per review: move the assigned approver from the approval payload to a dedicated
`approver_user_id` column on pending_approvals.

- New migration adds the column; createPendingApproval + requestApproval write it.
- isAuthorizedApprovalClick reads approval.approver_user_id directly (drops the
  payload-parsing helper); when set, only that exact user may resolve.
- The gate no longer stuffs `approver` into the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8c43f13d93 refactor(approvals): assigned approver is strict — only the named user may resolve
Per review: drop the owner/global-admin override on assigned approvals. When an
approval names an approver, only that exact user can resolve it. (Non-assigned
approvals are unchanged — still group/owner authorized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 5cf4ff1bd2 refactor: destructure approverUserId / policy.approver instead of repeated access
Per review: pull `approverUserId` into the `opts` destructure in requestApproval,
and `approver` out of `policy` in the gate, instead of accessing the property
twice. (policies.ts already binds args.* to locals.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 6e475e5503 chore(agent-to-agent): drop self-explanatory comments
Remove redundant doc/inline comments where the code speaks for itself; keep only
the non-obvious notes (return-vs-throw consume, ghost-gate cleanup, caller-does-
auth, reject-handled-elsewhere, stored-vs-click payload). Also drops a couple of
now-stale "target admin" descriptions. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 0f8499b141 refactor(agent-to-agent): drop set-time admin check on policy approver
With payload-based click-auth (clicker === approver), the approver no longer
needs to be a group admin — the operator (operator-only command) designates
whoever should approve, and only that user (or an owner) can resolve the card.
Removes the now-redundant hasAdminPrivilege validation and its import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 82e1dc4ae8 feat(approvals): authorize by approver named in payload; policy approver may be source or target
Per review (no new pending_approvals column): the gate carries `approver` inside
the existing approval `payload`, and isAuthorizedApprovalClick authorizes the
named approver (or an owner/global admin) when an approval names one — reading
the real value at click time, no group re-derivation.

- `ncl policies set --approver` validates the user is an admin/owner of the
  source OR target.
- Drops `approverAgentGroupId` and the agent_group_id stamp; `requestApproval`
  keeps `approverUserId` only for delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper e70b021cde refactor(agent-to-agent): destructure payload in applyA2aMessageGate
Per review: destructure the approval payload once instead of repeating
`payload.x`, and narrow `platform_id` up front so it's used directly (drops the
separate `targetAgentGroupId` local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper ea90a12846 feat(agent-to-agent): make policy approver mandatory
Per review, the policy approver is now required, not optional. Every policy
names one specific admin/owner of the target who approves.

- `approver` column is NOT NULL; `AgentMessagePolicy.approver` is non-nullable.
- `ncl policies set --approver <user-id>` is required and validated to be an
  admin/owner of the target.
- The gate always delivers the card to `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3b1f4501d6 feat(agent-to-agent): optional single approver per policy
Per review, add an optional `approver` to a policy: a specific admin/owner of
the target who receives the approval card (instead of all target admins). NULL
keeps the default (all target admins/owners).

- `approver` column on agent_message_policies; carried on AgentMessagePolicy.
- `ncl policies set --approver <user-id>` validates the user is an admin/owner
  of the target at set-time, so the existing click-auth gate is unchanged.
- `requestApproval` gains `approverUserId` (single) to deliver the card to that
  one user; the gate passes `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 385fb014fc refactor(agent-to-agent): split content parsing out of buildGateQuestion
Address PR review: extract `parseMessageContent` (text + attachment names from
the message content JSON) so `buildGateQuestion` reads as pure formatting, and
name the body-length cap (`GATE_CARD_BODY_MAX`) instead of a bare 1500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b2160a56aa refactor(agent-to-agent): drop named-approver list from v1
Address PR review: remove the `approvers` option entirely for v1 — the
approver is always the target group's admins/owners. Drops the `approvers`
DB column, the `--approvers` flag + its set-time validation, the now-unused
`approverUserIds` param on requestApproval, and the related tests. The
target-scoped approver pick (`approverAgentGroupId`) stays. Named approvers
can be re-added later via a migration when needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper f72658bb50 refactor(agent-to-agent): extract sourceAgentGroupId in routeAgentMessage
Address PR review: hoist session.agent_group_id into a named local
`sourceAgentGroupId`, mirroring `targetAgentGroupId`, and use it throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3180f3f881 chore(agent-to-agent): trim comments to match repo convention
Shorten the verbose doc/inline comments added with the approval-policy gate
down to terse one-liners, matching the surrounding style (e.g. agent-destinations,
write-destinations). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b0bdc57b37 refactor(agent-to-agent): align policy files with resource conventions
- policies.ts: drop the 10-line top banner. Sibling resource files carry no
  descriptive header (only destinations.ts, and only for a non-obvious
  side-effect); the prose already lives in the resource `description`.
- agent-message-policies.ts: remove `listMessagePolicies` — no production
  caller (the `ncl policies list` op uses the generic table-based CRUD); only
  its own test referenced it.
- message-gate.test.ts: assert the upsert-no-duplicate invariant via a direct
  row count instead of the removed helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Moshe Krupper 314b91efc0 feat(agent-to-agent): per-message approval policies on connected agents
Add an optional, directed, per-message require-approval gate on top of an
existing agent-to-agent connection. No policy = today's free flow (fully
backward compatible). When a policy exists for A→B, each message A sends to B
is held, an approval card showing the message goes to B's admins, and the
message is delivered on approve / declined on reject. Rejecting one message
never blocks the connection.

- New `agent_message_policies` table (directed from→to; row exists = require
  approval; `approvers` JSON, NULL = target admins). Deleted alongside its
  connection so a stale rule can't reactivate on re-wire.
- Gate inside `routeAgentMessage` after the self/`hasDestination` checks:
  holds the message via `requestApproval` and returns to consume it (like a
  system action); the held message rides in the approval payload and is
  re-routed by `applyA2aMessageGate` on approve. Self/internal messages are
  never gated.
- `requestApproval` gains `approverAgentGroupId` / `approverUserIds` and stamps
  `agent_group_id` on the pending row so the target's admins pass the
  click-auth gate.
- `ncl policies list/set/remove`, operator-only (not in the container cli_scope
  allowlist); `set` validates named approvers are admins/owners of the target.

Reuses the existing requestApproval / pending_approvals / approval-handler
spine (same shape as create_agent). Host-only; no container changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Amit Shafnir 53ed3b77c9 fix(setup): allow env-selected agent provider 2026-06-18 17:49:13 +03:00
Daniel M 070714ec58 Merge pull request #2810 from nanocoai/refactor/agents-claude-symlinks
refactor: mirror .claude skills + CLAUDE.md into .agents via symlinks
2026-06-18 17:11:26 +03:00
52 changed files with 911 additions and 64 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+29 -19
View File
@@ -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.
+1
View File
@@ -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.
+2
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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"
},
+14 -5
View File
@@ -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
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="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

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+1 -1
View File
@@ -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`.
+11
View File
@@ -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).
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+8
View File
@@ -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',
+138
View File
@@ -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
View File
@@ -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;
}
+7
View File
@@ -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'");
+6
View File
@@ -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);
+1
View File
@@ -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';
+56
View File
@@ -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 } };
},
},
},
});
+5
View File
@@ -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, '\\$&');
+31
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+75 -7
View File
@@ -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);
}
+4
View File
@@ -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,
});
};
+5 -2
View File
@@ -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);
+9
View File
@@ -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;
}