mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
e8148bc0a7
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>
152 lines
5.8 KiB
TypeScript
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);
|
|
}
|