refactor(modules): extract approvals + interactive as registry-based modules

Phase 2 / PR #3 of the module refactor. Moves the approval and interactive-
question flows out of core and into src/modules/, wired through the response
dispatcher and delivery action registries.

New modules:
- src/modules/interactive/ — registers a response handler that claims
  pending_questions rows, writes question_response to the session DB, wakes
  the container. createPendingQuestion call stays inline in delivery.ts
  (guarded by hasTable) per plan.
- src/modules/approvals/ — registers 3 delivery actions (install_packages,
  request_rebuild, add_mcp_server), a response handler for pending_approvals
  (including OneCLI action fall-through), an adapter-ready hook that boots
  the OneCLI manual-approval handler, and a shutdown hook that stops it.
  OneCLI implementation (src/onecli-approvals.ts) moves into the module.

Core lifecycle hooks added (narrow, not registries):
- onDeliveryAdapterReady(cb) in delivery.ts — fires when setDeliveryAdapter
  runs (or immediately if already set). Used by approvals for OneCLI boot.
- onShutdown(cb) in index.ts — fires on SIGTERM/SIGINT. Used by approvals
  for OneCLI teardown.
- getDeliveryAdapter() getter in delivery.ts — for live-flow adapter access
  in registered delivery actions.

Core shrinks: delivery.ts 911 → 665 lines, index.ts 405 → 224 lines.
dispatchResponse now logs "Unclaimed response" instead of falling through
to an inline handler — the inline fallback moved into the two modules.

Migration files renamed to the module-<name>-<short>.ts convention:
- 003-pending-approvals.ts → module-approvals-pending-approvals.ts
- 007-pending-approvals-title-options.ts → module-approvals-title-options.ts
Migration.name fields unchanged so existing DBs treat them as already-applied.

Degradation verified: emptying the modules barrel builds clean and 137/137
tests pass. Actions would log "Unknown system action"; button clicks would
log "Unclaimed response".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-18 15:16:53 +03:00
parent a612c2ca24
commit a4573395d9
15 changed files with 666 additions and 404 deletions

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.

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 '../../index.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();
});

View File

@@ -0,0 +1,269 @@
/**
* OneCLI manual-approval handler.
*
* When the OneCLI gateway intercepts a credentialed request that needs human
* approval, it holds the HTTP connection open and fires our `configureManualApproval`
* callback. We:
* 1. Deliver an ask_question card to the admin channel (same routing as
* `requestApproval()` — global admin agent group's first messaging group).
* 2. Persist a `pending_approvals` row (action='onecli_credential') so we can
* edit the card on expiry and sweep stale rows at startup.
* 3. Wait on an in-memory Promise: resolved by the admin click
* (`resolveOneCLIApproval`) or by a local expiry timer.
* 4. On expiry, edit the card to "Expired" and return 'deny' — the gateway's
* HTTP side will have already closed, but we need to release the Promise
* so the SDK callback returns cleanly.
*
* Startup sweep edits any leftover cards from a previous process to
* "Expired (host restarted)" and drops the rows.
*/
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 {
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';
export const ONECLI_ACTION = 'onecli_credential';
type Decision = 'approve' | 'deny';
const onecli = new OneCLI({ url: ONECLI_URL });
interface PendingState {
resolve: (decision: Decision) => void;
timer: NodeJS.Timeout;
}
const pending = new Map<string, PendingState>();
let handle: ManualApprovalHandle | null = null;
let adapterRef: ChannelDeliveryAdapter | null = null;
/**
* Generate a short approval id for card buttons.
*
* OneCLI's native request.id is a UUID (36 bytes). When we put it into a card
* button's action id as `ncq:<uuid>:Approve`, Chat SDK's Telegram adapter then
* serializes both `id` and `value` into the Telegram `callback_data` field,
* which has a hard 64-byte limit. UUIDs push past that limit.
*
* Instead we generate a 10-byte id (`oa-` + 8 base36 chars) for the card, and
* keep the OneCLI request.id in the persisted payload for audit. The pending
* map, DB row, and button callback all use this short id; click handling
* looks up the short id and resolves the Promise that was waiting on it.
*/
function shortApprovalId(): string {
return `oa-${Math.random().toString(36).slice(2, 10)}`;
}
/** 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;
pending.delete(approvalId);
clearTimeout(state.timer);
const decision: Decision = selectedOption === 'approve' ? 'approve' : 'deny';
updatePendingApprovalStatus(approvalId, decision === 'approve' ? 'approved' : 'rejected');
// Card is auto-edited to "✅ <option>" by chat-sdk-bridge's onAction handler,
// so we don't need to deliver an edit here.
deletePendingApproval(approvalId);
state.resolve(decision);
log.info('OneCLI approval resolved', { approvalId, decision });
return true;
}
export function startOneCLIApprovalHandler(deliveryAdapter: ChannelDeliveryAdapter): void {
if (handle) return;
adapterRef = deliveryAdapter;
// Sweep any rows left over from a previous process.
sweepStaleApprovals().catch((err) => log.error('OneCLI approval sweep failed', { err }));
handle = onecli.configureManualApproval(async (request: ApprovalRequest): Promise<Decision> => {
try {
return await handleRequest(request);
} catch (err) {
log.error('OneCLI approval handler errored', { id: request.id, err });
return 'deny';
}
});
log.info('OneCLI approval handler started');
}
export function stopOneCLIApprovalHandler(): void {
handle?.stop();
handle = null;
for (const state of pending.values()) {
clearTimeout(state.timer);
}
pending.clear();
adapterRef = null;
}
async function handleRequest(request: ApprovalRequest): Promise<Decision> {
if (!adapterRef) return 'deny';
// Originating agent group is carried on the request via OneCLI's agent
// identifier (set by container-runner.ts to agentGroup.id). Use it as
// the scope for approver selection: admin @ group → global admin → owner.
const originGroup = request.agent.externalId ? getAgentGroup(request.agent.externalId) : undefined;
const agentGroupId = originGroup?.id ?? null;
const approvers = pickApprover(agentGroupId);
if (approvers.length === 0) {
log.warn('OneCLI approval auto-denied: no eligible approver', {
id: request.id,
host: request.host,
agent: request.agent.externalId,
});
return 'deny';
}
// No origin channel preference — OneCLI requests don't carry one. First
// approver with a reachable DM wins.
const target = await pickApprovalDelivery(approvers, '');
if (!target) {
log.warn('OneCLI approval auto-denied: no DM channel for any approver', {
id: request.id,
approvers,
});
return 'deny';
}
// Use a short id for the card/button so Chat SDK's Telegram adapter can
// fit everything inside the 64-byte callback_data limit. The OneCLI
// request.id stays in the payload for audit.
const approvalId = shortApprovalId();
const question = buildQuestion(request, originGroup?.name ?? request.agent.name);
const onecliTitle = 'Credentials Request';
const onecliOptions = [
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
];
let platformMessageId: string | undefined;
try {
platformMessageId = await adapterRef.deliver(
target.messagingGroup.channel_type,
target.messagingGroup.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
questionId: approvalId,
title: onecliTitle,
question,
options: onecliOptions,
}),
);
} catch (err) {
log.error('Failed to deliver OneCLI approval card', { approvalId, oneCliRequestId: request.id, err });
return 'deny';
}
createPendingApproval({
approval_id: approvalId,
session_id: null,
request_id: request.id,
action: ONECLI_ACTION,
payload: JSON.stringify({
oneCliRequestId: request.id,
method: request.method,
host: request.host,
path: request.path,
bodyPreview: request.bodyPreview,
agent: request.agent,
approver: target.userId,
}),
created_at: new Date().toISOString(),
agent_group_id: agentGroupId,
channel_type: target.messagingGroup.channel_type,
platform_id: target.messagingGroup.platform_id,
platform_message_id: platformMessageId ?? null,
expires_at: request.expiresAt,
status: 'pending',
title: onecliTitle,
options_json: JSON.stringify(onecliOptions),
});
// Expiry timer fires just before the gateway's own TTL so our decision lands
// in time to be recorded, even though the HTTP side will already be closing.
const expiresAtMs = new Date(request.expiresAt).getTime();
const timeoutMs = Math.max(1000, expiresAtMs - Date.now() - 1000);
return new Promise<Decision>((resolve) => {
const timer = setTimeout(() => {
if (!pending.has(approvalId)) return;
pending.delete(approvalId);
expireApproval(approvalId, 'no response').catch((err) =>
log.error('Failed to mark OneCLI approval expired', { approvalId, err }),
);
resolve('deny');
}, timeoutMs);
pending.set(approvalId, { resolve, timer });
});
}
async function expireApproval(approvalId: string, reason: string): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION).filter((r) => r.approval_id === approvalId);
const row = rows[0];
if (!row) return;
updatePendingApprovalStatus(approvalId, 'expired');
await editCardExpired(row, reason);
deletePendingApproval(approvalId);
log.info('OneCLI approval expired', { approvalId, reason });
}
async function editCardExpired(row: PendingApproval, reason: string): Promise<void> {
if (!adapterRef || !row.platform_message_id || !row.channel_type || !row.platform_id) return;
try {
await adapterRef.deliver(
row.channel_type,
row.platform_id,
null,
'chat-sdk',
JSON.stringify({
operation: 'edit',
messageId: row.platform_message_id,
text: `Expired (${reason})`,
}),
);
} catch (err) {
log.warn('Failed to edit expired OneCLI approval card', { approvalId: row.approval_id, err });
}
}
async function sweepStaleApprovals(): Promise<void> {
const rows = getPendingApprovalsByAction(ONECLI_ACTION);
if (rows.length === 0) return;
log.info('Sweeping stale OneCLI approvals from previous process', { count: rows.length });
for (const row of rows) {
await editCardExpired(row, 'host restarted');
deletePendingApproval(row.approval_id);
}
}
function buildQuestion(request: ApprovalRequest, agentName: string): string {
const lines = [
'Credential access request',
`Agent: ${agentName}`,
'```',
`${request.method} ${request.host}${request.path}`,
'```',
];
if (request.bodyPreview) {
lines.push('Body:', '```', request.bodyPreview, '```');
}
return lines.join('\n');
}

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.

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})`,
);
}

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 '../../index.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);
}

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';

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.

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 '../../index.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);

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.