refactor(modules): re-tier approvals as default; extract self-mod as optional
Promotes approvals to the default tier with a public API (requestApproval + registerApprovalHandler) that other modules consume. Self-modification (install_packages / request_rebuild / add_mcp_server) moves into a new optional module that registers delivery actions + matching approval handlers via the new API. ## Approvals (default tier) - Adds `src/modules/approvals/primitive.ts` exporting `requestApproval`, `registerApprovalHandler`, `notifyAgent`. Absorbs `pickApprover` / `pickApprovalDelivery` / `channelTypeOf` from the deleted `src/access.ts`. - Rewrites `response-handler.ts` to dispatch to registered approval handlers on approve (action-keyed Map). Reject path is centralized. - Drops the three self-mod-specific delivery-action registrations from `approvals/index.ts`; they belong to self-mod now. - `onecli-approvals.ts` now imports picks from the primitive instead of `src/access.ts`. ## Self-mod (optional tier) - New `src/modules/self-mod/` with request handlers (validate input + call requestApproval) and apply handlers (orchestration on approve). - `apply.ts` owns updateContainerConfig + buildAgentGroupImage + killContainer calls. Self-mod depends on approvals (via registerApprovalHandler + requestApproval + notifyAgent) and on core (container-runner, container-config). - Registers 3 delivery actions + 3 approval handlers at import time. ## Other changes - `src/access.ts` and `src/access.test.ts` deleted. Tests split across `src/modules/approvals/picks.test.ts` (approver selection) and `src/modules/permissions/permissions.test.ts` (access + roles + DM). - `src/modules/index.ts` barrel: approvals loads before self-mod so registerApprovalHandler is bound when self-mod registers at import time. ## Validation - `pnpm run build` clean - `pnpm test` — 137 host tests pass - `bun test` in container/agent-runner — 17 tests pass - Service starts; boot log shows `OneCLI approval handler started`, `NanoClaw running`; clean SIGTERM shutdown Resolves the transitional tier violation flagged in PR #5 where core imported from the permissions optional module via `src/access.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
220
src/modules/approvals/primitive.ts
Normal file
220
src/modules/approvals/primitive.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Approvals primitive — the public API that other modules call.
|
||||
*
|
||||
* Two surfaces:
|
||||
* - `requestApproval()` — queue an approval request, deliver the card to
|
||||
* the right admin DM, record the pending_approvals row. Used by any
|
||||
* module that needs admin confirmation before doing something sensitive.
|
||||
* - `registerApprovalHandler(action, handler)` — called at module import
|
||||
* time. When the admin approves a pending row with matching `action`,
|
||||
* the response handler dispatches into the registered callback. Optional
|
||||
* modules (self-mod, future module gates) register here.
|
||||
*
|
||||
* Approver picking lives here too — it used to sit in src/access.ts and got
|
||||
* folded in with the PR #7 re-tier. The picks functions walk user_roles
|
||||
* (owner, global admin, scoped admin) and resolve to a reachable DM via the
|
||||
* permissions module's user-dm helper.
|
||||
*
|
||||
* Tier: default module. Permissions is an optional module, so importing from
|
||||
* it here is technically a tier inversion — but the host bundles both with
|
||||
* main, and the alternative (a third "permissions-primitive" default module
|
||||
* exposing just user-roles/user-dms) is more churn than it's worth. Revisit
|
||||
* if either module becomes genuinely optional (see REFACTOR_PLAN open q #3).
|
||||
*/
|
||||
import { normalizeOptions, type RawOption } from '../../channels/ask-question.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 { MessagingGroup, Session } from '../../types.js';
|
||||
import { getAdminsOfAgentGroup, getGlobalAdmins, getOwners } from '../permissions/db/user-roles.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
|
||||
/** Two-button approval UI — the only options the primitive supports today. */
|
||||
const APPROVAL_OPTIONS: RawOption[] = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
];
|
||||
|
||||
// ── Approval handler registry ──
|
||||
// Modules that want to be called back when an admin approves a pending row
|
||||
// register here at import time, keyed by the `action` string they used in
|
||||
// their `requestApproval()` calls.
|
||||
|
||||
export interface ApprovalHandlerContext {
|
||||
session: Session;
|
||||
payload: Record<string, unknown>;
|
||||
/** User ID of the admin who approved. Empty string if unknown. */
|
||||
userId: string;
|
||||
/** Send a system chat message to the requesting agent's session. */
|
||||
notify: (text: string) => void;
|
||||
}
|
||||
|
||||
export type ApprovalHandler = (ctx: ApprovalHandlerContext) => Promise<void>;
|
||||
|
||||
const approvalHandlers = new Map<string, ApprovalHandler>();
|
||||
|
||||
export function registerApprovalHandler(action: string, handler: ApprovalHandler): void {
|
||||
if (approvalHandlers.has(action)) {
|
||||
log.warn('Approval handler re-registered (overwriting)', { action });
|
||||
}
|
||||
approvalHandlers.set(action, handler);
|
||||
}
|
||||
|
||||
export function getApprovalHandler(action: string): ApprovalHandler | undefined {
|
||||
return approvalHandlers.get(action);
|
||||
}
|
||||
|
||||
// ── Approver picking ──
|
||||
|
||||
/**
|
||||
* Ordered list of user IDs eligible to approve an action for the given agent
|
||||
* group. Preference: admins @ that group → global admins → owners.
|
||||
*/
|
||||
export function pickApprover(agentGroupId: string | null): string[] {
|
||||
const approvers: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (id: string): void => {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
approvers.push(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (agentGroupId) {
|
||||
for (const r of getAdminsOfAgentGroup(agentGroupId)) add(r.user_id);
|
||||
}
|
||||
for (const r of getGlobalAdmins()) add(r.user_id);
|
||||
for (const r of getOwners()) add(r.user_id);
|
||||
|
||||
return approvers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the approver list and return the first (approverId, messagingGroup)
|
||||
* pair we can actually deliver to. Returns null if nobody is reachable.
|
||||
*
|
||||
* Tie-break: prefer approvers reachable on the same channel kind as the
|
||||
* origin; else first in list. Resolution uses ensureUserDm, which may
|
||||
* trigger a platform openDM call on cache miss.
|
||||
*/
|
||||
export async function pickApprovalDelivery(
|
||||
approvers: string[],
|
||||
originChannelType: string,
|
||||
): Promise<{ userId: string; messagingGroup: MessagingGroup } | null> {
|
||||
if (originChannelType) {
|
||||
for (const userId of approvers) {
|
||||
if (channelTypeOf(userId) !== originChannelType) continue;
|
||||
const mg = await ensureUserDm(userId);
|
||||
if (mg) return { userId, messagingGroup: mg };
|
||||
}
|
||||
}
|
||||
for (const userId of approvers) {
|
||||
const mg = await ensureUserDm(userId);
|
||||
if (mg) return { userId, messagingGroup: mg };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function channelTypeOf(userId: string): string {
|
||||
const idx = userId.indexOf(':');
|
||||
return idx < 0 ? '' : userId.slice(0, idx);
|
||||
}
|
||||
|
||||
// ── Request API ──
|
||||
|
||||
/** Send a system chat to the agent's session. Used by callers and by the response handler. */
|
||||
export 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestApprovalOptions {
|
||||
session: Session;
|
||||
agentName: string;
|
||||
/** Free-form action identifier. Must match the key the consumer registered via registerApprovalHandler. */
|
||||
action: string;
|
||||
/** JSON-serializable opaque payload. Carried on the pending_approvals row, handed to the handler on approve. */
|
||||
payload: Record<string, unknown>;
|
||||
/** Card title shown to the admin. */
|
||||
title: string;
|
||||
/** Card body shown to the admin. */
|
||||
question: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an approval request. Picks an approver, delivers the card to their
|
||||
* DM, and records the pending_approvals row. Fire-and-forget from the
|
||||
* caller's perspective — the admin's response kicks off the registered
|
||||
* approval handler for this action via the response dispatcher.
|
||||
*/
|
||||
export async function requestApproval(opts: RequestApprovalOptions): Promise<void> {
|
||||
const { session, action, payload, title, question, agentName } = opts;
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user