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:
53
src/modules/approvals/agent.md
Normal file
53
src/modules/approvals/agent.md
Normal 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
src/modules/approvals/index.ts
Normal file
37
src/modules/approvals/index.ts
Normal 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();
|
||||
});
|
||||
269
src/modules/approvals/onecli-approvals.ts
Normal file
269
src/modules/approvals/onecli-approvals.ts
Normal 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');
|
||||
}
|
||||
30
src/modules/approvals/project.md
Normal file
30
src/modules/approvals/project.md
Normal 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
src/modules/approvals/request-approval.ts
Normal file
214
src/modules/approvals/request-approval.ts
Normal 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
src/modules/approvals/response-handler.ts
Normal file
156
src/modules/approvals/response-handler.ts
Normal 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);
|
||||
}
|
||||
@@ -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
src/modules/interactive/agent.md
Normal file
21
src/modules/interactive/agent.md
Normal 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
src/modules/interactive/index.ts
Normal file
55
src/modules/interactive/index.ts
Normal 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);
|
||||
12
src/modules/interactive/project.md
Normal file
12
src/modules/interactive/project.md
Normal 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.
|
||||
Reference in New Issue
Block a user