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>
This commit is contained in:
gavrielc
2026-04-20 14:34:00 +03:00
parent a4061a0012
commit 719f97e483
9 changed files with 882 additions and 18 deletions

View File

@@ -0,0 +1,392 @@
/**
* Integration tests for the unknown-channel registration flow (ACTION-ITEMS
* item 22).
*
* Covers:
* - Mention on an unwired channel fires an owner-approval card
* - DM on an unwired channel fires a card (engage_mode will default to pattern='.')
* - In-flight dedup: second mention while a card is pending doesn't spam
* - Approve: wiring created with correct defaults, triggering sender added
* as member, replay wakes the container
* - Deny: messaging_groups.denied_at set, future mentions drop silently
* - Unauthorized clicker is rejected (same pattern as sender-approval)
* - No-owner install: no card, no row
* - No agent groups configured: no card, no row
*/
import fs from 'fs';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
import { upsertUser } from './db/users.js';
import { grantRole } from './db/user-roles.js';
// Mock container runner — prevent actual docker spawn.
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
// Mock delivery adapter.
const deliverMock = vi.fn().mockResolvedValue('plat-msg-id');
vi.mock('../../delivery.js', () => ({
getDeliveryAdapter: () => ({ deliver: deliverMock }),
}));
// Mock ensureUserDm — look up the owner's preconfigured DM row instead of
// hitting a real openDM RPC.
vi.mock('./user-dm.js', () => ({
ensureUserDm: vi.fn(async (userId: string) => {
const { getDb } = await import('../../db/connection.js');
const row = getDb()
.prepare(
`SELECT mg.* FROM messaging_groups mg
JOIN user_dms ud ON ud.messaging_group_id = mg.id
WHERE ud.user_id = ?`,
)
.get(userId);
return row;
}),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channel-approval' };
});
const TEST_DIR = '/tmp/nanoclaw-test-channel-approval';
function now() {
return new Date().toISOString();
}
beforeEach(async () => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
await import('./index.js'); // register hooks
// Base fixtures: one agent group + owner with a DM on 'telegram'.
createAgentGroup({ id: 'ag-1', name: 'Andy', folder: 'andy', agent_provider: null, created_at: now() });
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({
user_id: 'telegram:owner',
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: now(),
});
// Pre-seed owner's DM messaging group + user_dms mapping.
createMessagingGroup({
id: 'mg-dm-owner',
channel_type: 'telegram',
platform_id: 'dm-owner',
name: 'Owner DM',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
const { getDb } = await import('../../db/connection.js');
getDb()
.prepare(
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
VALUES (?, ?, ?, ?)`,
)
.run('telegram:owner', 'telegram', 'mg-dm-owner', now());
deliverMock.mockClear();
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
function groupMention(platformId: string, text = '@bot hello') {
return {
channelType: 'telegram',
platformId,
threadId: 'thread-1', // non-null → is_group=true per channel-approval default-picker logic
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text }),
timestamp: now(),
isMention: true,
},
};
}
function dmEvent(platformId: string, text = 'hello') {
return {
channelType: 'telegram',
platformId,
threadId: null,
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'stranger', senderName: 'Stranger', text }),
timestamp: now(),
isMention: true, // DM bridge sets isMention=true
},
};
}
describe('unknown-channel registration flow', () => {
it('delivers an approval card on mention into an unwired group', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-new'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const [channel, platformId, thread, kind, content] = deliverMock.mock.calls[0];
expect(channel).toBe('telegram');
expect(platformId).toBe('dm-owner'); // delivered to owner's DM
expect(thread).toBeNull();
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
// Card names the target agent so the owner knows what they're wiring to.
expect(payload.question).toContain('Andy');
const { getDb } = await import('../../db/connection.js');
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
messaging_group_id: string;
}>;
expect(rows).toHaveLength(1);
});
it('delivers a card on DM too (non-threaded event)', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(dmEvent('dm-new-user'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const { getDb } = await import('../../db/connection.js');
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(1);
});
it('dedups a second mention while the card is pending', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-busy'));
await new Promise((r) => setTimeout(r, 10));
await routeInbound(groupMention('chat-busy', '@bot still here'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const { getDb } = await import('../../db/connection.js');
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(1);
});
it('approve → creates wiring, admits triggering sender, replays', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
const { wakeContainer } = await import('../../container-runner.js');
(wakeContainer as unknown as ReturnType<typeof vi.fn>).mockClear();
await routeInbound(groupMention('chat-approve'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
expect(pending).toBeDefined();
// Owner clicks approve.
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
userId: 'owner', // raw platform id — handler namespaces it
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
// Wiring created with MVP defaults.
const mga = getDb()
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as {
engage_mode: string;
engage_pattern: string | null;
sender_scope: string;
ignored_message_policy: string;
agent_group_id: string;
};
expect(mga).toBeDefined();
expect(mga.engage_mode).toBe('mention-sticky'); // group (threadId != null)
expect(mga.engage_pattern).toBeNull();
expect(mga.sender_scope).toBe('known');
expect(mga.ignored_message_policy).toBe('accumulate');
expect(mga.agent_group_id).toBe('ag-1');
// Triggering sender auto-admitted so sender_scope='known' doesn't
// bounce the replay into sender-approval.
const member = getDb()
.prepare('SELECT 1 AS x FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.get('telegram:caller', 'ag-1');
expect(member).toBeDefined();
// Pending row cleared and container woken via replay.
const stillPending = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
).c;
expect(stillPending).toBe(0);
expect(wakeContainer).toHaveBeenCalled();
});
it('approve on a DM wires with pattern="." defaults', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(dmEvent('dm-approve-user'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
const mga = getDb()
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { engage_mode: string; engage_pattern: string };
expect(mga.engage_mode).toBe('pattern');
expect(mga.engage_pattern).toBe('.');
});
it('deny → sets denied_at; future mentions drop silently without a second card', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(groupMention('chat-deny'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'reject',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
// denied_at set, pending row cleared, no wiring.
const mg = getMessagingGroupByPlatform('telegram', 'chat-deny');
expect(mg?.denied_at).not.toBeNull();
expect(mg?.denied_at).toBeTruthy();
const mgaCount = (
getDb()
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { c: number }
).c;
expect(mgaCount).toBe(0);
// A follow-up mention on the denied channel: no new card, no new pending row.
deliverMock.mockClear();
await routeInbound(groupMention('chat-deny', '@bot please'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).not.toHaveBeenCalled();
const stillPending = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
).c;
expect(stillPending).toBe(0);
});
it('rejects clicks from an unauthorized user (prevents self-admit via forwarded card)', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
await routeInbound(groupMention('chat-unauth'));
await new Promise((r) => setTimeout(r, 10));
const { getDb } = await import('../../db/connection.js');
const pending = getDb()
.prepare('SELECT messaging_group_id FROM pending_channel_approvals')
.get() as { messaging_group_id: string };
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
userId: 'random-bystander',
channelType: 'telegram',
platformId: 'dm-random',
threadId: null,
});
if (claimed) break;
}
// No wiring created, pending row preserved so a real approver can act on it.
const mgaCount = (
getDb()
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { c: number }
).c;
expect(mgaCount).toBe(0);
const stillPending = (
getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }
).c;
expect(stillPending).toBe(1);
});
});
describe('no-owner / no-agent failure modes', () => {
it('no owner → no card, no pending row (fresh-install bootstrap path)', async () => {
// Wipe the owner grant set up in the outer beforeEach.
const { getDb } = await import('../../db/connection.js');
getDb().prepare('DELETE FROM user_roles').run();
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-noowner'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).not.toHaveBeenCalled();
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(0);
});
it('no agent groups → no card, no pending row', async () => {
const { getDb } = await import('../../db/connection.js');
// Drop foreign-key-dependent rows first, then the agent group itself.
getDb().prepare('DELETE FROM user_roles').run();
getDb().prepare('DELETE FROM agent_groups').run();
const { routeInbound } = await import('../../router.js');
await routeInbound(groupMention('chat-noagent'));
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).not.toHaveBeenCalled();
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(0);
});
});