diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index c4dfdc2..b3d7bd0 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -24,7 +24,8 @@ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] + * [--welcome "System instruction: ..."] \ + * [--role owner|admin|member] # default: owner * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. @@ -44,11 +45,13 @@ import { import { runMigrations } from '../src/db/migrations/index.js'; import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; import { addMember } from '../src/modules/permissions/db/agent-group-members.js'; -import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; +import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; +type Role = 'owner' | 'admin' | 'member'; + interface Args { channel: string; userId: string; @@ -56,11 +59,14 @@ interface Args { displayName: string; agentName: string; welcome: string; + role: Role; } const DEFAULT_WELCOME = 'System instruction: run /welcome to introduce yourself to the user on this new channel.'; +const DEFAULT_ROLE: Role = 'owner'; + function parseArgs(argv: string[]): Args { const out: Partial = {}; for (let i = 0; i < argv.length; i++) { @@ -91,6 +97,18 @@ function parseArgs(argv: string[]): Args { out.welcome = val; i++; break; + case '--role': { + const raw = (val ?? '').toLowerCase(); + if (raw !== 'owner' && raw !== 'admin' && raw !== 'member') { + console.error( + `Invalid --role: ${raw} (expected 'owner', 'admin', or 'member')`, + ); + process.exit(2); + } + out.role = raw; + i++; + break; + } } } @@ -111,6 +129,7 @@ function parseArgs(argv: string[]): Args { displayName: out.displayName!, agentName: out.agentName?.trim() || out.displayName!, welcome: out.welcome?.trim() || DEFAULT_WELCOME, + role: out.role ?? DEFAULT_ROLE, }; } @@ -173,17 +192,8 @@ async function main(): Promise { 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; - } + // Owner grant is deferred until after the agent group is resolved, since + // an admin grant is scoped to that group. See step 2b. // 2. Agent group + filesystem. const folder = `dm-with-${normalizeName(args.displayName)}`; @@ -209,12 +219,46 @@ async function main(): Promise { 'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.', }); - // 2b. Grant the user access to this agent group. Owner role is only - // assigned to the first user (above); subsequent DMs need explicit - // membership or the strict unknown_sender_policy on the DM messaging - // group will drop every message with accessReason='not_member'. addMember - // is INSERT OR IGNORE — idempotent when the global owner already has - // access by virtue of their role. + // 2b. Assign the user a role for this agent group. The caller picks via + // --role; the channel drivers default to 'owner' for the self-host case. + // - owner: global owner (agent_group_id=null). Cross-channel access. + // - admin: scoped admin for this agent group only. + // - member: no role grant, just the membership row below. + // grantRole inserts a new row per call — idempotence check against + // getUserRoles prevents duplicates on re-runs. + const existingRoles = getUserRoles(userId); + if (args.role === 'owner') { + const alreadyOwner = existingRoles.some( + (r) => r.role === 'owner' && r.agent_group_id === null, + ); + if (!alreadyOwner) { + grantRole({ + user_id: userId, + role: 'owner', + agent_group_id: null, + granted_by: null, + granted_at: now, + }); + } + } else if (args.role === 'admin') { + const alreadyAdmin = existingRoles.some( + (r) => r.role === 'admin' && r.agent_group_id === ag.id, + ); + if (!alreadyAdmin) { + grantRole({ + user_id: userId, + role: 'admin', + agent_group_id: ag.id, + granted_by: null, + granted_at: now, + }); + } + } + + // Always add a membership row so the access gate has a straightforward + // yes/no even for users without a role grant. INSERT OR IGNORE, so this + // is a no-op when the row already exists (e.g. re-runs, owners whose + // access already passes via role). addMember({ user_id: userId, agent_group_id: ag.id, @@ -254,9 +298,17 @@ async function main(): Promise { sender: args.displayName, }); + const roleLabel = + args.role === 'owner' + ? 'owner (global)' + : args.role === 'admin' + ? `admin (scoped to ${ag.id})` + : 'member'; + console.log(''); console.log('Init complete.'); - console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); + console.log(` user: ${userId}`); + console.log(` role: ${roleLabel}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` channel: ${args.channel} ${dmMg.platform_id}`); console.log(''); diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 010310e..f26dc23 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -28,6 +28,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -88,6 +89,9 @@ export async function runDiscordChannel(displayName: string): Promise { const dmChannelId = await openDmChannel(token, ownerUserId); const platformId = `discord:@me:${dmChannelId}`; + const role = await askOperatorRole('Discord'); + setupLog.userInput('discord_role', role); + const agentName = await resolveAgentName(); const init = await runQuietChild( @@ -100,6 +104,7 @@ export async function runDiscordChannel(displayName: string): Promise { '--platform-id', platformId, '--display-name', displayName, '--agent-name', agentName, + '--role', role, ], { running: `Connecting ${agentName} to your Discord DMs…`, diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df253c6..df97fcf 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -22,6 +22,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, type StepResult, @@ -96,6 +97,9 @@ export async function runTelegramChannel(displayName: string): Promise { ); } + const role = await askOperatorRole('Telegram'); + setupLog.userInput('telegram_role', role); + const agentName = await resolveAgentName(); const init = await runQuietChild( @@ -108,6 +112,7 @@ export async function runTelegramChannel(displayName: string): Promise { '--platform-id', platformId, '--display-name', displayName, '--agent-name', agentName, + '--role', role, ], { running: `Connecting ${agentName} to your Telegram chat…`, diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 4d8290f..29c70e3 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -43,6 +43,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { brandBold } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -101,6 +102,9 @@ export async function runWhatsAppChannel(displayName: string): Promise { writeAssistantHasOwnNumber(); } + const role = await askOperatorRole('WhatsApp'); + setupLog.userInput('whatsapp_role', role); + const agentName = await resolveAgentName(); const platformId = `${chatPhone}@s.whatsapp.net`; @@ -115,6 +119,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { '--platform-id', platformId, '--display-name', displayName, '--agent-name', agentName, + '--role', role, ], { running: `Connecting ${agentName} to WhatsApp…`, @@ -128,6 +133,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { AGENT_NAME: agentName, PLATFORM_ID: platformId, MODE: isDedicated ? 'dedicated' : 'shared', + ROLE: role, }, }, ); diff --git a/setup/lib/role-prompt.ts b/setup/lib/role-prompt.ts new file mode 100644 index 0000000..c5ac537 --- /dev/null +++ b/setup/lib/role-prompt.ts @@ -0,0 +1,44 @@ +/** + * Shared "who's connecting this channel?" prompt used by the channel setup + * drivers before they hand off to scripts/init-first-agent.ts. + * + * Default: owner. Self-hosted NanoClaw is almost always a single-operator + * deployment, and granting the same human owner status on every channel + * they wire up matches what you'd want 99% of the time. The prompt + * surfaces admin/member for the edge cases (shared instance, collaborators + * with limited access), but hitting Enter assigns owner. + */ +import * as p from '@clack/prompts'; + +import { ensureAnswer } from './runner.js'; + +export type OperatorRole = 'owner' | 'admin' | 'member'; + +export async function askOperatorRole( + channelLabel: string, +): Promise { + const choice = ensureAnswer( + await p.select({ + message: `How should this ${channelLabel} account be registered?`, + initialValue: 'owner', + options: [ + { + value: 'owner', + label: 'Owner', + hint: 'full access — recommended for your own account', + }, + { + value: 'admin', + label: 'Admin', + hint: 'can manage the agent for this channel', + }, + { + value: 'member', + label: 'Member', + hint: 'can chat with the agent but nothing more', + }, + ], + }), + ) as OperatorRole; + return choice; +}