feat(setup): operator role prompt per channel, owner by default
Previously init-first-agent auto-granted global owner to the first
user wired through it and left every subsequent user as nothing — no
role, no membership. That worked for the bootstrap path but broke the
second channel's welcome DM: the access gate saw no role + no
membership and dropped the message with accessReason='not_member'.
Make the role explicit:
- scripts/init-first-agent.ts accepts --role owner|admin|member
(default: owner). Role drives the grant:
owner -> global owner (agent_group_id=null)
admin -> admin scoped to this agent group
member -> no role row, just membership
Idempotent via getUserRoles pre-check — safe on re-runs. addMember
runs unconditionally (INSERT OR IGNORE) so the access gate has a
row even for users who'd otherwise pass via role alone.
- setup/lib/role-prompt.ts — shared askOperatorRole(channel) prompt
with owner as the default pick. Self-host single-operator is the
dominant case, so the user's fingers default to Enter.
- Telegram / Discord / WhatsApp drivers all call askOperatorRole
before resolving the agent name and pass --role <choice> through.
Captured in progression log via setupLog.userInput('<channel>_role').
Summary output drops the fragile "promoted on first owner" hint in
favor of a dedicated role: line ("owner (global)" / "admin (scoped to
<ag-id>)" / "member") so re-runs make the current grant legible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,8 @@
|
|||||||
* --platform-id discord:@me:1491573333382523708 \
|
* --platform-id discord:@me:1491573333382523708 \
|
||||||
* --display-name "Gavriel" \
|
* --display-name "Gavriel" \
|
||||||
* [--agent-name "Andy"] \
|
* [--agent-name "Andy"] \
|
||||||
* [--welcome "System instruction: ..."]
|
* [--welcome "System instruction: ..."] \
|
||||||
|
* [--role owner|admin|member] # default: owner
|
||||||
*
|
*
|
||||||
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
||||||
* is typically the same as the handle in --user-id, with the channel prefix.
|
* 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 { runMigrations } from '../src/db/migrations/index.js';
|
||||||
import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.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 { 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 { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||||
import { initGroupFilesystem } from '../src/group-init.js';
|
import { initGroupFilesystem } from '../src/group-init.js';
|
||||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||||
|
|
||||||
|
type Role = 'owner' | 'admin' | 'member';
|
||||||
|
|
||||||
interface Args {
|
interface Args {
|
||||||
channel: string;
|
channel: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -56,11 +59,14 @@ interface Args {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
welcome: string;
|
welcome: string;
|
||||||
|
role: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_WELCOME =
|
const DEFAULT_WELCOME =
|
||||||
'System instruction: run /welcome to introduce yourself to the user on this new channel.';
|
'System instruction: run /welcome to introduce yourself to the user on this new channel.';
|
||||||
|
|
||||||
|
const DEFAULT_ROLE: Role = 'owner';
|
||||||
|
|
||||||
function parseArgs(argv: string[]): Args {
|
function parseArgs(argv: string[]): Args {
|
||||||
const out: Partial<Args> = {};
|
const out: Partial<Args> = {};
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
@@ -91,6 +97,18 @@ function parseArgs(argv: string[]): Args {
|
|||||||
out.welcome = val;
|
out.welcome = val;
|
||||||
i++;
|
i++;
|
||||||
break;
|
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!,
|
displayName: out.displayName!,
|
||||||
agentName: out.agentName?.trim() || out.displayName!,
|
agentName: out.agentName?.trim() || out.displayName!,
|
||||||
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
|
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
|
||||||
|
role: out.role ?? DEFAULT_ROLE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,17 +192,8 @@ async function main(): Promise<void> {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
let promotedToOwner = false;
|
// Owner grant is deferred until after the agent group is resolved, since
|
||||||
if (!hasAnyOwner()) {
|
// an admin grant is scoped to that group. See step 2b.
|
||||||
grantRole({
|
|
||||||
user_id: userId,
|
|
||||||
role: 'owner',
|
|
||||||
agent_group_id: null,
|
|
||||||
granted_by: null,
|
|
||||||
granted_at: now,
|
|
||||||
});
|
|
||||||
promotedToOwner = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Agent group + filesystem.
|
// 2. Agent group + filesystem.
|
||||||
const folder = `dm-with-${normalizeName(args.displayName)}`;
|
const folder = `dm-with-${normalizeName(args.displayName)}`;
|
||||||
@@ -209,12 +219,46 @@ async function main(): Promise<void> {
|
|||||||
'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.',
|
'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
|
// 2b. Assign the user a role for this agent group. The caller picks via
|
||||||
// assigned to the first user (above); subsequent DMs need explicit
|
// --role; the channel drivers default to 'owner' for the self-host case.
|
||||||
// membership or the strict unknown_sender_policy on the DM messaging
|
// - owner: global owner (agent_group_id=null). Cross-channel access.
|
||||||
// group will drop every message with accessReason='not_member'. addMember
|
// - admin: scoped admin for this agent group only.
|
||||||
// is INSERT OR IGNORE — idempotent when the global owner already has
|
// - member: no role grant, just the membership row below.
|
||||||
// access by virtue of their role.
|
// 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({
|
addMember({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
agent_group_id: ag.id,
|
agent_group_id: ag.id,
|
||||||
@@ -254,9 +298,17 @@ async function main(): Promise<void> {
|
|||||||
sender: args.displayName,
|
sender: args.displayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const roleLabel =
|
||||||
|
args.role === 'owner'
|
||||||
|
? 'owner (global)'
|
||||||
|
: args.role === 'admin'
|
||||||
|
? `admin (scoped to ${ag.id})`
|
||||||
|
: 'member';
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Init complete.');
|
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(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
|
||||||
console.log(` channel: ${args.channel} ${dmMg.platform_id}`);
|
console.log(` channel: ${args.channel} ${dmMg.platform_id}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
@@ -88,6 +89,9 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
|||||||
const dmChannelId = await openDmChannel(token, ownerUserId);
|
const dmChannelId = await openDmChannel(token, ownerUserId);
|
||||||
const platformId = `discord:@me:${dmChannelId}`;
|
const platformId = `discord:@me:${dmChannelId}`;
|
||||||
|
|
||||||
|
const role = await askOperatorRole('Discord');
|
||||||
|
setupLog.userInput('discord_role', role);
|
||||||
|
|
||||||
const agentName = await resolveAgentName();
|
const agentName = await resolveAgentName();
|
||||||
|
|
||||||
const init = await runQuietChild(
|
const init = await runQuietChild(
|
||||||
@@ -100,6 +104,7 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
|||||||
'--platform-id', platformId,
|
'--platform-id', platformId,
|
||||||
'--display-name', displayName,
|
'--display-name', displayName,
|
||||||
'--agent-name', agentName,
|
'--agent-name', agentName,
|
||||||
|
'--role', role,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
running: `Connecting ${agentName} to your Discord DMs…`,
|
running: `Connecting ${agentName} to your Discord DMs…`,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import {
|
import {
|
||||||
type Block,
|
type Block,
|
||||||
type StepResult,
|
type StepResult,
|
||||||
@@ -96,6 +97,9 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const role = await askOperatorRole('Telegram');
|
||||||
|
setupLog.userInput('telegram_role', role);
|
||||||
|
|
||||||
const agentName = await resolveAgentName();
|
const agentName = await resolveAgentName();
|
||||||
|
|
||||||
const init = await runQuietChild(
|
const init = await runQuietChild(
|
||||||
@@ -108,6 +112,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
'--platform-id', platformId,
|
'--platform-id', platformId,
|
||||||
'--display-name', displayName,
|
'--display-name', displayName,
|
||||||
'--agent-name', agentName,
|
'--agent-name', agentName,
|
||||||
|
'--role', role,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
running: `Connecting ${agentName} to your Telegram chat…`,
|
running: `Connecting ${agentName} to your Telegram chat…`,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
spawnStep,
|
spawnStep,
|
||||||
writeStepEntry,
|
writeStepEntry,
|
||||||
} from '../lib/runner.js';
|
} from '../lib/runner.js';
|
||||||
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { brandBold } from '../lib/theme.js';
|
import { brandBold } from '../lib/theme.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
@@ -101,6 +102,9 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
|||||||
writeAssistantHasOwnNumber();
|
writeAssistantHasOwnNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const role = await askOperatorRole('WhatsApp');
|
||||||
|
setupLog.userInput('whatsapp_role', role);
|
||||||
|
|
||||||
const agentName = await resolveAgentName();
|
const agentName = await resolveAgentName();
|
||||||
|
|
||||||
const platformId = `${chatPhone}@s.whatsapp.net`;
|
const platformId = `${chatPhone}@s.whatsapp.net`;
|
||||||
@@ -115,6 +119,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
|||||||
'--platform-id', platformId,
|
'--platform-id', platformId,
|
||||||
'--display-name', displayName,
|
'--display-name', displayName,
|
||||||
'--agent-name', agentName,
|
'--agent-name', agentName,
|
||||||
|
'--role', role,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
running: `Connecting ${agentName} to WhatsApp…`,
|
running: `Connecting ${agentName} to WhatsApp…`,
|
||||||
@@ -128,6 +133,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
|||||||
AGENT_NAME: agentName,
|
AGENT_NAME: agentName,
|
||||||
PLATFORM_ID: platformId,
|
PLATFORM_ID: platformId,
|
||||||
MODE: isDedicated ? 'dedicated' : 'shared',
|
MODE: isDedicated ? 'dedicated' : 'shared',
|
||||||
|
ROLE: role,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
44
setup/lib/role-prompt.ts
Normal file
44
setup/lib/role-prompt.ts
Normal file
@@ -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<OperatorRole> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user