feat(v2): user-level privilege model + cold DM infra + init-first-agent skill

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>
This commit is contained in:
gavrielc
2026-04-15 00:02:39 +03:00
parent 8430e543c1
commit 0d3326aae5
45 changed files with 1875 additions and 981 deletions

306
src/access.test.ts Normal file
View File

@@ -0,0 +1,306 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { canAccessAgentGroup, pickApprovalDelivery, pickApprover } from './access.js';
import type { ChannelAdapter, OutboundMessage } from './channels/adapter.js';
import {
initChannelAdapters,
registerChannelAdapter,
teardownChannelAdapters,
} from './channels/channel-registry.js';
import {
addMember,
closeDb,
createAgentGroup,
createMessagingGroup,
createUser,
getUserDm,
grantRole,
hasAnyOwner,
initTestDb,
isMember,
isOwner,
runMigrations,
} from './db/index.js';
import { ensureUserDm } from './user-dm.js';
function now(): string {
return new Date().toISOString();
}
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
});
afterEach(async () => {
await teardownChannelAdapters();
closeDb();
});
/**
* Register and activate a mock adapter for tests. `openDM` optional — omit
* to simulate direct-addressable channels (Telegram/WhatsApp), provide to
* simulate resolution-required channels (Discord/Slack).
*/
async function mountMockAdapter(
channelType: string,
openDM?: (handle: string) => Promise<string>,
): Promise<{ delivered: OutboundMessage[]; openDMCalls: string[] }> {
const delivered: OutboundMessage[] = [];
const openDMCalls: string[] = [];
const adapter: ChannelAdapter = {
name: channelType,
channelType,
supportsThreads: false,
async setup() {},
async teardown() {},
isConnected() {
return true;
},
async deliver(_platformId, _threadId, message) {
delivered.push(message);
return undefined;
},
async setTyping() {},
};
if (openDM) {
adapter.openDM = async (handle: string) => {
openDMCalls.push(handle);
return openDM(handle);
};
}
registerChannelAdapter(channelType, { factory: () => adapter });
await initChannelAdapters(() => ({
conversations: [],
onInbound: () => {},
onMetadata: () => {},
onAction: () => {},
}));
return { delivered, openDMCalls };
}
function seedAgentGroup(id: string): void {
createAgentGroup({
id,
name: id.toUpperCase(),
folder: id,
agent_provider: null,
container_config: null,
created_at: now(),
});
}
function seedUser(id: string, kind: string): void {
createUser({ id, kind, display_name: null, created_at: now() });
}
describe('canAccessAgentGroup', () => {
beforeEach(() => {
seedAgentGroup('ag-1');
seedAgentGroup('ag-2');
});
it('denies unknown users', () => {
const d = canAccessAgentGroup('ghost', 'ag-1');
expect(d.allowed).toBe(false);
expect(d.allowed === false && d.reason).toBe('unknown_user');
});
it('allows owners globally', () => {
seedUser('u-owner', 'telegram');
grantRole({ user_id: 'u-owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
expect(canAccessAgentGroup('u-owner', 'ag-1').allowed).toBe(true);
expect(canAccessAgentGroup('u-owner', 'ag-2').allowed).toBe(true);
});
it('allows global admins', () => {
seedUser('u-ga', 'telegram');
grantRole({ user_id: 'u-ga', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() });
expect(canAccessAgentGroup('u-ga', 'ag-1').allowed).toBe(true);
expect(canAccessAgentGroup('u-ga', 'ag-2').allowed).toBe(true);
});
it('scopes admins to their agent group', () => {
seedUser('u-sa', 'telegram');
grantRole({ user_id: 'u-sa', role: 'admin', agent_group_id: 'ag-1', granted_by: null, granted_at: now() });
expect(canAccessAgentGroup('u-sa', 'ag-1').allowed).toBe(true);
const denied = canAccessAgentGroup('u-sa', 'ag-2');
expect(denied.allowed).toBe(false);
expect(denied.allowed === false && denied.reason).toBe('not_member');
});
it('admin @ group is implicitly a member', () => {
seedUser('u-sa', 'telegram');
grantRole({ user_id: 'u-sa', role: 'admin', agent_group_id: 'ag-1', granted_by: null, granted_at: now() });
expect(isMember('u-sa', 'ag-1')).toBe(true);
});
it('allows members of the group', () => {
seedUser('u-m', 'telegram');
addMember({ user_id: 'u-m', agent_group_id: 'ag-1', added_by: null, added_at: now() });
expect(canAccessAgentGroup('u-m', 'ag-1').allowed).toBe(true);
expect(canAccessAgentGroup('u-m', 'ag-2').allowed).toBe(false);
});
it('denies known-but-not-member users', () => {
seedUser('u-known', 'telegram');
const d = canAccessAgentGroup('u-known', 'ag-1');
expect(d.allowed).toBe(false);
expect(d.allowed === false && d.reason).toBe('not_member');
});
});
describe('role helpers', () => {
it('rejects owner rows with a scope', () => {
seedUser('u-1', 'telegram');
expect(() =>
grantRole({
user_id: 'u-1',
role: 'owner',
agent_group_id: 'ag-1',
granted_by: null,
granted_at: now(),
}),
).toThrow();
});
it('hasAnyOwner reflects owner grants', () => {
seedUser('u-1', 'telegram');
expect(hasAnyOwner()).toBe(false);
grantRole({ user_id: 'u-1', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
expect(hasAnyOwner()).toBe(true);
expect(isOwner('u-1')).toBe(true);
});
});
describe('pickApprover', () => {
beforeEach(() => {
seedAgentGroup('ag-1');
seedAgentGroup('ag-2');
});
it('prefers scoped admins, then globals, then owners — deduplicated', () => {
seedUser('u-owner', 'telegram');
seedUser('u-ga', 'telegram');
seedUser('u-sa', 'telegram');
grantRole({ user_id: 'u-owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
grantRole({ user_id: 'u-ga', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() });
grantRole({ user_id: 'u-sa', role: 'admin', agent_group_id: 'ag-1', granted_by: null, granted_at: now() });
expect(pickApprover('ag-1')).toEqual(['u-sa', 'u-ga', 'u-owner']);
expect(pickApprover('ag-2')).toEqual(['u-ga', 'u-owner']);
expect(pickApprover(null)).toEqual(['u-ga', 'u-owner']);
});
it('returns empty list when nobody is privileged', () => {
expect(pickApprover('ag-1')).toEqual([]);
});
});
describe('ensureUserDm', () => {
it('direct-addressable channels: lazily creates a messaging_group using the handle', async () => {
await mountMockAdapter('telegram'); // no openDM → direct-addressable
seedUser('telegram:123', 'telegram');
const mg = await ensureUserDm('telegram:123');
expect(mg).toBeDefined();
expect(mg!.channel_type).toBe('telegram');
expect(mg!.platform_id).toBe('123');
expect(mg!.is_group).toBe(0);
// Cache row written
const cached = getUserDm('telegram:123', 'telegram');
expect(cached?.messaging_group_id).toBe(mg!.id);
});
it('resolution-required channels: calls adapter.openDM, uses its result, caches', async () => {
const mock = await mountMockAdapter('discord', async (handle) => `dm-channel-${handle}`);
seedUser('discord:user-1', 'discord');
const mg = await ensureUserDm('discord:user-1');
expect(mg).toBeDefined();
expect(mg!.platform_id).toBe('dm-channel-user-1');
expect(mock.openDMCalls).toEqual(['user-1']);
// Second call should hit the cache, not openDM.
const mg2 = await ensureUserDm('discord:user-1');
expect(mg2!.id).toBe(mg!.id);
expect(mock.openDMCalls).toEqual(['user-1']); // unchanged
});
it('returns null when the adapter is not registered', async () => {
seedUser('missing:42', 'missing');
expect(await ensureUserDm('missing:42')).toBeNull();
});
it('returns null when adapter.openDM throws', async () => {
await mountMockAdapter('slack', async () => {
throw new Error('openDM boom');
});
seedUser('slack:u1', 'slack');
expect(await ensureUserDm('slack:u1')).toBeNull();
// No cache row should be written on failure
expect(getUserDm('slack:u1', 'slack')).toBeUndefined();
});
it('reuses an existing messaging_group row if one already matches', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:555', 'telegram');
const existing = {
id: 'mg-preexisting',
channel_type: 'telegram',
platform_id: '555',
name: 'Pre-existing',
is_group: 0 as const,
unknown_sender_policy: 'strict' as const,
created_at: now(),
};
createMessagingGroup(existing);
const mg = await ensureUserDm('telegram:555');
expect(mg?.id).toBe('mg-preexisting');
expect(getUserDm('telegram:555', 'telegram')?.messaging_group_id).toBe('mg-preexisting');
});
});
describe('pickApprovalDelivery', () => {
beforeEach(() => {
seedAgentGroup('ag-1');
});
it('returns the first reachable approver', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:111', 'telegram');
seedUser('telegram:222', 'telegram');
// Both users are reachable (direct-addressable), so the first wins.
const result = await pickApprovalDelivery(['telegram:111', 'telegram:222'], 'telegram');
expect(result?.userId).toBe('telegram:111');
expect(result?.messagingGroup.platform_id).toBe('111');
});
it('prefers same-channel-kind approver on tie-break', async () => {
await mountMockAdapter('telegram');
await mountMockAdapter('discord', async (h) => `dm-${h}`);
seedUser('telegram:111', 'telegram');
seedUser('discord:222', 'discord');
// Origin is discord → discord approver wins even though telegram is first.
const result = await pickApprovalDelivery(['telegram:111', 'discord:222'], 'discord');
expect(result?.userId).toBe('discord:222');
});
it('falls through to any reachable approver when none match origin', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:111', 'telegram');
const result = await pickApprovalDelivery(['telegram:111'], 'discord');
expect(result?.userId).toBe('telegram:111');
});
it('returns null when nobody is reachable', async () => {
// No adapter registered → no user is reachable.
seedUser('telegram:111', 'telegram');
expect(await pickApprovalDelivery(['telegram:111'], 'telegram')).toBeNull();
});
});

115
src/access.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Access control + approval routing.
*
* Privilege is user-level, not group-level. A user holds zero or more roles
* (owner | admin) via `user_roles`, and is optionally "known" in specific
* agent groups via `agent_group_members`. Admins are implicitly members of
* the groups they administer.
*
* Sensitive actions trigger an approval flow, routed to the admin of the
* originating agent group; if none, the owner. Approval delivery lands in
* the approver's DM on (ideally) the same channel kind as the originating
* request. DM resolution (including cold DMs) is handled by ensureUserDm.
*/
import { getAgentGroup } from './db/agent-groups.js';
import { isMember } from './db/agent-group-members.js';
import {
getAdminsOfAgentGroup,
getGlobalAdmins,
getOwners,
hasAdminPrivilege,
isAdminOfAgentGroup,
isGlobalAdmin,
isOwner,
} from './db/user-roles.js';
import { getUser } from './db/users.js';
import { ensureUserDm } from './user-dm.js';
import type { MessagingGroup } from './types.js';
export type AccessDecision =
| { allowed: true; reason: 'owner' | 'global_admin' | 'admin_of_group' | 'member' }
| { allowed: false; reason: 'unknown_user' | 'not_member' };
/** Can this user interact with this agent group? */
export function canAccessAgentGroup(userId: string, agentGroupId: string): AccessDecision {
if (!getUser(userId)) return { allowed: false, reason: 'unknown_user' };
if (isOwner(userId)) return { allowed: true, reason: 'owner' };
if (isGlobalAdmin(userId)) return { allowed: true, reason: 'global_admin' };
if (isAdminOfAgentGroup(userId, agentGroupId)) return { allowed: true, reason: 'admin_of_group' };
if (isMember(userId, agentGroupId)) return { allowed: true, reason: 'member' };
return { allowed: false, reason: 'not_member' };
}
/** Can this user perform privileged (admin) operations on this agent group? */
export function canAdminAgentGroup(userId: string, agentGroupId: string): boolean {
return hasAdminPrivilege(userId, agentGroupId);
}
/**
* Ordered list of user IDs eligible to approve an action for the given agent
* group. Preference: admins @ that group → global admins → owners.
*
* The approver-picking policy is to try local admins first (they have direct
* context for the group), then fall back to global scope.
*/
export function pickApprover(agentGroupId: string | null): string[] {
const approvers: string[] = [];
const seen = new Set<string>();
const add = (id: string): void => {
if (!seen.has(id)) {
seen.add(id);
approvers.push(id);
}
};
if (agentGroupId) {
for (const r of getAdminsOfAgentGroup(agentGroupId)) add(r.user_id);
}
for (const r of getGlobalAdmins()) add(r.user_id);
for (const r of getOwners()) add(r.user_id);
return approvers;
}
/**
* Walk the approver list and return the first (approverId, messagingGroup)
* pair we can actually deliver to. Returns null if nobody is reachable.
*
* Tie-break rule (per model): prefer approvers reachable on the same channel
* kind as the origin; else first in list. Resolution uses ensureUserDm,
* which may trigger a platform openDM call on cache miss — that's how we
* support cold DMs to users who have never messaged the bot.
*/
export async function pickApprovalDelivery(
approvers: string[],
originChannelType: string,
): Promise<{ userId: string; messagingGroup: MessagingGroup } | null> {
// Pass 1: approvers whose channel matches the origin (prefix on user id).
if (originChannelType) {
for (const userId of approvers) {
if (channelTypeOf(userId) !== originChannelType) continue;
const mg = await ensureUserDm(userId);
if (mg) return { userId, messagingGroup: mg };
}
}
// Pass 2: any reachable approver, in order.
for (const userId of approvers) {
const mg = await ensureUserDm(userId);
if (mg) return { userId, messagingGroup: mg };
}
return null;
}
/**
* Resolve the agent group id for a session's originating request. Used by
* approval routing so we know which scope to pick admins from.
*/
export function agentGroupIdForSession(sessionAgentGroupId: string | null): string | null {
if (!sessionAgentGroupId) return null;
return getAgentGroup(sessionAgentGroupId)?.id ?? null;
}
function channelTypeOf(userId: string): string {
const idx = userId.indexOf(':');
return idx < 0 ? '' : userId.slice(0, idx);
}

View File

@@ -94,6 +94,23 @@ export interface ChannelAdapter {
setTyping?(platformId: string, threadId: string | null): Promise<void>;
syncConversations?(): Promise<ConversationInfo[]>;
updateConversations?(conversations: ConversationConfig[]): void;
/**
* Open (or fetch) a DM with this user, returning the platform_id of the
* resulting DM channel. Called by the host on demand to initiate cold
* DMs — approvals, pairing handshakes, host-initiated notifications — to
* users who may never have messaged the bot themselves.
*
* Omit this method on channels where the user handle IS already the DM
* chat id (Telegram, WhatsApp, iMessage, email, Matrix). Callers will
* fall through to using the handle directly.
*
* For channels that distinguish user id from DM channel id (Discord,
* Slack, Teams, Webex, gChat): implement by delegating to Chat SDK's
* chat.openDM, which hits the platform's idempotent open-DM endpoint.
* Returning the same platform_id on repeated calls is expected.
*/
openDM?(userHandle: string): Promise<string>;
}
/** Factory function that creates a channel adapter (returns null if credentials missing). */

View File

@@ -133,7 +133,6 @@ describe('channel + router integration', () => {
id: 'ag-1',
name: 'Test Agent',
folder: 'test-agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
@@ -144,7 +143,7 @@ describe('channel + router integration', () => {
platform_id: 'chan-100',
name: 'Test Channel',
is_group: 1,
admin_user_id: null,
unknown_sender_policy: 'public',
created_at: now(),
});
createMessagingGroupAgent({

View File

@@ -426,6 +426,22 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
await adapter.startTyping(tid);
},
/**
* Open (or fetch) a DM with a user via Chat SDK's chat.openDM. The
* returned Thread's id is encoded platform-specifically (e.g. Discord
* encodes @me:channelId:threadId), so we unwrap with
* channelIdFromThreadId to get the plain DM channel id — that's what
* the rest of NanoClaw uses as `platform_id`.
*
* Throws if Chat SDK's underlying adapter doesn't implement openDM.
* Channels without DM support (Telegram, WhatsApp native) don't go
* through chat-sdk-bridge at all, so this path isn't invoked for them.
*/
async openDM(userHandle: string): Promise<string> {
const thread = await chat.openDM(userHandle);
return adapter.channelIdFromThreadId(thread.id);
},
async teardown() {
gatewayAbort?.abort();
await chat.shutdown();

View File

@@ -7,8 +7,9 @@
* register. The message must be exactly the 4 digits (optionally prefixed by
* `@botname ` for groups with privacy ON) — arbitrary messages that happen to
* contain a 4-digit number do NOT match. The inbound interceptor in
* telegram.ts matches the code and records the chat (with admin_user_id)
* before it ever reaches the router.
* telegram.ts matches the code, records the chat, upserts the paired user,
* and (if no owner exists yet) promotes them to owner — all before the
* message ever reaches the router.
*
* Storage is a JSON file at data/telegram-pairings.json — single-process,
* read-modify-write under an in-process mutex.

View File

@@ -8,6 +8,8 @@ import { createTelegramAdapter } from '@chat-adapter/telegram';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { createMessagingGroup, getMessagingGroupByPlatform, updateMessagingGroup } from '../db/messaging-groups.js';
import { grantRole, hasAnyOwner } from '../db/user-roles.js';
import { upsertUser } from '../db/users.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
import { registerChannelAdapter } from './channel-registry.js';
@@ -79,8 +81,9 @@ function readInboundFields(message: InboundMessage): InboundFields {
/**
* Build an onInbound interceptor that consumes pairing codes before they
* reach the router. On match: upserts messaging_groups with admin_user_id
* and short-circuits. On miss: forwards to the host.
* reach the router. On match: records the chat + its paired user, promotes
* the user to owner if the instance has no owner yet, and short-circuits.
* On miss: forwards to the host.
*/
function createPairingInterceptor(
botUsernamePromise: Promise<string | null>,
@@ -109,13 +112,13 @@ function createPairingInterceptor(
hostOnInbound(platformId, threadId, message);
return;
}
// Pairing matched — upsert the messaging_group with admin binding and
// short-circuit. Skip the router entirely so this code-bearing message
// never reaches an agent.
// Pairing matched — record the chat and short-circuit so the
// code-bearing message never reaches an agent. Privilege is now a
// property of the paired user, not the chat: upsert the user, and if
// this instance has no owner yet, promote them to owner.
const existing = getMessagingGroupByPlatform('telegram', platformId);
if (existing) {
updateMessagingGroup(existing.id, {
admin_user_id: consumed.consumed!.adminUserId,
is_group: consumed.consumed!.isGroup ? 1 : 0,
});
} else {
@@ -125,13 +128,35 @@ function createPairingInterceptor(
platform_id: platformId,
name: consumed.consumed!.name,
is_group: consumed.consumed!.isGroup ? 1 : 0,
admin_user_id: consumed.consumed!.adminUserId,
unknown_sender_policy: 'strict',
created_at: new Date().toISOString(),
});
}
const pairedUserId = `telegram:${consumed.consumed!.adminUserId}`;
upsertUser({
id: pairedUserId,
kind: 'telegram',
display_name: null,
created_at: new Date().toISOString(),
});
let promotedToOwner = false;
if (!hasAnyOwner()) {
grantRole({
user_id: pairedUserId,
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: new Date().toISOString(),
});
promotedToOwner = true;
}
log.info('Telegram pairing accepted — chat registered', {
platformId,
adminUserId: consumed.consumed!.adminUserId,
pairedUser: pairedUserId,
promotedToOwner,
intent: consumed.intent,
});
})().catch((err) => {

View File

@@ -12,7 +12,7 @@ import { OneCLI } from '@onecli-sh/sdk';
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZONE } from './config.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { getAgentGroup } from './db/agent-groups.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from './db/user-roles.js';
import { initGroupFilesystem } from './group-init.js';
import { log } from './log.js';
import { validateAdditionalMounts } from './mount-security.js';
@@ -92,11 +92,9 @@ async function spawnContainer(session: Session): Promise<void> {
const mounts = buildMounts(agentGroup, session);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
// OneCLI agent identifier is the agent group id. The admin group uses OneCLI's
// default agent (undefined), so unscoped credentials apply. Non-admin groups
// use their stable ag-xxx id, which is reversible via getAgentGroup() for
// approval-request routing.
const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.id;
// OneCLI agent identifier is always the agent group id — stable across
// sessions and reversible via getAgentGroup() for approval routing.
const agentIdentifier = agentGroup.id;
const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier);
log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName });
@@ -173,7 +171,6 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] {
initGroupFilesystem(agentGroup);
const mounts: VolumeMount[] = [];
const projectRoot = process.cwd();
const sessDir = sessionDir(agentGroup.id, session.id);
const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
@@ -183,12 +180,11 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] {
// Agent group folder at /workspace/agent
mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false });
// Global memory directory — read-only for non-admin so the @import
// in each group's CLAUDE.md can resolve it without risk of being
// overwritten by an agent in some other group.
// Global memory directory — always read-only. Edits to global config
// happen through the approval flow, not by handing one workspace RW.
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: !agentGroup.is_admin });
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
}
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
@@ -201,23 +197,10 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] {
const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src');
mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false });
// Admin: mount project root read-only
if (agentGroup.is_admin) {
mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', readonly: true });
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: true });
}
}
// Additional mounts from container config
const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {};
if (containerConfig.additionalMounts) {
const validated = validateAdditionalMounts(
containerConfig.additionalMounts,
agentGroup.name,
!!agentGroup.is_admin,
);
const validated = validateAdditionalMounts(containerConfig.additionalMounts, agentGroup.name);
mounts.push(...validated);
}
@@ -241,19 +224,22 @@ async function buildContainerArgs(
args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.db');
args.push('-e', 'SESSION_HEARTBEAT_PATH=/workspace/.heartbeat');
// Pass admin user ID and assistant name from messaging group/agent group
if (session.messaging_group_id) {
const mg = getMessagingGroup(session.messaging_group_id);
if (mg?.admin_user_id) {
args.push('-e', `NANOCLAW_ADMIN_USER_ID=${mg.admin_user_id}`);
}
}
if (agentGroup.name) {
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
}
args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`);
args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`);
args.push('-e', `NANOCLAW_IS_ADMIN=${agentGroup.is_admin ? '1' : '0'}`);
// Users allowed to run admin commands (e.g. /clear) inside this container.
// Computed at wake time: owners + global admins + admins scoped to this
// agent group. Role changes take effect on next container spawn.
const adminUserIds = new Set<string>();
for (const r of getOwners()) adminUserIds.add(r.user_id);
for (const r of getGlobalAdmins()) adminUserIds.add(r.user_id);
for (const r of getAdminsOfAgentGroup(agentGroup.id)) adminUserIds.add(r.user_id);
if (adminUserIds.size > 0) {
args.push('-e', `NANOCLAW_ADMIN_USER_IDS=${Array.from(adminUserIds).join(',')}`);
}
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection.

View File

@@ -0,0 +1,46 @@
import type { AgentGroupMember } from '../types.js';
import { getDb } from './connection.js';
import { isAdminOfAgentGroup, isGlobalAdmin, isOwner } from './user-roles.js';
export function addMember(row: AgentGroupMember): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES (@user_id, @agent_group_id, @added_by, @added_at)`,
)
.run(row);
}
export function removeMember(userId: string, agentGroupId: string): void {
getDb()
.prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.run(userId, agentGroupId);
}
export function getMembers(agentGroupId: string): AgentGroupMember[] {
return getDb()
.prepare('SELECT * FROM agent_group_members WHERE agent_group_id = ? ORDER BY added_at')
.all(agentGroupId) as AgentGroupMember[];
}
/**
* Is the user "known" in this agent group?
* Owner, global admin, and scoped admin are implicitly members.
*/
export function isMember(userId: string, agentGroupId: string): boolean {
if (isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId)) {
return true;
}
const row = getDb()
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
.get(userId, agentGroupId);
return !!row;
}
/** Direct row lookup — does not honor the admin/owner implicit-membership rule. */
export function hasMembershipRow(userId: string, agentGroupId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM agent_group_members WHERE user_id = ? AND agent_group_id = ? LIMIT 1')
.get(userId, agentGroupId);
return !!row;
}

View File

@@ -4,8 +4,8 @@ import { getDb } from './connection.js';
export function createAgentGroup(group: AgentGroup): void {
getDb()
.prepare(
`INSERT INTO agent_groups (id, name, folder, is_admin, agent_provider, container_config, created_at)
VALUES (@id, @name, @folder, @is_admin, @agent_provider, @container_config, @created_at)`,
`INSERT INTO agent_groups (id, name, folder, agent_provider, container_config, created_at)
VALUES (@id, @name, @folder, @agent_provider, @container_config, @created_at)`,
)
.run(group);
}
@@ -22,10 +22,6 @@ export function getAllAgentGroups(): AgentGroup[] {
return getDb().prepare('SELECT * FROM agent_groups ORDER BY name').all() as AgentGroup[];
}
export function getAdminAgentGroup(): AgentGroup | undefined {
return getDb().prepare('SELECT * FROM agent_groups WHERE is_admin = 1 LIMIT 1').get() as AgentGroup | undefined;
}
export function updateAgentGroup(
id: string,
updates: Partial<Pick<AgentGroup, 'name' | 'agent_provider' | 'container_config'>>,

View File

@@ -8,7 +8,6 @@ import {
getAgentGroup,
getAgentGroupByFolder,
getAllAgentGroups,
getAdminAgentGroup,
updateAgentGroup,
deleteAgentGroup,
createMessagingGroup,
@@ -66,7 +65,6 @@ describe('agent groups', () => {
id: 'ag-1',
name: 'Test Agent',
folder: 'test-agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
@@ -93,14 +91,6 @@ describe('agent groups', () => {
expect(getAllAgentGroups()).toHaveLength(2);
});
it('should find admin group', () => {
createAgentGroup(ag());
createAgentGroup({ ...ag(), id: 'ag-admin', name: 'Admin', folder: 'admin', is_admin: 1 });
const admin = getAdminAgentGroup();
expect(admin).toBeDefined();
expect(admin!.id).toBe('ag-admin');
});
it('should update', () => {
createAgentGroup(ag());
updateAgentGroup('ag-1', { name: 'Updated' });
@@ -128,7 +118,7 @@ describe('messaging groups', () => {
platform_id: 'chan-123',
name: 'General',
is_group: 1,
admin_user_id: 'user-1',
unknown_sender_policy: 'strict' as const,
created_at: now(),
});
@@ -172,7 +162,6 @@ describe('messaging group agents', () => {
id: 'ag-1',
name: 'Agent',
folder: 'agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
@@ -183,7 +172,7 @@ describe('messaging group agents', () => {
platform_id: 'chan-1',
name: 'Gen',
is_group: 1,
admin_user_id: null,
unknown_sender_policy: 'strict',
created_at: now(),
});
});
@@ -212,7 +201,6 @@ describe('messaging group agents', () => {
id: 'ag-2',
name: 'Agent2',
folder: 'agent2',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
@@ -243,6 +231,47 @@ describe('messaging group agents', () => {
it('should enforce foreign key on agent_group_id', () => {
expect(() => createMessagingGroupAgent({ ...mga(), agent_group_id: 'nonexistent' })).toThrow();
});
it('auto-creates an agent_destinations row for the wiring', async () => {
const { getDestinationByTarget, getDestinations } = await import('./agent-destinations.js');
createMessagingGroupAgent(mga());
const dest = getDestinationByTarget('ag-1', 'channel', 'mg-1');
expect(dest).toBeDefined();
expect(dest!.local_name).toBe('gen'); // normalized from mg.name='Gen'
expect(getDestinations('ag-1')).toHaveLength(1);
});
it('does not duplicate destination row on re-wiring', async () => {
const { getDestinations } = await import('./agent-destinations.js');
createMessagingGroupAgent(mga());
// Re-create the same wiring throws (PK unique), but even if we got the
// row in some other way (e.g. via createDestination directly followed
// by createMessagingGroupAgent), we should not end up with two rows.
deleteMessagingGroupAgent('mga-1');
createMessagingGroupAgent(mga());
expect(getDestinations('ag-1')).toHaveLength(1);
});
it('breaks local_name collisions within an agent group', async () => {
const { getDestinations } = await import('./agent-destinations.js');
// Two messaging groups with the same `name` wired to the same agent
// should get distinct local_names (gen, gen-2).
createMessagingGroupAgent(mga());
createMessagingGroup({
id: 'mg-2',
channel_type: 'discord',
platform_id: 'chan-2',
name: 'Gen',
is_group: 1,
unknown_sender_policy: 'strict',
created_at: now(),
});
createMessagingGroupAgent({ ...mga(), id: 'mga-2', messaging_group_id: 'mg-2' });
const dests = getDestinations('ag-1').map((d) => d.local_name).sort();
expect(dests).toEqual(['gen', 'gen-2']);
});
});
// ── Sessions ──
@@ -253,7 +282,6 @@ describe('sessions', () => {
id: 'ag-1',
name: 'Agent',
folder: 'agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
@@ -264,7 +292,7 @@ describe('sessions', () => {
platform_id: 'chan-1',
name: 'Gen',
is_group: 1,
admin_user_id: null,
unknown_sender_policy: 'strict',
created_at: now(),
});
});
@@ -349,7 +377,6 @@ describe('pending questions', () => {
id: 'ag-1',
name: 'Agent',
folder: 'agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),

View File

@@ -5,10 +5,25 @@ export {
getAgentGroup,
getAgentGroupByFolder,
getAllAgentGroups,
getAdminAgentGroup,
updateAgentGroup,
deleteAgentGroup,
} from './agent-groups.js';
export { createUser, upsertUser, getUser, getAllUsers, updateDisplayName, deleteUser } from './users.js';
export {
grantRole,
revokeRole,
getUserRoles,
isOwner,
isGlobalAdmin,
isAdminOfAgentGroup,
hasAdminPrivilege,
getOwners,
hasAnyOwner,
getGlobalAdmins,
getAdminsOfAgentGroup,
} from './user-roles.js';
export { addMember, removeMember, getMembers, isMember, hasMembershipRow } from './agent-group-members.js';
export { upsertUserDm, getUserDm, getUserDmsForUser, deleteUserDm } from './user-dms.js';
export {
createMessagingGroup,
getMessagingGroup,

View File

@@ -1,4 +1,10 @@
import type { MessagingGroup, MessagingGroupAgent } from '../types.js';
import {
createDestination,
getDestinationByName,
getDestinationByTarget,
normalizeName,
} from './agent-destinations.js';
import { getDb } from './connection.js';
// ── Messaging Groups ──
@@ -6,8 +12,8 @@ import { getDb } from './connection.js';
export function createMessagingGroup(group: MessagingGroup): void {
getDb()
.prepare(
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, admin_user_id, created_at)
VALUES (@id, @channel_type, @platform_id, @name, @is_group, @admin_user_id, @created_at)`,
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES (@id, @channel_type, @platform_id, @name, @is_group, @unknown_sender_policy, @created_at)`,
)
.run(group);
}
@@ -32,7 +38,7 @@ export function getMessagingGroupsByChannel(channelType: string): MessagingGroup
export function updateMessagingGroup(
id: string,
updates: Partial<Pick<MessagingGroup, 'name' | 'is_group' | 'admin_user_id'>>,
updates: Partial<Pick<MessagingGroup, 'name' | 'is_group' | 'unknown_sender_policy'>>,
): void {
const fields: string[] = [];
const values: Record<string, unknown> = { id };
@@ -56,6 +62,19 @@ export function deleteMessagingGroup(id: string): void {
// ── Messaging Group Agents ──
/**
* Wire a messaging group to an agent group. Also auto-creates the matching
* `agent_destinations` row so the agent can deliver to this chat as a
* target, not just reply to the origin. Without this, routing to chats that
* aren't the session's origin (agent-shared sessions, cross-channel sends)
* would require an operator to hand-insert destination rows every time.
*
* The destination row is skipped if one already exists for the same target,
* so re-wiring is a no-op. The local_name uses the messaging group's `name`
* field when set, falling back to `${channel_type}-${mg_id prefix}`, with
* a numeric suffix to break collisions within the agent's namespace. This
* mirrors the backfill logic in migration 004.
*/
export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
getDb()
.prepare(
@@ -63,6 +82,30 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
VALUES (@id, @messaging_group_id, @agent_group_id, @trigger_rules, @response_scope, @session_mode, @priority, @created_at)`,
)
.run(mga);
// Auto-create an agent_destinations row so delivery's ACL doesn't block
// outbound messages that target this chat.
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);
if (existing) return;
const mg = getMessagingGroup(mga.messaging_group_id);
if (!mg) return;
const base = normalizeName(mg.name || `${mg.channel_type}-${mga.messaging_group_id.slice(0, 8)}`);
let localName = base;
let suffix = 2;
while (getDestinationByName(mga.agent_group_id, localName)) {
localName = `${base}-${suffix}`;
suffix++;
}
createDestination({
agent_group_id: mga.agent_group_id,
local_name: localName,
target_type: 'channel',
target_id: mga.messaging_group_id,
created_at: mga.created_at,
});
}
export function getMessagingGroupAgents(messagingGroupId: string): MessagingGroupAgent[] {

View File

@@ -11,20 +11,19 @@ export const migration001: Migration = {
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
is_admin INTEGER DEFAULT 0,
agent_provider TEXT,
container_config TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE messaging_groups (
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
name TEXT,
is_group INTEGER DEFAULT 0,
admin_user_id TEXT,
created_at TEXT NOT NULL,
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
name TEXT,
is_group INTEGER DEFAULT 0,
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
created_at TEXT NOT NULL,
UNIQUE(channel_type, platform_id)
);
@@ -40,6 +39,50 @@ export const migration001: Migration = {
UNIQUE(messaging_group_id, agent_group_id)
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL
);
-- role ∈ {owner, admin}
-- owner: agent_group_id must be NULL (always global)
-- admin: agent_group_id NULL = global, else scoped
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL,
agent_group_id TEXT REFERENCES agent_groups(id),
granted_by TEXT REFERENCES users(id),
granted_at TEXT NOT NULL,
PRIMARY KEY (user_id, role, agent_group_id)
);
CREATE INDEX idx_user_roles_scope ON user_roles(agent_group_id, role);
-- "known" membership in an agent group. Admin @ A implies membership
-- without needing a row (invariant enforced in code).
CREATE TABLE agent_group_members (
user_id TEXT NOT NULL REFERENCES users(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
added_by TEXT REFERENCES users(id),
added_at TEXT NOT NULL,
PRIMARY KEY (user_id, agent_group_id)
);
-- DM channel cache: for each (user, channel) pair, which messaging_group
-- row is their direct-message channel. Populated on demand by
-- ensureUserDm() — either from adapter.openDM() for channels that
-- distinguish user id from DM chat id (Discord, Slack, Teams) or by
-- pointing directly at the user's handle for channels where they're
-- the same (Telegram, WhatsApp, iMessage, email, Matrix).
CREATE TABLE user_dms (
user_id TEXT NOT NULL REFERENCES users(id),
channel_type TEXT NOT NULL,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
resolved_at TEXT NOT NULL,
PRIMARY KEY (user_id, channel_type)
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),

View File

@@ -5,26 +5,27 @@
*/
export const SCHEMA = `
-- Agent workspaces: folder, skills, CLAUDE.md, container config
-- Agent workspaces: folder, skills, CLAUDE.md, container config.
-- All workspaces are equal; privilege lives on users, not groups.
CREATE TABLE agent_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
is_admin INTEGER DEFAULT 0,
agent_provider TEXT,
container_config TEXT,
created_at TEXT NOT NULL
);
-- Platform groups/channels
-- Platform groups/channels. unknown_sender_policy governs what happens
-- when a sender we've never seen before posts in this chat.
CREATE TABLE messaging_groups (
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
name TEXT,
is_group INTEGER DEFAULT 0,
admin_user_id TEXT,
created_at TEXT NOT NULL,
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
name TEXT,
is_group INTEGER DEFAULT 0,
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public'
created_at TEXT NOT NULL,
UNIQUE(channel_type, platform_id)
);
@@ -41,6 +42,52 @@ CREATE TABLE messaging_group_agents (
UNIQUE(messaging_group_id, agent_group_id)
);
-- Users are messaging-platform identifiers, namespaced: "phone:+1555...",
-- "tg:123", "discord:456", "email:a@x.com". A single human can own multiple
-- user rows if they have identifiers on unrelated channels (no linking yet).
CREATE TABLE users (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL
);
-- Role grants on users. Privilege is user-level, not group-level.
-- role ∈ {owner, admin}
-- owner: always global (agent_group_id IS NULL)
-- admin: agent_group_id NULL = global, else scoped to that agent group
-- Invariant: admin @ A implies membership in A (no row needed).
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL,
agent_group_id TEXT REFERENCES agent_groups(id),
granted_by TEXT REFERENCES users(id),
granted_at TEXT NOT NULL,
PRIMARY KEY (user_id, role, agent_group_id)
);
CREATE INDEX idx_user_roles_scope ON user_roles(agent_group_id, role);
-- "Known" membership in an agent group. Required for an unprivileged user
-- to interact with a workspace. Admin @ A is implicitly a member of A.
CREATE TABLE agent_group_members (
user_id TEXT NOT NULL REFERENCES users(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
added_by TEXT REFERENCES users(id),
added_at TEXT NOT NULL,
PRIMARY KEY (user_id, agent_group_id)
);
-- Cached mapping from (user, channel) to the DM messaging group. Lets the
-- host initiate cold DMs (pairing, approvals) without reprobing the
-- platform API on every send. Populated lazily by ensureUserDm().
CREATE TABLE user_dms (
user_id TEXT NOT NULL REFERENCES users(id),
channel_type TEXT NOT NULL,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
resolved_at TEXT NOT NULL,
PRIMARY KEY (user_id, channel_type)
);
-- Sessions: one folder = one session = one container when running
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
@@ -105,9 +152,9 @@ CREATE TABLE IF NOT EXISTS delivered (
);
-- Destination map for this session's agent.
-- Host overwrites on every container wake AND on demand (admin rewires, new child agents, etc.).
-- Container queries this live on every lookup, so admin changes take effect
-- mid-session without requiring a container restart.
-- Host overwrites on every container wake AND on demand (rewires, new child
-- agents, etc.). Container queries this live on every lookup, so changes
-- take effect mid-session without requiring a container restart.
CREATE TABLE IF NOT EXISTS destinations (
name TEXT PRIMARY KEY,
display_name TEXT,

28
src/db/user-dms.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { UserDm } from '../types.js';
import { getDb } from './connection.js';
export function upsertUserDm(row: UserDm): void {
getDb()
.prepare(
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
VALUES (@user_id, @channel_type, @messaging_group_id, @resolved_at)
ON CONFLICT(user_id, channel_type) DO UPDATE SET
messaging_group_id = excluded.messaging_group_id,
resolved_at = excluded.resolved_at`,
)
.run(row);
}
export function getUserDm(userId: string, channelType: string): UserDm | undefined {
return getDb()
.prepare('SELECT * FROM user_dms WHERE user_id = ? AND channel_type = ?')
.get(userId, channelType) as UserDm | undefined;
}
export function getUserDmsForUser(userId: string): UserDm[] {
return getDb().prepare('SELECT * FROM user_dms WHERE user_id = ?').all(userId) as UserDm[];
}
export function deleteUserDm(userId: string, channelType: string): void {
getDb().prepare('DELETE FROM user_dms WHERE user_id = ? AND channel_type = ?').run(userId, channelType);
}

85
src/db/user-roles.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { UserRole, UserRoleKind } from '../types.js';
import { getDb } from './connection.js';
/**
* Grant a role. Owner rows must have agent_group_id = null (enforced here,
* not by schema, so callers get a clean error path).
*/
export function grantRole(row: UserRole): void {
if (row.role === 'owner' && row.agent_group_id !== null) {
throw new Error('owner role must be global (agent_group_id = null)');
}
getDb()
.prepare(
`INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES (@user_id, @role, @agent_group_id, @granted_by, @granted_at)`,
)
.run(row);
}
export function revokeRole(userId: string, role: UserRoleKind, agentGroupId: string | null): void {
if (agentGroupId === null) {
getDb()
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL')
.run(userId, role);
} else {
getDb()
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ?')
.run(userId, role, agentGroupId);
}
}
export function getUserRoles(userId: string): UserRole[] {
return getDb().prepare('SELECT * FROM user_roles WHERE user_id = ?').all(userId) as UserRole[];
}
export function isOwner(userId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
.get(userId, 'owner');
return !!row;
}
export function isGlobalAdmin(userId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS NULL LIMIT 1')
.get(userId, 'admin');
return !!row;
}
export function isAdminOfAgentGroup(userId: string, agentGroupId: string): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id = ? LIMIT 1')
.get(userId, 'admin', agentGroupId);
return !!row;
}
/** Any admin privilege over this agent group: global admin OR scoped admin. */
export function hasAdminPrivilege(userId: string, agentGroupId: string): boolean {
return isOwner(userId) || isGlobalAdmin(userId) || isAdminOfAgentGroup(userId, agentGroupId);
}
export function getOwners(): UserRole[] {
return getDb()
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
.all('owner') as UserRole[];
}
export function hasAnyOwner(): boolean {
const row = getDb()
.prepare('SELECT 1 FROM user_roles WHERE role = ? AND agent_group_id IS NULL LIMIT 1')
.get('owner');
return !!row;
}
export function getGlobalAdmins(): UserRole[] {
return getDb()
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id IS NULL ORDER BY granted_at')
.all('admin') as UserRole[];
}
export function getAdminsOfAgentGroup(agentGroupId: string): UserRole[] {
return getDb()
.prepare('SELECT * FROM user_roles WHERE role = ? AND agent_group_id = ? ORDER BY granted_at')
.all('admin', agentGroupId) as UserRole[];
}

38
src/db/users.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { User } from '../types.js';
import { getDb } from './connection.js';
export function createUser(user: User): void {
getDb()
.prepare(
`INSERT INTO users (id, kind, display_name, created_at)
VALUES (@id, @kind, @display_name, @created_at)`,
)
.run(user);
}
export function upsertUser(user: User): void {
getDb()
.prepare(
`INSERT INTO users (id, kind, display_name, created_at)
VALUES (@id, @kind, @display_name, @created_at)
ON CONFLICT(id) DO UPDATE SET
display_name = COALESCE(excluded.display_name, users.display_name)`,
)
.run(user);
}
export function getUser(id: string): User | undefined {
return getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
}
export function getAllUsers(): User[] {
return getDb().prepare('SELECT * FROM users ORDER BY created_at').all() as User[];
}
export function updateDisplayName(id: string, displayName: string): void {
getDb().prepare('UPDATE users SET display_name = ? WHERE id = ?').run(displayName, id);
}
export function deleteUser(id: string): void {
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
}

View File

@@ -21,13 +21,13 @@ import {
} from './db/sessions.js';
import {
getAgentGroup,
getAdminAgentGroup,
createAgentGroup,
updateAgentGroup,
getAgentGroupByFolder,
} from './db/agent-groups.js';
import { createDestination, getDestinationByName, hasDestination, normalizeName } from './db/agent-destinations.js';
import { getMessagingGroupByPlatform, getMessagingGroupsByAgentGroup } from './db/messaging-groups.js';
import { getMessagingGroup, getMessagingGroupByPlatform } from './db/messaging-groups.js';
import { pickApprovalDelivery, pickApprover } from './access.js';
import {
getDueOutboundMessages,
getDeliveredIds,
@@ -107,9 +107,12 @@ function notifyAgent(session: Session, text: string): void {
}
/**
* Send an approval request to the admin channel and record a pending_approval row.
* The admin's button click routes via the existing ncq: card infrastructure to
* handleApprovalResponse in index.ts, which completes the action.
* Send an approval request to a privileged user's DM and record a
* pending_approval row. Routing: admin @ originating agent group → owner.
* Tie-break: prefer an approver reachable on the same channel kind as the
* originating session's messaging group. Delivery always lands in the
* approver's DM (not the origin group), regardless of where the action
* was triggered.
*/
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
@@ -124,13 +127,22 @@ async function requestApproval(
title: string,
question: string,
): Promise<void> {
const adminGroup = getAdminAgentGroup();
const adminMGs = adminGroup ? getMessagingGroupsByAgentGroup(adminGroup.id) : [];
if (adminMGs.length === 0) {
notifyAgent(session, `${action} failed: no admin channel configured for approvals.`);
const approvers = pickApprover(session.agent_group_id);
if (approvers.length === 0) {
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
return;
}
// Origin channel kind drives the tie-break preference in approval delivery.
const originChannelType = session.messaging_group_id
? (getMessagingGroup(session.messaging_group_id)?.channel_type ?? '')
: '';
const target = await pickApprovalDelivery(approvers, originChannelType);
if (!target) {
notifyAgent(session, `${action} failed: no DM channel found for any eligible approver.`);
return;
}
const adminChannel = adminMGs[0];
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS);
@@ -148,8 +160,8 @@ async function requestApproval(
if (deliveryAdapter) {
try {
await deliveryAdapter.deliver(
adminChannel.channel_type,
adminChannel.platform_id,
target.messagingGroup.channel_type,
target.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
@@ -162,12 +174,12 @@ async function requestApproval(
);
} catch (err) {
log.error('Failed to deliver approval card', { action, approvalId, err });
notifyAgent(session, `${action} failed: could not deliver approval request to admin.`);
notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`);
return;
}
}
log.info('Approval requested', { action, approvalId, agentName });
log.info('Approval requested', { action, approvalId, agentName, approver: target.userId });
}
/** Show typing indicator on a channel. Called when a message is routed to the agent. */
@@ -316,8 +328,7 @@ async function deliverMessage(
if (msg.channel_type === 'agent') {
const targetAgentGroupId = msg.platform_id;
if (!targetAgentGroupId) {
log.warn('Agent message missing target agent group ID', { id: msg.id });
return;
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
}
// Self-messages are always allowed — used for system notes injected back
// into an agent's own session (e.g. post-approval follow-up prompts).
@@ -325,15 +336,12 @@ async function deliverMessage(
targetAgentGroupId !== session.agent_group_id &&
!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)
) {
log.warn('Unauthorized agent-to-agent message — dropping', {
source: session.agent_group_id,
target: targetAgentGroupId,
});
return;
throw new Error(
`unauthorized agent-to-agent: ${session.agent_group_id} has no destination for ${targetAgentGroupId}`,
);
}
if (!getAgentGroup(targetAgentGroupId)) {
log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId });
return;
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
}
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
writeSessionMessage(targetAgentGroupId, targetSession.id, {
@@ -355,18 +363,34 @@ async function deliverMessage(
return;
}
// Permission check: the source agent must have a destination row for this target.
// Defense in depth — the container already validates via its local map, but the
// host's central DB is the authoritative ACL.
// Permission check: the source agent must be allowed to deliver to this
// channel destination. Two ways it passes:
//
// 1. The target is the session's own origin chat (session.messaging_group_id
// matches). An agent can always reply to the chat it was spawned from;
// requiring a destinations row for the obvious case is a footgun.
//
// 2. Otherwise, the agent must have an explicit agent_destinations row
// targeting that messaging group. createMessagingGroupAgent() inserts
// these automatically when wiring, so an operator wiring additional
// chats to the agent doesn't need a separate ACL step.
//
// Failures throw — unlike a silent `return`, an Error falls into the retry
// path in deliverSessionMessages and eventually marks the message as failed
// (instead of marking it delivered when nothing was actually delivered,
// which was the pre-refactor bug).
if (msg.channel_type && msg.platform_id) {
const mg = getMessagingGroupByPlatform(msg.channel_type, msg.platform_id);
if (!mg || !hasDestination(session.agent_group_id, 'channel', mg.id)) {
log.warn('Unauthorized channel destination — dropping message', {
sourceAgentGroup: session.agent_group_id,
channelType: msg.channel_type,
platformId: msg.platform_id,
});
return;
if (!mg) {
throw new Error(
`unknown messaging group for ${msg.channel_type}/${msg.platform_id} (message ${msg.id})`,
);
}
const isOriginChat = session.messaging_group_id === mg.id;
if (!isOriginChat && !hasDestination(session.agent_group_id, 'channel', mg.id)) {
throw new Error(
`unauthorized channel destination: ${session.agent_group_id} cannot send to ${mg.channel_type}/${mg.platform_id}`,
);
}
}
@@ -501,10 +525,9 @@ async function handleSystemAction(
const instructions = content.instructions as string | null;
const sourceGroup = getAgentGroup(session.agent_group_id);
if (!sourceGroup?.is_admin) {
// Notify the agent via a chat message (fire-and-forget pattern)
notifyAgent(session, `Your create_agent request for "${name}" was rejected: admin permission required.`);
log.warn('create_agent denied (not admin)', { sessionAgentGroup: session.agent_group_id, name });
if (!sourceGroup) {
notifyAgent(session, `create_agent failed: source agent group not found.`);
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
break;
}
@@ -540,7 +563,6 @@ async function handleSystemAction(
id: agentGroupId,
name,
folder,
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now,

View File

@@ -69,7 +69,6 @@ describe('session manager', () => {
id: 'ag-1',
name: 'Test Agent',
folder: 'test-agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
@@ -80,7 +79,7 @@ describe('session manager', () => {
platform_id: 'chan-123',
name: 'General',
is_group: 1,
admin_user_id: null,
unknown_sender_policy: 'strict',
created_at: now(),
});
});
@@ -185,18 +184,19 @@ describe('router', () => {
id: 'ag-1',
name: 'Test Agent',
folder: 'test-agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
});
// Use 'public' policy so the router tests exercise routing, not the
// access gate. Dedicated access-gate tests live with the access module.
createMessagingGroup({
id: 'mg-1',
channel_type: 'discord',
platform_id: 'chan-123',
name: 'General',
is_group: 1,
admin_user_id: null,
unknown_sender_policy: 'public',
created_at: now(),
});
createMessagingGroupAgent({
@@ -307,7 +307,6 @@ describe('delivery', () => {
id: 'ag-1',
name: 'Agent',
folder: 'agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
@@ -318,7 +317,7 @@ describe('delivery', () => {
platform_id: 'chan-test',
name: 'Test',
is_group: 0,
admin_user_id: null,
unknown_sender_policy: 'strict',
created_at: now(),
});

View File

@@ -21,7 +21,6 @@ export interface AdditionalMount {
export interface MountAllowlist {
allowedRoots: AllowedRoot[];
blockedPatterns: string[];
nonMainReadOnly: boolean;
}
export interface AllowedRoot {
@@ -95,10 +94,6 @@ export function loadMountAllowlist(): MountAllowlist | null {
throw new Error('blockedPatterns must be an array');
}
if (typeof allowlist.nonMainReadOnly !== 'boolean') {
throw new Error('nonMainReadOnly must be a boolean');
}
// Merge with default blocked patterns
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
allowlist.blockedPatterns = mergedBlockedPatterns;
@@ -232,7 +227,7 @@ export interface MountValidationResult {
* Validate a single additional mount against the allowlist.
* Returns validation result with reason.
*/
export function validateMount(mount: AdditionalMount, isMain: boolean): MountValidationResult {
export function validateMount(mount: AdditionalMount): MountValidationResult {
const allowlist = loadMountAllowlist();
// If no allowlist, block all additional mounts
@@ -285,24 +280,19 @@ export function validateMount(mount: AdditionalMount, isMain: boolean): MountVal
};
}
// Determine effective readonly status
// Determine effective readonly status.
// RW is only granted if the mount explicitly requests it AND the allowed
// root permits it. Otherwise it's forced read-only.
const requestedReadWrite = mount.readonly === false;
let effectiveReadonly = true; // Default to readonly
let effectiveReadonly = true;
if (requestedReadWrite) {
if (!isMain && allowlist.nonMainReadOnly) {
// Non-main groups forced to read-only
effectiveReadonly = true;
log.info('Mount forced to read-only for non-main group', { mount: mount.hostPath });
} else if (!allowedRoot.allowReadWrite) {
// Root doesn't allow read-write
effectiveReadonly = true;
if (!allowedRoot.allowReadWrite) {
log.info('Mount forced to read-only - root does not allow read-write', {
mount: mount.hostPath,
root: allowedRoot.path,
});
} else {
// Read-write allowed
effectiveReadonly = false;
}
}
@@ -324,7 +314,6 @@ export function validateMount(mount: AdditionalMount, isMain: boolean): MountVal
export function validateAdditionalMounts(
mounts: AdditionalMount[],
groupName: string,
isMain: boolean,
): Array<{
hostPath: string;
containerPath: string;
@@ -337,7 +326,7 @@ export function validateAdditionalMounts(
}> = [];
for (const mount of mounts) {
const result = validateMount(mount, isMain);
const result = validateMount(mount);
if (result.allowed) {
validatedMounts.push({
@@ -394,7 +383,6 @@ export function generateAllowlistTemplate(): string {
'secret',
'token',
],
nonMainReadOnly: true,
};
return JSON.stringify(template, null, 2);

View File

@@ -19,9 +19,9 @@
*/
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
import { pickApprovalDelivery, pickApprover } from './access.js';
import { ONECLI_URL } from './config.js';
import { getAdminAgentGroup, getAgentGroup } from './db/agent-groups.js';
import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js';
import { getAgentGroup } from './db/agent-groups.js';
import {
createPendingApproval,
deletePendingApproval,
@@ -113,23 +113,31 @@ export function stopOneCLIApprovalHandler(): void {
async function handleRequest(request: ApprovalRequest): Promise<Decision> {
if (!adapterRef) return 'deny';
// Same routing as requestApproval(): global admin agent group's first messaging group.
// Per-group routing is a follow-up (see admin-model refactor in docs/v2-checklist.md).
const adminGroup = getAdminAgentGroup();
const adminMGs = adminGroup ? getMessagingGroupsByAgentGroup(adminGroup.id) : [];
if (adminMGs.length === 0) {
log.warn('OneCLI approval auto-denied: no admin channel configured', {
// Originating agent group is carried on the request via OneCLI's agent
// identifier (set by container-runner.ts to agentGroup.id). Use it as
// the scope for approver selection: admin @ group → global admin → owner.
const originGroup = request.agent.externalId ? getAgentGroup(request.agent.externalId) : undefined;
const agentGroupId = originGroup?.id ?? null;
const approvers = pickApprover(agentGroupId);
if (approvers.length === 0) {
log.warn('OneCLI approval auto-denied: no eligible approver', {
id: request.id,
host: request.host,
agent: request.agent.externalId,
});
return 'deny';
}
const adminChannel = adminMGs[0];
// Resolve the originating agent group (for logging / future per-group routing).
const originGroup = request.agent.externalId ? getAgentGroup(request.agent.externalId) : adminGroup;
const agentGroupId = originGroup?.id ?? null;
// No origin channel preference — OneCLI requests don't carry one. First
// approver with a reachable DM wins.
const target = await pickApprovalDelivery(approvers, '');
if (!target) {
log.warn('OneCLI approval auto-denied: no DM channel for any approver', {
id: request.id,
approvers,
});
return 'deny';
}
// Use a short id for the card/button so Chat SDK's Telegram adapter can
// fit everything inside the 64-byte callback_data limit. The OneCLI
@@ -145,8 +153,8 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
let platformMessageId: string | undefined;
try {
platformMessageId = await adapterRef.deliver(
adminChannel.channel_type,
adminChannel.platform_id,
target.messagingGroup.channel_type,
target.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
@@ -174,11 +182,12 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
path: request.path,
bodyPreview: request.bodyPreview,
agent: request.agent,
approver: target.userId,
}),
created_at: new Date().toISOString(),
agent_group_id: agentGroupId,
channel_type: adminChannel.channel_type,
platform_id: adminChannel.platform_id,
channel_type: target.messagingGroup.channel_type,
platform_id: target.messagingGroup.platform_id,
platform_message_id: platformMessageId ?? null,
expires_at: request.expiresAt,
status: 'pending',

View File

@@ -1,17 +1,32 @@
/**
* Inbound message routing for v2.
*
* Channel adapter event → resolve messaging group → resolve agent group
* → resolve/create session → write messages_in → wake container
* Channel adapter event → resolve messaging group → access gate → resolve
* agent group → resolve/create session → write messages_in → wake container.
*
* Privilege / access model:
* - Owners and global admins: always allowed
* - Scoped admins: allowed in their agent group
* - Known members (agent_group_members row): allowed in that agent group
* - Everyone else: message is dropped per `messaging_groups.unknown_sender_policy`
* (strict / request_approval / public)
*
* Sender normalization: we derive a namespaced user id from the message
* content. This is best-effort — native adapters put `sender` in content,
* chat-sdk-bridge adapters put `senderId`. Adapters should populate both
* wherever possible so the gate can land on a real user row.
*/
import { canAccessAgentGroup } from './access.js';
import { getChannelAdapter } from './channels/channel-registry.js';
import { isMember } from './db/agent-group-members.js';
import { getMessagingGroupByPlatform, createMessagingGroup, getMessagingGroupAgents } from './db/messaging-groups.js';
import { upsertUser, getUser } from './db/users.js';
import { triggerTyping } from './delivery.js';
import { log } from './log.js';
import { resolveSession, writeSessionMessage } from './session-manager.js';
import { wakeContainer } from './container-runner.js';
import { getSession } from './db/sessions.js';
import type { MessagingGroupAgent } from './types.js';
import type { MessagingGroup, MessagingGroupAgent } from './types.js';
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -47,7 +62,6 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
let mg = getMessagingGroupByPlatform(event.channelType, event.platformId);
if (!mg) {
// Auto-create messaging group (adapter already decided to forward this)
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
mg = {
id: mgId,
@@ -55,7 +69,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
platform_id: event.platformId,
name: null,
is_group: 0,
admin_user_id: null,
unknown_sender_policy: 'strict',
created_at: new Date().toISOString(),
};
createMessagingGroup(mg);
@@ -66,11 +80,15 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
});
}
// 2. Resolve agent group via messaging_group_agents
// 2. Resolve sender → user id. Upsert into users table on first sight so
// subsequent messages find an existing row. `userId` is null if the
// adapter didn't give us enough to identify a sender (the gate will
// then apply unknown_sender_policy).
const userId = extractAndUpsertUser(event);
// 3. Resolve agent groups wired to this messaging group
const agents = getMessagingGroupAgents(mg.id);
if (agents.length === 0) {
// This is a common fresh-install issue: channels work but no agent group
// is wired to handle messages. Run setup/register to create the wiring.
log.warn('MESSAGE DROPPED — no agent groups wired to this channel. Run setup register step to configure.', {
messagingGroupId: mg.id,
channelType: event.channelType,
@@ -89,7 +107,16 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
return;
}
// 3. Resolve or create session.
// 4. Access gate. Public channels skip the gate entirely.
if (mg.unknown_sender_policy !== 'public') {
const gate = enforceAccess(userId, match.agent_group_id);
if (!gate.allowed) {
handleUnknownSender(mg, userId, match.agent_group_id, gate.reason);
return;
}
}
// 5. Resolve or create session.
//
// Adapter thread policy overrides the wiring's session_mode: if the adapter
// is threaded, each thread gets its own session regardless of what the
@@ -102,7 +129,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
}
const { session, created } = resolveSession(match.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
// 4. Write message to session DB
// 6. Write message to session DB
writeSessionMessage(session.agent_group_id, session.id, {
id: event.message.id || generateId(),
kind: event.message.kind,
@@ -117,13 +144,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
sessionId: session.id,
agentGroup: match.agent_group_id,
kind: event.message.kind,
userId,
created,
});
// 5. Show typing indicator while agent processes
// 7. Show typing indicator while agent processes
triggerTyping(event.channelType, event.platformId, event.threadId);
// 6. Wake container
// 8. Wake container
const freshSession = getSession(session.id);
if (freshSession) {
await wakeContainer(freshSession);
@@ -139,3 +167,89 @@ function pickAgent(agents: MessagingGroupAgent[], _event: InboundEvent): Messagi
// TODO: apply trigger_rules matching (pattern, mentionOnly, etc.)
return agents[0] ?? null;
}
/**
* Best-effort sender extraction. Returns a namespaced user id like
* `telegram:123` or null if nothing usable is present.
*
* Side-effect: upserts the user into the `users` table so access/approval
* lookups can find them on subsequent messages.
*
* The namespace uses the channel_type as `kind` for now — e.g. `whatsapp:...`
* rather than `phone:...`. That's imprecise (a phone number is really the
* identifier, not the channel) but it keeps the first cut simple. A proper
* kind mapping (channel → kind) can happen when we start linking identities
* across channels.
*/
function extractAndUpsertUser(event: InboundEvent): string | null {
let content: Record<string, unknown>;
try {
content = JSON.parse(event.message.content) as Record<string, unknown>;
} catch {
return null;
}
const senderId = typeof content.senderId === 'string' ? content.senderId : undefined;
const sender = typeof content.sender === 'string' ? content.sender : undefined;
const senderName = typeof content.senderName === 'string' ? content.senderName : undefined;
const handle = senderId ?? sender;
if (!handle) return null;
const userId = `${event.channelType}:${handle}`;
if (!getUser(userId)) {
upsertUser({
id: userId,
kind: event.channelType,
display_name: senderName ?? null,
created_at: new Date().toISOString(),
});
}
return userId;
}
function enforceAccess(
userId: string | null,
agentGroupId: string,
): { allowed: boolean; reason: string } {
if (!userId) return { allowed: false, reason: 'unknown_user' };
const decision = canAccessAgentGroup(userId, agentGroupId);
if (decision.allowed) return { allowed: true, reason: decision.reason };
return { allowed: false, reason: decision.reason };
}
function handleUnknownSender(
mg: MessagingGroup,
userId: string | null,
agentGroupId: string,
accessReason: string,
): void {
// In 'strict' mode we just drop. In 'request_approval' mode we log and
// queue an approval to add the sender as a member — the approval flow
// itself is a follow-up (needs an action kind like `add_group_member`).
if (mg.unknown_sender_policy === 'strict') {
log.info('MESSAGE DROPPED — unknown sender (strict policy)', {
messagingGroupId: mg.id,
agentGroupId,
userId,
accessReason,
});
return;
}
if (mg.unknown_sender_policy === 'request_approval') {
// Placeholder: drop for now but log as a request. Follow-up wires this
// into the approval flow (request admin-of-group / owner to add user).
log.info('MESSAGE DROPPED — unknown sender (approval flow TODO)', {
messagingGroupId: mg.id,
agentGroupId,
userId,
accessReason,
});
return;
}
// Should be unreachable — 'public' was handled before the gate.
// Ensure the membership invariant isn't in an odd state.
void isMember;
}

View File

@@ -4,22 +4,70 @@ export interface AgentGroup {
id: string;
name: string;
folder: string;
is_admin: number; // 0 | 1
agent_provider: string | null;
container_config: string | null; // JSON: { additionalMounts, timeout }
created_at: string;
}
export type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public';
export interface MessagingGroup {
id: string;
channel_type: string;
platform_id: string;
name: string | null;
is_group: number; // 0 | 1
admin_user_id: string | null;
unknown_sender_policy: UnknownSenderPolicy;
created_at: string;
}
// ── Identity & privilege ──
/**
* User = a messaging-platform identifier. Namespaced so distinct channels
* with numeric IDs don't collide: "phone:+1555...", "tg:123", "discord:456",
* "email:a@x.com". A single human with a phone AND a telegram handle has
* two separate users — no cross-channel linking (yet).
*/
export interface User {
id: string;
kind: string; // 'phone' | 'email' | 'discord' | 'telegram' | 'matrix' | ...
display_name: string | null;
created_at: string;
}
export type UserRoleKind = 'owner' | 'admin';
/**
* Role grant. Owner is always global. Admin is either global
* (agent_group_id = null) or scoped to a specific agent group.
* Admin @ A implicitly makes the user a member of A — we do not require
* a separate agent_group_members row for admins.
*/
export interface UserRole {
user_id: string;
role: UserRoleKind;
agent_group_id: string | null;
granted_by: string | null;
granted_at: string;
}
/** "Known" membership in an agent group — required for unprivileged users. */
export interface AgentGroupMember {
user_id: string;
agent_group_id: string;
added_by: string | null;
added_at: string;
}
/** Cached DM channel for a user on a specific channel_type. */
export interface UserDm {
user_id: string;
channel_type: string;
messaging_group_id: string;
resolved_at: string;
}
export interface MessagingGroupAgent {
id: string;
messaging_group_id: string;

146
src/user-dm.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* User DM resolution.
*
* Exposes one primitive: `ensureUserDm(userId)` returns (or lazily creates)
* the `messaging_groups` row that the host should deliver to when it wants
* to DM a given user. Everything that needs to cold-DM a user — approvals,
* pairing handshakes, host notifications — goes through this function.
*
* ## Two-class resolution
*
* Channels split cleanly into two classes based on whether the user id is
* already the DM platform id:
*
* - **Direct-addressable** (Telegram, WhatsApp, iMessage, email, Matrix):
* user handle IS the DM chat id. No adapter method needed; we just
* mint a messaging_group row with `platform_id = handle`.
*
* - **Resolution-required** (Discord, Slack, Teams, Webex, gChat):
* user id and DM channel id are different. The adapter must implement
* `openDM(handle)`, which Chat SDK's `chat.openDM` handles for us via
* the bridge. The returned channel id becomes the `platform_id`.
*
* ## Caching
*
* Successful resolutions are persisted in `user_dms (user_id, channel_type
* → messaging_group_id)`. The cache survives restarts; first-time DMs on a
* given channel pay one `openDM` round trip, everyone after is a pure DB
* read.
*
* The underlying platform APIs (`POST /users/@me/channels` on Discord,
* `conversations.open` on Slack, etc.) are idempotent and return the same
* channel on repeated calls, so re-resolving after a cache miss is always
* safe — worst case we round-trip redundantly.
*/
import { getChannelAdapter } from './channels/channel-registry.js';
import { getMessagingGroup, getMessagingGroupByPlatform, createMessagingGroup } from './db/messaging-groups.js';
import { getUser } from './db/users.js';
import { getUserDm, upsertUserDm } from './db/user-dms.js';
import { log } from './log.js';
import type { MessagingGroup, User } from './types.js';
/**
* Return a messaging_group usable to DM this user, creating it lazily if
* needed. Returns null when:
* - the user id isn't namespaced (no `kind:handle` prefix)
* - the user's channel has no adapter registered
* - the channel needs openDM but its adapter doesn't implement it
* - openDM throws (platform error, user blocked bot, etc.)
*
* Callers should treat null as "this user is unreachable on this channel".
*/
export async function ensureUserDm(userId: string): Promise<MessagingGroup | null> {
const user = getUser(userId);
if (!user) {
log.warn('ensureUserDm: user not found', { userId });
return null;
}
const { channelType, handle } = parseUserId(user);
if (!channelType || !handle) {
log.warn('ensureUserDm: user id not namespaced', { userId });
return null;
}
// Cache hit: existing user_dms row → load and return the messaging_group.
const cached = getUserDm(userId, channelType);
if (cached) {
const mg = getMessagingGroup(cached.messaging_group_id);
if (mg) return mg;
// Row points to a deleted messaging_group — fall through and re-resolve.
log.warn('ensureUserDm: cached row references missing messaging_group, re-resolving', {
userId,
messagingGroupId: cached.messaging_group_id,
});
}
// Cache miss: resolve the DM platform_id either via openDM or directly.
const dmPlatformId = await resolveDmPlatformId(channelType, handle);
if (!dmPlatformId) return null;
// Find-or-create the underlying messaging_group. A DM we received
// earlier may already have a row matching (channel_type, platform_id).
const now = new Date().toISOString();
let mg = getMessagingGroupByPlatform(channelType, dmPlatformId);
if (!mg) {
const mgId = `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
mg = {
id: mgId,
channel_type: channelType,
platform_id: dmPlatformId,
name: user.display_name,
is_group: 0,
unknown_sender_policy: 'strict',
created_at: now,
};
createMessagingGroup(mg);
log.info('ensureUserDm: created DM messaging_group', {
userId,
channelType,
messagingGroupId: mgId,
});
}
upsertUserDm({
user_id: userId,
channel_type: channelType,
messaging_group_id: mg.id,
resolved_at: now,
});
return mg;
}
/**
* Call the adapter's openDM if it has one; otherwise fall through to using
* the handle directly. Returns null if the adapter is missing entirely.
*/
async function resolveDmPlatformId(channelType: string, handle: string): Promise<string | null> {
const adapter = getChannelAdapter(channelType);
if (!adapter) {
log.warn('ensureUserDm: no adapter for channel', { channelType });
return null;
}
if (!adapter.openDM) {
// Direct-addressable channel — handle doubles as the DM chat id.
return handle;
}
try {
return await adapter.openDM(handle);
} catch (err) {
log.error('ensureUserDm: adapter.openDM failed', { channelType, handle, err });
return null;
}
}
function parseUserId(user: User): { channelType: string; handle: string } | { channelType: null; handle: null } {
const idx = user.id.indexOf(':');
if (idx < 0) return { channelType: null, handle: null };
const channelType = user.id.slice(0, idx);
const handle = user.id.slice(idx + 1);
if (!channelType || !handle) return { channelType: null, handle: null };
// The `kind` on users mirrors the channel_type prefix in our current
// scheme. Pull it from `user.kind` if we ever decouple them later, but
// today the id prefix is authoritative.
return { channelType, handle };
}