mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
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:
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: init-first-agent
|
||||
description: Walk the operator through creating the first NanoClaw v2 agent for a DM channel — resolve the operator's channel identity, wire the DM messaging group to a new agent, and trigger a welcome DM via the normal delivery path. Use after channel credentials are configured and the service is running.
|
||||
---
|
||||
|
||||
# Init First Agent
|
||||
|
||||
Stand up the first NanoClaw v2 agent for a channel and verify end-to-end delivery by having the agent DM the operator. Everything the skill does is idempotent — rerunning is safe.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
|
||||
- **Target channel installed.** At least one `/add-<channel>-v2` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`.
|
||||
- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel.
|
||||
|
||||
## 1. Pick the channel
|
||||
|
||||
Read `src/channels/index.ts` to find enabled channels (uncommented imports). Cross-check `.env` for the relevant credentials.
|
||||
|
||||
AskUserQuestion: "Which channel should host the welcome DM?" with one option per enabled channel (Discord, Slack, Telegram, WhatsApp, Webex, Teams, Google Chat, Matrix, iMessage, Resend, …).
|
||||
|
||||
Record the choice as `CHANNEL` (lowercase, e.g. `discord`).
|
||||
|
||||
## 2. Ask for the operator's identity
|
||||
|
||||
Read the channel's own skill for its `## Channel Info > how-to-find-id` section (e.g. `.claude/skills/add-discord-v2/SKILL.md`, `.claude/skills/add-telegram-v2/SKILL.md`). Show those instructions to the user in plain text.
|
||||
|
||||
Then ask in plain text (NOT `AskUserQuestion` — these are free-form):
|
||||
|
||||
1. **Your user id on this channel** — e.g. a Discord user ID, Telegram user ID, Slack user ID. Record as `USER_HANDLE`.
|
||||
2. **Your display name** — human name, used to name the agent group (`dm-with-<normalized>`) and as the welcome-message addressee. Record as `DISPLAY_NAME`.
|
||||
3. **Agent persona name** — the assistant's display name. Default: `DISPLAY_NAME`. Record as `AGENT_NAME`.
|
||||
|
||||
## 3. Resolve the DM platform id
|
||||
|
||||
This depends on whether the channel supports cold DM via `adapter.openDM`.
|
||||
|
||||
**Channels without cold DM (direct-addressable): telegram, whatsapp, imessage, matrix, resend.** The user handle doubles as the DM chat id. Set:
|
||||
|
||||
```
|
||||
PLATFORM_ID=${CHANNEL}:${USER_HANDLE}
|
||||
```
|
||||
|
||||
Skip to step 4.
|
||||
|
||||
**Channels with cold DM (resolution-required): discord, slack, teams, webex, gchat.** The bot can DM cold at runtime via Chat SDK, but this skill runs standalone — it can't call the adapter. Two resolutions:
|
||||
|
||||
### 3a. User DMs the bot once (Discord / Slack / Teams / Webex / gChat)
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Send any single message to the bot as a DM from your account on `${CHANNEL}`. The router will record the DM as a messaging group. Reply `done` here when you've sent the message.
|
||||
|
||||
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues.
|
||||
|
||||
### 3b. Telegram pair-code path (if the user prefers not to DM first)
|
||||
|
||||
For Telegram only, there's an existing pair-code primitive:
|
||||
|
||||
```bash
|
||||
npx tsx setup/index.ts --step pair-telegram -- --intent new-agent:dm-with-<folder>
|
||||
```
|
||||
|
||||
Parse the `PAIR_TELEGRAM_ISSUED` status block for `CODE`. Tell the user to DM the bot with exactly the 4-digit code. Wait for the `PAIR_TELEGRAM` block — read `PLATFORM_ID` and `PAIRED_USER_ID` from it. telegram.ts's interceptor has already upserted the user and granted owner if none existed yet. Use `PLATFORM_ID` and `PAIRED_USER_ID` directly in step 4.
|
||||
|
||||
## 4. Run the init script
|
||||
|
||||
```bash
|
||||
npx tsx scripts/init-first-agent.ts \
|
||||
--channel "${CHANNEL}" \
|
||||
--user-id "${CHANNEL}:${USER_HANDLE}" \
|
||||
--platform-id "${PLATFORM_ID}" \
|
||||
--display-name "${DISPLAY_NAME}" \
|
||||
--agent-name "${AGENT_NAME}"
|
||||
```
|
||||
|
||||
Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
|
||||
The script:
|
||||
1. Upserts the `users` row and grants `owner` role if no owner exists.
|
||||
2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-<name>/`.
|
||||
3. Reuses or creates the DM `messaging_groups` row.
|
||||
4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row).
|
||||
5. Resolves the session (creates `inbound.db` / `outbound.db`).
|
||||
6. Writes a `kind: 'chat'`, `sender: 'system'` welcome message into `inbound.db`.
|
||||
|
||||
Show the script's output to the user.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
Host sweep runs every ~60s. Within one sweep window the container wakes, the agent processes the system message, and the reply flows through `outbound.db` to the channel.
|
||||
|
||||
Tail the log to watch it happen:
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
If nothing arrives within two minutes:
|
||||
|
||||
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
|
||||
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists. Replace `<agent-group-id>` with the value from the script's output.
|
||||
- `sqlite3 data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows.
|
||||
|
||||
Once the welcome DM arrives, confirm with the user and the skill is done.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Missing required args"** — the script wants `--channel`, `--user-id`, `--platform-id`, `--display-name` at minimum. Re-check the command you assembled.
|
||||
|
||||
**No `messaging_groups` row appears after the user DMs (step 3a)** — the router silently drops messages from unknown senders under `strict` policy but still creates the `messaging_groups` row. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
|
||||
|
||||
**Owner already exists** — `hasAnyOwner()` returned true, so the grant is skipped silently. That's fine; the script still creates the agent and wiring. Reassigning ownership needs a separate flow (not this skill).
|
||||
|
||||
**Wrong person got the welcome DM** — the `--platform-id` you passed is someone else's DM channel. Rerun with the correct one; the script is idempotent on user/messaging-group/agent-group but writes a new session welcome each run.
|
||||
|
||||
**Agent group name collision** — if `dm-with-<display-name>` already exists (e.g. rerunning with the same display name), the script reuses it. Pass a different `--display-name` to get a distinct folder.
|
||||
@@ -7,27 +7,19 @@ description: Wire channels to agent groups, manage isolation levels, add new cha
|
||||
|
||||
Wire messaging channels to agent groups. See `docs/v2-isolation-model.md` for the full isolation model.
|
||||
|
||||
Privilege is a **user-level** concept, not a channel-level one (see `src/db/user-roles.ts`, `src/access.ts`). There is no "main channel" / "main group" — any user can be granted `owner` or `admin` (global or scoped to an agent group) via `grantRole()`, and messages from unknown senders are gated per-messaging-group by `unknown_sender_policy` (`strict` | `request_approval` | `public`).
|
||||
|
||||
## Assess Current State
|
||||
|
||||
Read the v2 central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, and `messaging_group_agents` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
Read the v2 central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
|
||||
Categorize channels as: **wired** (has DB entities), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
|
||||
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
|
||||
|
||||
If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='owner' AND agent_group_id IS NULL` returns 0), tell the user they should run `/init-first-agent` first — it stands up the first agent group, promotes the operator to owner, and verifies delivery end-to-end by having the agent DM them. Then return here for any additional channels/groups.
|
||||
|
||||
## First Channel (No Agent Groups Exist)
|
||||
|
||||
1. Ask the assistant name (default: project name or "Andy")
|
||||
2. Ask which channel is the primary/admin channel
|
||||
3. **Telegram special case:** if the chosen channel is `telegram`, do not ask for an ID. Run `npx tsx setup/index.ts --step pair-telegram -- --intent main`, show the user the 4-digit CODE from the `PAIR_TELEGRAM_ISSUED` block, and tell them to DM the bot with `@<botname> CODE` from the chat they want as their main. Wait for the `PAIR_TELEGRAM` block — `PLATFORM_ID`, `IS_GROUP`, `ADMIN_USER_ID` come back from there. Skip step 4 of this list (the messaging group is already created with admin binding); instead run only the agent-group + wiring portion via `setup --step register` with the returned `PLATFORM_ID`.
|
||||
4. Otherwise (non-Telegram), ask for the platform ID — read the channel's SKILL.md `## Channel Info` > `how-to-find-id` to guide them, then register:
|
||||
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- \
|
||||
--platform-id "<id>" --name "<name>" --folder "main" \
|
||||
--channel "<type>" --is-main --no-trigger-required \
|
||||
--assistant-name "<name>" --session-mode "shared"
|
||||
```
|
||||
|
||||
5. Continue to "Wire New Channel" for any remaining configured channels.
|
||||
**Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels.
|
||||
|
||||
## Wire New Channel
|
||||
|
||||
@@ -54,26 +46,42 @@ Use the channel's `typical-use` and `default-isolation` fields to pick the recom
|
||||
npx tsx setup/index.ts --step register -- \
|
||||
--platform-id "<id>" --name "<name>" \
|
||||
--folder "<folder>" --channel "<type>" \
|
||||
--session-mode "<shared|agent-shared>" \
|
||||
--session-mode "<shared|agent-shared|per-thread>" \
|
||||
--assistant-name "<name>"
|
||||
```
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name — no separate destination step needed.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
## Add Channel Group
|
||||
|
||||
When adding another group/chat on an already-configured platform (e.g. a second Telegram group):
|
||||
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `npx tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the CODE, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the `PAIR_TELEGRAM` block, then run `setup --step register` with the returned `PLATFORM_ID` and the chosen folder/session-mode. The messaging group row is already created with `admin_user_id` set — `register` only needs to add the wiring.
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `npx tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the CODE, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
|
||||
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- \
|
||||
--platform-id "<PLATFORM_ID>" --name "<group-name>" \
|
||||
--folder "<folder>" --channel "telegram" \
|
||||
--session-mode "<shared|agent-shared|per-thread>" \
|
||||
--assistant-name "<name>"
|
||||
```
|
||||
|
||||
2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register. No package or credential changes needed.
|
||||
|
||||
## Change Wiring
|
||||
|
||||
1. Show current wiring
|
||||
1. Show current wiring (agent_groups × messaging_group_agents)
|
||||
2. Ask which channel to move and to which agent group
|
||||
3. Delete the old `messaging_group_agents` entry, create a new one
|
||||
4. Note: existing sessions stay with the old agent group; new messages route to the new one
|
||||
4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually.
|
||||
|
||||
## Show Configuration
|
||||
|
||||
Display a readable summary showing agent groups with their wired channels, configured-but-unwired channels, and unconfigured channels.
|
||||
Display a readable summary showing:
|
||||
|
||||
- **Agent groups** with their wired channels (from `messaging_group_agents`)
|
||||
- **Configured-but-unwired** channels (credentials present, no DB entities)
|
||||
- **Unconfigured** channels
|
||||
- **Privileged users**: `SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC`
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MessageInRow } from './db/messages-in.js';
|
||||
|
||||
/**
|
||||
* Command categories for messages starting with '/'.
|
||||
* - admin: requires NANOCLAW_ADMIN_USER_ID check
|
||||
* - admin: sender must be in NANOCLAW_ADMIN_USER_IDS
|
||||
* - filtered: silently drop (mark completed without processing)
|
||||
* - passthrough: pass raw to the agent (no XML wrapping)
|
||||
* - none: not a command — format normally
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat)
|
||||
* - AGENT_PROVIDER: 'claude' | 'mock' (default: claude)
|
||||
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
|
||||
* - NANOCLAW_ADMIN_USER_ID: admin user ID for permission checks
|
||||
* - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands
|
||||
*
|
||||
* Mount structure:
|
||||
* /workspace/
|
||||
@@ -39,7 +39,12 @@ const CWD = '/workspace/agent';
|
||||
async function main(): Promise<void> {
|
||||
const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName;
|
||||
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
|
||||
const adminUserId = process.env.NANOCLAW_ADMIN_USER_ID;
|
||||
const adminUserIds = new Set(
|
||||
(process.env.NANOCLAW_ADMIN_USER_IDS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
log(`Starting v2 agent-runner (provider: ${providerName})`);
|
||||
|
||||
@@ -105,7 +110,7 @@ async function main(): Promise<void> {
|
||||
provider,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
adminUserId,
|
||||
adminUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,17 +21,11 @@ function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
// Only admin agents get the create_agent tool. Non-admins never see it in the
|
||||
// listTools response; the host also re-checks permission on receive as defense
|
||||
// in depth (see delivery.ts create_agent handler).
|
||||
const isAdmin = process.env.NANOCLAW_IS_ADMIN === '1';
|
||||
const conditionalAgentTools = isAdmin ? agentTools : [];
|
||||
|
||||
const allTools: McpToolDefinition[] = [
|
||||
...coreTools,
|
||||
...schedulingTools,
|
||||
...interactiveTools,
|
||||
...conditionalAgentTools,
|
||||
...agentTools,
|
||||
...selfModTools,
|
||||
...credentialTools,
|
||||
];
|
||||
|
||||
@@ -24,8 +24,12 @@ export interface PollLoopConfig {
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
};
|
||||
/** Admin user ID for permission checks on admin commands (e.g. /clear). */
|
||||
adminUserId?: string;
|
||||
/**
|
||||
* Set of user IDs allowed to run admin commands (e.g. /clear) in this
|
||||
* agent group. Host populates from owners + global admins + scoped admins
|
||||
* at container wake time, so role changes take effect on next spawn.
|
||||
*/
|
||||
adminUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +79,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
const routing = extractRouting(messages);
|
||||
|
||||
// Handle commands: categorize chat messages
|
||||
const adminUserId = config.adminUserId;
|
||||
const adminUserIds = config.adminUserIds ?? new Set<string>();
|
||||
const normalMessages = [];
|
||||
const commandIds: string[] = [];
|
||||
|
||||
@@ -95,7 +99,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
}
|
||||
|
||||
if (cmdInfo.category === 'admin') {
|
||||
if (!adminUserId || cmdInfo.senderId !== adminUserId) {
|
||||
if (!cmdInfo.senderId || !adminUserIds.has(cmdInfo.senderId)) {
|
||||
log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
name: capabilities
|
||||
description: Show what this NanoClaw instance can do — installed skills, available tools, and system info. Read-only. Use when the user asks what the bot can do, what's installed, or runs /capabilities.
|
||||
---
|
||||
|
||||
# /capabilities — System Capabilities Report
|
||||
|
||||
Generate a structured read-only report of what this NanoClaw instance can do.
|
||||
|
||||
**Main-channel check:** Only the main channel has `/workspace/project` mounted. Run:
|
||||
|
||||
```bash
|
||||
test -d /workspace/project && echo "MAIN" || echo "NOT_MAIN"
|
||||
```
|
||||
|
||||
If `NOT_MAIN`, respond with:
|
||||
> This command is available in your main chat only. Send `/capabilities` there to see what I can do.
|
||||
|
||||
Then stop — do not generate the report.
|
||||
|
||||
## How to gather the information
|
||||
|
||||
Run these commands and compile the results into the report format below.
|
||||
|
||||
### 1. Installed skills
|
||||
|
||||
List skill directories available to you:
|
||||
|
||||
```bash
|
||||
ls -1 /home/node/.claude/skills/ 2>/dev/null || echo "No skills found"
|
||||
```
|
||||
|
||||
Each directory is an installed skill. The directory name is the skill name (e.g., `agent-browser` → `/agent-browser`).
|
||||
|
||||
### 2. Available tools
|
||||
|
||||
Read the allowed tools from your SDK configuration. You always have access to:
|
||||
- **Core:** Bash, Read, Write, Edit, Glob, Grep
|
||||
- **Web:** WebSearch, WebFetch
|
||||
- **Orchestration:** Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage
|
||||
- **Other:** TodoWrite, ToolSearch, Skill, NotebookEdit
|
||||
- **MCP:** mcp__nanoclaw__* (messaging, tasks, group management)
|
||||
|
||||
### 3. MCP server tools
|
||||
|
||||
The NanoClaw MCP server exposes these tools (via `mcp__nanoclaw__*` prefix):
|
||||
- `send_message` — send a message to the user/group
|
||||
- `schedule_task` — schedule a recurring or one-time task
|
||||
- `list_tasks` — list scheduled tasks
|
||||
- `pause_task` — pause a scheduled task
|
||||
- `resume_task` — resume a paused task
|
||||
- `cancel_task` — cancel and delete a task
|
||||
- `update_task` — update an existing task
|
||||
- `register_group` — register a new chat/group (main only)
|
||||
|
||||
### 4. Container skills (Bash tools)
|
||||
|
||||
Check for executable tools in the container:
|
||||
|
||||
```bash
|
||||
which agent-browser 2>/dev/null && echo "agent-browser: available" || echo "agent-browser: not found"
|
||||
```
|
||||
|
||||
### 5. Group info
|
||||
|
||||
```bash
|
||||
ls /workspace/group/CLAUDE.md 2>/dev/null && echo "Group memory: yes" || echo "Group memory: no"
|
||||
ls /workspace/extra/ 2>/dev/null && echo "Extra mounts: $(ls /workspace/extra/ 2>/dev/null | wc -l | tr -d ' ')" || echo "Extra mounts: none"
|
||||
```
|
||||
|
||||
## Report format
|
||||
|
||||
Present the report as a clean, readable message. Example:
|
||||
|
||||
```
|
||||
📋 *NanoClaw Capabilities*
|
||||
|
||||
*Installed Skills:*
|
||||
• /agent-browser — Browse the web, fill forms, extract data
|
||||
• /capabilities — This report
|
||||
(list all found skills)
|
||||
|
||||
*Tools:*
|
||||
• Core: Bash, Read, Write, Edit, Glob, Grep
|
||||
• Web: WebSearch, WebFetch
|
||||
• Orchestration: Task, TeamCreate, SendMessage
|
||||
• MCP: send_message, schedule_task, list_tasks, pause/resume/cancel/update_task, register_group
|
||||
|
||||
*Container Tools:*
|
||||
• agent-browser: ✓
|
||||
|
||||
*System:*
|
||||
• Group memory: yes/no
|
||||
• Extra mounts: N directories
|
||||
• Main channel: yes
|
||||
```
|
||||
|
||||
Adapt the output based on what you actually find — don't list things that aren't installed.
|
||||
|
||||
**See also:** `/status` for a quick health check of session, workspace, and tasks.
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
name: status
|
||||
description: Quick read-only health check — session context, workspace mounts, tool availability, and task snapshot. Use when the user asks for system status or runs /status.
|
||||
---
|
||||
|
||||
# /status — System Status Check
|
||||
|
||||
Generate a quick read-only status report of the current agent environment.
|
||||
|
||||
**Main-channel check:** Only the main channel has `/workspace/project` mounted. Run:
|
||||
|
||||
```bash
|
||||
test -d /workspace/project && echo "MAIN" || echo "NOT_MAIN"
|
||||
```
|
||||
|
||||
If `NOT_MAIN`, respond with:
|
||||
> This command is available in your main chat only. Send `/status` there to check system status.
|
||||
|
||||
Then stop — do not generate the report.
|
||||
|
||||
## How to gather the information
|
||||
|
||||
Run the checks below and compile results into the report format.
|
||||
|
||||
### 1. Session context
|
||||
|
||||
```bash
|
||||
echo "Timestamp: $(date)"
|
||||
echo "Working dir: $(pwd)"
|
||||
echo "Channel: main"
|
||||
```
|
||||
|
||||
### 2. Workspace and mount visibility
|
||||
|
||||
```bash
|
||||
echo "=== Workspace ==="
|
||||
ls /workspace/ 2>/dev/null
|
||||
echo "=== Group folder ==="
|
||||
ls /workspace/group/ 2>/dev/null | head -20
|
||||
echo "=== Extra mounts ==="
|
||||
ls /workspace/extra/ 2>/dev/null || echo "none"
|
||||
echo "=== IPC ==="
|
||||
ls /workspace/ipc/ 2>/dev/null
|
||||
```
|
||||
|
||||
### 3. Tool availability
|
||||
|
||||
Confirm which tool families are available to you:
|
||||
|
||||
- **Core:** Bash, Read, Write, Edit, Glob, Grep
|
||||
- **Web:** WebSearch, WebFetch
|
||||
- **Orchestration:** Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage
|
||||
- **MCP:** mcp__nanoclaw__* (send_message, schedule_task, list_tasks, pause_task, resume_task, cancel_task, update_task, register_group)
|
||||
|
||||
### 4. Container utilities
|
||||
|
||||
```bash
|
||||
which agent-browser 2>/dev/null && echo "agent-browser: available" || echo "agent-browser: not installed"
|
||||
node --version 2>/dev/null
|
||||
claude --version 2>/dev/null
|
||||
```
|
||||
|
||||
### 5. Task snapshot
|
||||
|
||||
Use the MCP tool to list tasks:
|
||||
|
||||
```
|
||||
Call mcp__nanoclaw__list_tasks to get scheduled tasks.
|
||||
```
|
||||
|
||||
If no tasks exist, report "No scheduled tasks."
|
||||
|
||||
## Report format
|
||||
|
||||
Present as a clean, readable message:
|
||||
|
||||
```
|
||||
🔍 *NanoClaw Status*
|
||||
|
||||
*Session:*
|
||||
• Channel: main
|
||||
• Time: 2026-03-14 09:30 UTC
|
||||
• Working dir: /workspace/group
|
||||
|
||||
*Workspace:*
|
||||
• Group folder: ✓ (N files)
|
||||
• Extra mounts: none / N directories
|
||||
• IPC: ✓ (messages, tasks, input)
|
||||
|
||||
*Tools:*
|
||||
• Core: ✓ Web: ✓ Orchestration: ✓ MCP: ✓
|
||||
|
||||
*Container:*
|
||||
• agent-browser: ✓ / not installed
|
||||
• Node: vXX.X.X
|
||||
• Claude Code: vX.X.X
|
||||
|
||||
*Scheduled Tasks:*
|
||||
• N active tasks / No scheduled tasks
|
||||
```
|
||||
|
||||
Adapt based on what you actually find. Keep it concise — this is a quick health check, not a deep diagnostic.
|
||||
|
||||
**See also:** `/capabilities` for a full list of installed skills and tools.
|
||||
@@ -313,7 +313,6 @@ erDiagram
|
||||
int id
|
||||
string name
|
||||
string folder
|
||||
bool is_admin
|
||||
string agent_provider
|
||||
json container_config
|
||||
}
|
||||
@@ -323,7 +322,26 @@ erDiagram
|
||||
string platform_id
|
||||
string name
|
||||
bool is_group
|
||||
string admin_user_id
|
||||
string unknown_sender_policy "strict | request_approval | public"
|
||||
}
|
||||
users {
|
||||
string id PK "namespaced <channel>:<handle>"
|
||||
string kind
|
||||
string display_name
|
||||
}
|
||||
user_roles {
|
||||
string user_id FK
|
||||
string role "owner | admin"
|
||||
string agent_group_id FK "null = global"
|
||||
}
|
||||
agent_group_members {
|
||||
string user_id FK
|
||||
string agent_group_id FK
|
||||
}
|
||||
user_dms {
|
||||
string user_id FK
|
||||
string channel_type
|
||||
string messaging_group_id FK
|
||||
}
|
||||
messaging_group_agents {
|
||||
int messaging_group_id
|
||||
|
||||
@@ -142,7 +142,6 @@ erDiagram
|
||||
int id
|
||||
string name
|
||||
string folder
|
||||
bool is_admin
|
||||
string agent_provider
|
||||
json container_config
|
||||
}
|
||||
@@ -152,7 +151,26 @@ erDiagram
|
||||
string platform_id
|
||||
string name
|
||||
bool is_group
|
||||
string admin_user_id
|
||||
string unknown_sender_policy "strict | request_approval | public"
|
||||
}
|
||||
users {
|
||||
string id PK "namespaced <channel>:<handle>"
|
||||
string kind
|
||||
string display_name
|
||||
}
|
||||
user_roles {
|
||||
string user_id FK
|
||||
string role "owner | admin"
|
||||
string agent_group_id FK "null = global"
|
||||
}
|
||||
agent_group_members {
|
||||
string user_id FK
|
||||
string agent_group_id FK
|
||||
}
|
||||
user_dms {
|
||||
string user_id FK
|
||||
string channel_type
|
||||
string messaging_group_id FK
|
||||
}
|
||||
messaging_group_agents {
|
||||
int messaging_group_id
|
||||
|
||||
@@ -331,9 +331,7 @@ Two patterns, both handled at the host level:
|
||||
|
||||
In both cases, the approval and action execution happen on the host side, not the agent side.
|
||||
|
||||
**Approval routing:** Each messaging group has a designated admin stored in the central DB (`messaging_groups.admin_user_id`). Default is whoever set up the group, can be reassigned. When an action requires approval, the host sends an approval card to the admin's DM conversation (not the channel the agent is operating in). The admin responds there, and the host relays the result back to the agent's session. Approval cards are host-generated (not agent-initiated) — they have a standardized format.
|
||||
|
||||
> **TODO: flesh out** — How does the host find the admin's DM conversation? What happens if the admin hasn't set up a DM channel? Is the approval list configurable per agent group or global?
|
||||
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/access.ts`, `src/user-dm.ts`.
|
||||
|
||||
**Editing a sent message:**
|
||||
|
||||
@@ -671,7 +669,6 @@ 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, -- default for sessions (null = system default)
|
||||
container_config TEXT, -- JSON: { additionalMounts, timeout }
|
||||
created_at TEXT NOT NULL
|
||||
@@ -679,16 +676,53 @@ CREATE TABLE agent_groups (
|
||||
|
||||
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL, -- 'whatsapp', 'slack', 'discord', 'telegram', 'email'
|
||||
platform_id TEXT NOT NULL, -- platform-specific ID (JID, channel ID, etc.)
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
admin_user_id TEXT, -- platform user ID of the group admin (default: whoever set it up)
|
||||
created_at TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL, -- 'whatsapp', 'slack', 'discord', 'telegram', 'email'
|
||||
platform_id TEXT NOT NULL, -- platform-specific ID (JID, channel ID, etc.)
|
||||
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)
|
||||
);
|
||||
|
||||
-- Users (messaging platform identities, namespaced "<channel_type>:<handle>")
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, -- e.g. 'telegram:123456', 'discord:1470...'
|
||||
kind TEXT NOT NULL, -- mirrors the channel_type prefix
|
||||
display_name TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Roles (owner is global only; admin can be global or scoped to an agent_group)
|
||||
CREATE TABLE user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL, -- 'owner' | 'admin'
|
||||
agent_group_id TEXT REFERENCES agent_groups(id), -- NULL for global
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, role, agent_group_id)
|
||||
);
|
||||
-- owner rows must have agent_group_id = NULL (enforced in db/user-roles.ts)
|
||||
|
||||
-- Membership (explicit non-privileged access; admin/owner imply membership)
|
||||
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,
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, agent_group_id)
|
||||
);
|
||||
|
||||
-- DM resolution cache (so cold DMs aren't re-resolved every time)
|
||||
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)
|
||||
);
|
||||
|
||||
-- Which agent groups handle which messaging groups, with what rules
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -864,7 +898,7 @@ Messages starting with `/` are checked against three lists:
|
||||
- Commands that don't make sense in the NanoClaw context or could cause issues
|
||||
- Silently dropped — no error, no forwarding
|
||||
|
||||
The command lists are hardcoded in the agent-runner. Admin verification: the agent-runner checks the `senderId` in the message content against the messaging group's `admin_user_id` (passed to the container as config).
|
||||
The command lists are hardcoded in the agent-runner. Admin verification: the host passes `NANOCLAW_ADMIN_USER_IDS` (a comma-separated list of owner + global-admin + scoped-admin user ids for the current agent group, see `src/container-runner.ts`) to the container. The agent-runner membership-tests the inbound `senderId` against that set before forwarding admin commands.
|
||||
|
||||
### Recurring Tasks
|
||||
|
||||
|
||||
@@ -151,13 +151,12 @@ Status: [x] done, [~] partial, [ ] not started
|
||||
|
||||
## Permissions and Approval Flows
|
||||
|
||||
- [x] Admin user ID per group
|
||||
- [x] Admin-only command filtering in container
|
||||
- [ ] Admin model refactor — instance-level default admin (user + messaging app) for all approval routing, overridable per agent group; deliver approval cards to admin's DM when the platform supports it
|
||||
- [ ] `/remote-control` and `/remote-control-end` are v1 host-level commands not ported to v2 — listed in `ADMIN_COMMANDS` (formatter.ts:13) but unhandled in poll-loop, so they fall through to the Claude SDK which replies "Unknown skill". Found when `/remote-control` failed in a Telegram DM after the admin check passed.
|
||||
- [ ] `messaging_groups.admin_user_id` is hardcoded `null` at registration (`setup/register.ts:175`), so privileged commands like `/remote-control` always deny access until the column is manually backfilled in SQLite. Found when `/remote-control` failed in a freshly-registered Telegram DM.
|
||||
- [ ] Self-approval UX: when the user requesting a sensitive action IS the recorded admin/owner of the agent group, the agent still posts an Approve button back to that same person and narrates "waiting on admin approval" — confusing, redundant, and the "waiting" line stays visible even after the user immediately clicks approve.
|
||||
- [ ] Replace the `is_admin`/main vs. non-main distinction on agent groups with an explicit owner/admin model — every agent group has a recorded owner (the platform user who created or installed it) and an admin (who receives approvals and can change wiring); the two default to the same identity but can diverge (e.g. handoff). Drops the "main group = admin group" coupling. Downstream consequence: non-main group registration on Telegram must use the same pairing-code flow as main (`setup --step pair-telegram` with a `wire-to:<folder>` or `new-agent:<folder>` intent), since there's no longer a privileged "main" chat whose identity is trusted transitively — every group binds its own admin at registration time.
|
||||
- [x] User-level privilege model — `users` + `user_roles` (owner / admin, global or scoped to an agent group). Replaces the old `agent_groups.is_admin` / `messaging_groups.admin_user_id` coupling. See `src/db/users.ts`, `src/db/user-roles.ts`, `src/access.ts`.
|
||||
- [x] Admin-only command filtering in container — host passes `NANOCLAW_ADMIN_USER_IDS` (owners + global admins + scoped admins for the agent group) to the agent-runner; `poll-loop.ts` gates slash commands against that set.
|
||||
- [x] Approval routing — `pickApprover` (scoped admin → global admin → owner, dedup) + `pickApprovalDelivery` (first reachable, same-channel-kind tie-break); delivery lands in the approver's DM via `ensureUserDm` / `user_dms` cache. See `src/access.ts`, `src/onecli-approvals.ts`.
|
||||
- [x] Per-messaging-group unknown-sender gating — `messaging_groups.unknown_sender_policy` (`strict` | `request_approval` | `public`), enforced in `src/router.ts`.
|
||||
- [ ] `/remote-control` and `/remote-control-end` are v1 host-level commands not ported to v2 — listed in `ADMIN_COMMANDS` (formatter.ts:13) but unhandled in poll-loop, so they fall through to the Claude SDK which replies "Unknown skill".
|
||||
- [ ] Self-approval UX: when the user requesting a sensitive action IS an owner/admin, the agent still posts an Approve button back to that same person and narrates "waiting on admin approval" — confusing and redundant.
|
||||
- [x] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) — `pending_approvals` table, `requestApproval()` helper, reuses interactive card infra
|
||||
- [x] Agent requests dependency/package install (install_packages, admin approval, rebuild on approval)
|
||||
- [x] Self-modification — direct tools:
|
||||
@@ -167,7 +166,6 @@ Status: [x] done, [~] partial, [ ] not started
|
||||
- [x] Fire-and-forget model (write request, return immediately; chat notification on approval; container killed so next wake picks up new config/image)
|
||||
- [ ] Role definitions beyond admin (custom roles, per-group permissions)
|
||||
- [ ] Configurable sensitive action list (hardcoded today)
|
||||
- [ ] Non-main groups requesting sensitive actions
|
||||
- [~] OneCLI integration for human-loop approvals on credentialed requests (agent touching a credentialed resource → OneCLI gates → approval card to admin → OneCLI releases credential) — SDK 0.3.1 `configureManualApproval` wired into host, routes to admin via existing `pending_approvals` infra
|
||||
- [~] Credential collection from chat — `trigger_credential_collection` MCP tool; agent researches API config, card → modal → `onecli secrets create` via internal facade (`src/onecli-secrets.ts`); credential value never enters agent context
|
||||
- [ ] Replace `src/onecli-secrets.ts` shell facade with SDK-native secret management when `@onecli-sh/sdk` adds it
|
||||
|
||||
@@ -69,13 +69,20 @@ Added `session_mode: 'agent-shared'` for cross-channel shared sessions (e.g. Git
|
||||
|
||||
### v2 Entity Model
|
||||
```
|
||||
agent_groups (id, name, folder, is_admin, agent_provider, container_config)
|
||||
agent_groups (id, name, folder, agent_provider, container_config)
|
||||
↕ many-to-many
|
||||
messaging_groups (id, channel_type, platform_id, name, is_group, admin_user_id)
|
||||
messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy)
|
||||
via
|
||||
messaging_group_agents (messaging_group_id, agent_group_id, trigger_rules, session_mode, priority)
|
||||
|
||||
users (id, kind, display_name) -- namespaced as "<channel>:<handle>"
|
||||
user_roles (user_id, role, agent_group_id) -- owner / admin (global or scoped)
|
||||
agent_group_members (user_id, agent_group_id) -- unprivileged access gate
|
||||
user_dms (user_id, channel_type, messaging_group_id) -- cold-DM cache
|
||||
```
|
||||
|
||||
Privilege is a user-level concept — there is no "main" agent group or "admin" messaging group. `user_roles` carries `owner` (global only, first pairing sets it) and `admin` (global or scoped to an `agent_group_id`). Unknown-sender gating is per-messaging-group via `messaging_groups.unknown_sender_policy` (`strict | request_approval | public`).
|
||||
|
||||
### Message Flow
|
||||
```
|
||||
Channel adapter → routeInbound() → resolve messaging_group → resolve agent via messaging_group_agents
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Init the first (or Nth) NanoClaw v2 agent for a DM channel.
|
||||
*
|
||||
* Creates/reuses: user, owner grant (if none), agent group + filesystem,
|
||||
* DM messaging group, wiring, session. Stages a system welcome message so
|
||||
* the host sweep wakes the container and the agent DMs the operator via
|
||||
* the normal delivery path.
|
||||
*
|
||||
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize
|
||||
* channel adapters, so there's no Gateway conflict.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/init-first-agent.ts \
|
||||
* --channel discord \
|
||||
* --user-id discord:1470183333427675709 \
|
||||
* --platform-id discord:@me:1491573333382523708 \
|
||||
* --display-name "Gavriel" \
|
||||
* [--agent-name "Andy"] \
|
||||
* [--welcome "System instruction: ..."]
|
||||
*
|
||||
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
||||
* is typically the same as the handle in --user-id, with the channel prefix.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { normalizeName } from '../src/db/agent-destinations.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
createMessagingGroupAgent,
|
||||
getMessagingGroupAgentByPair,
|
||||
getMessagingGroupByPlatform,
|
||||
} from '../src/db/messaging-groups.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { grantRole, hasAnyOwner } from '../src/db/user-roles.js';
|
||||
import { upsertUser } from '../src/db/users.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||
import type { AgentGroup } from '../src/types.js';
|
||||
|
||||
interface Args {
|
||||
channel: string;
|
||||
userId: string;
|
||||
platformId: string;
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
welcome: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WELCOME =
|
||||
'System instruction: please send a short, friendly welcome message to the user. ' +
|
||||
'Introduce yourself as their NanoClaw agent, confirm the channel is working, and invite them to chat. ' +
|
||||
'Keep it under three sentences.';
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const out: Partial<Args> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
const val = argv[i + 1];
|
||||
switch (key) {
|
||||
case '--channel':
|
||||
out.channel = (val ?? '').toLowerCase();
|
||||
i++;
|
||||
break;
|
||||
case '--user-id':
|
||||
out.userId = val;
|
||||
i++;
|
||||
break;
|
||||
case '--platform-id':
|
||||
out.platformId = val;
|
||||
i++;
|
||||
break;
|
||||
case '--display-name':
|
||||
out.displayName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--agent-name':
|
||||
out.agentName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--welcome':
|
||||
out.welcome = val;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const required: (keyof Args)[] = ['channel', 'userId', 'platformId', 'displayName'];
|
||||
const missing = required.filter((k) => !out[k]);
|
||||
if (missing.length) {
|
||||
console.error(`Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`);
|
||||
console.error('See scripts/init-first-agent.ts header for usage.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return {
|
||||
channel: out.channel!,
|
||||
userId: out.userId!,
|
||||
platformId: out.platformId!,
|
||||
displayName: out.displayName!,
|
||||
agentName: out.agentName?.trim() || out.displayName!,
|
||||
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
|
||||
};
|
||||
}
|
||||
|
||||
function namespacedUserId(channel: string, raw: string): string {
|
||||
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function namespacedPlatformId(channel: string, raw: string): string {
|
||||
return raw.startsWith(`${channel}:`) ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(db); // idempotent
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 1. User + (conditional) owner grant
|
||||
const userId = namespacedUserId(args.channel, args.userId);
|
||||
upsertUser({
|
||||
id: userId,
|
||||
kind: args.channel,
|
||||
display_name: args.displayName,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
let promotedToOwner = false;
|
||||
if (!hasAnyOwner()) {
|
||||
grantRole({
|
||||
user_id: userId,
|
||||
role: 'owner',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: now,
|
||||
});
|
||||
promotedToOwner = true;
|
||||
}
|
||||
|
||||
// 2. Agent group + filesystem
|
||||
const folder = `dm-with-${normalizeName(args.displayName)}`;
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
createAgentGroup({
|
||||
id: agId,
|
||||
name: args.agentName,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now,
|
||||
});
|
||||
ag = getAgentGroupByFolder(folder)!;
|
||||
console.log(`Created agent group: ${ag.id} (${folder})`);
|
||||
} else {
|
||||
console.log(`Reusing agent group: ${ag.id} (${folder})`);
|
||||
}
|
||||
initGroupFilesystem(ag, {
|
||||
instructions:
|
||||
`# ${args.agentName}\n\n` +
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When you receive a system welcome prompt, introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
});
|
||||
|
||||
// 3. DM messaging group
|
||||
const platformId = namespacedPlatformId(args.channel, args.platformId);
|
||||
let mg = getMessagingGroupByPlatform(args.channel, platformId);
|
||||
if (!mg) {
|
||||
const mgId = generateId('mg');
|
||||
createMessagingGroup({
|
||||
id: mgId,
|
||||
channel_type: args.channel,
|
||||
platform_id: platformId,
|
||||
name: args.displayName,
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now,
|
||||
});
|
||||
mg = getMessagingGroupByPlatform(args.channel, platformId)!;
|
||||
console.log(`Created messaging group: ${mg.id} (${platformId})`);
|
||||
} else {
|
||||
console.log(`Reusing messaging group: ${mg.id} (${platformId})`);
|
||||
}
|
||||
|
||||
// 4. Wire (auto-creates the companion agent_destinations row)
|
||||
const existingMga = getMessagingGroupAgentByPair(mg.id, ag.id);
|
||||
if (!existingMga) {
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: ag.id,
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now,
|
||||
});
|
||||
console.log(`Wired ${mg.id} -> ${ag.id}`);
|
||||
} else {
|
||||
console.log(`Wiring already exists: ${existingMga.id}`);
|
||||
}
|
||||
|
||||
// 5. Session + staged welcome message
|
||||
const { session, created } = resolveSession(ag.id, mg.id, null, 'shared');
|
||||
console.log(`${created ? 'Created' : 'Reusing'} session: ${session.id}`);
|
||||
|
||||
writeSessionMessage(ag.id, session.id, {
|
||||
id: generateId('sys-welcome'),
|
||||
kind: 'chat',
|
||||
timestamp: now,
|
||||
platformId: mg.platform_id,
|
||||
channelType: args.channel,
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
text: args.welcome,
|
||||
sender: 'system',
|
||||
senderId: 'system',
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('Init complete.');
|
||||
console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`);
|
||||
console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
|
||||
console.log(` channel: ${args.channel} ${platformId}`);
|
||||
console.log(` session: ${session.id}`);
|
||||
console.log('');
|
||||
console.log('Host sweep (<=60s) will wake the container and the agent will send the welcome DM.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -28,7 +28,6 @@ if (!getAgentGroup(AGENT_GROUP_ID)) {
|
||||
id: AGENT_GROUP_ID,
|
||||
name: 'Main',
|
||||
folder: 'main',
|
||||
is_admin: 1,
|
||||
agent_provider: 'claude',
|
||||
container_config: null,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -46,7 +45,7 @@ if (!getMessagingGroup(MESSAGING_GROUP_ID)) {
|
||||
platform_id: CHANNEL_ID,
|
||||
name: 'Discord Test',
|
||||
is_group: 1,
|
||||
admin_user_id: null,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
console.log('Created messaging group:', MESSAGING_GROUP_ID);
|
||||
|
||||
@@ -35,7 +35,6 @@ createAgentGroup({
|
||||
id: 'ag-chan',
|
||||
name: 'Channel E2E Agent',
|
||||
folder: 'test-channel-e2e',
|
||||
is_admin: 1, // admin so OneCLI uses default agent for auth
|
||||
agent_provider: 'claude',
|
||||
container_config: null,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -47,7 +46,7 @@ createMessagingGroup({
|
||||
platform_id: 'mock-channel-1',
|
||||
name: 'Mock Channel',
|
||||
is_group: 0,
|
||||
admin_user_id: null,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ createAgentGroup({
|
||||
id: 'ag-e2e',
|
||||
name: 'E2E Test Agent',
|
||||
folder: 'test-agent-e2e',
|
||||
is_admin: 0,
|
||||
agent_provider: 'claude',
|
||||
container_config: null,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -49,7 +48,7 @@ createMessagingGroup({
|
||||
platform_id: 'e2e-channel',
|
||||
name: 'E2E Test Channel',
|
||||
is_group: 0,
|
||||
admin_user_id: null,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* Step: pair-telegram — issue a one-time pairing code and wait for the
|
||||
* operator to send `@botname CODE` from the chat they want to register.
|
||||
*
|
||||
* On success, prints platformId / isGroup / adminUserId / intent. The caller
|
||||
* (skill) then runs `setup --step register` with those values.
|
||||
* On success, prints platformId / isGroup / pairedUserId / intent. The caller
|
||||
* (skill) can then wire the chat to an agent group (e.g. via /init-first-agent
|
||||
* or setup --step register). telegram.ts's inbound interceptor has already
|
||||
* upserted the paired user and granted owner if no owner existed yet.
|
||||
*
|
||||
* The service must already be running so the telegram adapter is polling.
|
||||
*/
|
||||
@@ -93,7 +95,9 @@ export async function run(args: string[]): Promise<void> {
|
||||
INTENT: intentToString(consumed.intent),
|
||||
PLATFORM_ID: consumed.consumed!.platformId,
|
||||
IS_GROUP: consumed.consumed!.isGroup,
|
||||
ADMIN_USER_ID: consumed.consumed!.adminUserId ?? '',
|
||||
PAIRED_USER_ID: consumed.consumed!.adminUserId
|
||||
? `telegram:${consumed.consumed!.adminUserId}`
|
||||
: '',
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Tests for the register step.
|
||||
*
|
||||
* Verifies: parameterized SQL (no injection), file templating,
|
||||
* apostrophe in names, .env updates, CLAUDE.md template copy.
|
||||
*/
|
||||
|
||||
function createTestDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0
|
||||
)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('parameterized SQL registration', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
it('registers a group with parameterized query', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Test Group',
|
||||
'test-group',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||
.get('123@g.us') as {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number;
|
||||
};
|
||||
|
||||
expect(row.jid).toBe('123@g.us');
|
||||
expect(row.name).toBe('Test Group');
|
||||
expect(row.folder).toBe('test-group');
|
||||
expect(row.trigger_pattern).toBe('@Andy');
|
||||
expect(row.requires_trigger).toBe(1);
|
||||
});
|
||||
|
||||
it('handles apostrophes in group names safely', () => {
|
||||
const name = "O'Brien's Group";
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
name,
|
||||
'obriens-group',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT name FROM registered_groups WHERE jid = ?')
|
||||
.get('456@g.us') as {
|
||||
name: string;
|
||||
};
|
||||
|
||||
expect(row.name).toBe(name);
|
||||
});
|
||||
|
||||
it('prevents SQL injection in JID field', () => {
|
||||
const maliciousJid = "'; DROP TABLE registered_groups; --";
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
|
||||
// Table should still exist and have the row
|
||||
const count = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as {
|
||||
count: number;
|
||||
};
|
||||
expect(count.count).toBe(1);
|
||||
|
||||
const row = db.prepare('SELECT jid FROM registered_groups').get() as {
|
||||
jid: string;
|
||||
};
|
||||
expect(row.jid).toBe(maliciousJid);
|
||||
});
|
||||
|
||||
it('handles requiresTrigger=false', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'789@s.whatsapp.net',
|
||||
'Personal',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
|
||||
.get('789@s.whatsapp.net') as { requires_trigger: number };
|
||||
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
});
|
||||
|
||||
it('stores is_main flag', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
||||
).run(
|
||||
'789@s.whatsapp.net',
|
||||
'Personal',
|
||||
'whatsapp_main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
||||
.get('789@s.whatsapp.net') as { is_main: number };
|
||||
|
||||
expect(row.is_main).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults is_main to 0', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Some Group',
|
||||
'whatsapp_some-group',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
||||
.get('123@g.us') as { is_main: number };
|
||||
|
||||
expect(row.is_main).toBe(0);
|
||||
});
|
||||
|
||||
it('upserts on conflict', () => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
);
|
||||
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Original',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Updated',
|
||||
'main',
|
||||
'@Bot',
|
||||
'2024-02-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const rows = db.prepare('SELECT * FROM registered_groups').all();
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const row = rows[0] as {
|
||||
name: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number;
|
||||
};
|
||||
expect(row.name).toBe('Updated');
|
||||
expect(row.trigger_pattern).toBe('@Bot');
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file templating', () => {
|
||||
it('replaces assistant name in CLAUDE.md content', () => {
|
||||
let content = '# Andy\n\nYou are Andy, a personal assistant.';
|
||||
|
||||
content = content.replace(/^# Andy$/m, '# Nova');
|
||||
content = content.replace(/You are Andy/g, 'You are Nova');
|
||||
|
||||
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
|
||||
});
|
||||
|
||||
it('handles names with special regex characters', () => {
|
||||
let content = '# Andy\n\nYou are Andy.';
|
||||
|
||||
const newName = 'C.L.A.U.D.E';
|
||||
content = content.replace(/^# Andy$/m, `# ${newName}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${newName}`);
|
||||
|
||||
expect(content).toContain('# C.L.A.U.D.E');
|
||||
expect(content).toContain('You are C.L.A.U.D.E.');
|
||||
});
|
||||
|
||||
it('updates .env ASSISTANT_NAME line', () => {
|
||||
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
|
||||
|
||||
envContent = envContent.replace(
|
||||
/^ASSISTANT_NAME=.*$/m,
|
||||
'ASSISTANT_NAME="Nova"',
|
||||
);
|
||||
|
||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
||||
expect(envContent).toContain('SOME_KEY=value');
|
||||
});
|
||||
|
||||
it('appends ASSISTANT_NAME to .env if not present', () => {
|
||||
let envContent = 'SOME_KEY=value\n';
|
||||
|
||||
if (!envContent.includes('ASSISTANT_NAME=')) {
|
||||
envContent += '\nASSISTANT_NAME="Nova"';
|
||||
}
|
||||
|
||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAUDE.md template copy', () => {
|
||||
let tmpDir: string;
|
||||
let groupsDir: string;
|
||||
|
||||
// Replicates register.ts template copy + name update logic
|
||||
function simulateRegister(
|
||||
folder: string,
|
||||
isMain: boolean,
|
||||
assistantName = 'Andy',
|
||||
): void {
|
||||
const folderDir = path.join(groupsDir, folder);
|
||||
fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true });
|
||||
|
||||
// Template copy — never overwrite existing (register.ts lines 119-135)
|
||||
const dest = path.join(folderDir, 'CLAUDE.md');
|
||||
if (!fs.existsSync(dest)) {
|
||||
const templatePath = isMain
|
||||
? path.join(groupsDir, 'main', 'CLAUDE.md')
|
||||
: path.join(groupsDir, 'global', 'CLAUDE.md');
|
||||
if (fs.existsSync(templatePath)) {
|
||||
fs.copyFileSync(templatePath, dest);
|
||||
}
|
||||
}
|
||||
|
||||
// Name update across all groups (register.ts lines 140-165)
|
||||
if (assistantName !== 'Andy') {
|
||||
const mdFiles = fs
|
||||
.readdirSync(groupsDir)
|
||||
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
|
||||
.filter((f) => fs.existsSync(f));
|
||||
|
||||
for (const mdFile of mdFiles) {
|
||||
let content = fs.readFileSync(mdFile, 'utf-8');
|
||||
content = content.replace(/^# Andy$/m, `# ${assistantName}`);
|
||||
content = content.replace(
|
||||
/You are Andy/g,
|
||||
`You are ${assistantName}`,
|
||||
);
|
||||
fs.writeFileSync(mdFile, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readGroupMd(folder: string): string {
|
||||
return fs.readFileSync(
|
||||
path.join(groupsDir, folder, 'CLAUDE.md'),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-'));
|
||||
groupsDir = path.join(tmpDir, 'groups');
|
||||
fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(groupsDir, 'main', 'CLAUDE.md'),
|
||||
'# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(groupsDir, 'global', 'CLAUDE.md'),
|
||||
'# Andy\n\nYou are Andy, a personal assistant.',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('copies global template for non-main group', () => {
|
||||
simulateRegister('telegram_dev-team', false);
|
||||
|
||||
const content = readGroupMd('telegram_dev-team');
|
||||
expect(content).toContain('You are Andy');
|
||||
expect(content).not.toContain('Admin Context');
|
||||
});
|
||||
|
||||
it('copies main template for main group', () => {
|
||||
simulateRegister('whatsapp_main', true);
|
||||
|
||||
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
|
||||
});
|
||||
|
||||
it('each channel can have its own main with admin context', () => {
|
||||
simulateRegister('whatsapp_main', true);
|
||||
simulateRegister('telegram_main', true);
|
||||
simulateRegister('slack_main', true);
|
||||
simulateRegister('discord_main', true);
|
||||
|
||||
for (const folder of [
|
||||
'whatsapp_main',
|
||||
'telegram_main',
|
||||
'slack_main',
|
||||
'discord_main',
|
||||
]) {
|
||||
const content = readGroupMd(folder);
|
||||
expect(content).toContain('Admin Context');
|
||||
expect(content).toContain('You are Andy');
|
||||
}
|
||||
});
|
||||
|
||||
it('non-main groups across channels get global template', () => {
|
||||
simulateRegister('whatsapp_main', true);
|
||||
simulateRegister('telegram_friends', false);
|
||||
simulateRegister('slack_engineering', false);
|
||||
simulateRegister('discord_general', false);
|
||||
|
||||
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
|
||||
for (const folder of [
|
||||
'telegram_friends',
|
||||
'slack_engineering',
|
||||
'discord_general',
|
||||
]) {
|
||||
const content = readGroupMd(folder);
|
||||
expect(content).toContain('You are Andy');
|
||||
expect(content).not.toContain('Admin Context');
|
||||
}
|
||||
});
|
||||
|
||||
it('custom name propagates to all channels and groups', () => {
|
||||
// Register multiple channels, last one sets custom name
|
||||
simulateRegister('whatsapp_main', true);
|
||||
simulateRegister('telegram_main', true);
|
||||
simulateRegister('slack_devs', false);
|
||||
// Final registration triggers name update across all
|
||||
simulateRegister('discord_main', true, 'Luna');
|
||||
|
||||
for (const folder of [
|
||||
'main',
|
||||
'global',
|
||||
'whatsapp_main',
|
||||
'telegram_main',
|
||||
'slack_devs',
|
||||
'discord_main',
|
||||
]) {
|
||||
const content = readGroupMd(folder);
|
||||
expect(content).toContain('# Luna');
|
||||
expect(content).toContain('You are Luna');
|
||||
expect(content).not.toContain('Andy');
|
||||
}
|
||||
});
|
||||
|
||||
it('never overwrites existing CLAUDE.md on re-registration', () => {
|
||||
simulateRegister('slack_main', true);
|
||||
// User customizes the file extensively (persona, workspace, rules)
|
||||
const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md');
|
||||
fs.writeFileSync(
|
||||
mdPath,
|
||||
'# Gambi\n\nCustom persona with workspace rules and family context.',
|
||||
);
|
||||
// Re-registering same folder (e.g. re-running /add-slack)
|
||||
simulateRegister('slack_main', true);
|
||||
|
||||
const content = readGroupMd('slack_main');
|
||||
expect(content).toContain('Custom persona');
|
||||
expect(content).not.toContain('Admin Context');
|
||||
});
|
||||
|
||||
it('never overwrites when non-main becomes main (isMain changes)', () => {
|
||||
// User registers a family group as non-main
|
||||
simulateRegister('whatsapp_casa', false);
|
||||
// User extensively customizes it (PARA system, task management, etc.)
|
||||
const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md');
|
||||
fs.writeFileSync(
|
||||
mdPath,
|
||||
'# Casa\n\nFamily group with PARA system, task management, shopping lists.',
|
||||
);
|
||||
// Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved
|
||||
simulateRegister('whatsapp_casa', true);
|
||||
|
||||
const content = readGroupMd('whatsapp_casa');
|
||||
expect(content).toContain('PARA system');
|
||||
expect(content).not.toContain('Admin Context');
|
||||
});
|
||||
|
||||
it('preserves custom CLAUDE.md across channels when changing main', () => {
|
||||
// Real-world scenario: WhatsApp main + customized Discord research channel
|
||||
simulateRegister('whatsapp_main', true);
|
||||
simulateRegister('discord_main', false);
|
||||
const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md');
|
||||
fs.writeFileSync(
|
||||
discordPath,
|
||||
'# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.',
|
||||
);
|
||||
|
||||
// Discord becomes main too — custom content must survive
|
||||
simulateRegister('discord_main', true);
|
||||
expect(readGroupMd('discord_main')).toContain('Research Assistant');
|
||||
// WhatsApp main also untouched
|
||||
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
|
||||
});
|
||||
|
||||
it('handles missing templates gracefully', () => {
|
||||
fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md'));
|
||||
fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md'));
|
||||
|
||||
simulateRegister('discord_general', false);
|
||||
|
||||
expect(
|
||||
fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+7
-57
@@ -11,11 +11,6 @@ import { DATA_DIR } from '../src/config.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import {
|
||||
createDestination,
|
||||
getDestinationByName,
|
||||
normalizeName,
|
||||
} from '../src/db/agent-destinations.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
createMessagingGroupAgent,
|
||||
@@ -23,6 +18,7 @@ import {
|
||||
getMessagingGroupAgentByPair,
|
||||
} from '../src/db/messaging-groups.js';
|
||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||
import { emitStatus } from './status.js';
|
||||
@@ -40,14 +36,10 @@ interface RegisterArgs {
|
||||
channel: string;
|
||||
/** Whether messages require the trigger pattern to activate */
|
||||
requiresTrigger: boolean;
|
||||
/** Whether this is the admin/main agent group */
|
||||
isMain: boolean;
|
||||
/** Display name for the assistant */
|
||||
assistantName: string;
|
||||
/** Session mode: 'shared' (one session per channel) or 'per-thread' */
|
||||
sessionMode: string;
|
||||
/** Optional local name the agent uses for this channel (defaults to normalized messaging group name) */
|
||||
localName: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): RegisterArgs {
|
||||
@@ -58,16 +50,12 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
folder: '',
|
||||
channel: 'discord',
|
||||
requiresTrigger: true,
|
||||
isMain: false,
|
||||
assistantName: 'Andy',
|
||||
sessionMode: 'shared',
|
||||
localName: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
// Accept both --jid (v1 compat) and --platform-id (v2)
|
||||
case '--jid':
|
||||
case '--platform-id':
|
||||
result.platformId = args[++i] || '';
|
||||
break;
|
||||
@@ -86,18 +74,12 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
case '--no-trigger-required':
|
||||
result.requiresTrigger = false;
|
||||
break;
|
||||
case '--is-main':
|
||||
result.isMain = true;
|
||||
break;
|
||||
case '--assistant-name':
|
||||
result.assistantName = args[++i] || 'Andy';
|
||||
break;
|
||||
case '--session-mode':
|
||||
result.sessionMode = args[++i] || 'shared';
|
||||
break;
|
||||
case '--local-name':
|
||||
result.localName = args[++i] || null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +135,6 @@ export async function run(args: string[]): Promise<void> {
|
||||
id: agId,
|
||||
name: parsed.assistantName,
|
||||
folder: parsed.folder,
|
||||
is_admin: parsed.isMain ? 1 : 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -161,6 +142,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
agentGroup = getAgentGroupByFolder(parsed.folder)!;
|
||||
log.info('Created agent group', { id: agId, folder: parsed.folder });
|
||||
}
|
||||
initGroupFilesystem(agentGroup);
|
||||
|
||||
// 2. Create or find messaging group
|
||||
let messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId);
|
||||
@@ -172,14 +154,15 @@ export async function run(args: string[]): Promise<void> {
|
||||
platform_id: parsed.platformId,
|
||||
name: parsed.name,
|
||||
is_group: 1,
|
||||
admin_user_id: null,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId)!;
|
||||
log.info('Created messaging group', { id: mgId, channel: parsed.channel, platformId: parsed.platformId });
|
||||
}
|
||||
|
||||
// 3. Wire agent to messaging group + create destination row for the agent's map
|
||||
// 3. Wire agent to messaging group — createMessagingGroupAgent auto-creates
|
||||
// the companion agent_destinations row so delivery's ACL admits this target.
|
||||
let newlyWired = false;
|
||||
const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id);
|
||||
if (!existing) {
|
||||
@@ -198,31 +181,13 @@ export async function run(args: string[]): Promise<void> {
|
||||
trigger_rules: triggerRules,
|
||||
response_scope: 'all',
|
||||
session_mode: parsed.sessionMode,
|
||||
priority: parsed.isMain ? 10 : 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Create destination row so the agent can address this channel by name.
|
||||
// Auto-suffix on collision within this agent's namespace.
|
||||
const baseLocalName = normalizeName(parsed.localName || parsed.name);
|
||||
let localName = baseLocalName;
|
||||
let suffix = 2;
|
||||
while (getDestinationByName(agentGroup.id, localName)) {
|
||||
localName = `${baseLocalName}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
createDestination({
|
||||
agent_group_id: agentGroup.id,
|
||||
local_name: localName,
|
||||
target_type: 'channel',
|
||||
target_id: messagingGroup.id,
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Wired agent to messaging group', {
|
||||
mgaId,
|
||||
agentGroup: agentGroup.id,
|
||||
messagingGroup: messagingGroup.id,
|
||||
localName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,22 +207,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel });
|
||||
}
|
||||
|
||||
// 5. Create group folders
|
||||
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true });
|
||||
|
||||
// Create CLAUDE.md from template if it doesn't exist
|
||||
const groupClaudeMdPath = path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md');
|
||||
if (!fs.existsSync(groupClaudeMdPath)) {
|
||||
const templatePath = parsed.isMain
|
||||
? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md')
|
||||
: path.join(projectRoot, 'groups', 'global', 'CLAUDE.md');
|
||||
if (fs.existsSync(templatePath)) {
|
||||
fs.copyFileSync(templatePath, groupClaudeMdPath);
|
||||
log.info('Created CLAUDE.md from template', { file: groupClaudeMdPath, template: templatePath });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Update assistant name in CLAUDE.md files if different from default
|
||||
// 5. Update assistant name in CLAUDE.md files if different from default
|
||||
let nameUpdated = false;
|
||||
if (parsed.assistantName !== 'Andy') {
|
||||
log.info('Updating assistant name', { from: 'Andy', to: parsed.assistantName });
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
+19
-33
@@ -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.
|
||||
|
||||
@@ -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'>>,
|
||||
|
||||
+44
-17
@@ -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(),
|
||||
|
||||
+16
-1
@@ -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),
|
||||
|
||||
+60
-13
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+60
-38
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
+7
-19
@@ -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);
|
||||
|
||||
+25
-16
@@ -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',
|
||||
|
||||
+126
-12
@@ -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;
|
||||
}
|
||||
|
||||
+50
-2
@@ -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
@@ -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