diff --git a/src/cli/resources/wirings.test.ts b/src/cli/resources/wirings.test.ts new file mode 100644 index 000000000..ec00a3fab --- /dev/null +++ b/src/cli/resources/wirings.test.ts @@ -0,0 +1,91 @@ +/** + * Regression test — `ncl wirings create` must delegate to + * `createMessagingGroupAgent` so the matching `agent_destinations` ACL row is + * auto-created. The generic single-table INSERT skipped it, leaving ncl-wired + * agents silently without the send authorization skill-wired agents get + * (delivery throws "unauthorized channel destination" for non-origin sends). + * + * The approval handler in `dispatch.ts` re-enters `dispatch()` with + * `caller: 'host'` after admin approval, so the test invokes dispatch with the + * host caller — same code path a real approval would take. + */ +import fs from 'fs'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; + +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('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-wirings' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-cli-wirings'; + +import { initTestDb, closeDb, runMigrations, createAgentGroup, getDb } from '../../db/index.js'; +import { dispatch } from '../dispatch.js'; +// Side-effect import: registers the `wirings-*` commands (including create). +import './wirings.js'; + +function now(): string { + return new Date().toISOString(); +} + +describe('wirings CLI create auto-creates the send-authorization ACL row', () => { + const GID = 'ag-1'; + const MGID = 'mg-1'; + + beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ id: GID, name: 'agent', folder: 'agent', agent_provider: null, created_at: now() }); + db.prepare( + `INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at) + VALUES (?, 'telegram', 'tg-1', 'telegram', 'chat', 1, 'strict', ?)`, + ).run(MGID, now()); + }); + + afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + }); + + it('creates the wiring and the matching agent_destinations row', async () => { + // Precondition: no destination exists yet. + const before = getDb() + .prepare('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?') + .get(GID) as { c: number }; + expect(before.c).toBe(0); + + const resp = await dispatch( + { + id: 'req-create', + command: 'wirings-create', + args: { messaging_group_id: MGID, agent_group_id: GID }, + }, + { caller: 'host' }, + ); + + expect(resp.ok).toBe(true); + + // The wiring row exists. + const wiring = getDb() + .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?') + .get(MGID, GID) as Record | undefined; + expect(wiring).toBeDefined(); + + // The send-authorization ACL row was auto-created and points at the chat. + const dest = getDb() + .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?') + .get(GID, 'channel', MGID) as Record | undefined; + expect(dest).toBeDefined(); + }); +}); diff --git a/src/cli/resources/wirings.ts b/src/cli/resources/wirings.ts index d52f8b152..a9314a8cc 100644 --- a/src/cli/resources/wirings.ts +++ b/src/cli/resources/wirings.ts @@ -1,3 +1,7 @@ +import { randomUUID } from 'crypto'; + +import { createMessagingGroupAgent } from '../../db/messaging-groups.js'; +import type { EngageMode, IgnoredMessagePolicy, MessagingGroupAgent, SenderScope } from '../../types.js'; import { registerResource } from '../crud.js'; registerResource({ @@ -66,5 +70,60 @@ registerResource({ }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], - operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, + // `create` is intentionally not in `operations` — the generic single-table + // INSERT skips the `agent_destinations` ACL row that the canonical helper + // `createMessagingGroupAgent` auto-creates, so ncl-wired agents would + // silently lack the send authorization skill-wired agents get (delivery + // throws "unauthorized channel destination" for non-origin sends). Provided + // as a `customOperation` that delegates to the helper instead. + operations: { list: 'open', get: 'open', update: 'approval', delete: 'approval' }, + customOperations: { + create: { + access: 'approval', + description: + 'Wire a messaging group to an agent group. Delegates to createMessagingGroupAgent so the ' + + 'matching agent_destinations ACL row is auto-created (a bare INSERT would skip it, leaving ' + + 'the agent unauthorized to send to the chat). Use --messaging-group-id and --agent-group-id, ' + + 'plus optional --engage-mode, --engage-pattern, --sender-scope, --ignored-message-policy, --session-mode.', + handler: async (args) => { + const messagingGroupId = args.messaging_group_id ? String(args.messaging_group_id) : ''; + const agentGroupId = args.agent_group_id ? String(args.agent_group_id) : ''; + if (!messagingGroupId) throw new Error('--messaging-group-id is required'); + if (!agentGroupId) throw new Error('--agent-group-id is required'); + + const engageMode = args.engage_mode !== undefined ? String(args.engage_mode) : 'mention'; + if (!['pattern', 'mention', 'mention-sticky'].includes(engageMode)) { + throw new Error('engage_mode must be one of: pattern, mention, mention-sticky'); + } + const senderScope = args.sender_scope !== undefined ? String(args.sender_scope) : 'all'; + if (!['all', 'known'].includes(senderScope)) { + throw new Error('sender_scope must be one of: all, known'); + } + const ignoredMessagePolicy = + args.ignored_message_policy !== undefined ? String(args.ignored_message_policy) : 'drop'; + if (!['drop', 'accumulate'].includes(ignoredMessagePolicy)) { + throw new Error('ignored_message_policy must be one of: drop, accumulate'); + } + const sessionMode = args.session_mode !== undefined ? String(args.session_mode) : 'shared'; + if (!['shared', 'per-thread', 'agent-shared'].includes(sessionMode)) { + throw new Error('session_mode must be one of: shared, per-thread, agent-shared'); + } + + const mga: MessagingGroupAgent = { + id: randomUUID(), + messaging_group_id: messagingGroupId, + agent_group_id: agentGroupId, + engage_mode: engageMode as EngageMode, + engage_pattern: args.engage_pattern !== undefined ? String(args.engage_pattern) : null, + sender_scope: senderScope as SenderScope, + ignored_message_policy: ignoredMessagePolicy as IgnoredMessagePolicy, + session_mode: sessionMode as MessagingGroupAgent['session_mode'], + priority: 0, + created_at: new Date().toISOString(), + }; + createMessagingGroupAgent(mga); + return mga; + }, + }, + }, });