Compare commits

...

37 Commits

Author SHA1 Message Date
Moshe Krupper 3eb4207c3f 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 11:53:50 +03:00
Moshe Krupper 63a175c3df 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 11:53:50 +03:00
Moshe Krupper f87dbfc75c 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 11:53:50 +03:00
Moshe Krupper aad6efc874 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 11:53:50 +03:00
Moshe Krupper cebaa0246c 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 11:53:50 +03:00
Moshe Krupper c1b9c43281 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 11:53:50 +03:00
Moshe Krupper b24e189af9 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 11:53:50 +03:00
Moshe Krupper 28e59257e1 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 11:53:50 +03:00
Moshe Krupper e834e31bbf 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 11:53:50 +03:00
Moshe Krupper 9dd3fa5f16 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 11:53:50 +03:00
Moshe Krupper 257802ea49 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 11:53:50 +03:00
Moshe Krupper 0376854c2c 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 11:53:50 +03:00
Moshe Krupper f24efb78ea 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 11:53:50 +03:00
Moshe Krupper f27b233a5e 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 11:53:50 +03:00
Moshe Krupper 969818c735 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 11:53:50 +03:00
github-actions[bot] 3f39f57653 chore: bump version to 2.1.18 2026-06-18 06:15:49 +00:00
gavrielc 1b86950f10 Merge pull request #2803 from sturdy4days/refactor/remove-dead-resolvegroupipcpath
refactor: remove dead resolveGroupIpcPath
2026-06-18 09:15:36 +03:00
gavrielc 8b435eb02d Merge branch 'main' into refactor/remove-dead-resolvegroupipcpath 2026-06-18 09:15:23 +03:00
gavrielc 7e2004f945 Merge pull request #2806 from arkjun/docs/add-korean-readme
docs: add Korean README
2026-06-18 09:14:52 +03:00
gavrielc 63901d1bde Merge branch 'main' into docs/add-korean-readme 2026-06-18 09:14:35 +03:00
gavrielc e5d96e348f Merge pull request #2805 from amit-shafnir/fix/setup-token-pty-parsing
fix(setup): parse Claude OAuth token from wrapped PTY capture
2026-06-18 09:12:56 +03:00
Juntai Park 439c24f1b7 docs: link Korean README in language switchers 2026-06-18 11:21:51 +09:00
Juntai Park 2a144bb8d6 docs: add Korean README 2026-06-18 11:21:50 +09:00
Amit Shafnir 197faaaa14 fix(setup): parse Claude OAuth token from wrapped PTY capture
`claude setup-token` runs under script(1) so the browser OAuth flow keeps a
TTY while we capture the printed token. On terminals that wrap long lines
(e.g. sbx), the token lands split across lines with padding spaces, and the
old parser — which stripped only ANSI codes and newlines — matched just the
first fragment and failed the trailing `AA` check. Login succeeded; only our
parse of the human-oriented output failed (`No sk-ant-oat…AA token found`).

Add setup/lib/captured-token.ts: normalize the capture (strip ANSI/control
bytes and all whitespace, un-wrapping the token) then extract. The TS caller
(claude-assist.ts) and the bash registration script now share it, so the
normalization rules can't drift. Placeholder lines like
`export CLAUDE_CODE_OAUTH_TOKEN=<token>` are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:20:21 +03:00
sturdy4days 3ffd6dde00 refactor: remove dead resolveGroupIpcPath
resolveGroupIpcPath has no production callers (only its own test); IPC was
removed in the v2 architecture (host<->container communicate solely via the two
session DBs). Drop the function, the now-unused DATA_DIR import, and its tests.
2026-06-17 15:19:29 -04:00
github-actions[bot] ee7f891698 docs: update token count to 196k tokens · 98% of context window 2026-06-16 11:15:10 +00:00
github-actions[bot] 7fde348e2b chore: bump version to 2.1.17 2026-06-16 11:15:04 +00:00
Gabi Simons 122135e6dc Merge pull request #2759 from assapin/fix/budget-error-surfaced-to-user
fix(agent-runner): deliver budget/billing error turns instead of dropping them
2026-06-16 14:14:48 +03:00
Gabi Simons 8563fb0681 Merge remote-tracking branch 'origin/main' into fix/budget-error-surfaced-to-user
# Conflicts:
#	CHANGELOG.md
2026-06-16 11:35:45 +03:00
omri-maya 0155ab1943 Merge pull request #2775 from nanocoai/docs/onecli-gateway-upgrade-notice
docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
2026-06-16 09:55:25 +03:00
Koshkoshinsk d1f94fcd24 docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
The breaking notice said the onecli setup step enforces the pinned versions, which is only true for fresh installs — on an existing install, updating does not upgrade the running gateway. Clarify that the gateway is separate: /update-nanoclaw upgrades it when the pin moves, otherwise upgrade manually per docs/onecli-upgrades.md.
2026-06-15 20:25:42 +03:00
gavrielc dd60983f7f Merge pull request #2774 from nanocoai/feat/update-nanoclaw-onecli-pin
feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
2026-06-15 20:09:01 +03:00
Koshkoshinsk 096b8bf589 feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
When an update moves the onecli-gateway/onecli-cli pin in versions.json, the running gateway must be upgraded to match — otherwise the new code's @onecli-sh/sdk calls fail (404 on /v1/agents) and agents can't spawn. update-nanoclaw never detected this, so the upgrade was silently skipped. Add a conditional step that follows docs/onecli-upgrades.md before restart when the pin moves.
2026-06-15 19:37:23 +03:00
Gabi Simons 59c4d33adc Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 17:42:01 +03:00
omri-maya 5f5c28d18d Merge pull request #2773 from nanocoai/docs/codex-fix-docs
docs(add-codex): drop redundant TTY warning in auth note
2026-06-15 16:04:28 +03:00
Gabi Simons e03c5c194a Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 12:17:20 +03:00
assafpin 01433bae32 fix(agent-runner): deliver budget/billing error turns instead of dropping them
A turn that ends in a non-retryable provider error (e.g. an Anthropic
403 billing_error) comes back from the streaming SDK as a result with
is_error=true and no <message> envelope. dispatchResultText treated it
as scratchpad and dropped it, then the poll-loop pushed a re-wrap nudge
-> new turn -> same error, re-hammering the gateway until idle-kill. The
user saw silence.

- providers/claude.ts: surface is_error on the result event, and fall
  back to errors[] for the message text (error subtypes carry no result).
- poll-loop.ts: when a result has no <message> blocks and is_error, deliver
  the notice verbatim to the originating channel and skip the nudge.

Verified live (real agent image + SDK, 403 mock): the notice is delivered
to the channel and the retry loop is gone.

Refs #2751
2026-06-14 12:56:02 +03:00
34 changed files with 1011 additions and 74 deletions
+6
View File
@@ -121,6 +121,7 @@ Bucket the upstream changed files:
- **Host source** (`src/`): may conflict if user modified the same files
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Version pins** (`versions.json`): a changed `onecli-gateway` / `onecli-cli` value requires upgrading the OneCLI gateway/CLI to match — see Step 5.5
- **Other**: docs, tests, setup scripts, misc
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
@@ -215,6 +216,11 @@ If build fails:
- Do not refactor unrelated code.
- If unclear, ask the user before making changes.
# Step 5.5: OneCLI upgrade (if pins moved)
The OneCLI gateway and CLI are external components pinned in `versions.json`; when a pin moves, the running version must be upgraded to match or the new code may fail against it.
If `git diff <backup-tag-from-step-1>..HEAD -- versions.json` shows the `onecli-gateway` or `onecli-cli` value changed, follow `docs/onecli-upgrades.md` before the service restart (Step 8). Otherwise skip.
# Step 6: Breaking changes check
After validation succeeds, check if the update introduced any breaking changes.
+2 -1
View File
@@ -4,7 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- [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 `onecli` setup step enforces them. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **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.
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
+1
View File
@@ -11,6 +11,7 @@
<a href="https://docs.nanoclaw.dev">docs</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
+1
View File
@@ -11,6 +11,7 @@
<a href="https://docs.nanoclaw.dev">ドキュメント</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
+228
View File
@@ -0,0 +1,228 @@
<p align="center">
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
</p>
<p align="center">
에이전트를 각자의 컨테이너에서 안전하게 실행하는 AI 어시스턴트입니다. 가볍고, 쉽게 이해할 수 있으며, 여러분의 필요에 맞게 완전히 커스터마이즈할 수 있도록 만들어졌습니다.
</p>
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">문서</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
---
## NanoClaw를 만든 이유
[OpenClaw](https://github.com/openclaw/openclaw)는 인상적인 프로젝트지만, 제가 이해하지 못하는 복잡한 소프트웨어에 제 삶 전체에 대한 접근 권한을 줬다면 저는 잠을 이루지 못했을 것입니다. OpenClaw는 거의 50만 줄에 달하는 코드, 53개의 설정 파일, 70개 이상의 의존성을 가지고 있습니다. 보안은 진정한 OS 수준의 격리가 아니라 애플리케이션 수준(허용 목록, 페어링 코드)에 의존합니다. 모든 것이 메모리를 공유하는 하나의 Node 프로세스에서 실행됩니다.
NanoClaw는 그와 동일한 핵심 기능을 제공하지만, 이해할 수 있을 만큼 작은 코드베이스로 구현합니다. 하나의 프로세스와 몇 개의 파일뿐입니다. Claude 에이전트는 단순한 권한 검사 뒤가 아니라, 파일시스템이 격리된 각자의 Linux 컨테이너에서 실행됩니다.
## 빠른 시작
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
`nanoclaw.sh`는 갓 준비한 머신에서 시작해 메시지를 보낼 수 있는 이름 붙은 에이전트까지 안내합니다. 누락된 경우 Node, pnpm, Docker를 설치하고, Anthropic 자격 증명을 OneCLI에 등록하며, 에이전트 컨테이너를 빌드하고, 첫 채널(Telegram, Discord, WhatsApp 또는 로컬 CLI)을 페어링합니다. 어떤 단계가 실패하면 Claude Code가 자동으로 호출되어 원인을 진단하고 중단된 지점부터 재개합니다.
<details>
<summary><strong>NanoClaw v1에서 마이그레이션하시나요?</strong></summary>
기존 v1 설치 옆에 새로운 v2 체크아웃을 만들어 실행하세요:
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
`migrate-v2.sh`는 v1 설치(형제 디렉터리, 또는 `NANOCLAW_V1_PATH=/path/to/nanoclaw`)를 찾아 상태를 v2 체크아웃으로 마이그레이션한 다음, 판단이 필요한 부분(소유자 시딩, CLAUDE.local.md 정리, 포크 커스터마이징 재적용)을 마무리하기 위해 Claude Code로 `exec`합니다.
이 스크립트는 Claude 세션 내부가 아니라 직접 실행하세요. 결정론적인 부분에서 Node/pnpm 부트스트랩, Docker, OneCLI, 컨테이너 빌드를 위해 대화형 프롬프트와 실제 셸 I/O가 필요합니다.
**무엇을 하는가:** `.env`를 병합하고, `registered_groups`로부터 v2 DB를 시딩하며, 그룹 폴더 + 세션 데이터 + 예약 작업을 복사하고, 선택한 채널 어댑터를 설치하며, 채널 인증 상태(WhatsApp의 Baileys 키스토어 + LID 매핑 포함)를 복사하고, 에이전트 컨테이너를 빌드합니다.
**무엇을 하지 않는가:** 시스템 서비스를 전환하지 않습니다. 프롬프트에서 *"switch to v2"*를 선택하거나, 테스트 후 수동으로 전환하세요. 기존 v1 설치는 그대로 유지됩니다.
무엇이 달라졌는지는 [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md)를, 개발 노트는 [docs/migration-dev.md](docs/migration-dev.md)를 참고하세요.
</details>
## 철학
**이해할 수 있을 만큼 작게.** 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. NanoClaw 코드베이스 전체를 이해하고 싶다면 Claude Code에게 안내해 달라고 요청하기만 하면 됩니다.
**격리를 통한 보안.** 에이전트는 Linux 컨테이너에서 실행되며 명시적으로 마운트된 것만 볼 수 있습니다. 명령이 호스트가 아니라 컨테이너 안에서 실행되기 때문에 Bash 접근도 안전합니다.
**개별 사용자를 위해 설계.** NanoClaw는 거대한 단일 프레임워크가 아니라, 각 사용자의 정확한 필요에 맞는 소프트웨어입니다. 비대한 소프트웨어가 되는 대신, NanoClaw는 맞춤형이 되도록 설계되었습니다. 직접 포크를 만들고 Claude Code가 여러분의 필요에 맞게 수정하도록 합니다.
**커스터마이징 = 코드 변경.** 설정의 난립이 없습니다. 다른 동작을 원하시나요? 코드를 수정하세요. 코드베이스가 충분히 작아서 안전하게 변경할 수 있습니다.
**AI 네이티브, 설계상 하이브리드.** 설치와 온보딩 흐름은 최적화된 스크립트 경로로, 빠르고 결정론적입니다. 어떤 단계에 판단이 필요할 때 — 설치 실패, 안내가 필요한 결정, 커스터마이징 등 — 제어권이 Claude Code로 매끄럽게 넘어갑니다. 설정 이후에도 모니터링 대시보드나 디버깅 UI가 없습니다. 채팅으로 문제를 설명하면 Claude Code가 처리합니다.
**기능보다 스킬.** 트렁크는 특정 채널 어댑터나 대체 에이전트 프로바이더가 아니라 레지스트리와 인프라를 제공합니다. 채널(Discord, Slack, Telegram, WhatsApp, …)은 오래 유지되는 `channels` 브랜치에, 대체 프로바이더(OpenCode, Ollama)는 `providers` 브랜치에 있습니다. `/add-telegram`, `/add-opencode` 등을 실행하면 스킬이 여러분이 필요로 하는 모듈만 정확히 포크로 복사합니다. 요청하지 않은 기능은 없습니다.
**최고의 하니스, 최고의 모델.** NanoClaw는 Anthropic의 공식 Claude Agent SDK를 통해 Claude Code를 네이티브로 사용하므로, 최신 Claude 모델과 Claude Code의 전체 도구 세트를 누릴 수 있습니다. 여기에는 자신의 NanoClaw 포크를 직접 수정하고 확장하는 능력도 포함됩니다. 다른 프로바이더는 드롭인 옵션입니다. OpenAI의 Codex는 `/add-codex`(ChatGPT 구독 또는 API 키), OpenRouter·Google·DeepSeek 등은 OpenCode를 통한 `/add-opencode`, 로컬 오픈 웨이트 모델은 `/add-ollama-provider`로 추가합니다. 프로바이더는 에이전트 그룹별로 설정할 수 있습니다.
## 지원 기능
- **멀티 채널 메시징** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, 그리고 Resend를 통한 이메일. `/add-<channel>` 스킬로 필요할 때 설치합니다. 하나 또는 여러 개를 동시에 실행할 수 있습니다.
- **유연한 격리** — 완전한 프라이버시를 위해 각 채널을 자체 에이전트에 연결하거나, 대화는 분리하되 메모리는 통합하기 위해 하나의 에이전트를 여러 채널에서 공유하거나, 여러 채널을 하나의 공유 세션으로 묶어 하나의 대화가 여러 채널에 걸쳐 이어지도록 할 수 있습니다. `/manage-channels`로 채널별로 선택하세요. [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
- **에이전트별 작업 공간** — 각 에이전트 그룹은 자체 `CLAUDE.md`, 자체 메모리, 자체 컨테이너, 그리고 여러분이 허용한 마운트만 갖습니다. 직접 연결하지 않는 한 경계를 넘는 것은 아무것도 없습니다.
- **예약 작업** — Claude를 실행하고 여러분에게 다시 메시지를 보낼 수 있는 반복 작업
- **웹 접근** — 웹에서 검색하고 콘텐츠를 가져오기
- **컨테이너 격리** — 에이전트는 Docker(macOS/Linux/WSL2)에서 샌드박스화되며, 선택적으로 [Docker Sandboxes](docs/docker-sandboxes.md) 마이크로 VM 격리나 macOS 네이티브 런타임인 Apple Container를 사용할 수 있습니다
- **자격 증명 보안** — 에이전트는 원시 API 키를 절대 보유하지 않습니다. 아웃바운드 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 요청 시점에 자격 증명을 주입하고 에이전트별 정책과 속도 제한을 적용합니다.
## 사용법
트리거 단어(기본값: `@Andy`)로 어시스턴트에게 말을 거세요:
```
@Andy 매주 평일 오전 9시에 영업 파이프라인 개요를 보내줘 (내 Obsidian 보관함 폴더에 접근 가능)
@Andy 매주 금요일에 지난 한 주간의 git 히스토리를 검토하고, 내용이 어긋나면 README를 업데이트해줘
@Andy 매주 월요일 오전 8시에 Hacker News와 TechCrunch에서 AI 관련 소식을 모아 브리핑을 보내줘
```
여러분이 소유하거나 관리하는 채널에서는 그룹과 작업을 관리할 수 있습니다:
```
@Andy 모든 그룹에 걸친 예약 작업을 전부 나열해줘
@Andy 월요일 브리핑 작업을 일시 정지해줘
@Andy Family Chat 그룹에 참여해줘
```
## 커스터마이징
NanoClaw는 설정 파일을 사용하지 않습니다. 변경하려면 Claude Code에게 원하는 것을 말하기만 하면 됩니다:
- "트리거 단어를 @Bob으로 바꿔줘"
- "앞으로는 응답을 더 짧고 직접적으로 하도록 기억해줘"
- "내가 좋은 아침이라고 인사하면 맞춤 인사를 추가해줘"
- "매주 대화 요약을 저장해줘"
또는 안내형 변경을 위해 `/customize`를 실행하세요.
코드베이스가 충분히 작아서 Claude가 안전하게 수정할 수 있습니다.
## 기여하기
**기능을 추가하지 마세요. 스킬을 추가하세요.**
새로운 채널이나 에이전트 프로바이더를 추가하고 싶다면 트렁크에 추가하지 마세요. 새 채널 어댑터는 `channels` 브랜치에, 새 에이전트 프로바이더는 `providers` 브랜치에 들어갑니다. 사용자는 `/add-<name>` 스킬로 자신의 포크에 설치하며, 이 스킬은 관련 모듈을 표준 경로로 복사하고, 등록을 연결하며, 의존성을 고정합니다.
이를 통해 트렁크는 순수한 레지스트리이자 인프라로 유지되고, 모든 포크는 가벼운 상태를 유지합니다. 사용자는 요청한 채널과 프로바이더만 얻고 그 외에는 아무것도 얻지 않습니다.
### RFS (Request for Skills)
저희가 보고 싶은 스킬:
**커뮤니케이션 채널**
- `/add-signal` — Signal을 채널로 추가
## 요구 사항
- macOS 또는 Linux (Windows는 WSL2 경유)
- Node.js 20+ 및 pnpm 10+ (설치 프로그램이 누락 시 둘 다 설치합니다)
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) 또는 Docker Engine (Linux)
- `/customize`, `/debug`, 설정 중 오류 복구, 그리고 모든 `/add-<channel>` 스킬을 위한 [Claude Code](https://claude.ai/download)
## 아키텍처
```
메시징 앱 → 호스트 프로세스(라우터) → inbound.db → 컨테이너(Bun, Claude Agent SDK) → outbound.db → 호스트 프로세스(전송) → 메시징 앱
```
하나의 Node 호스트가 세션별 에이전트 컨테이너를 오케스트레이션합니다. 메시지가 도착하면 호스트는 엔티티 모델(사용자 → 메시징 그룹 → 에이전트 그룹 → 세션)을 통해 라우팅하고, 세션의 `inbound.db`에 기록한 뒤 컨테이너를 깨웁니다. 컨테이너 내부의 에이전트 러너는 `inbound.db`를 폴링하고, Claude를 실행하며, 응답을 `outbound.db`에 기록합니다. 호스트는 `outbound.db`를 폴링하여 채널 어댑터를 통해 다시 전송합니다.
세션당 두 개의 SQLite 파일이 있으며 각각 정확히 하나의 작성자만 갖습니다. 교차 마운트 경합이 없고, IPC가 없으며, stdin 파이핑이 없습니다. 채널과 대체 프로바이더는 시작 시 자체 등록됩니다. 트렁크는 레지스트리와 Chat SDK 브리지를 제공하고, 어댑터 자체는 포크별로 스킬을 통해 설치됩니다.
전체 아키텍처 설명은 [docs/architecture.md](docs/architecture.md)를, 3단계 격리 모델은 [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
핵심 파일:
- `src/index.ts` — 진입점: DB 초기화, 채널 어댑터, 전송 폴링, 스윕
- `src/router.ts` — 인바운드 라우팅: 메시징 그룹 → 에이전트 그룹 → 세션 → `inbound.db`
- `src/delivery.ts``outbound.db` 폴링, 어댑터를 통한 전송, 시스템 액션 처리
- `src/host-sweep.ts` — 60초 스윕: 정체 감지, 예정 메시지 깨우기, 반복 처리
- `src/session-manager.ts` — 세션 확인, `inbound.db` / `outbound.db` 열기
- `src/container-runner.ts` — 에이전트 그룹별 컨테이너 생성, OneCLI 자격 증명 주입
- `src/db/` — 중앙 DB (사용자, 역할, 에이전트 그룹, 메시징 그룹, 연결, 마이그레이션)
- `src/channels/` — 채널 어댑터 인프라 (어댑터는 `/add-<channel>` 스킬로 설치)
- `src/providers/` — 호스트 측 프로바이더 설정 (`claude`는 기본 내장, 그 외는 스킬 경유)
- `container/agent-runner/` — Bun 에이전트 러너: 폴 루프, MCP 도구, 프로바이더 추상화
- `groups/<folder>/` — 에이전트 그룹별 파일시스템 (`CLAUDE.md`, 스킬, 컨테이너 설정)
## FAQ
**왜 Docker인가요?**
Docker는 크로스 플랫폼 지원(macOS, Linux, 그리고 WSL2 경유 Windows)과 성숙한 생태계를 제공합니다. macOS에서는 더 가벼운 네이티브 런타임인 Apple Container도 지원됩니다. 추가 격리를 위해 [Docker Sandboxes](docs/docker-sandboxes.md)는 각 컨테이너를 마이크로 VM 안에서 실행합니다.
**Linux나 Windows에서 실행할 수 있나요?**
네. Docker가 기본 런타임이며 macOS, Linux, Windows(WSL2 경유)에서 작동합니다. `bash nanoclaw.sh`를 실행하기만 하면 됩니다.
**이것은 안전한가요?**
에이전트는 애플리케이션 수준의 권한 검사 뒤가 아니라 컨테이너에서 실행됩니다. 명시적으로 마운트된 디렉터리만 접근할 수 있습니다. 자격 증명은 컨테이너에 들어가지 않습니다. 아웃바운드 API 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 프록시 수준에서 인증을 주입하고 속도 제한과 접근 정책을 지원합니다. 여전히 실행하는 것을 검토해야 하지만, 코드베이스가 충분히 작아서 실제로 검토할 수 있습니다. 전체 보안 모델은 [보안 문서](https://docs.nanoclaw.dev/concepts/security)를 참고하세요.
**왜 설정 파일이 없나요?**
설정의 난립을 원하지 않습니다. 모든 사용자는 일반적인 시스템을 설정하는 대신, 코드가 정확히 원하는 대로 동작하도록 NanoClaw를 커스터마이즈해야 합니다. 설정 파일을 선호한다면 Claude에게 추가해 달라고 할 수 있습니다.
**서드파티나 오픈소스 모델을 사용할 수 있나요?**
네. 지원되는 경로는 `/add-opencode`(OpenCode 설정을 통한 OpenRouter, OpenAI, Google, DeepSeek 등) 또는 `/add-ollama-provider`(Ollama를 통한 로컬 오픈 웨이트 모델)입니다. 둘 다 에이전트 그룹별로 설정할 수 있으므로, 같은 설치 내에서 서로 다른 에이전트가 서로 다른 백엔드에서 실행될 수 있습니다.
일회성 실험의 경우, Claude API 호환 엔드포인트라면 `.env`를 통해서도 작동합니다:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
**문제를 어떻게 디버깅하나요?**
Claude Code에게 물어보세요. "스케줄러가 왜 실행되지 않지?" "최근 로그에 뭐가 있지?" "이 메시지는 왜 응답을 받지 못했지?" 그것이 NanoClaw의 바탕에 깔린 AI 네이티브 접근 방식입니다.
**설정이 왜 작동하지 않나요?**
어떤 단계가 실패하면 `nanoclaw.sh`는 진단하고 재개하기 위해 Claude Code로 넘깁니다. 그래도 해결되지 않으면 `claude`를 실행한 뒤 `/debug`를 실행하세요. Claude가 다른 사용자에게도 영향을 줄 만한 문제를 발견하면, 관련 설정 단계나 스킬에 대한 PR을 열어주세요.
**NanoClaw를 어떻게 제거하나요?**
```bash
bash nanoclaw.sh --uninstall
```
모든 설치는 체크아웃별 ID로 태깅되므로, 제거 프로그램은 해당 사본에 속한 것만 제거합니다: 백그라운드 서비스, 컨테이너와 이미지, 앱 데이터와 로그, 에이전트 파일, 그리고 이 사본의 OneCLI 볼트 에이전트입니다. 공유되는 것 — OneCLI 앱과 여러분의 자격 증명, 머신의 다른 NanoClaw 사본 — 은 그대로 둡니다. 무엇을 발견했는지 정확히 보여주고 그룹별로 확인을 요청합니다. 여러분이 동의하기 전까지는 아무것도 삭제되지 않습니다. 변경 없이 미리 보려면 `--dry-run`을, 프롬프트를 건너뛰려면 `--yes`를 사용하세요. `.env`는 제거 전에 백업됩니다. 마무리하려면 체크아웃 폴더 자체를 삭제하세요.
**어떤 변경이 코드베이스에 받아들여지나요?**
기본 구성에는 보안 수정, 버그 수정, 명확한 개선만 받아들여집니다. 그게 전부입니다.
그 외의 모든 것(새로운 기능, OS 호환성, 하드웨어 지원, 향상)은 스킬로 기여해야 합니다. 채널과 프로바이더 코드는 `channels`/`providers` 레지스트리 브랜치에, 그 외에는 자체 완결형 스킬로 기여합니다. [docs/customizing.md](docs/customizing.md)와 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
이를 통해 기본 시스템을 최소한으로 유지하고, 모든 사용자가 원하지 않는 기능을 떠안지 않으면서 자신의 설치를 커스터마이즈할 수 있습니다.
## 커뮤니티
질문이 있나요? 아이디어가 있나요? [Discord에 참여하세요](https://discord.gg/VDdww8qS42).
## 변경 이력
호환성을 깨는 변경 사항은 [CHANGELOG.md](CHANGELOG.md)를, 또는 문서 사이트의 [전체 릴리스 히스토리](https://docs.nanoclaw.dev/changelog)를 참고하세요.
## 라이선스
MIT
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
+1
View File
@@ -11,6 +11,7 @@
<a href="https://docs.nanoclaw.dev">文档</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
+60 -1
View File
@@ -4,8 +4,9 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
import { getPendingMessages, markCompleted } from './db/messages-in.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { formatMessages, extractRouting } from './formatter.js';
import { isCorruptionError } from './poll-loop.js';
import { isCorruptionError, processQuery } from './poll-loop.js';
import { MockProvider } from './providers/mock.js';
import type { AgentQuery, ProviderEvent } from './providers/types.js';
beforeEach(() => {
initTestSessionDb();
@@ -379,6 +380,64 @@ describe('end-to-end with mock provider', () => {
});
});
/**
* Build a one-shot stub query that yields init + a single result event, then
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
*/
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
const pushes: string[] = [];
async function* events(): AsyncGenerator<ProviderEvent> {
yield { type: 'init', continuation: 'sess-1' };
yield result;
}
return {
pushes,
query: {
push: (m: string) => {
pushes.push(m);
},
end: () => {},
events: events(),
abort: () => {},
},
};
}
const ERR_ROUTING = {
platformId: 'chan-1',
channelType: 'discord',
threadId: null,
inReplyTo: 'm1',
};
describe('error result with no <message> envelope', () => {
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe(budgetText);
expect(out[0].platform_id).toBe('chan-1');
expect(out[0].channel_type).toBe('discord');
// No re-wrap nudge — an error result must not re-hammer the gateway.
expect(pushes).toHaveLength(0);
});
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
expect(getUndeliveredMessages()).toHaveLength(0);
expect(pushes).toHaveLength(1);
expect(pushes[0]).toContain('was not delivered');
});
});
describe('isCorruptionError', () => {
it('matches the Docker Desktop macOS torn-read symptom', () => {
expect(isCorruptionError('database disk image is malformed')).toBe(true);
+57 -22
View File
@@ -323,7 +323,7 @@ interface QueryResult {
continuation?: string;
}
async function processQuery(
export async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
@@ -482,28 +482,43 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
if (sent === 0 && event.isError === true) {
// Non-retryable error turn (e.g. a 403 billing_error) with no
// <message> envelope: deliver the notice instead of dropping it as
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
// the failing gateway turn after turn.
deliverErrorResult(event.text, routing);
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: 'error',
});
archivePrompts.shift();
} else {
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
} else {
archivePrompts.shift();
}
@@ -557,6 +572,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
}
}
/**
* Deliver a turn's text straight to the channel the batch arrived on. Used when
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
* This is the same user-facing write the outer catch block does, minus the
* `Error:` prefix — the provider's text is already a user-facing message.
*/
function deliverErrorResult(text: string, routing: RoutingContext): void {
log('Error result with no <message> envelope — delivering to channel');
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text }),
});
}
/**
* Parse the agent's final text for <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
@@ -440,8 +440,13 @@ export class ClaudeProvider implements AgentProvider {
if (message.type === 'system' && message.subtype === 'init') {
yield { type: 'init', continuation: message.session_id };
} else if (message.type === 'result') {
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
yield { type: 'result', text };
// `result` text exists only on subtype:"success"; error subtypes
// (e.g. a non-retryable 403 billing_error) carry their message in
// `errors[]` instead. Surface either so the poll-loop can deliver a
// billing/quota notice to the user rather than dropping the turn.
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
yield { type: 'result', text, isError: m.is_error === true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
yield { type: 'error', message: 'API retry', retryable: true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
@@ -125,7 +125,13 @@ export interface AgentQuery {
export type ProviderEvent =
| { type: 'init'; continuation: string }
| { type: 'result'; text: string | null }
/**
* A completed turn. `isError` is set when the underlying SDK flagged the
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
* poll-loop uses it to surface the result text to the user instead of
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
*/
| { type: 'result'; text: string | null; isError?: boolean }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string }
/**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.16",
"version": "2.1.18",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
<title>195k 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="196k tokens, 98% of context window">
<title>196k tokens, 98% 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">195k</text>
<text x="71" y="14">195k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">196k</text>
<text x="71" y="14">196k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { extractClaudeOAuthToken } from './captured-token.js';
// A syntactically valid token: sk-ant-oat + 93 token chars + AA.
const TOKEN = `sk-ant-oat01-${'a'.repeat(90)}AA`;
describe('extractClaudeOAuthToken', () => {
it('extracts the token from clean single-line output (normal terminal)', () => {
const raw = `Login successful.\nYour token:\n${TOKEN}\n`;
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
});
// The actual sbx failure shape: the real token wrapped across two lines AND
// the `export CLAUDE_CODE_OAUTH_TOKEN=<token>` placeholder in the same
// capture. The old parser returned null (matched only the first fragment);
// the normalizer must un-wrap the real token and never mistake the
// placeholder for it.
it('extracts the real wrapped token from sbx capture and ignores the placeholder export', () => {
const head = TOKEN.slice(0, 72);
const tail = TOKEN.slice(72);
const raw = `
\x1b[?2026h Long-lived authentication token created successfully!
Your OAuth token (valid for 1 year):
${head}
${tail}
Store this token securely. You won't be able to see it again.
Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token>
`;
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
});
it('returns null for the placeholder env-var line, not a real token', () => {
expect(extractClaudeOAuthToken('export CLAUDE_CODE_OAUTH_TOKEN=<token>\n')).toBeNull();
});
it('returns null when no token is present', () => {
expect(extractClaudeOAuthToken('claude: authentication cancelled\n')).toBeNull();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Parse a provider auth token out of interactive CLI output captured through
* a PTY (`script(1)`).
*
* Secret this module hides: the menagerie of PTY-capture artifacts that
* corrupt an otherwise whitespace-free secret. A real terminal wraps long
* lines, pads with spaces, and interleaves ANSI/control sequences, so a token
* the CLI printed as one string lands in the capture split across lines with
* escape codes embedded. Provider login itself succeeds only our parse of
* the human-oriented output fails.
*
* A normalize step strips the capture artifacts; the extractor matches the
* token shape against the clean string. A future provider adds its own
* extractor here rather than regexing raw `script(1)` output.
*
* Runnable as a CLI for the bash callers that can't import TS:
* tsx setup/lib/captured-token.ts claude <capture-file>
* Prints the token and exits 0, or exits 1 with nothing on stdout.
*/
import fs from 'fs';
import { pathToFileURL } from 'url';
/* eslint-disable no-control-regex -- these patterns exist precisely to match
the ESC/control bytes a PTY capture is full of. */
// CSI sequences (colors, cursor moves): ESC [ , optional private '?' /
// parameter bytes, optional intermediate bytes, one final byte. Stripped
// explicitly because a colour reset mid-token (sk…\x1b[0m…AA) would otherwise
// leave a `[` that breaks the token's character run.
const CSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
// Everything <= space (control bytes incl. any stray ESC, CR/LF, tabs, and the
// wrap-padding spaces inserted mid-token) plus DEL. Tokens contain none of these.
const CONTROL_AND_SPACE = /[\x00-\x20\x7f]/g;
/* eslint-enable no-control-regex */
/**
* Collapse PTY-capture artifacts so a whitespace-free secret printed across
* wrapped lines becomes a single contiguous string. Drops ALL whitespace by
* design these captures exist only to recover a token, never prose.
*/
function normalizeCapturedTerminalOutput(raw: string): string {
return raw.replace(CSI, '').replace(CONTROL_AND_SPACE, '');
}
// Claude subscription OAuth tokens: sk-ant-oat<base64url>AA. Bounded length
// keeps a greedy match from running off the end of the token.
const CLAUDE_OAUTH_TOKEN = /sk-ant-oat[A-Za-z0-9_-]{80,500}AA/g;
/**
* Extract the Claude OAuth token from a PTY capture of `claude setup-token`,
* or `null` if none is present. Returns the LAST match setup-token can echo
* partial/intermediate output before the final token. Placeholder strings like
* `<token>` never match (they lack the `sk-ant-oat` prefix).
*/
export function extractClaudeOAuthToken(raw: string): string | null {
const matches = normalizeCapturedTerminalOutput(raw).match(CLAUDE_OAUTH_TOKEN);
return matches ? matches[matches.length - 1] : null;
}
function runCli(argv: string[]): number {
const [provider, file] = argv;
if (provider !== 'claude' || !file) {
process.stderr.write('usage: captured-token.ts claude <capture-file>\n');
return 2;
}
const token = extractClaudeOAuthToken(fs.readFileSync(file, 'utf-8'));
if (!token) return 1;
process.stdout.write(token);
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
process.exit(runCli(process.argv.slice(2)));
}
+4 -8
View File
@@ -27,6 +27,7 @@ import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { extractClaudeOAuthToken } from './captured-token.js';
import { ensureAnswer } from './runner.js';
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
@@ -207,16 +208,11 @@ export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
});
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
const raw = fs.readFileSync(tmpfile, 'utf-8');
const stripped = raw
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\n\r]/g, '');
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
if (matches) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
}
const token = extractClaudeOAuthToken(fs.readFileSync(tmpfile, 'utf-8'));
if (token) process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
}
} finally {
// eslint-disable-next-line no-empty -- best-effort temp cleanup
try { fs.unlinkSync(tmpfile); } catch {}
}
+7 -7
View File
@@ -9,7 +9,8 @@ set -euo pipefail
# Flow:
# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser
# OAuth dance works and its token is captured into a tempfile.
# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture.
# 2. Parse the sk-ant-oat…AA token out of the capture via the shared
# PTY-capture parser (setup/lib/captured-token.ts).
# 3. Register it with OneCLI.
#
# Env overrides:
@@ -99,12 +100,11 @@ else
script -q "$tmpfile" $cmd
fi
# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match
# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255.
token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \
| tr -d '\n\r' \
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
| tail -1 || true)
# Extract the token via the shared PTY-capture parser (setup/lib/captured-token.ts),
# so this script and setup/lib/claude-assist.ts stay in lockstep on the
# normalization rules (ANSI/control stripping, un-wrapping the token).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
token=$(pnpm exec tsx "$SCRIPT_DIR/lib/captured-token.ts" claude "$tmpfile" || true)
if [ -z "$token" ]; then
keep=$(mktemp -t claude-setup-token-log.XXXXXX)
+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 } };
},
},
},
});
+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 { moduleAgentMessagePolicies } from './module-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 { moduleApprovalsApprover } from './module-approvals-approver.js';
export interface Migration {
version: number;
@@ -36,7 +38,9 @@ export const migrations: Migration[] = [
migration002,
moduleApprovalsPendingApprovals,
moduleAgentToAgentDestinations,
moduleAgentMessagePolicies,
moduleApprovalsTitleOptions,
moduleApprovalsApprover,
migration008,
migration009,
migration010,
@@ -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 moduleAgentMessagePolicies: 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 moduleApprovalsApprover: Migration = {
version: 18,
name: 'approvals-approver-user-id',
up(db) {
db.exec(`ALTER TABLE pending_approvals ADD COLUMN approver_user_id TEXT;`);
},
};
+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;
+1 -7
View File
@@ -2,7 +2,7 @@ import path from 'path';
import { describe, expect, it } from 'vitest';
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { isValidGroupFolder, resolveGroupFolderPath } from './group-folder.js';
describe('group folder validation', () => {
it('accepts normal group folder names', () => {
@@ -23,13 +23,7 @@ describe('group folder validation', () => {
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true);
});
it('resolves safe paths under data ipc directory', () => {
const resolved = resolveGroupIpcPath('family-chat');
expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true);
});
it('throws for unsafe folder names', () => {
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
});
});
+1 -9
View File
@@ -1,6 +1,6 @@
import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { GROUPS_DIR } from './config.js';
const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const RESERVED_FOLDERS = new Set(['global']);
@@ -34,11 +34,3 @@ export function resolveGroupFolderPath(folder: string): string {
ensureWithinBase(GROUPS_DIR, groupPath);
return groupPath;
}
export function resolveGroupIpcPath(folder: string): string {
assertValidGroupFolder(folder);
const ipcBaseDir = path.resolve(DATA_DIR, 'ipc');
const ipcPath = path.resolve(ipcBaseDir, folder);
ensureWithinBase(ipcBaseDir, ipcPath);
return ipcPath;
}
+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;
}