ncl wirings create: create the send-authorization ACL row

Delegate `ncl wirings create` to createMessagingGroupAgent instead of the
generic single-table INSERT, so the matching agent_destinations ACL row is
auto-created. Without it, ncl-wired agents silently lacked the send
authorization skill-wired agents get and delivery rejected non-origin sends
with "unauthorized channel destination".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-07-04 16:05:54 +03:00
parent aecad864e6
commit e52f4eaa54
2 changed files with 151 additions and 1 deletions
+91
View File
@@ -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<string, unknown> | 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<string, unknown> | undefined;
expect(dest).toBeDefined();
});
});
+60 -1
View File
@@ -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;
},
},
},
});