Commit Graph

26 Commits

Author SHA1 Message Date
Adam
fd03b89333 fix(agent-route): reject unsafe attachment filenames to prevent path traversal
Filenames in forwardAttachedFiles arrived from the source agent's
messages_out content and were used directly in path.join on both
source outbox read and target inbox write. A value like `../evil.sh`
could escape `inbox/<a2a-id>/` on the target session (and similarly
the source outbox on read), breaking session isolation — an
adversarial or hallucinating sub-agent could overwrite files in
a sibling session.

Adds isSafeAttachmentName(name) — exported so it's unit-testable —
which rejects empty, `.`, `..`, anything containing `/`, `\`, or
NUL, and anything path.basename would strip. Guard runs before any
I/O. Unsafe names are dropped with a warning log, same pattern as
missing-source-file handling; a bad filename in one attachment
doesn't kill the whole route's text delivery.

Addresses Codex Review P1 on qwibitai/nanoclaw#1967.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:45:08 +10:00
Adam
672e228876 fix(agent-route): forward file attachments between agents
Before: `send_file(to='parent')` from a sub-agent wrote the bytes to
the sub-agent's own session outbox, but agent-to-agent routing copied
only the content JSON — the target's inbound message referenced
`files: ['x.png']` but the bytes lived in a session directory the
target couldn't mount. Parent agents orchestrating sub-agents (e.g.
Design Team delegating illustration work to an Illustrator sub-agent
on Codex) received file-reference messages with nothing to forward.

Fix: on route, if the source's content has `files`, copy each referenced
file from `<source>/outbox/<src-msg-id>/` to
`<target>/inbox/<a2a-msg-id>/`, and emit `attachments` (the existing
formatter convention — see formatter.ts:223) with `localPath` relative
to `/workspace/`. The target formatter already renders these as
`[file: <name> — saved to /workspace/inbox/<a2a-id>/<name>]`, so the
target agent sees the path and can call `send_file(path=…, to=…)` to
forward onward.

Convention matches what session-manager.ts:256 already does for
base64-encoded channel-inbound attachments — same inbox layout, same
content shape. Nothing on the formatter/agent side needed to change.

## Scope

- `forwardAttachedFiles(source, target)` — pure-ish helper that copies
  files and returns the attachments array.
- `forwardFileAttachments(msg, …)` — wraps the helper for the route
  path: parses content, copies files if present, merges into any
  existing `attachments`, re-serialises.
- `routeAgentMessage` — uses the rewritten content when writing the
  target's inbound row.
- Log line now includes `forwardedFileCount` for observability.

Missing source files are skipped with a warning rather than killing
the route — a bad filename in a batch shouldn't drop the
accompanying text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:34:29 +10:00
gavrielc
f351e46008 refactor(approvals): persist title+options on channel/sender approval tables
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>
2026-04-23 22:54:47 +03:00
exe.dev user
15f30682d7 fix(approvals): show human-readable names in approval cards
Channel and sender approval cards showed raw platform IDs
(e.g. discord:1475578393738219540:...) instead of readable context.
Extract sender name from the event content for channel approvals,
and use the channel type name for sender approvals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:23:34 +00:00
gavrielc
3b8240a91b refactor(self-mod): drop request_rebuild — approvals now bundle rebuild+restart
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>
2026-04-22 17:28:36 +03:00
gavrielc
95e74d8383 docs(onecli): expand secrets section; correct stale admin-roles refs
Document the selective-mode gotcha for auto-created OneCLI agents
(no secrets injected by default) with the CLI commands to inspect
and fix it. Note that approval policies are not configurable via
the SDK or `onecli@1.3.0` CLI — web UI only.

Replace stale `NANOCLAW_ADMIN_USER_IDS` / `src/access.ts` references
across CLAUDE.md, docs/architecture.md, docs/checklist.md, and
docs/module-contract.md. Admin gating now runs host-side in
src/command-gate.ts against `user_roles`; approver picks live in
src/modules/approvals/primitive.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:46:17 +03:00
gavrielc
3db66c0ced fix: forward ONECLI_API_KEY to OneCLI SDK for authenticated container config
Ports the v1 fix from PR #1777 (originally 8b5b581 by @johnnyfish).
Cherry-pick did not apply cleanly because v2 reformatted the surrounding
code and split OneCLI usage into two sites — manual port was needed.

v2-specific adaptations:
- Also forward apiKey at the second OneCLI call site in
  src/modules/approvals/onecli-approvals.ts (v2 split the approvals
  module out of container-runner).
- Skipped the companion test-mock commit (38163bc) — it patches
  src/container-runner.test.ts, which no longer exists in v2 (tests
  consolidated into host-core.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: johnnyfish <jonathanfishner11@gmail.com>
2026-04-22 15:16:59 +03:00
gavrielc
8e1c8f8f61 style: apply prettier formatting to touched files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:57:09 +03:00
gavrielc
01ffce6f74 Revert "fix(permissions): welcome new approved channels via /welcome, route to them"
This reverts commit 9776dd4f32.
2026-04-21 15:20:06 +03:00
Koshkoshinsk
9776dd4f32 fix(permissions): welcome new approved channels via /welcome, route to them
When the unknown-channel approval flow completes, seed a /welcome task
into the newly-wired session so the agent greets the new user on first
contact. The replayed /start (Telegram's default first-message) is filtered
by the agent-runner's command-command filter, so without an explicit
onboarding trigger the first interaction produced nothing.

Pin the destination by its local_name from agent_destinations to avoid the
agent picking the wrong named destination (previously it greeted the owner,
whose DM is in CLAUDE.md).

Also guard dispatchResultText against echoing trailing status lines when
the agent has already sent messages explicitly via send_message. Otherwise
a task-triggered flow that calls send_message then emits "welcome message
sent" produces a duplicate chat to the recipient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:40:12 +00:00
gavrielc
d8d61d3695 fix: Teams user-id prefix + defer cli:local owner grant
parseUserId now falls back to user.kind when the id prefix isn't a
registered adapter — Teams uses `29:` rather than `teams:`, so the
literal prefix wouldn't resolve the channel adapter for cold DMs.

init-cli-agent no longer claims the first-owner slot on `cli:local`.
The CLI identity is scratch; owner promotion belongs to
init-first-agent once the real channel user is wired.
2026-04-21 10:16:13 +03:00
gavrielc
0f6a1ba1ed style: apply prettier formatting to touched files
Pre-commit hook reflowed imports on files changed in the previous commit.
Unrelated format drift on other files intentionally left unstaged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:31:42 +03:00
gavrielc
6c26c0413a feat(router,cli): replyTo override + CLI admin-transport flows
- InboundEvent gains an optional replyTo; router stamps the row's address
  fields from it when set, so replies can route to a different channel than
  the one the inbound came in on.
- ChannelSetup adds onInboundEvent for admin-transport adapters that build
  the full event themselves.
- CLI wire format accepts {text, to, reply_to}. Routed messages go through
  onInboundEvent and do not evict an active chat client.
- init-first-agent hands the DM welcome to the running service via
  data/cli.sock — synchronous wake, no sweep wait. Fails loudly if the
  service is down; no silent fallback.
- Split the CLI scratch-agent bootstrap into scripts/init-cli-agent.ts;
  init-first-agent is DM-only.

Agents cannot set replyTo: it lives only on the inbound/router seam and is
consumed once when writing messages_in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:30:47 +03:00
gavrielc
719f97e483 feat(permissions): unknown-channel registration flow with owner approval
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>
2026-04-20 14:34:00 +03:00
gavrielc
68058cbc4a fix(permissions): authorize unknown-sender approval clicks
The approval click handler trusted row.approver_user_id as the actor
regardless of who actually clicked the card. A random user who received
the forwarded card could click Approve and get the stranger admitted
to the agent group — their click was simply not checked.

Separately, payload.userId arrives as the raw platform userId from
Chat SDK onAction (e.g. "6037840640"), not the namespaced form
("telegram:6037840640") that matches users(id). Without namespacing,
users-table lookups miss.

Namespace the clicker id with payload.channelType, then authorize: the
clicker must be either the designated approver OR have
owner / admin privilege over the agent group (hasAdminPrivilege covers
owner, global admin, scoped admin). Unauthorized clicks return true
(claim the response so the registry doesn't log it as unclaimed) but
take no action — the pending row stays in place so a legitimate
approver can still act on it.

Existing tests passed a pre-namespaced userId directly, masking the
first bug. Fixed the fixtures to match production plumbing and added
two tests: one asserts a random bystander's click is rejected (row
stays pending, no member added), the other asserts a global admin can
approve even when they weren't the designated approver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:16:35 +03:00
gavrielc
622a370815 feat(permissions): unknown-sender request_approval flow + flipped default policy
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>
2026-04-20 01:36:11 +03:00
gavrielc
16b9499532 feat(routing): engage modes + sender scope + accumulate/drop + per-agent fan-out
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>
2026-04-20 01:30:04 +03:00
gavrielc
dcfa12ea06 feat(timezone): recreate v1 TZ-aware formatting + scheduling behavior
The agent needs to perceive times in the user's timezone, not UTC.
Dropping this in the v1→v2 port produced a class of bugs where the agent
would schedule tasks for the wrong hour, suggest dinner at midnight, etc.
This restores v1 parity.

Container side:
- New container/agent-runner/src/timezone.ts mirrors src/timezone.ts with
  isValidTimezone / resolveTimezone / formatLocalTime, plus:
  * TIMEZONE constant resolved at load from process.env.TZ (host sets this
    from src/container-runner.ts:254)
  * parseZonedToUtc(input, tz) — treats a naive ISO as wall-clock time in
    `tz`, returns the corresponding UTC Date. Strings with Z or offset
    are passed through.

- formatter.ts:
  * formatMessages() now prepends <context timezone="IANA"/>\n — matches
    v1 src/v1/router.ts:20-22
  * formatSingleChat uses formatLocalTime(ts, TIMEZONE) instead of a
    home-rolled HH:MM 24h formatter → outputs like "Jun 15, 2026, 8:00 AM"
  * reply_to="<id>" attribute + <quoted_message from="X">Y</quoted_message>
    element — matches v1 format exactly; old <reply-to/> shape is gone
  * stripInternalTags() exported for the dispatch path to reuse

- poll-loop.ts uses the exported stripInternalTags() instead of inline regex.

- mcp-tools/scheduling.ts:
  * schedule_task/update_task descriptions now explicitly document that
    processAfter accepts either UTC or naive local time (interpreted in
    the user's TZ from the context header)
  * handlers normalize through parseZonedToUtc() and store a UTC ISO

Host side:
- src/modules/scheduling/recurrence.ts passes { tz: TIMEZONE } to
  CronExpressionParser.parse. Without this, "0 9 * * *" fires at 09:00
  UTC instead of 09:00 user-local — this was the v1 behavior
  (src/v1/task-scheduler.ts:20-49).

Tests:
- container/agent-runner/src/timezone.test.ts — mirror of src/timezone.test.ts
  + new parseZonedToUtc cases
- container/agent-runner/src/formatter.test.ts — context header, reply_to,
  quoted_message, XML escaping, stripInternalTags (ported from v1
  formatting.test.ts)
- src/modules/scheduling/recurrence.test.ts — cron TZ respected, completed
  rows only cloned when recurrence is set

Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 18 + timezone-formatting-v1-recreation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:09:14 +03:00
gavrielc
95fdec335a refactor(modules): re-tier approvals as default; extract self-mod as optional
Promotes approvals to the default tier with a public API (requestApproval +
registerApprovalHandler) that other modules consume. Self-modification
(install_packages / request_rebuild / add_mcp_server) moves into a new
optional module that registers delivery actions + matching approval handlers
via the new API.

## Approvals (default tier)

- Adds `src/modules/approvals/primitive.ts` exporting `requestApproval`,
  `registerApprovalHandler`, `notifyAgent`. Absorbs `pickApprover` /
  `pickApprovalDelivery` / `channelTypeOf` from the deleted `src/access.ts`.
- Rewrites `response-handler.ts` to dispatch to registered approval handlers
  on approve (action-keyed Map). Reject path is centralized.
- Drops the three self-mod-specific delivery-action registrations from
  `approvals/index.ts`; they belong to self-mod now.
- `onecli-approvals.ts` now imports picks from the primitive instead of
  `src/access.ts`.

## Self-mod (optional tier)

- New `src/modules/self-mod/` with request handlers (validate input + call
  requestApproval) and apply handlers (orchestration on approve).
- `apply.ts` owns updateContainerConfig + buildAgentGroupImage + killContainer
  calls. Self-mod depends on approvals (via registerApprovalHandler +
  requestApproval + notifyAgent) and on core (container-runner, container-config).
- Registers 3 delivery actions + 3 approval handlers at import time.

## Other changes

- `src/access.ts` and `src/access.test.ts` deleted. Tests split across
  `src/modules/approvals/picks.test.ts` (approver selection) and
  `src/modules/permissions/permissions.test.ts` (access + roles + DM).
- `src/modules/index.ts` barrel: approvals loads before self-mod so
  registerApprovalHandler is bound when self-mod registers at import time.

## Validation

- `pnpm run build` clean
- `pnpm test` — 137 host tests pass
- `bun test` in container/agent-runner — 17 tests pass
- Service starts; boot log shows `OneCLI approval handler started`,
  `NanoClaw running`; clean SIGTERM shutdown

Resolves the transitional tier violation flagged in PR #5 where core
imported from the permissions optional module via `src/access.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:41:26 +03:00
gavrielc
46b19dcf9c refactor(modules): extract agent-to-agent as registry-based module
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>
2026-04-18 19:00:10 +03:00
gavrielc
32bcc2c5ae refactor(permissions): preserve pre-PR behavior in three spots
PR #5 review flagged three behavior changes that shouldn't have slipped
in. This commit reverts each to match the pre-refactor behavior exactly.

1. User upsert ordering. Split the router hook into two setters:
   setSenderResolver (runs before agent resolution) and setAccessGate
   (runs after). Restores the pre-PR sequence where the users row is
   upserted even if the message is dropped by wiring or trigger rules.

2. dropped_messages audit. Moved src/modules/permissions/db/dropped-messages.ts
   back to src/db/dropped-messages.ts. The table is core audit infra, not
   permissions-specific. Router re-writes rows for no_agent_wired and
   no_trigger_match; the access gate writes rows for policy refusals.

3. Permissionless container fallback. Dropped. poll-loop restores the
   original deny-all check when NANOCLAW_ADMIN_USER_IDS is empty.

Module contract doc updated with the two-hook shape.

Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering both hooks and clean SIGTERM shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:00:10 +03:00
gavrielc
7cc4ecc3be refactor(modules): extract permissions as optional module
Moves user-roles / users / agent-group-members / user-dms /
dropped-messages / user-dm / canAccessAgentGroup into
src/modules/permissions/. Module registers a single inbound-gate that
owns sender resolution, access decision, unknown-sender policy, and
drop-audit recording.

Router slimmed from 357 → 179 lines; the inline fallback chain
(extractAndUpsertUser / enforceAccess / handleUnknownSender /
recordDroppedMessage) is gone — without the permissions module core
defaults to allow-all with userId=null.

container-runner's admin-ID query is now inline SQL guarded by
sqlite_master on user_roles, keeping core free of any import from the
permissions module. The container-side formatter falls back to
permissionless mode when NANOCLAW_ADMIN_USER_IDS is empty: every sender
with an identifiable senderId is treated as admin.

Module contract doc formalizes the tier model and the dependency rule
(core ← default modules ← optional modules). One transitional violation
flagged: src/access.ts (core) imports from the permissions module for
its remaining approver-picking helpers; resolves in the planned PR #7
re-tier.

Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering its gate and clean SIGTERM shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:42:14 +03:00
gavrielc
473f766585 refactor(modules): extract scheduling as registry-based module
Moves the scheduling surface — 5 delivery actions (schedule_task,
cancel_task, pause_task, resume_task, update_task), handleRecurrence,
applyPreTaskScripts, and task DB helpers — out of core and into
src/modules/scheduling/ (host) and container/agent-runner/src/scheduling/
(container).

First PR to fill the MODULE-HOOK markers introduced in PR #2:
  - src/host-sweep.ts MODULE-HOOK:scheduling-recurrence now dynamically
    imports handleRecurrence from the module each sweep tick.
  - container/agent-runner/src/poll-loop.ts MODULE-HOOK:scheduling-pre-task
    dynamically imports applyPreTaskScripts before the provider call.
    When the marker block is empty (scheduling uninstalled), `keep`
    falls back to `normalMessages` so non-task messages still flow.

The 5 task cases are removed from delivery.ts's handleSystemAction
switch — the registry now routes them. Task DB helpers moved out of
src/db/session-db.ts (which kept `nextEvenSeq` as a named export so
the module can uphold the host-writes-even-seq invariant). Test suite
split to match: scheduling-specific tests live in the module.

No migration — tasks are messages_in rows with kind='task'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:17:47 +03:00
gavrielc
626c565a70 fix(modules): break circular import TDZ between index.ts and modules
PR #3 introduced a circular-import temporal-dead-zone bug that didn't
surface in unit tests but crashed the service at startup:

  src/index.ts imports './modules/index.js' for side effects
  → src/modules/interactive/index.ts calls registerResponseHandler()
  → that function is declared in src/index.ts
  → but src/index.ts's const responseHandlers = [] hasn't been
    initialized yet (we're in the middle of its module-init)
  → ReferenceError: Cannot access 'responseHandlers' before initialization

Same issue for registerResponseHandler itself (the function reference
resolves to undefined) and for onShutdown in the approvals module.

Caught when the operator started the service and systemd flagged the
process as crashing in auto-restart loop.

Fix: extract responseHandlers + registerResponseHandler + shutdownCallbacks
+ onShutdown into src/response-registry.ts, which has no dependencies on
src/index.ts or on modules. index.ts re-exports the same surface for any
existing consumers; modules import directly from response-registry.js.

The bug was latent because:
- Unit tests import pieces, never src/index.ts's main() flow.
- Host builds clean because TypeScript doesn't catch runtime circular
  init order.
- Only surfaces when the ES module loader actually executes src/index.ts
  as the entry point.

Verified: service boots on Linux host with approvals + interactive
loaded; OneCLI handler starts via onDeliveryAdapterReady callback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:48:43 +03:00
gavrielc
a4573395d9 refactor(modules): extract approvals + interactive as registry-based modules
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>
2026-04-18 15:16:53 +03:00
gavrielc
4202041d0b refactor: scaffold module registries and default-module layout
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>
2026-04-18 14:46:19 +03:00