Files
nanoclaw/src/modules/interactive/index.ts
T
Moshe Krupper 4ef7d7367c refactor(guard): consults carry the defined action value — delete the registry walk
The guard's weak point was the wiring: catalog entries registered by
side-effect import, consult sites naming them by string, a map miss
resolving to ALLOW, and a boot walk that could only see 2 of the 4
registries (response handlers and interceptors erased their specs into
closures). Dropping one unreferenced import silently disarmed
channel-registration click-auth.

Make the broken wiring unconstructible instead of detected:

- defineGuardedAction returns a branded GuardedAction value; guard(action,
  input) takes the value, not a name. A dropped module-edge import or a
  typo'd action is now a compile error; there is no lookup and no
  fail-open branch. A forged value (outside TS) is denied at runtime.
- Every registry (delivery actions, response handlers, interceptors)
  requires a guard spec or an explicit unguarded(<reason>) declaration at
  the registration site — unguarded-by-omission is not representable, and
  the justification travels with the registration (grep "unguarded(" for
  the complete inventory). CLI commands derive their guard inside
  register(), so a command cannot exist without one.
- guardConformanceViolations() and EXEMPT_DELIVERY_ACTIONS are gone. The
  boot check shrinks to the one cross-registry invariant the compiler
  can't see: every holding action has a registered approve continuation
  (grantContinuationGaps), same fail-closed refuse-to-start posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:39:52 +03:00

64 lines
2.4 KiB
TypeScript

/**
* 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 { unguarded } from '../../guard/index.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.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,
unguarded('not privileged — relays an in-chat answer back into the asking session; only the question row is touched'),
);