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:
306
src/access.test.ts
Normal file
306
src/access.test.ts
Normal 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
115
src/access.ts
Normal 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);
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
46
src/db/agent-group-members.ts
Normal file
46
src/db/agent-group-members.ts
Normal 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;
|
||||
}
|
||||
@@ -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'>>,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
28
src/db/user-dms.ts
Normal 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
85
src/db/user-roles.ts
Normal 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
38
src/db/users.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
138
src/router.ts
138
src/router.ts
@@ -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;
|
||||
}
|
||||
|
||||
52
src/types.ts
52
src/types.ts
@@ -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
146
src/user-dm.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user