@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.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/send-endpoint-busy.test.ts +284 -0
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/subagent-manager-notify.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
- package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/daemon-control.ts +13 -12
- package/src/daemon/handlers/subagents.ts +10 -3
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +100 -1
- package/src/daemon/server.ts +8 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -1
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +15 -0
- package/src/runtime/http-types.ts +22 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +107 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/subagent/manager.ts +6 -6
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/tools/subagent/message.ts +9 -2
- 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
|
+
}
|