Files
nanoclaw/src/modules/approvals/response-handler.ts
T
Moshe Krupper e8148bc0a7 feat(approvals): reject-with-reason — relay an optional decline reason to the agent
Add a third "Reject with reason…" button to module approval cards. Plain
Reject stays the instant fast path; the new option holds the row
(status='awaiting_reason'), DM-prompts the approver, and captures their
next DM (≤280 chars, truncated) as a one-line reason relayed to the
requesting agent as a single combined message. A ghosted hold is
finalized as a plain reject by the host sweep after ~5 min — restart-safe
via the durable DB row.

- Generalize the router message-interceptor to a list
  (registerMessageInterceptor) so approvals can capture replies alongside
  the permissions agent-naming flow.
- Share reject finalization across the instant, captured, and swept paths
  via finalizeReject.
- Scope: all module approvals (create_agent, install_packages,
  add_mcp_server); OneCLI credential cards are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:55:01 +03:00

152 lines
5.8 KiB
TypeScript

/**
* Handle an admin's response to an approval card.
*
* Two categories of pending_approvals rows exist:
* 1. Module-initiated actions — the module called `requestApproval()` with
* some free-form `action` string and registered a handler via
* `registerApprovalHandler(action, handler)`. On approve, we look up the
* handler and call it; on plain reject we relay a decline to the agent; on
* "Reject with reason…" we hold the row and capture the admin's next DM as
* a one-line reason (see reason-capture.ts). Reject finalization is shared
* via finalizeReject.
* 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 { wakeContainer } from '../../container-runner.js';
import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
import type { ResponsePayload } from '../../response-registry.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
import type { PendingApproval } from '../../types.js';
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
import { finalizeReject } from './finalize.js';
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
import { getApprovalHandler, notifyApprovalResolved, REJECT_WITH_REASON_VALUE } from './primitive.js';
import { armReasonCapture } from './reason-capture.js';
export async function handleApprovalsResponse(payload: ResponsePayload): Promise<boolean> {
const approval = getPendingApproval(payload.questionId);
if (!approval) return false;
if (!isAuthorizedApprovalClick(approval, payload)) {
log.warn('Ignoring unauthorized approval response', {
approvalId: approval.approval_id,
action: approval.action,
userId: payload.userId,
channelType: payload.channelType,
});
return true;
}
if (approval.action === ONECLI_ACTION) {
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
return true;
}
// Row exists but the in-memory resolver is gone (timer fired or the process
// was in a weird state). Nothing to do — just drop the row.
deletePendingApproval(payload.questionId);
return true;
}
await handleRegisteredApproval(approval, payload.value, namespacedUserId(payload) ?? '');
return true;
}
async function handleRegisteredApproval(
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;
}
// "Reject with reason…" — hold the row and capture the admin's next DM
// instead of finalizing now. The agent is notified exactly once: after the
// reason arrives, or after the sweep's timeout if the admin ghosts.
if (selectedOption === REJECT_WITH_REASON_VALUE) {
await armReasonCapture(approval, session, userId);
return;
}
// Plain Reject (or any other non-approve value) — instant fast path.
if (selectedOption !== 'approve') {
await finalizeReject(approval, session, userId);
return;
}
// Approved — dispatch to the module that registered for this action.
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' }),
});
};
const handler = getApprovalHandler(approval.action);
if (!handler) {
log.warn('No approval handler registered — row dropped', {
approvalId: approval.approval_id,
action: approval.action,
});
notify(`Your ${approval.action} was approved, but no handler is installed to apply it.`);
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
await wakeContainer(session);
return;
}
const payload = JSON.parse(approval.payload);
try {
await handler({ session, payload, userId, notify });
log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId });
} catch (err) {
log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err });
notify(
`Your ${approval.action} was approved, but applying it failed: ${err instanceof Error ? err.message : String(err)}.`,
);
}
deletePendingApproval(approval.approval_id);
await notifyApprovalResolved({ approval, session, outcome: 'approve', userId });
await wakeContainer(session);
}
function namespacedUserId(payload: ResponsePayload): string | null {
if (!payload.userId) return null;
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
}
function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
const userId = namespacedUserId(payload);
if (!userId) return false;
// An approval may name a specific approver; only that exact user may resolve it.
if (approval.approver_user_id) {
return userId === approval.approver_user_id;
}
const agentGroupId =
approval.agent_group_id ?? (approval.session_id ? getSession(approval.session_id)?.agent_group_id : null);
if (!agentGroupId) {
return isOwner(userId) || isGlobalAdmin(userId);
}
return hasAdminPrivilege(userId, agentGroupId);
}