Add cli_scope column to container_configs with three levels:
- disabled: agent never learns about ncl (instructions excluded from
CLAUDE.md) and host dispatch rejects any cli_request
- group (default): agent can only access groups, sessions, destinations,
and members resources, scoped to its own agent group with auto-filled
--id/--agent_group_id/--group args. Help output reflects the scope.
- global: unrestricted access (current behavior)
Enforcement is host-side only — no image rebuild or env var needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Source of truth for container runtime config moves from
groups/<folder>/container.json to a new container_configs table.
The file becomes a materialized view written at spawn time.
- New container_configs table with scalar columns (provider, model,
effort, image_tag, assistant_name, max_messages_per_prompt) and
JSON columns (mcp_servers, packages_apt, packages_npm, skills,
additional_mounts)
- Startup backfill seeds DB from existing container.json files
- materializeContainerJson() replaces readContainerConfig + ensureRuntimeFields
- Self-mod handlers (install_packages, add_mcp_server) write to DB
- Provider cascade simplified: session -> container_configs -> 'claude'
- ncl groups config-{get,update,add-mcp-server,remove-mcp-server,
add-package,remove-package} custom operations
- restartAgentGroupContainers() helper for config change propagation
- Container side unchanged (still reads /workspace/agent/container.json)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
getAskQuestionRender used to hardcode the card title and option labels
for pending_channel_approvals and pending_sender_approvals in the
DB-access layer, duplicating wording that already lived in the approval
modules. That caused a visible drift between the initial card title —
picked per event in channel-approval.ts ("📣 Bot mentioned in new chat"
vs. "💬 New direct message") — and the post-click render, which
always showed the constant "📣 Channel registration".
Mirror the pattern already used by pending_approvals: add title /
options_json columns on both pending_*_approvals tables via migration
013, have the approval modules write them at creation time, and let
getAskQuestionRender just SELECT.
- Migration 013 ALTERs the two tables to add title + options_json.
- PendingChannelApproval / PendingSenderApproval types and their
create functions grow the two fields.
- channel-approval.ts / sender-approval.ts normalize options once
and pass both title and options_json into the insert.
- getAskQuestionRender drops the hardcoded render objects and reads
the stored values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install_packages and add_mcp_server already did the right thing on approve
(install auto-rebuilt+killed, add_mcp_server just killed), so request_rebuild
was redundant plumbing agents sometimes called after an install — wasting an
admin approval round-trip. Delete it end-to-end:
- container/agent-runner/src/mcp-tools/self-mod.ts: remove requestRebuild
tool + registration; update install_packages description.
- src/modules/self-mod/{request,apply,index}.ts: drop handleRequestRebuild
+ applyRequestRebuild + registrations; rewrite the rebuild-failed notify
to point admins at retrying install_packages instead.
- src/modules/{approvals,self-mod}/{agent,project}.md and skill/self-
customize/SKILL.md: scrub agent-facing references; clarify that
add_mcp_server needs no rebuild (bun runs TS directly).
- docs/{module-contract,architecture-diagram,checklist,db-central,shared-
source,v1-vs-v2/*}.md, CLAUDE.md, pending-approvals migration comment,
approvals/index.ts docstring, REFACTOR.md: trailing references.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the router sees a mention or DM on a messaging group that isn't wired
to any agent, it now escalates to an owner for approval instead of silently
dropping. Mirrors the existing unknown-sender approval pattern (ACTION-ITEMS
item 22).
Schema (migration 012):
- `messaging_groups.denied_at TEXT NULL` — timestamp set on deny so future
mentions stop escalating. ALTER TABLE ADD COLUMN, FK-safe (unlike the
rebuild that bit migration 011).
- `pending_channel_approvals` — PK on `messaging_group_id` gives free
in-flight dedup. One card per channel, no spam on rapid retries.
Router:
- New hook `setChannelRequestGate(mg, event) => Promise<void>`, invoked
from the no-wirings branch when the message was addressed to the bot
(isMention=true). Hook is fire-and-forget.
- Checks `mg.denied_at` before escalating — denied channels drop silently
and do not re-prompt.
- The two "no-wirings" branches (fresh auto-create and existing mg with
no agents) are consolidated into one escalation path that calls the
gate once. Without the module, behavior is log + record (no regression).
Permissions module:
- `channel-approval.ts::requestChannelApproval` — MVP picker: target
agent is `getAllAgentGroups()[0]`, card names it explicitly ("Wire it
to <Andy>?"). Approver via existing `pickApprover` + `pickApprovalDelivery`
primitives.
- Response handler: same click-auth pattern as sender-approval (clicker
must be the designated approver OR have admin privilege over the
target agent group).
- Approve defaults per the feature spec:
engage_mode = 'mention-sticky' for groups, 'pattern' + '.' for DMs
sender_scope = 'known'
ignored_message_policy = 'accumulate'
session_mode = 'shared'
DM vs group inferred from the original event's threadId (non-null →
group) because the auto-created mg has a placeholder is_group=0 until
the adapter fills it in.
- Triggering sender is auto-added to agent_group_members so sender_scope=
'known' doesn't bounce the replayed message into a sender-approval
cascade.
- Deny: stamps messaging_groups.denied_at, clears pending row.
- Failure modes — no owner, no agent groups, no reachable DM — log and
drop without creating a pending row, letting a future attempt try
again (same as sender-approval).
9 new integration tests cover every branch: mention triggers card, DM
triggers card, dedup, approve creates correct wiring + admits sender +
replays, approve-on-DM uses pattern/'.' defaults, deny sets denied_at
and future mentions drop silently, unauthorized clicker rejected,
no-owner drops, no-agent-groups drops.
168 tests pass (was 159; +9).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original 011 also rebuilt `messaging_groups` to flip the
`unknown_sender_policy` column DEFAULT from "strict" to "request_approval".
On live DBs the DROP TABLE step fails SQLite's foreign-key integrity
check because `sessions`, `user_dms`, and `pending_sender_approvals` all
reference `messaging_groups(id)`. `PRAGMA foreign_keys=OFF` /
`defer_foreign_keys` can't be toggled inside the implicit migration
transaction, so the rebuild can't be made to apply cleanly.
The default-flip was cosmetic anyway: every `createMessagingGroup`
callsite passes `unknown_sender_policy` explicitly. Router auto-create
was already updated to hardcode "request_approval" (router.ts:151), and
setup / seed scripts pick per context.
Changes:
- Migration 011 now only creates the `pending_sender_approvals` table +
index. The rebuild block is gone.
- Reference `SCHEMA` in src/db/schema.ts updated to reflect what the
DB actually has: DEFAULT 'strict' (from migration 001), with a note
about the effective policy applied at insert sites.
Discovered on v2 post-merge during live restart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an unknown sender writes into a wired messaging group, surface the
situation to an admin instead of silently dropping. Flow:
1. Router → access gate → handleUnknownSender (policy='request_approval')
2. Fire-and-forget requestSenderApproval: pickApprover + pickApprovalDelivery
pick a reachable admin DM; deliver an Approve / Deny card; insert a
pending_sender_approvals row carrying the original InboundEvent JSON.
3. In-flight dedup: UNIQUE(messaging_group_id, sender_identity) — a retry
from the same stranger while pending is silently dropped, not re-carded.
4. Admin clicks → Chat SDK bridge → onAction → host response-registry.
The new handleSenderApprovalResponse in the permissions module claims
responses whose questionId matches a pending_sender_approvals row.
5. approve: addMember(stranger, agent_group) + replay the stored event via
routeInbound — the second attempt clears the gate because the user is
now known.
6. deny: delete the pending row. No denial persistence (ACTION-ITEMS item 5
decision) — a future attempt triggers a fresh card.
Schema:
- Migration 011 adds pending_sender_approvals (id, mg_id, agent_group_id,
sender_identity, sender_name, original_message JSON, approver_user_id,
created_at, UNIQUE(mg_id, sender_identity)).
- Also flips messaging_groups.unknown_sender_policy default from 'strict'
to 'request_approval' (rebuild-table). Existing rows unchanged — only
the default applied to new rows flips.
- Router auto-create for unknown platform/chat drops the hardcoded
'strict' override; schema default applies.
- src/db/schema.ts reference updated to match.
Why default-flip: users wire their DM during setup and don't discover that
'strict' means "silent drop of everyone not in user_roles/members". The
approval flow is the safe default — the admin sees the stranger, explicitly
decides. 'public' stays opt-in for truly open channels.
Failure modes (row NOT created so a future attempt can try again):
- No eligible approver configured (fresh install before first owner).
- No reachable DM for any approver.
- Delivery adapter missing.
Tests (src/modules/permissions/sender-approval.test.ts, 4 cases):
- First unknown message → card delivered + row created
- Retry while pending → dedup'd (1 card, 1 row)
- Approve → member added + message replayed + container woken
- Deny → row cleared + no member added
Closes: ACTION-ITEMS item 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the opaque trigger_rules JSON + response_scope enum on
messaging_group_agents with four explicit orthogonal columns:
engage_mode 'pattern' | 'mention' | 'mention-sticky'
engage_pattern regex source; required when mode='pattern';
'.' is the "always" sentinel
sender_scope 'all' | 'known'
ignored_message_policy 'drop' | 'accumulate'
Inbound routing becomes a fan-out — every wired agent is evaluated
independently. A match gets its own session + container wake. A miss
with accumulate keeps the message as context-only (trigger=0) in that
agent's session, so when the agent does eventually engage it sees the
prior chatter.
## Schema
- Migration 010 (`engage-modes`): adds the 4 new columns, backfills
from trigger_rules.pattern + requiresTrigger + response_scope, drops
the legacy columns.
- messages_in gains `trigger INTEGER NOT NULL DEFAULT 1` (session DB
schema + `migrateMessagesInTable` forward-compat).
- countDueMessages gates waking on `trigger = 1`.
## Routing
- `pickAgent` (returns one) → loop over all wired agents. Per agent:
evaluate engage_mode; run access gate + sender-scope gate; on full
match → resolveSession + writeSessionMessage(trigger=1) + wake. On
miss with accumulate → writeSessionMessage(trigger=0), no wake. On
miss with drop → skip.
- New `findSessionForAgent(agentGroupId, mgId, threadId)` scopes
session lookup by agent so fan-out doesn't cross sessions.
- `messageIdForAgent` namespaces inbound message ids by agent_group_id
so PRIMARY KEY doesn't collide across per-agent session DBs.
## Adapter layer
- `ConversationConfig` replaces `triggerPattern` + `requiresTrigger`
with `engageMode` + `engagePattern`.
- Chat SDK bridge stores `Map<platformId, ConversationConfig[]>` (multi-
agent per conversation) and applies union gating pre-onInbound:
* onSubscribedMessage: engage if any wiring keeps firing in
subscribed state (mention-sticky or pattern)
* onNewMention: engage on mention; only subscribes the thread if
at least one wiring is `mention-sticky`
* onDirectMessage: engage per mode; sticky follows same rule
- Bridge no longer unconditionally calls `thread.subscribe()`.
## Sender scope
- Permissions module registers a second hook `setSenderScopeGate` that
runs per-wiring after the existing access gate. `sender_scope='known'`
requires canAccessAgentGroup(); `'all'` is a no-op. Not installed →
no-op everywhere (default allow).
## Container side
- Host passes `NANOCLAW_MAX_MESSAGES_PER_PROMPT` (reuses existing
MAX_MESSAGES_PER_PROMPT config; was dead code from v1).
- `getPendingMessages` queries `ORDER BY seq DESC LIMIT N`, reverses to
chronological order for the prompt — accumulated context rides along
with trigger rows up to the cap.
- `MessageInRow` gains `trigger: number` so the container can tell them
apart in downstream code (container still processes both; only the
host uses `trigger=0` for don't-wake).
## Defaults (per ACTION-ITEMS item 1 decision)
- DM (is_group=0): `engage_mode='pattern'`, `engage_pattern='.'` (always)
- Threaded group: `engage_mode='mention-sticky'` (seed-discord)
- Non-threaded group / CLI: pattern '.' in bootstrap scripts
## Tests
- src/host-core.test.ts: 3 new cases — fan-out (2 agents, 2 sessions,
2 wakes), accumulate (trigger=0 + no wake), drop (no session created).
- Existing 10 host-core tests still pass.
- Migration 010 runs on an empty DB in 0-row path — verified.
Closes: ACTION-ITEMS items 1, 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last extraction of Phase 3. Moves inter-agent messaging + create_agent +
destination projection into src/modules/agent-to-agent/. Core retains:
- `channel_type === 'agent'` dispatch in delivery.ts, guarded by
hasTable('agent_destinations') + dynamic import into module.
- Channel-permission ACL in delivery.ts, guarded by hasTable, with
inlined SQL (no module import from core).
- writeDestinations call in container-runner.ts, guarded by hasTable +
dynamic import into module.
- createMessagingGroupAgent's destination side effect in db/messaging-groups.ts,
guarded by hasTable. This is a documented transitional tier violation
(core imports from optional module), analogous to src/access.ts.
Migration `004-agent-destinations.ts` renamed to `module-agent-to-agent-
destinations.ts` preserving `name: 'agent-destinations'` so existing DBs
don't re-run it.
delivery.ts: 600 → 449 lines. handleSystemAction's last switch case gone
(just registry + default log-and-drop). notifyAgent helper removed (only
create_agent used it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 / PR #3 of the module refactor. Moves the approval and interactive-
question flows out of core and into src/modules/, wired through the response
dispatcher and delivery action registries.
New modules:
- src/modules/interactive/ — registers a response handler that claims
pending_questions rows, writes question_response to the session DB, wakes
the container. createPendingQuestion call stays inline in delivery.ts
(guarded by hasTable) per plan.
- src/modules/approvals/ — registers 3 delivery actions (install_packages,
request_rebuild, add_mcp_server), a response handler for pending_approvals
(including OneCLI action fall-through), an adapter-ready hook that boots
the OneCLI manual-approval handler, and a shutdown hook that stops it.
OneCLI implementation (src/onecli-approvals.ts) moves into the module.
Core lifecycle hooks added (narrow, not registries):
- onDeliveryAdapterReady(cb) in delivery.ts — fires when setDeliveryAdapter
runs (or immediately if already set). Used by approvals for OneCLI boot.
- onShutdown(cb) in index.ts — fires on SIGTERM/SIGINT. Used by approvals
for OneCLI teardown.
- getDeliveryAdapter() getter in delivery.ts — for live-flow adapter access
in registered delivery actions.
Core shrinks: delivery.ts 911 → 665 lines, index.ts 405 → 224 lines.
dispatchResponse now logs "Unclaimed response" instead of falling through
to an inline handler — the inline fallback moved into the two modules.
Migration files renamed to the module-<name>-<short>.ts convention:
- 003-pending-approvals.ts → module-approvals-pending-approvals.ts
- 007-pending-approvals-title-options.ts → module-approvals-title-options.ts
Migration.name fields unchanged so existing DBs treat them as already-applied.
Degradation verified: emptying the modules barrel builds clean and 137/137
tests pass. Actions would log "Unknown system action"; button clicks would
log "Unclaimed response".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive change — existing code paths still run via inline fallbacks.
Prepares core for per-module extractions in PR #3 onward.
Four registries added with empty defaults:
- delivery action handlers (delivery.ts)
- router inbound gate (router.ts)
- response dispatcher (index.ts)
- MCP tool self-registration (container/agent-runner/src/mcp-tools/server.ts)
Default modules moved to src/modules/ for signaling:
- src/modules/typing/ (extracted from delivery.ts)
- src/modules/mount-security/ (moved from src/mount-security.ts)
Both are imported directly by core — no hook, no registry. Removal
requires editing core imports.
Migrator now keys applied rows by name (uniqueness) so module
migrations can pick arbitrary version numbers. Stored version column
is auto-assigned as an applied-order sequence.
sqlite_master guards added around core calls into module-owned tables
(user_roles, agent_destinations, pending_questions). No-ops today;
load-bearing after the owning modules are extracted.
MODULE-HOOK markers placed at scheduling's two skill-edit sites
(host-sweep.ts recurrence call, poll-loop.ts pre-task gate). PR #4
replaces the marked blocks when scheduling moves to its module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the in-chat credential-collection flow introduced in e92b245. Agents
can no longer collect API keys via a secure modal — users must add secrets
through OneCLI directly. Keeps the OneCLI manual-approval handler and
threaded-routing work from the same commit intact.
Removed:
* container/agent-runner/src/mcp-tools/credentials.ts (MCP tool)
* src/credentials.ts (host-side modal/OneCLI pipeline)
* src/db/credentials.ts + migration 005 (pending_credentials table)
* src/onecli-secrets.ts (createSecret CLI facade, only caller was credentials.ts)
* findCredentialResponse from agent-runner DB layer
* PendingCredential types
* Four credential hooks from ChannelSetup (getCredentialForModal,
onCredentialReject, onCredentialSubmit, onCredentialChannelUnsupported)
* Credential card/modal handling in chat-sdk-bridge (nccr/nccm prefixes,
Modal/TextInput imports)
* credential_request text fallback in WhatsApp adapter
* request_credential system-action case in delivery.ts
Added:
* Migration 009 drops pending_credentials on existing installs.
Vercel skill now tells the agent to ask the user to register the token via
OneCLI instead of invoking the removed tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Baileys 6.7.21 silently failed the pairing handshake. Upgrade to 6.17.16
which fixes this. Three related issues:
1. proto is no longer a named ESM export in 6.17.x — use createRequire
to import via CJS (matching the proven v1 pattern).
2. Setup auth script didn't handle the 515 stream restart that WhatsApp
sends after successful pairing. Refactored to reconnect (matching v1's
connectSocket(isReconnect) pattern) instead of hanging until timeout.
3. Added succeeded guard and process.exit(0) to prevent timeout race
after successful auth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add unregistered_senders table to capture dropped message origins
(one row per sender, upserted with message_count and last_seen)
- Add inbound DM logging to chat-sdk-bridge for debugging
- Add vercel CLI to base container image
- Install vercel-cli and frontend-engineer container skills
- Default requiresTrigger to false in register step
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The dev-agent-in-worktree approach for source self-modification is abandoned
in favor of a direct draft/activate flow with OS-level RO enforcement
(planned, not yet implemented). Strip the whole subgraph:
src/builder-agent/, pending-swaps DB module + migration 006, builder-agent
MCP tools, and all host wiring (startup sweep, approval routing, deadman,
worktree mount, freeze gate). Tool descriptions in self-mod.ts / agents.ts
no longer cross-reference create_dev_agent. CLAUDE.md + v2-checklist updated
to describe the new direction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Checkpoints the builder-agent dev-agent/worktree/swap flow (create_dev_agent,
request_swap, classifier, deadman, promote) before pivoting to a unified
draft-activate approach with OS-level RO enforcement. Lifts container_config
out of the agent_groups row into groups/<folder>/container.json so install_packages,
add_mcp_server, and rebuild flows can eventually route through the same draft
path as source edits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the agent-group-centric "main group" concept with user-level
privileges and adds the cold-DM infrastructure needed for proactive
outbound messaging (pairing, approvals, welcome flows).
Privilege model
- New tables: users, user_roles (owner global-only; admin global or
scoped to an agent_group), agent_group_members (explicit non-
privileged access; admin/owner imply membership), user_dms (cold-DM
resolution cache).
- Removed agent_groups.is_admin, messaging_groups.admin_user_id. Replaced
with messaging_groups.unknown_sender_policy (strict | request_approval
| public) for per-chat unknown-sender gating.
- src/access.ts: canAccessAgentGroup, pickApprover, pickApprovalDelivery.
- src/router.ts: access gate on every inbound, honoring
unknown_sender_policy for unknown senders.
- src/channels/telegram.ts: pairing interceptor upserts the paired user
and promotes them to owner if hasAnyOwner() is false (first-pair-wins).
Cold DM infrastructure
- ChannelAdapter.openDM?(handle) — optional method. Chat-SDK-bridge wires
it to chat.openDM() for resolution-required channels (Discord, Slack,
Teams, Webex, gChat); direct-addressable channels (Telegram, WhatsApp,
iMessage, Matrix, Resend) fall through to the handle directly.
- src/user-dm.ts: ensureUserDm(userId) — resolves + caches via user_dms.
Approval routing
- onecli-approvals + delivery use pickApprover + pickApprovalDelivery:
scoped admins → global admins → owners (dedup), first reachable via
ensureUserDm, same-channel-kind tie-break. Approvals land in the
approver's DM, not the origin chat.
Delivery fixes
- delivery.ts ACL rejection now throws instead of returning undefined —
the outer loop previously marked rejected messages as delivered.
- Implicit-origin allow: session.messaging_group_id === target skips the
destination check.
- createMessagingGroupAgent auto-creates the companion agent_destinations
row (normalized local_name from the messaging group's name, collision-
broken within the agent's namespace).
Container
- container-runner.ts: /workspace/global always read-only; drops
NANOCLAW_IS_ADMIN; adds NANOCLAW_ADMIN_USER_IDS (owners + global admins
+ scoped admins for this agent group). Agent-runner poll-loop gates
slash commands against that set.
New skill: /init-first-agent
- Walks the operator through standing up the first agent for a channel:
channel pick → identity lookup (reads each channel SKILL.md's
## Channel Info > how-to-find-id) → DM platform_id resolution (direct-
addressable, cold-DM via "user DMs bot first + sqlite lookup", or
Telegram pair-code fallback) → run scripts/init-first-agent.ts →
verify via tail of nanoclaw.log.
- scripts/init-first-agent.ts: parameterized helper that upserts the
user + grants owner (if none), creates dm-with-<display-name> agent
group + initGroupFilesystem, reuses/creates the DM messaging_group,
wires it (auto-creates destination), resolves the session, and writes
a kind:'chat' / sender:'system' welcome message into inbound.db. Host
sweep wakes the container and the agent DMs the operator via the
normal delivery path.
/manage-channels rewrite
- Drops --is-main / --jid / main-vs-non-main isolation references.
- First-channel flow delegates to /init-first-agent.
- Explains createMessagingGroupAgent auto-creates destinations.
- Adds a privileged-users show section.
setup/
- register.ts: drop --is-main, --jid, --local-name, --trigger
requiresTrigger defaults; call initGroupFilesystem; normalize to
v2 schema (no is_admin, no admin_user_id, sets unknown_sender_policy
'strict'); let createMessagingGroupAgent handle the destination row.
- pair-telegram.ts: emit PAIRED_USER_ID (namespaced "telegram:<id>")
instead of ADMIN_USER_ID; update header comment.
- register.test.ts deleted — was v1-only, tested a registered_groups
table that no longer exists.
Docs
- v2-architecture-diagram.{md,html}: ER diagram updated to drop
is_admin/admin_user_id, add unknown_sender_policy, and include
users/user_roles/agent_group_members/user_dms.
- v2-architecture-draft.md: approval-routing paragraph rewritten for
pickApprover/pickApprovalDelivery/ensureUserDm; SQL schema block
updated; admin-verification paragraph references
NANOCLAW_ADMIN_USER_IDS.
- v2-setup-wiring.md: entity-model sketch rewritten.
- v2-checklist.md: marked privilege refactor / container filtering /
approval routing / unknown-sender gating done; removed obsolete
admin_user_id and main-vs-non-main items.
Scripts
- scripts/init-first-agent.ts (new) replaces scripts/welcome-owner-dm.ts
(removed; welcome-owner was a Discord-specific one-off).
- test-v2-host.ts, test-v2-channel-e2e.ts, seed-discord.ts: drop
is_admin + admin_user_id, use unknown_sender_policy.
Tests
- src/access.test.ts (new): 14 tests for canAccessAgentGroup, role
helpers, pickApprover, ensureUserDm, pickApprovalDelivery.
- src/db/db-v2.test.ts: adds 3 tests for the auto-created
agent_destinations row (normalized name, no duplicates, collision
break within an agent group).
- host-core.test.ts, channel-registry.test.ts: updated fixtures to
use unknown_sender_policy: 'public' where the test exercises routing
rather than the access gate.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Approval cards bypass the deliverMessage path that populates
pending_questions, so the post-click lookup found nothing and the
card edit fell back to "❓ Question" + the raw option value
("approve"/"reject"). Store title and normalized options on
pending_approvals as well, and look up either table via a shared
getAskQuestionRender helper so the chat-sdk post-click edit and the
Discord interaction callback render the per-card title and the
selectedLabel (e.g. "✅ Approved").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Approval cards now carry a required title (Add MCP Request, Install
Packages Request, Rebuild Request, Credentials Request) and structured
options with distinct pre-click label, post-click selectedLabel (e.g.
"✅ Approved" / "❌ Rejected"), and value used for click routing. The
title and normalized options are persisted in pending_questions so the
post-click card edit can render the correct per-type title and selected
label on both chat-sdk channels and Discord interactions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three features built on top of @onecli-sh/sdk 0.3.1, landed together because
they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK
bridge, channel adapter contract).
## OneCLI manual-approval handler
* `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's
`configureManualApproval`; on each request, delivers an `ask_question` card
to the admin agent group's first messaging group, persists a
`pending_approvals` row, and waits on an in-memory Promise resolved by the
admin's button click or an expiry timer. Expired cards are edited to
"Expired (...)" and a startup sweep flushes any rows left over from a
previous process.
* Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the
Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays
in the persisted payload for audit.
* Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware
columns from the start (`agent_group_id`, `channel_type`, `platform_id`,
`platform_message_id`, `expires_at`, `status`), `session_id` relaxed to
nullable so cross-session approvals fit.
* `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals
through `resolveOneCLIApproval` before falling back to the
session-bound approval path.
## Credential collection from chat
New `trigger_credential_collection` MCP tool — the agent researches a
third-party API, calls the tool with `{name, hostPattern, headerName,
valueFormat, description}`, and blocks until the host reports saved, rejected,
or failed. The credential value never enters the agent's context: the user
submits it into a Chat SDK Modal on the host side, the host writes it to
OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to
`onecli secrets create`, shape mirrors the SDK we expect upstream), and only
the status string flows back to the container via a system message.
* `src/credentials.ts` — host-side handler: delivers the card to the
conversation's own channel (not the admin channel — credential collection
is a user-facing flow, distinct from admin approval), persists a
`pending_credentials` row, drives the submit → `createSecret` → notify
pipeline. Falls back gracefully when the channel doesn't support modals.
* `src/db/credentials.ts` + migration 005: `pending_credentials` table.
* `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card,
handles the `nccr:` action prefix by opening a Modal with a TextInput,
registers an `onModalSubmit` handler for the `nccm:` callback prefix.
* `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP
tool, mirroring the `ask_user_question` polling pattern.
* `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse`
helper to pick up the system message the host writes back.
## Threaded adapter routing
The destination layer previously didn't carry thread context, so agent replies
to Discord always landed in the root channel regardless of which thread the
inbound came from.
* `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill
at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat,
Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix,
Resend, iMessage.
* `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads
collapse to channel-level sessions). Threaded adapters override the
wiring's `session_mode` to `'per-thread'` so each thread = a session
(except `agent-shared`, which is preserved as a cross-channel intent the
adapter can't know about).
* `session_routing` table in `inbound.db` — single-row default reply routing
written by the host on every container wake from
`session.messaging_group_id` + `session.thread_id`. Forward-compat
`CREATE TABLE IF NOT EXISTS` handles older session DBs lazily.
* `container/agent-runner/src/db/session-routing.ts` — container-side reader.
* `send_message` / `send_file` / `ask_user_question` / `send_card` /
scheduling tools all default their routing (channel, platform, **and**
thread) from the session when no explicit `to` is given. Explicit `to`
uses the destination's channel with `thread_id = null` (cross-destination
sends start a new conversation elsewhere).
* `poll-loop.ts::sendToDestination` (the final-text single-destination
shortcut) now inherits `thread_id` from `RoutingContext` too — this was
the root cause of Discord replies landing in the root channel even after
`send_message` was wired correctly.
## Related cleanups
* `src/container-runner.ts`: OneCLI agent identifier switched from the lossy
folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)`
a trivial reverse lookup for per-agent scoping.
* `wakeContainer` race fix via an in-flight promise map — concurrent wakes
during the async buildContainerArgs / OneCLI `applyContainerConfig` window
no longer double-spawn containers against the same session directory.
* `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema
version assertion — it had to be bumped on every migration addition.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces implicit routing context (NANOCLAW_PLATFORM_ID env vars) with
per-agent named destination maps. Agents reference channels and peer
agents by local names; the host re-validates every outbound route against
a new agent_destinations table that is both the routing map and the ACL.
Model changes:
- New migration 004 adds agent_destinations (agent_group_id, local_name,
target_type, target_id). Backfills from existing messaging_group_agents.
- Host writes /workspace/.nanoclaw-destinations.json before every container
wake so admin changes take effect on next start.
- Container loads map at startup, appends system-prompt addendum listing
available destinations and the <message to="name">…</message> syntax.
- Agent main output is parsed for <message to="..."> blocks; each block
becomes a messages_out row with routing resolved via the local map.
Untagged text and <internal>…</internal> are scratchpad (logged only).
- send_message MCP tool now takes `to` (destination name) instead of raw
routing fields. send_to_agent deleted (redundant — agents are just
destinations). send_file/edit_message/add_reaction route via map too.
- Inbound formatter adds from="name" attribute via reverse-lookup so the
agent sees a consistent namespace in both directions.
Permission enforcement:
- Host checks hasDestination() before every channel delivery AND every
agent-to-agent route. Unauthorized messages dropped and logged.
- routeAgentMessage simplified: ~15 lines, no JSON parse, content copied
verbatim (target formatter resolves the sender via its own local map).
- create_agent is admin-only, checked at both the container (tool not
registered for non-admins) and the host (re-check on receive). Inserts
bidirectional destination rows so parent↔child comms work immediately.
Includes path-traversal guard on folder name.
Self-modification cleanup:
- add_mcp_server now requires admin approval (previously had none).
- install_packages validates package names on BOTH sides (container tool
+ host receiver) with strict regex. Max 20 packages per request.
- All three self-mod tools are fire-and-forget: write request, return
immediately with "submitted" message. Admin approval triggers a chat
notification to the requesting agent — no tool-call polling, no 5-min
holds. On rebuild/mcp_server approval, the container is killed so the
next wake picks up new config/image.
- Approval delivery extracted into requestApproval() helper (the one
place where three call sites were literally identical).
Also folded in the phase-1 dynamic import cleanup (create_agent no longer
does `await import('./db/agent-groups.js')`) and removes NANOCLAW_PLATFORM_ID
/ CHANNEL_TYPE / THREAD_ID env-var routing entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace in-memory Chat SDK state with SqliteStateAdapter — thread
subscriptions now persist across restarts
- Add migration 002 for chat_sdk_kv, subscriptions, locks, lists tables
- Handle /clear in agent-runner (reset sessionId) — SDK has
supportsNonInteractive:false for this command
- Pass /compact, /context, /cost, /files through to SDK as admin commands
- Skip admin commands in follow-up poll so they start fresh queries
- Emit compact_boundary events as user-visible feedback messages
- Pass NANOCLAW_ADMIN_USER_ID and NANOCLAW_ASSISTANT_NAME to containers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add the v2 data layer: typed interfaces, central DB with migration
runner, per-entity CRUD, and agent-runner session DB operations.
- src/log.ts: concise message-first logging API
- src/types-v2.ts: AgentGroup, MessagingGroup, Session, MessageIn/Out
- src/db/: connection (WAL), migration runner, 001-initial schema,
CRUD for agent_groups, messaging_groups, sessions, pending_questions
- container/agent-runner/src/db/: session DB connection, messages_in
reads + status transitions, messages_out writes
- 31 new tests, all 277 tests pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>