Merge pull request #1849 from qwibitai/refactor/pr7-retier-approvals

refactor(modules): re-tier approvals as default, extract self-mod as optional
This commit is contained in:
gavrielc
2026-04-18 19:54:49 +03:00
committed by GitHub
14 changed files with 715 additions and 484 deletions

View File

@@ -1,80 +0,0 @@
/**
* Approval routing helpers (temporary home).
*
* These functions pick an approver for a sensitive action and resolve the
* DM messaging_group they should be delivered to. They're called only from
* the approvals module.
*
* PR #5 moved the access-decision half of this file (canAccessAgentGroup +
* AccessDecision) into src/modules/permissions/. The approver-picking half
* stays here as a temporary shim — PR #7 relocates it into a new default
* approvals-primitive module alongside the approvals re-tier.
*
* Tier note: this file lives in core but imports from the permissions
* optional module. That's a deliberate temporary violation; see the module
* contract + REFACTOR_PLAN open question #3.
*/
import {
getAdminsOfAgentGroup,
getGlobalAdmins,
getOwners,
} from './modules/permissions/db/user-roles.js';
import { ensureUserDm } from './modules/permissions/user-dm.js';
import type { MessagingGroup } from './types.js';
/**
* Ordered list of user IDs eligible to approve an action for the given agent
* group. Preference: admins @ that group → global admins → owners.
*/
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;
}
function channelTypeOf(userId: string): string {
const idx = userId.indexOf(':');
return idx < 0 ? '' : userId.slice(0, idx);
}

View File

@@ -1,30 +1,29 @@
/**
* Approvals module — admin-gated self-modification and OneCLI credential flow.
* Approvals module — admin approval primitive + response plumbing.
*
* Default-tier module. Ships with main. Other modules depend on it by
* importing `requestApproval` / `registerApprovalHandler` from this module.
*
* Registers:
* - Three delivery actions the container writes via self-mod MCP tools:
* install_packages, request_rebuild, add_mcp_server.
* - A response handler that claims `pending_approvals` rows (agent-initiated
* approvals) + OneCLI credential approvals (resolved via in-memory Promise).
* - A response handler that claims pending_approvals rows and dispatches
* to whatever module registered for the row's `action` string. Also
* resolves in-memory OneCLI credential approvals.
* - An adapter-ready callback that starts the OneCLI manual-approval handler
* once the delivery adapter is set.
* - A shutdown callback that stops the OneCLI handler cleanly.
*
* Self-mod flows (install_packages, request_rebuild, add_mcp_server) moved
* out to `src/modules/self-mod/` in PR #7 — they now register delivery
* actions + approval handlers via this module's public API.
*/
import { registerDeliveryAction, onDeliveryAdapterReady } from '../../delivery.js';
import { onDeliveryAdapterReady } from '../../delivery.js';
import { registerResponseHandler, onShutdown } from '../../response-registry.js';
import { handleAddMcpServer, handleInstallPackages, handleRequestRebuild } from './request-approval.js';
import { handleApprovalsResponse } from './response-handler.js';
import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js';
registerDeliveryAction('install_packages', async (content, session) => {
await handleInstallPackages(content, session);
});
registerDeliveryAction('request_rebuild', async (content, session) => {
await handleRequestRebuild(content, session);
});
registerDeliveryAction('add_mcp_server', async (content, session) => {
await handleAddMcpServer(content, session);
});
// Public API re-exports so consumers import from the module root.
export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js';
export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } from './primitive.js';
registerResponseHandler(handleApprovalsResponse);

View File

@@ -19,7 +19,7 @@
*/
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
import { pickApprovalDelivery, pickApprover } from '../../access.js';
import { pickApprovalDelivery, pickApprover } from './primitive.js';
import { ONECLI_URL } from '../../config.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import {

View File

@@ -0,0 +1,141 @@
/**
* Tests for pickApprover + pickApprovalDelivery — the approver-selection
* half of what used to live in src/access.ts. Moved here in PR #7 alongside
* the approvals re-tier.
*/
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js';
import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from '../../channels/channel-registry.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../../db/index.js';
import { createUser } from '../permissions/db/users.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { pickApprovalDelivery, pickApprover } from './primitive.js';
function now(): string {
return new Date().toISOString();
}
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
});
afterEach(async () => {
await teardownChannelAdapters();
closeDb();
});
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,
created_at: now(),
});
}
function seedUser(id: string, kind: string): void {
createUser({ id, kind, display_name: null, created_at: now() });
}
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('pickApprovalDelivery', () => {
beforeEach(() => {
seedAgentGroup('ag-1');
});
it('returns the first reachable approver', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:111', 'telegram');
seedUser('telegram:222', 'telegram');
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');
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 () => {
seedUser('telegram:111', 'telegram');
expect(await pickApprovalDelivery(['telegram:111'], 'telegram')).toBeNull();
});
});

View File

@@ -0,0 +1,220 @@
/**
* Approvals primitive — the public API that other modules call.
*
* Two surfaces:
* - `requestApproval()` — queue an approval request, deliver the card to
* the right admin DM, record the pending_approvals row. Used by any
* module that needs admin confirmation before doing something sensitive.
* - `registerApprovalHandler(action, handler)` — called at module import
* time. When the admin approves a pending row with matching `action`,
* the response handler dispatches into the registered callback. Optional
* modules (self-mod, future module gates) register here.
*
* Approver picking lives here too — it used to sit in src/access.ts and got
* folded in with the PR #7 re-tier. The picks functions walk user_roles
* (owner, global admin, scoped admin) and resolve to a reachable DM via the
* permissions module's user-dm helper.
*
* Tier: default module. Permissions is an optional module, so importing from
* it here is technically a tier inversion — but the host bundles both with
* main, and the alternative (a third "permissions-primitive" default module
* exposing just user-roles/user-dms) is more churn than it's worth. Revisit
* if either module becomes genuinely optional (see REFACTOR_PLAN open q #3).
*/
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { createPendingApproval, getSession } from '../../db/sessions.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { wakeContainer } from '../../container-runner.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { MessagingGroup, Session } from '../../types.js';
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
import { ensureUserDm } from '../permissions/user-dm.js';
/** Two-button approval UI — the only options the primitive supports today. */
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
];
// ── Approval handler registry ──
// Modules that want to be called back when an admin approves a pending row
// register here at import time, keyed by the `action` string they used in
// their `requestApproval()` calls.
export interface ApprovalHandlerContext {
session: Session;
payload: Record<string, unknown>;
/** User ID of the admin who approved. Empty string if unknown. */
userId: string;
/** Send a system chat message to the requesting agent's session. */
notify: (text: string) => void;
}
export type ApprovalHandler = (ctx: ApprovalHandlerContext) => Promise<void>;
const approvalHandlers = new Map<string, ApprovalHandler>();
export function registerApprovalHandler(action: string, handler: ApprovalHandler): void {
if (approvalHandlers.has(action)) {
log.warn('Approval handler re-registered (overwriting)', { action });
}
approvalHandlers.set(action, handler);
}
export function getApprovalHandler(action: string): ApprovalHandler | undefined {
return approvalHandlers.get(action);
}
// ── Approver picking ──
/**
* Ordered list of user IDs eligible to approve an action for the given agent
* group. Preference: admins @ that group → global admins → owners.
*/
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: 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.
*/
export async function pickApprovalDelivery(
approvers: string[],
originChannelType: string,
): Promise<{ userId: string; messagingGroup: MessagingGroup } | null> {
if (originChannelType) {
for (const userId of approvers) {
if (channelTypeOf(userId) !== originChannelType) continue;
const mg = await ensureUserDm(userId);
if (mg) return { userId, messagingGroup: mg };
}
}
for (const userId of approvers) {
const mg = await ensureUserDm(userId);
if (mg) return { userId, messagingGroup: mg };
}
return null;
}
function channelTypeOf(userId: string): string {
const idx = userId.indexOf(':');
return idx < 0 ? '' : userId.slice(0, idx);
}
// ── Request API ──
/** Send a system chat to the agent's session. Used by callers and by the response handler. */
export function notifyAgent(session: Session, text: string): void {
writeSessionMessage(session.agent_group_id, session.id, {
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
const fresh = getSession(session.id);
if (fresh) {
wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
}
}
export interface RequestApprovalOptions {
session: Session;
agentName: string;
/** Free-form action identifier. Must match the key the consumer registered via registerApprovalHandler. */
action: string;
/** JSON-serializable opaque payload. Carried on the pending_approvals row, handed to the handler on approve. */
payload: Record<string, unknown>;
/** Card title shown to the admin. */
title: string;
/** Card body shown to the admin. */
question: string;
}
/**
* Queue an approval request. Picks an approver, delivers the card to their
* DM, and records the pending_approvals row. Fire-and-forget from the
* caller's perspective — the admin's response kicks off the registered
* approval handler for this action via the response dispatcher.
*/
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
const { session, action, payload, title, question, agentName } = opts;
const approvers = pickApprover(session.agent_group_id);
if (approvers.length === 0) {
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
return;
}
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 approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS);
createPendingApproval({
approval_id: approvalId,
session_id: session.id,
request_id: approvalId,
action,
payload: JSON.stringify(payload),
created_at: new Date().toISOString(),
title,
options_json: JSON.stringify(normalizedOptions),
});
const adapter = getDeliveryAdapter();
if (adapter) {
try {
await adapter.deliver(
target.messagingGroup.channel_type,
target.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
questionId: approvalId,
title,
question,
options: APPROVAL_OPTIONS,
}),
);
} catch (err) {
log.error('Failed to deliver approval card', { action, approvalId, err });
notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`);
return;
}
}
log.info('Approval requested', { action, approvalId, agentName, approver: target.userId });
}

View File

@@ -1,214 +0,0 @@
/**
* Delivery-action handlers for agent-initiated approval requests.
*
* Three actions the container can write into messages_out (via self-mod
* MCP tools): install_packages, request_rebuild, add_mcp_server. Each one
* delivers an approval card to an admin's DM and records a pending_approvals
* row. The admin clicks a button → handleApprovalResponse picks it up.
*
* Host-side sanitization for install_packages is defense-in-depth (the MCP
* tool validates first). Both layers matter — the DB row and eventual
* shell-exec trust it.
*/
import { pickApprovalDelivery, pickApprover } from '../../access.js';
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { createPendingApproval, getSession } from '../../db/sessions.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { wakeContainer } from '../../container-runner.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { Session } from '../../types.js';
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
];
/** Inline copy of delivery.ts's notifyAgent — sends a system chat to the agent. */
function notifyAgent(session: Session, text: string): void {
writeSessionMessage(session.agent_group_id, session.id, {
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
const fresh = getSession(session.id);
if (fresh) {
wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
}
}
/**
* 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.
*/
async function requestApproval(
session: Session,
agentName: string,
action: 'install_packages' | 'request_rebuild' | 'add_mcp_server',
payload: Record<string, unknown>,
title: string,
question: string,
): Promise<void> {
const approvers = pickApprover(session.agent_group_id);
if (approvers.length === 0) {
notifyAgent(session, `${action} failed: no owner or admin configured to approve.`);
return;
}
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 approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS);
createPendingApproval({
approval_id: approvalId,
session_id: session.id,
request_id: approvalId,
action,
payload: JSON.stringify(payload),
created_at: new Date().toISOString(),
title,
options_json: JSON.stringify(normalizedOptions),
});
const adapter = getDeliveryAdapter();
if (adapter) {
try {
await adapter.deliver(
target.messagingGroup.channel_type,
target.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
questionId: approvalId,
title,
question,
options: APPROVAL_OPTIONS,
}),
);
} catch (err) {
log.error('Failed to deliver approval card', { action, approvalId, err });
notifyAgent(session, `${action} failed: could not deliver approval request to ${target.userId}.`);
return;
}
}
log.info('Approval requested', { action, approvalId, agentName, approver: target.userId });
}
export async function handleInstallPackages(
content: Record<string, unknown>,
session: Session,
): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'install_packages failed: agent group not found.');
return;
}
const apt = (content.apt as string[]) || [];
const npm = (content.npm as string[]) || [];
const reason = (content.reason as string) || '';
const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const MAX_PACKAGES = 20;
if (apt.length + npm.length === 0) {
notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.');
return;
}
if (apt.length + npm.length > MAX_PACKAGES) {
notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
return;
}
const invalidApt = apt.find((p) => !APT_RE.test(p));
if (invalidApt) {
notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`);
log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt });
return;
}
const invalidNpm = npm.find((p) => !NPM_RE.test(p));
if (invalidNpm) {
notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`);
log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm });
return;
}
const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', ');
await requestApproval(
session,
agentGroup.name,
'install_packages',
{ apt, npm, reason },
'Install Packages Request',
`Agent "${agentGroup.name}" is attempting to install a package + rebuild container:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
);
}
export async function handleRequestRebuild(
content: Record<string, unknown>,
session: Session,
): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'request_rebuild failed: agent group not found.');
return;
}
const reason = (content.reason as string) || '';
await requestApproval(
session,
agentGroup.name,
'request_rebuild',
{ reason },
'Rebuild Request',
`Agent "${agentGroup.name}" is attempting to rebuild container.${reason ? `\nReason: ${reason}` : ''}`,
);
}
export async function handleAddMcpServer(
content: Record<string, unknown>,
session: Session,
): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'add_mcp_server failed: agent group not found.');
return;
}
const serverName = content.name as string;
const command = content.command as string;
if (!serverName || !command) {
notifyAgent(session, 'add_mcp_server failed: name and command are required.');
return;
}
await requestApproval(
session,
agentGroup.name,
'add_mcp_server',
{
name: serverName,
command,
args: (content.args as string[]) || [],
env: (content.env as Record<string, string>) || {},
},
'Add MCP Request',
`Agent "${agentGroup.name}" is attempting to add a new MCP server:\n${serverName} (${command})`,
);
}

View File

@@ -2,25 +2,24 @@
* Handle an admin's response to an approval card.
*
* Two categories of pending_approvals rows exist:
* 1. Agent-initiated actions (install_packages, request_rebuild, add_mcp_server).
* Fire-and-forget from the agent's perspective: we notify via chat on
* approve/reject, rebuild the image if applicable, then kill the container
* so the next wake picks up the new image.
* 2. OneCLI credential approvals (action = 'onecli_credential'). Resolved
* 1. Module-initiated actions — the module called `requestApproval()` with
* some free-form `action` string and registered a handler via
* `registerApprovalHandler(action, handler)`. On approve, we look up the
* handler and call it; on reject, we notify the agent and move on.
* 2. OneCLI credential approvals (`action = 'onecli_credential'`). Resolved
* via an in-memory Promise — see onecli-approvals.ts.
*
* The response handler is registered via core's `registerResponseHandler`;
* core iterates handlers and the first one to return `true` claims the response.
*/
import { updateContainerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { wakeContainer } from '../../container-runner.js';
import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
import type { ResponsePayload } from '../../response-registry.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { PendingApproval } from '../../types.js';
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
import { getApprovalHandler } from './primitive.js';
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
// OneCLI credential approvals — resolved via in-memory Promise first.
@@ -33,17 +32,17 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
if (!approval) return false;
if (approval.action === ONECLI_ACTION) {
// Row exists but the in-memory resolver is gone (timer fired or process
// Row exists but the in-memory resolver is gone (timer fired or the process
// was in a weird state). Nothing to do — just drop the row.
deletePendingApproval(payload.questionId);
return true;
}
await handleAgentApproval(approval, payload.value, payload.userId ?? '');
await handleRegisteredApproval(approval, payload.value, payload.userId ?? '');
return true;
}
async function handleAgentApproval(
async function handleRegisteredApproval(
approval: PendingApproval,
selectedOption: string,
userId: string,
@@ -78,77 +77,26 @@ async function handleAgentApproval(
return;
}
// Approved — dispatch to the module that registered for this action.
const handler = getApprovalHandler(approval.action);
if (!handler) {
log.warn('No approval handler registered — row dropped', {
approvalId: approval.approval_id,
action: approval.action,
});
notify(`Your ${approval.action} was approved, but no handler is installed to apply it.`);
deletePendingApproval(approval.approval_id);
await wakeContainer(session);
return;
}
const payload = JSON.parse(approval.payload);
if (approval.action === 'install_packages') {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notify('install_packages approved but agent group missing.');
return;
}
updateContainerConfig(agentGroup.folder, (cfg) => {
if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[]));
if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[]));
});
const pkgs = [...(payload.apt || []), ...(payload.npm || [])].join(', ');
log.info('Package install approved', { approvalId: approval.approval_id, userId });
try {
await buildAgentGroupImage(session.agent_group_id);
killContainer(session.id, 'rebuild applied');
// Schedule a follow-up prompt a few seconds after kill so the host sweep
// respawns the container on the new image and the agent verifies + reports.
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({
text: `Packages installed (${pkgs}) and container rebuilt. Verify the new packages are available (e.g. run them or check versions) and report the result to the user.`,
sender: 'system',
senderId: 'system',
}),
processAfter: new Date(Date.now() + 5000)
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, ''),
});
log.info('Container rebuild completed (bundled with install)', { approvalId: approval.approval_id });
} catch (e) {
notify(
`Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Call request_rebuild to retry.`,
);
log.error('Bundled rebuild failed after install approval', { approvalId: approval.approval_id, err: e });
}
} else if (approval.action === 'request_rebuild') {
try {
await buildAgentGroupImage(session.agent_group_id);
killContainer(session.id, 'rebuild applied');
notify('Container image rebuilt. Your container will restart with the new image on the next message.');
log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId });
} catch (e) {
notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e });
}
} else if (approval.action === 'add_mcp_server') {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notify('add_mcp_server approved but agent group missing.');
return;
}
updateContainerConfig(agentGroup.folder, (cfg) => {
cfg.mcpServers[payload.name as string] = {
command: payload.command as string,
args: (payload.args as string[]) || [],
env: (payload.env as Record<string, string>) || {},
};
});
killContainer(session.id, 'mcp server added');
notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
log.info('MCP server add approved', { approvalId: approval.approval_id, userId });
try {
await handler({ session, payload, userId, notify });
log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId });
} catch (err) {
log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err });
notify(`Your ${approval.action} was approved, but applying it failed: ${err instanceof Error ? err.message : String(err)}.`);
}
deletePendingApproval(approval.approval_id);

View File

@@ -13,9 +13,13 @@
* Registry-based modules (installed via /add-<name> skills, pulled from the
* `modules` branch): append imports below.
*/
import './interactive/index.js';
// Approvals (default tier) must load before self-mod (optional) so the
// registerApprovalHandler / requestApproval symbols are bound when self-mod
// registers its handlers at import time.
import './approvals/index.js';
import './interactive/index.js';
import './scheduling/index.js';
import './permissions/index.js';
import './agent-to-agent/index.js';
import './self-mod/index.js';

View File

@@ -1,15 +1,19 @@
/**
* Tests for the permissions module canAccessAgentGroup, role helpers, and
* ensureUserDm. Moved here from src/access.test.ts in PR #7 alongside the
* approvals re-tier that deleted src/access.ts.
*/
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { pickApprovalDelivery, pickApprover } from './access.js';
import type { ChannelAdapter, OutboundMessage } from './channels/adapter.js';
import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from './channels/channel-registry.js';
import { closeDb, createAgentGroup, createMessagingGroup, initTestDb, runMigrations } from './db/index.js';
import { canAccessAgentGroup } from './modules/permissions/access.js';
import { addMember, isMember } from './modules/permissions/db/agent-group-members.js';
import { createUser } from './modules/permissions/db/users.js';
import { grantRole, hasAnyOwner, isOwner } from './modules/permissions/db/user-roles.js';
import { getUserDm } from './modules/permissions/db/user-dms.js';
import { ensureUserDm } from './modules/permissions/user-dm.js';
import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js';
import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from '../../channels/channel-registry.js';
import { closeDb, createAgentGroup, createMessagingGroup, initTestDb, runMigrations } from '../../db/index.js';
import { canAccessAgentGroup } from './access.js';
import { addMember, isMember } from './db/agent-group-members.js';
import { createUser } from './db/users.js';
import { grantRole, hasAnyOwner, isOwner } from './db/user-roles.js';
import { getUserDm } from './db/user-dms.js';
import { ensureUserDm } from './user-dm.js';
function now(): string {
return new Date().toISOString();
@@ -25,11 +29,6 @@ afterEach(async () => {
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>,
@@ -160,33 +159,9 @@ describe('role helpers', () => {
});
});
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('adapter without openDM: falls through to using the bare handle as platform_id', async () => {
await mountMockAdapter('nodm'); // no openDM → direct-addressable fallback
await mountMockAdapter('nodm');
seedUser('nodm:123', 'nodm');
const mg = await ensureUserDm('nodm:123');
@@ -195,16 +170,11 @@ describe('ensureUserDm', () => {
expect(mg!.platform_id).toBe('123');
expect(mg!.is_group).toBe(0);
// Cache row written
const cached = getUserDm('nodm:123', 'nodm');
expect(cached?.messaging_group_id).toBe(mg!.id);
});
it('Telegram via chat-sdk-bridge: adapter.openDM returns prefixed platform_id', async () => {
// Post-fix bridge behavior: the bridged Telegram adapter exposes openDM
// that delegates to the underlying @chat-adapter/telegram adapter, whose
// channelIdFromThreadId returns "telegram:<chatId>". That's the same
// encoding onInbound stores in messaging_groups, so cache hits on repeat.
const mock = await mountMockAdapter('telegram', async (handle) => `telegram:${handle}`);
seedUser('telegram:6037840640', 'telegram');
@@ -213,7 +183,6 @@ describe('ensureUserDm', () => {
expect(mg!.platform_id).toBe('telegram:6037840640');
expect(mock.openDMCalls).toEqual(['6037840640']);
// Second call hits the user_dms cache, not openDM again.
const mg2 = await ensureUserDm('telegram:6037840640');
expect(mg2!.id).toBe(mg!.id);
expect(mock.openDMCalls).toEqual(['6037840640']);
@@ -228,10 +197,9 @@ describe('ensureUserDm', () => {
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
expect(mock.openDMCalls).toEqual(['user-1']);
});
it('returns null when the adapter is not registered', async () => {
@@ -245,7 +213,6 @@ describe('ensureUserDm', () => {
});
seedUser('slack:u1', 'slack');
expect(await ensureUserDm('slack:u1')).toBeNull();
// No cache row should be written on failure
expect(getUserDm('slack:u1', 'slack')).toBeUndefined();
});
@@ -268,45 +235,3 @@ describe('ensureUserDm', () => {
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();
});
});

View File

@@ -0,0 +1,31 @@
# Self-modification
You can install additional OS or npm packages, rebuild your container image,
or add new MCP servers — but only with admin approval.
## Tools
- `install_packages({ apt?: string[], npm?: string[], reason?: string })`
adds the listed packages to your container config and rebuilds the image
after admin approval. Package names are validated strictly (`[a-z0-9._+-]`
for apt, standard npm naming with optional scope). Max 20 packages per
request.
- `request_rebuild({ reason?: string })` — rebuilds your container image
without config changes. Useful if the image has drifted from config.
- `add_mcp_server({ name, command, args?, env? })` — adds a new MCP server
to your container config. The container restarts on next message so the
new server is available.
## Flow
You call one of these tools → the host asks an admin via DM → admin approves
or rejects. On approve, the config is applied and the container is killed;
the host respawns it on the next message. You'll get a system chat message
confirming the outcome (either "Packages installed..." or a failure reason).
On reject you'll see "Your X request was rejected by admin."
If no admin is configured or reachable, the request fails immediately with
a chat notification explaining why.

View File

@@ -0,0 +1,89 @@
/**
* Approval handlers for self-modification actions.
*
* The approvals module calls these when an admin clicks Approve on a
* pending_approvals row whose action matches. Each handler mutates the
* container config, rebuilds/kills the container as needed, and lets the
* host sweep respawn it on the new image on the next message.
*/
import { updateContainerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer } from '../../container-runner.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { ApprovalHandler } from '../approvals/index.js';
export const applyInstallPackages: ApprovalHandler = async ({ session, payload, userId, notify }) => {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notify('install_packages approved but agent group missing.');
return;
}
updateContainerConfig(agentGroup.folder, (cfg) => {
if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[]));
if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[]));
});
const pkgs = [...((payload.apt as string[] | undefined) || []), ...((payload.npm as string[] | undefined) || [])].join(', ');
log.info('Package install approved', { agentGroupId: session.agent_group_id, userId });
try {
await buildAgentGroupImage(session.agent_group_id);
killContainer(session.id, 'rebuild applied');
// Schedule a follow-up prompt a few seconds after kill so the host sweep
// respawns the container on the new image and the agent verifies + reports.
writeSessionMessage(session.agent_group_id, session.id, {
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({
text: `Packages installed (${pkgs}) and container rebuilt. Verify the new packages are available (e.g. run them or check versions) and report the result to the user.`,
sender: 'system',
senderId: 'system',
}),
processAfter: new Date(Date.now() + 5000)
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, ''),
});
log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id });
} catch (e) {
notify(
`Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Call request_rebuild to retry.`,
);
log.error('Bundled rebuild failed after install approval', { agentGroupId: session.agent_group_id, err: e });
}
};
export const applyRequestRebuild: ApprovalHandler = async ({ session, userId, notify }) => {
try {
await buildAgentGroupImage(session.agent_group_id);
killContainer(session.id, 'rebuild applied');
notify('Container image rebuilt. Your container will restart with the new image on the next message.');
log.info('Container rebuild approved and completed', { agentGroupId: session.agent_group_id, userId });
} catch (e) {
notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
log.error('Container rebuild failed', { agentGroupId: session.agent_group_id, err: e });
}
};
export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, userId, notify }) => {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notify('add_mcp_server approved but agent group missing.');
return;
}
updateContainerConfig(agentGroup.folder, (cfg) => {
cfg.mcpServers[payload.name as string] = {
command: payload.command as string,
args: (payload.args as string[]) || [],
env: (payload.env as Record<string, string>) || {},
};
});
killContainer(session.id, 'mcp server added');
notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId });
};

View File

@@ -0,0 +1,28 @@
/**
* Self-modification module — admin-approved container mutations.
*
* Optional tier. Depends on the approvals default module for the request/
* handler plumbing. On install the module registers:
* - Three delivery actions (install_packages, request_rebuild, add_mcp_server)
* that validate input and queue an approval via requestApproval().
* - Three matching approval handlers that run on approve: mutate the
* container config, rebuild the image, kill the container so the next
* wake picks up the change.
*
* Without this module: the three MCP tools in the container still write
* outbound system messages with these actions, but delivery logs
* "Unknown system action" and drops them. Admin never sees a card; nothing
* changes.
*/
import { registerDeliveryAction } from '../../delivery.js';
import { registerApprovalHandler } from '../approvals/index.js';
import { applyAddMcpServer, applyInstallPackages, applyRequestRebuild } from './apply.js';
import { handleAddMcpServer, handleInstallPackages, handleRequestRebuild } from './request.js';
registerDeliveryAction('install_packages', handleInstallPackages);
registerDeliveryAction('request_rebuild', handleRequestRebuild);
registerDeliveryAction('add_mcp_server', handleAddMcpServer);
registerApprovalHandler('install_packages', applyInstallPackages);
registerApprovalHandler('request_rebuild', applyRequestRebuild);
registerApprovalHandler('add_mcp_server', applyAddMcpServer);

View File

@@ -0,0 +1,34 @@
# Self-mod module
Optional-tier module that gives agents admin-gated self-modification:
installing OS/npm packages, rebuilding the container image, and registering
new MCP servers. All three paths go through the approvals module's request
primitive — no unapproved changes ever land.
## What this module adds
- Three delivery actions (`install_packages`, `request_rebuild`, `add_mcp_server`)
that the container's self-mod MCP tools write into outbound.db. On the host,
each handler validates input and queues an approval via
`approvals.requestApproval()`.
- Three matching approval handlers that run on approve: mutate the container
config via `updateContainerConfig`, rebuild the image via
`buildAgentGroupImage`, and kill the container so the host sweep respawns
it on the new image.
## Dependency
Self-mod depends on the approvals default module for:
- `requestApproval()` to enqueue admin confirmation cards
- `registerApprovalHandler(action, handler)` to run orchestration on approve
- `notifyAgent()` to send failure feedback back to the requesting agent
It also calls core's `buildAgentGroupImage`, `killContainer`, and
`updateContainerConfig`.
## Removing the module
Delete `src/modules/self-mod/` and its import line in `src/modules/index.ts`.
The container's self-mod MCP tools will still write outbound system messages,
but core delivery will log `"Unknown system action"` and drop them — no
admin card, no container mutation.

View File

@@ -0,0 +1,106 @@
/**
* Delivery-action handlers for agent-initiated self-modification requests.
*
* Three actions the container can write into messages_out (via the self-mod
* MCP tools): install_packages, request_rebuild, add_mcp_server. Each one
* validates input and queues an approval request. The admin's approval
* triggers the matching approval handler in ./apply.ts.
*
* Host-side sanitization for install_packages is defense-in-depth — the MCP
* tool validates first. Both layers matter: the DB row carries the payload
* verbatim through to shell exec on apply.
*/
import { getAgentGroup } from '../../db/agent-groups.js';
import { log } from '../../log.js';
import type { Session } from '../../types.js';
import { notifyAgent, requestApproval } from '../approvals/index.js';
export async function handleInstallPackages(content: Record<string, unknown>, session: Session): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'install_packages failed: agent group not found.');
return;
}
const apt = (content.apt as string[]) || [];
const npm = (content.npm as string[]) || [];
const reason = (content.reason as string) || '';
const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const MAX_PACKAGES = 20;
if (apt.length + npm.length === 0) {
notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.');
return;
}
if (apt.length + npm.length > MAX_PACKAGES) {
notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
return;
}
const invalidApt = apt.find((p) => !APT_RE.test(p));
if (invalidApt) {
notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`);
log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt });
return;
}
const invalidNpm = npm.find((p) => !NPM_RE.test(p));
if (invalidNpm) {
notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`);
log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm });
return;
}
const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', ');
await requestApproval({
session,
agentName: agentGroup.name,
action: 'install_packages',
payload: { apt, npm, reason },
title: 'Install Packages Request',
question: `Agent "${agentGroup.name}" is attempting to install a package + rebuild container:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
});
}
export async function handleRequestRebuild(content: Record<string, unknown>, session: Session): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'request_rebuild failed: agent group not found.');
return;
}
const reason = (content.reason as string) || '';
await requestApproval({
session,
agentName: agentGroup.name,
action: 'request_rebuild',
payload: { reason },
title: 'Rebuild Request',
question: `Agent "${agentGroup.name}" is attempting to rebuild container.${reason ? `\nReason: ${reason}` : ''}`,
});
}
export async function handleAddMcpServer(content: Record<string, unknown>, session: Session): Promise<void> {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'add_mcp_server failed: agent group not found.');
return;
}
const serverName = content.name as string;
const command = content.command as string;
if (!serverName || !command) {
notifyAgent(session, 'add_mcp_server failed: name and command are required.');
return;
}
await requestApproval({
session,
agentName: agentGroup.name,
action: 'add_mcp_server',
payload: {
name: serverName,
command,
args: (content.args as string[]) || [],
env: (content.env as Record<string, string>) || {},
},
title: 'Add MCP Request',
question: `Agent "${agentGroup.name}" is attempting to add a new MCP server:\n${serverName} (${command})`,
});
}