@vellumai/assistant 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/send-endpoint-busy.test.ts +284 -0
  11. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  12. package/src/__tests__/subagent-manager-notify.test.ts +3 -3
  13. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  14. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  15. package/src/calls/call-domain.ts +21 -21
  16. package/src/calls/call-state.ts +12 -12
  17. package/src/calls/guardian-dispatch.ts +43 -3
  18. package/src/calls/relay-server.ts +34 -39
  19. package/src/calls/twilio-routes.ts +3 -3
  20. package/src/calls/voice-session-bridge.ts +244 -0
  21. package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
  22. package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
  23. package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/notifications-schema.ts +15 -0
  26. package/src/config/schema.ts +13 -0
  27. package/src/config/types.ts +1 -0
  28. package/src/daemon/daemon-control.ts +13 -12
  29. package/src/daemon/handlers/subagents.ts +10 -3
  30. package/src/daemon/ipc-contract/notifications.ts +9 -0
  31. package/src/daemon/ipc-contract-inventory.json +2 -0
  32. package/src/daemon/ipc-contract.ts +4 -1
  33. package/src/daemon/lifecycle.ts +100 -1
  34. package/src/daemon/server.ts +8 -0
  35. package/src/daemon/session-agent-loop.ts +4 -0
  36. package/src/daemon/session-process.ts +51 -0
  37. package/src/daemon/session-runtime-assembly.ts +32 -0
  38. package/src/daemon/session.ts +5 -0
  39. package/src/memory/db-init.ts +80 -0
  40. package/src/memory/guardian-action-store.ts +2 -2
  41. package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
  42. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  43. package/src/memory/migrations/index.ts +1 -0
  44. package/src/memory/migrations/registry.ts +5 -0
  45. package/src/memory/schema-migration.ts +1 -0
  46. package/src/memory/schema.ts +59 -1
  47. package/src/notifications/README.md +134 -0
  48. package/src/notifications/adapters/macos.ts +55 -0
  49. package/src/notifications/adapters/telegram.ts +65 -0
  50. package/src/notifications/broadcaster.ts +175 -0
  51. package/src/notifications/copy-composer.ts +118 -0
  52. package/src/notifications/decision-engine.ts +391 -0
  53. package/src/notifications/decisions-store.ts +158 -0
  54. package/src/notifications/deliveries-store.ts +130 -0
  55. package/src/notifications/destination-resolver.ts +54 -0
  56. package/src/notifications/deterministic-checks.ts +187 -0
  57. package/src/notifications/emit-signal.ts +191 -0
  58. package/src/notifications/events-store.ts +145 -0
  59. package/src/notifications/preference-extractor.ts +223 -0
  60. package/src/notifications/preference-summary.ts +110 -0
  61. package/src/notifications/preferences-store.ts +142 -0
  62. package/src/notifications/runtime-dispatch.ts +100 -0
  63. package/src/notifications/signal.ts +24 -0
  64. package/src/notifications/types.ts +75 -0
  65. package/src/runtime/http-server.ts +15 -0
  66. package/src/runtime/http-types.ts +22 -0
  67. package/src/runtime/pending-interactions.ts +73 -0
  68. package/src/runtime/routes/approval-routes.ts +179 -0
  69. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  70. package/src/runtime/routes/conversation-routes.ts +107 -1
  71. package/src/runtime/routes/run-routes.ts +1 -1
  72. package/src/runtime/run-orchestrator.ts +157 -2
  73. package/src/subagent/manager.ts +6 -6
  74. package/src/tools/browser/browser-manager.ts +1 -1
  75. package/src/tools/subagent/message.ts +9 -2
  76. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -0,0 +1,142 @@
1
+ /**
2
+ * CRUD operations for notification preferences.
3
+ *
4
+ * Each row stores a natural-language notification preference expressed by
5
+ * the user (e.g. "Use Telegram for urgent alerts"), along with structured
6
+ * conditions for when the preference applies and a priority for conflict
7
+ * resolution.
8
+ */
9
+
10
+ import { and, desc, eq } from 'drizzle-orm';
11
+ import { v4 as uuid } from 'uuid';
12
+ import { getDb } from '../memory/db.js';
13
+ import { notificationPreferences } from '../memory/schema.js';
14
+
15
+ // ── Row type ────────────────────────────────────────────────────────────
16
+
17
+ export interface NotificationPreferenceRow {
18
+ id: string;
19
+ assistantId: string;
20
+ preferenceText: string;
21
+ appliesWhenJson: string; // serialised JSON
22
+ priority: number;
23
+ createdAt: number;
24
+ updatedAt: number;
25
+ }
26
+
27
+ function rowToPreference(row: typeof notificationPreferences.$inferSelect): NotificationPreferenceRow {
28
+ return {
29
+ id: row.id,
30
+ assistantId: row.assistantId,
31
+ preferenceText: row.preferenceText,
32
+ appliesWhenJson: row.appliesWhenJson,
33
+ priority: row.priority,
34
+ createdAt: row.createdAt,
35
+ updatedAt: row.updatedAt,
36
+ };
37
+ }
38
+
39
+ // ── Structured conditions type ──────────────────────────────────────────
40
+
41
+ export interface AppliesWhenConditions {
42
+ timeRange?: { after?: string; before?: string }; // e.g. "22:00", "06:00"
43
+ channels?: string[]; // e.g. ["telegram", "macos"]
44
+ urgencyLevels?: string[]; // e.g. ["high", "critical"]
45
+ contexts?: string[]; // e.g. ["work_calls", "meetings"]
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ // ── Create ──────────────────────────────────────────────────────────────
50
+
51
+ export interface CreatePreferenceParams {
52
+ assistantId: string;
53
+ preferenceText: string;
54
+ appliesWhen?: AppliesWhenConditions;
55
+ priority?: number;
56
+ }
57
+
58
+ export function createPreference(params: CreatePreferenceParams): NotificationPreferenceRow {
59
+ const db = getDb();
60
+ const now = Date.now();
61
+
62
+ const row = {
63
+ id: uuid(),
64
+ assistantId: params.assistantId,
65
+ preferenceText: params.preferenceText,
66
+ appliesWhenJson: JSON.stringify(params.appliesWhen ?? {}),
67
+ priority: params.priority ?? 0,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ };
71
+
72
+ db.insert(notificationPreferences).values(row).run();
73
+
74
+ return row;
75
+ }
76
+
77
+ // ── List ────────────────────────────────────────────────────────────────
78
+
79
+ export function listPreferences(assistantId: string): NotificationPreferenceRow[] {
80
+ const db = getDb();
81
+
82
+ const rows = db
83
+ .select()
84
+ .from(notificationPreferences)
85
+ .where(eq(notificationPreferences.assistantId, assistantId))
86
+ .orderBy(desc(notificationPreferences.priority))
87
+ .all();
88
+
89
+ return rows.map(rowToPreference);
90
+ }
91
+
92
+ // ── Update ──────────────────────────────────────────────────────────────
93
+
94
+ export interface UpdatePreferenceParams {
95
+ preferenceText?: string;
96
+ appliesWhen?: AppliesWhenConditions;
97
+ priority?: number;
98
+ }
99
+
100
+ export function updatePreference(id: string, params: UpdatePreferenceParams): boolean {
101
+ const db = getDb();
102
+ const now = Date.now();
103
+
104
+ const updates: Record<string, unknown> = { updatedAt: now };
105
+ if (params.preferenceText !== undefined) updates.preferenceText = params.preferenceText;
106
+ if (params.appliesWhen !== undefined) updates.appliesWhenJson = JSON.stringify(params.appliesWhen);
107
+ if (params.priority !== undefined) updates.priority = params.priority;
108
+
109
+ const result = db
110
+ .update(notificationPreferences)
111
+ .set(updates)
112
+ .where(eq(notificationPreferences.id, id))
113
+ .run() as unknown as { changes?: number };
114
+
115
+ return (result.changes ?? 0) > 0;
116
+ }
117
+
118
+ // ── Delete ──────────────────────────────────────────────────────────────
119
+
120
+ export function deletePreference(id: string): boolean {
121
+ const db = getDb();
122
+
123
+ const result = db
124
+ .delete(notificationPreferences)
125
+ .where(eq(notificationPreferences.id, id))
126
+ .run() as unknown as { changes?: number };
127
+
128
+ return (result.changes ?? 0) > 0;
129
+ }
130
+
131
+ // ── Get by ID ───────────────────────────────────────────────────────────
132
+
133
+ export function getPreferenceById(id: string): NotificationPreferenceRow | null {
134
+ const db = getDb();
135
+ const row = db
136
+ .select()
137
+ .from(notificationPreferences)
138
+ .where(eq(notificationPreferences.id, id))
139
+ .get();
140
+ if (!row) return null;
141
+ return rowToPreference(row);
142
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * In-loop dispatch helper that wires the decision engine output to the
3
+ * broadcaster/adapters for end-to-end signal → decision → dispatch → delivery.
4
+ *
5
+ * Not a standalone service — called inline from the notification processing
6
+ * loop after the decision engine and deterministic checks have run.
7
+ */
8
+
9
+ import { getConfig } from '../config/loader.js';
10
+ import { getLogger } from '../util/logger.js';
11
+ import type { NotificationBroadcaster } from './broadcaster.js';
12
+ import type { NotificationSignal } from './signal.js';
13
+ import type { NotificationDecision, NotificationDeliveryResult } from './types.js';
14
+
15
+ const log = getLogger('notification-dispatch');
16
+
17
+ export interface DispatchResult {
18
+ dispatched: boolean;
19
+ reason: string;
20
+ deliveryResults: NotificationDeliveryResult[];
21
+ }
22
+
23
+ /**
24
+ * Dispatch a notification decision through the broadcaster.
25
+ *
26
+ * Handles three early-exit cases before delegating to the broadcaster:
27
+ * 1. shouldNotify === false — the decision says not to notify
28
+ * 2. Shadow mode — decisions are logged but not actually dispatched
29
+ * 3. No selected channels — nothing to dispatch
30
+ */
31
+ export async function dispatchDecision(
32
+ signal: NotificationSignal,
33
+ decision: NotificationDecision,
34
+ broadcaster: NotificationBroadcaster,
35
+ ): Promise<DispatchResult> {
36
+ // No-op when the decision engine says not to notify
37
+ if (!decision.shouldNotify) {
38
+ log.info(
39
+ { signalId: signal.signalId, reason: decision.reasoningSummary },
40
+ 'Decision: do not notify',
41
+ );
42
+ return {
43
+ dispatched: false,
44
+ reason: 'Decision: shouldNotify=false',
45
+ deliveryResults: [],
46
+ };
47
+ }
48
+
49
+ // Shadow mode: log the decision but skip actual delivery
50
+ const config = getConfig();
51
+ if (config.notifications.shadowMode) {
52
+ log.info(
53
+ {
54
+ signalId: signal.signalId,
55
+ channels: decision.selectedChannels,
56
+ confidence: decision.confidence,
57
+ fallbackUsed: decision.fallbackUsed,
58
+ },
59
+ 'Shadow mode: skipping dispatch (decision logged only)',
60
+ );
61
+ return {
62
+ dispatched: false,
63
+ reason: 'Shadow mode enabled — dispatch skipped',
64
+ deliveryResults: [],
65
+ };
66
+ }
67
+
68
+ // Guard against empty channel list
69
+ if (decision.selectedChannels.length === 0) {
70
+ log.info(
71
+ { signalId: signal.signalId },
72
+ 'No channels selected in decision — nothing to dispatch',
73
+ );
74
+ return {
75
+ dispatched: false,
76
+ reason: 'No channels selected',
77
+ deliveryResults: [],
78
+ };
79
+ }
80
+
81
+ // Dispatch through the broadcaster
82
+ const deliveryResults = await broadcaster.broadcastDecision(signal, decision);
83
+
84
+ const sentCount = deliveryResults.filter((r) => r.status === 'sent').length;
85
+ log.info(
86
+ {
87
+ signalId: signal.signalId,
88
+ channels: decision.selectedChannels,
89
+ sentCount,
90
+ totalAttempted: deliveryResults.length,
91
+ },
92
+ 'Dispatch complete',
93
+ );
94
+
95
+ return {
96
+ dispatched: true,
97
+ reason: `Dispatched to ${sentCount}/${deliveryResults.length} channels`,
98
+ deliveryResults,
99
+ };
100
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * NotificationSignal -- the flexible input from producers.
3
+ * Uses free-form event names and structured attention hints that let the
4
+ * decision engine route contextually.
5
+ */
6
+
7
+ export interface AttentionHints {
8
+ requiresAction: boolean;
9
+ urgency: 'low' | 'medium' | 'high';
10
+ deadlineAt?: number; // epoch ms
11
+ isAsyncBackground: boolean;
12
+ visibleInSourceNow: boolean;
13
+ }
14
+
15
+ export interface NotificationSignal {
16
+ signalId: string;
17
+ assistantId: string;
18
+ createdAt: number; // epoch ms
19
+ sourceChannel: string; // free-form: 'macos', 'telegram', 'voice', 'scheduler', etc.
20
+ sourceSessionId: string;
21
+ sourceEventName: string; // free-form: 'reminder_fired', 'schedule_complete', 'guardian_question', etc.
22
+ contextPayload: Record<string, unknown>;
23
+ attentionHints: AttentionHints;
24
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Core domain types for the unified notification system.
3
+ *
4
+ * Defines the channel-adapter interfaces that the broadcaster and adapters
5
+ * depend on, plus the decision engine output contract.
6
+ */
7
+
8
+ export type NotificationChannel = 'macos' | 'telegram';
9
+
10
+ export type NotificationDeliveryStatus = 'pending' | 'sent' | 'failed' | 'skipped';
11
+
12
+ /** Result of attempting to deliver a notification to a single channel. */
13
+ export interface NotificationDeliveryResult {
14
+ channel: NotificationChannel;
15
+ destination: string;
16
+ status: NotificationDeliveryStatus;
17
+ errorCode?: string;
18
+ errorMessage?: string;
19
+ sentAt?: number;
20
+ }
21
+
22
+ // -- Channel adapter interfaces -----------------------------------------------
23
+
24
+ /** Result returned by a channel adapter after attempting to send. */
25
+ export interface DeliveryResult {
26
+ success: boolean;
27
+ error?: string;
28
+ }
29
+
30
+ /** Resolved destination for a specific channel. */
31
+ export interface ChannelDestination {
32
+ channel: NotificationChannel;
33
+ endpoint?: string;
34
+ metadata?: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * Delivery payload assembled from the decision engine's rendered copy
39
+ * plus contextual fields the adapters need for formatting and routing.
40
+ */
41
+ export interface ChannelDeliveryPayload {
42
+ sourceEventName: string;
43
+ copy: RenderedChannelCopy;
44
+ deepLinkTarget?: Record<string, unknown>;
45
+ }
46
+
47
+ /** Interface that each channel adapter must implement. */
48
+ export interface ChannelAdapter {
49
+ channel: NotificationChannel;
50
+ send(payload: ChannelDeliveryPayload, destination: ChannelDestination): Promise<DeliveryResult>;
51
+ }
52
+
53
+ // -- Decision engine output ---------------------------------------------------
54
+
55
+ /** Rendered notification copy for a single channel. */
56
+ export interface RenderedChannelCopy {
57
+ title: string;
58
+ body: string;
59
+ threadTitle?: string;
60
+ threadSeedMessage?: string;
61
+ }
62
+
63
+ /** Output produced by the notification decision engine for a given signal. */
64
+ export interface NotificationDecision {
65
+ shouldNotify: boolean;
66
+ selectedChannels: NotificationChannel[];
67
+ reasoningSummary: string;
68
+ renderedCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>>;
69
+ deepLinkTarget?: Record<string, unknown>;
70
+ dedupeKey: string;
71
+ confidence: number;
72
+ fallbackUsed: boolean;
73
+ /** UUID of the persisted decision row (set after persistence in the decision engine). */
74
+ persistedDecisionId?: string;
75
+ }
@@ -35,6 +35,11 @@ import {
35
35
  handleRunSecret,
36
36
  handleAddTrustRule,
37
37
  } from './routes/run-routes.js';
38
+ import {
39
+ handleConfirm,
40
+ handleSecret,
41
+ handleTrustRule,
42
+ } from './routes/approval-routes.js';
38
43
  import {
39
44
  handleDeleteConversation,
40
45
  handleChannelInbound,
@@ -121,6 +126,7 @@ export type {
121
126
  RuntimeAttachmentMetadata,
122
127
  ApprovalCopyGenerator,
123
128
  ApprovalConversationGenerator,
129
+ SendMessageDeps,
124
130
  } from './http-types.js';
125
131
 
126
132
  import type {
@@ -129,6 +135,7 @@ import type {
129
135
  RuntimeHttpServerOptions,
130
136
  ApprovalCopyGenerator,
131
137
  ApprovalConversationGenerator,
138
+ SendMessageDeps,
132
139
  } from './http-types.js';
133
140
 
134
141
  const log = getLogger('runtime-http');
@@ -156,6 +163,7 @@ export class RuntimeHttpServer {
156
163
  private sweepInProgress = false;
157
164
  private pairingStore = new PairingStore();
158
165
  private pairingBroadcast?: (msg: ServerMessage) => void;
166
+ private sendMessageDeps?: SendMessageDeps;
159
167
 
160
168
  constructor(options: RuntimeHttpServerOptions = {}) {
161
169
  this.port = options.port ?? DEFAULT_PORT;
@@ -167,6 +175,7 @@ export class RuntimeHttpServer {
167
175
  this.approvalCopyGenerator = options.approvalCopyGenerator;
168
176
  this.approvalConversationGenerator = options.approvalConversationGenerator;
169
177
  this.interfacesDir = options.interfacesDir ?? null;
178
+ this.sendMessageDeps = options.sendMessageDeps;
170
179
  }
171
180
 
172
181
  /** The port the server is actually listening on (resolved after start). */
@@ -558,9 +567,15 @@ export class RuntimeHttpServer {
558
567
  return await handleSendMessage(req, {
559
568
  processMessage: this.processMessage,
560
569
  persistAndProcessMessage: this.persistAndProcessMessage,
570
+ sendMessageDeps: this.sendMessageDeps,
561
571
  });
562
572
  }
563
573
 
574
+ // Standalone approval endpoints — keyed by requestId, orthogonal to message sending
575
+ if (endpoint === 'confirm' && req.method === 'POST') return await handleConfirm(req);
576
+ if (endpoint === 'secret' && req.method === 'POST') return await handleSecret(req);
577
+ if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req);
578
+
564
579
  if (endpoint === 'attachments' && req.method === 'POST') return await handleUploadAttachment(req);
565
580
  if (endpoint === 'attachments' && req.method === 'DELETE') return await handleDeleteAttachment(req);
566
581
 
@@ -5,6 +5,8 @@ import type { ChannelId } from '../channels/types.js';
5
5
  import type { RunOrchestrator } from './run-orchestrator.js';
6
6
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
7
7
  import type { ApprovalMessageContext, ComposeApprovalMessageGenerativeOptions } from './approval-message-composer.js';
8
+ import type { Session } from '../daemon/session.js';
9
+ import type { AssistantEventHub } from './assistant-event-hub.js';
8
10
 
9
11
  /**
10
12
  * Daemon-injected function that generates approval copy using a provider.
@@ -84,6 +86,24 @@ export type NonBlockingMessageProcessor = (
84
86
  sourceChannel?: ChannelId,
85
87
  ) => Promise<{ messageId: string }>;
86
88
 
89
+ /**
90
+ * Dependencies for the POST /v1/messages handler.
91
+ *
92
+ * The handler needs direct access to the session so it can check busy state,
93
+ * persist user messages, fire the agent loop, or queue messages when busy.
94
+ * Hub publishing wires outbound events to the SSE stream.
95
+ */
96
+ export interface SendMessageDeps {
97
+ getOrCreateSession: (conversationId: string) => Promise<Session>;
98
+ assistantEventHub: AssistantEventHub;
99
+ resolveAttachments: (attachmentIds: string[]) => Array<{
100
+ id: string;
101
+ filename: string;
102
+ mimeType: string;
103
+ data: string;
104
+ }>;
105
+ }
106
+
87
107
  export interface RuntimeHttpServerOptions {
88
108
  port?: number;
89
109
  /** Hostname / IP to bind to. Defaults to '127.0.0.1' (loopback-only). */
@@ -101,6 +121,8 @@ export interface RuntimeHttpServerOptions {
101
121
  approvalCopyGenerator?: ApprovalCopyGenerator;
102
122
  /** Daemon-injected generator for conversational approval flow (provider-backed). */
103
123
  approvalConversationGenerator?: ApprovalConversationGenerator;
124
+ /** Dependencies for the POST /v1/messages queue-if-busy handler. */
125
+ sendMessageDeps?: SendMessageDeps;
104
126
  }
105
127
 
106
128
  export interface RuntimeAttachmentMetadata {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * In-memory tracker that maps requestId to session info for pending
3
+ * confirmation and secret interactions.
4
+ *
5
+ * When the agent loop emits a confirmation_request or secret_request,
6
+ * the onEvent callback registers the interaction here. Standalone HTTP
7
+ * endpoints (/v1/confirm, /v1/secret, /v1/trust-rules) look up the
8
+ * session from this tracker to resolve the interaction.
9
+ */
10
+
11
+ import type { Session } from '../daemon/session.js';
12
+
13
+ export interface ConfirmationDetails {
14
+ toolName: string;
15
+ input: Record<string, unknown>;
16
+ riskLevel: string;
17
+ executionTarget?: 'sandbox' | 'host';
18
+ allowlistOptions: Array<{ label: string; description: string; pattern: string }>;
19
+ scopeOptions: Array<{ label: string; scope: string }>;
20
+ persistentDecisionsAllowed?: boolean;
21
+ }
22
+
23
+ export interface PendingInteraction {
24
+ session: Session;
25
+ conversationId: string;
26
+ kind: 'confirmation' | 'secret';
27
+ confirmationDetails?: ConfirmationDetails;
28
+ }
29
+
30
+ const pending = new Map<string, PendingInteraction>();
31
+
32
+ export function register(requestId: string, interaction: PendingInteraction): void {
33
+ pending.set(requestId, interaction);
34
+ }
35
+
36
+ /**
37
+ * Remove and return the pending interaction for the given requestId.
38
+ * Returns undefined if no interaction is registered.
39
+ */
40
+ export function resolve(requestId: string): PendingInteraction | undefined {
41
+ const interaction = pending.get(requestId);
42
+ if (interaction) {
43
+ pending.delete(requestId);
44
+ }
45
+ return interaction;
46
+ }
47
+
48
+ /**
49
+ * Return the pending interaction without removing it.
50
+ * Used by trust-rule endpoint which doesn't resolve the confirmation itself.
51
+ */
52
+ export function get(requestId: string): PendingInteraction | undefined {
53
+ return pending.get(requestId);
54
+ }
55
+
56
+ /**
57
+ * Return all pending interactions for a given conversation.
58
+ * Needed by channel approval migration (PR 3).
59
+ */
60
+ export function getByConversation(conversationId: string): Array<{ requestId: string } & PendingInteraction> {
61
+ const results: Array<{ requestId: string } & PendingInteraction> = [];
62
+ for (const [requestId, interaction] of pending) {
63
+ if (interaction.conversationId === conversationId) {
64
+ results.push({ requestId, ...interaction });
65
+ }
66
+ }
67
+ return results;
68
+ }
69
+
70
+ /** Clear all pending interactions. Useful for testing. */
71
+ export function clear(): void {
72
+ pending.clear();
73
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Route handlers for standalone approval endpoints.
3
+ *
4
+ * These endpoints resolve pending confirmations, secrets, and trust rules
5
+ * by requestId — orthogonal to message sending.
6
+ */
7
+ import * as pendingInteractions from '../pending-interactions.js';
8
+ import { addRule } from '../../permissions/trust-store.js';
9
+ import { getTool } from '../../tools/registry.js';
10
+ import { getLogger } from '../../util/logger.js';
11
+
12
+ const log = getLogger('approval-routes');
13
+
14
+ /**
15
+ * POST /v1/confirm — resolve a pending confirmation by requestId.
16
+ */
17
+ export async function handleConfirm(req: Request): Promise<Response> {
18
+ const body = await req.json() as {
19
+ requestId?: string;
20
+ decision?: string;
21
+ };
22
+
23
+ const { requestId, decision } = body;
24
+
25
+ if (!requestId || typeof requestId !== 'string') {
26
+ return Response.json({ error: 'requestId is required' }, { status: 400 });
27
+ }
28
+
29
+ if (decision !== 'allow' && decision !== 'deny') {
30
+ return Response.json(
31
+ { error: 'decision must be "allow" or "deny"' },
32
+ { status: 400 },
33
+ );
34
+ }
35
+
36
+ const interaction = pendingInteractions.resolve(requestId);
37
+ if (!interaction) {
38
+ return Response.json(
39
+ { error: 'No pending interaction found for this requestId' },
40
+ { status: 404 },
41
+ );
42
+ }
43
+
44
+ interaction.session.handleConfirmationResponse(requestId, decision);
45
+ return Response.json({ accepted: true });
46
+ }
47
+
48
+ /**
49
+ * POST /v1/secret — resolve a pending secret request by requestId.
50
+ */
51
+ export async function handleSecret(req: Request): Promise<Response> {
52
+ const body = await req.json() as {
53
+ requestId?: string;
54
+ value?: string;
55
+ delivery?: string;
56
+ };
57
+
58
+ const { requestId, value, delivery } = body;
59
+
60
+ if (!requestId || typeof requestId !== 'string') {
61
+ return Response.json({ error: 'requestId is required' }, { status: 400 });
62
+ }
63
+
64
+ if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') {
65
+ return Response.json(
66
+ { error: 'delivery must be "store" or "transient_send"' },
67
+ { status: 400 },
68
+ );
69
+ }
70
+
71
+ const interaction = pendingInteractions.resolve(requestId);
72
+ if (!interaction) {
73
+ return Response.json(
74
+ { error: 'No pending interaction found for this requestId' },
75
+ { status: 404 },
76
+ );
77
+ }
78
+
79
+ interaction.session.handleSecretResponse(
80
+ requestId,
81
+ value,
82
+ delivery as 'store' | 'transient_send' | undefined,
83
+ );
84
+ return Response.json({ accepted: true });
85
+ }
86
+
87
+ /**
88
+ * POST /v1/trust-rules — add a trust rule for a pending confirmation.
89
+ *
90
+ * Does NOT resolve the confirmation itself (the client still needs to
91
+ * POST /v1/confirm to approve/deny). Validates the pattern and scope
92
+ * against the server-provided allowlist options from the original
93
+ * confirmation_request.
94
+ */
95
+ export async function handleTrustRule(req: Request): Promise<Response> {
96
+ const body = await req.json() as {
97
+ requestId?: string;
98
+ pattern?: string;
99
+ scope?: string;
100
+ decision?: string;
101
+ };
102
+
103
+ const { requestId, pattern, scope, decision } = body;
104
+
105
+ if (!requestId || typeof requestId !== 'string') {
106
+ return Response.json({ error: 'requestId is required' }, { status: 400 });
107
+ }
108
+
109
+ if (!pattern || typeof pattern !== 'string') {
110
+ return Response.json({ error: 'pattern is required' }, { status: 400 });
111
+ }
112
+
113
+ if (!scope || typeof scope !== 'string') {
114
+ return Response.json({ error: 'scope is required' }, { status: 400 });
115
+ }
116
+
117
+ if (decision !== 'allow' && decision !== 'deny') {
118
+ return Response.json({ error: 'decision must be "allow" or "deny"' }, { status: 400 });
119
+ }
120
+
121
+ // Look up without removing — trust rule doesn't resolve the confirmation
122
+ const interaction = pendingInteractions.get(requestId);
123
+ if (!interaction) {
124
+ return Response.json(
125
+ { error: 'No pending interaction found for this requestId' },
126
+ { status: 404 },
127
+ );
128
+ }
129
+
130
+ if (!interaction.confirmationDetails) {
131
+ return Response.json(
132
+ { error: 'No confirmation details available for this request' },
133
+ { status: 409 },
134
+ );
135
+ }
136
+
137
+ const confirmation = interaction.confirmationDetails;
138
+
139
+ if (confirmation.persistentDecisionsAllowed === false) {
140
+ return Response.json(
141
+ { error: 'Persistent trust rules are not allowed for this tool invocation' },
142
+ { status: 403 },
143
+ );
144
+ }
145
+
146
+ // Validate pattern against server-provided allowlist options
147
+ const validPatterns = (confirmation.allowlistOptions ?? []).map((o) => o.pattern);
148
+ if (!validPatterns.includes(pattern)) {
149
+ return Response.json(
150
+ { error: 'pattern does not match any server-provided allowlist option' },
151
+ { status: 403 },
152
+ );
153
+ }
154
+
155
+ // Validate scope against server-provided scope options
156
+ const validScopes = (confirmation.scopeOptions ?? []).map((o) => o.scope);
157
+ if (!validScopes.includes(scope)) {
158
+ return Response.json(
159
+ { error: 'scope does not match any server-provided scope option' },
160
+ { status: 403 },
161
+ );
162
+ }
163
+
164
+ try {
165
+ const tool = getTool(confirmation.toolName);
166
+ const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined;
167
+ addRule(confirmation.toolName, pattern, scope, decision, undefined, {
168
+ executionTarget,
169
+ });
170
+ log.info(
171
+ { tool: confirmation.toolName, pattern, scope, decision, requestId },
172
+ 'Trust rule added via HTTP (bound to pending confirmation)',
173
+ );
174
+ return Response.json({ accepted: true });
175
+ } catch (err) {
176
+ log.error({ err }, 'Failed to add trust rule');
177
+ return Response.json({ error: 'Failed to add trust rule' }, { status: 500 });
178
+ }
179
+ }