@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { existsSync, readFileSync } from 'node:fs';
9
- import { resolve } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { join, resolve } from 'node:path';
10
11
 
11
12
  import type { ServerWebSocket } from 'bun';
12
13
 
@@ -117,6 +118,10 @@ import {
117
118
  } from './routes/conversation-routes.js';
118
119
  import { handleDebug } from './routes/debug-routes.js';
119
120
  import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
121
+ import {
122
+ handleGuardianActionDecision,
123
+ handleGuardianActionsPending,
124
+ } from './routes/guardian-action-routes.js';
120
125
  import { handleGetIdentity,handleHealth } from './routes/identity-routes.js';
121
126
  import {
122
127
  handleBlockMember,
@@ -237,11 +242,24 @@ export class RuntimeHttpServer {
237
242
  this.pairingBroadcast = fn;
238
243
  }
239
244
 
245
+ /** Read the feature-flag client token from disk so it can be included in pairing approval responses. */
246
+ private readFeatureFlagToken(): string | undefined {
247
+ try {
248
+ const baseDir = process.env.BASE_DATA_DIR?.trim() || homedir();
249
+ const tokenPath = join(baseDir, '.vellum', 'feature-flag-token');
250
+ const token = readFileSync(tokenPath, 'utf-8').trim();
251
+ return token || undefined;
252
+ } catch {
253
+ return undefined;
254
+ }
255
+ }
256
+
240
257
  private get pairingContext(): PairingHandlerContext {
241
258
  const ipcBroadcast = this.pairingBroadcast;
242
259
  return {
243
260
  pairingStore: this.pairingStore,
244
261
  bearerToken: this.bearerToken,
262
+ featureFlagToken: this.readFeatureFlagToken(),
245
263
  pairingBroadcast: ipcBroadcast
246
264
  ? (msg) => {
247
265
  // Broadcast to IPC socket clients (local Unix socket)
@@ -690,6 +708,10 @@ export class RuntimeHttpServer {
690
708
  if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req);
691
709
  if (endpoint === 'pending-interactions' && req.method === 'GET') return handleListPendingInteractions(url);
692
710
 
711
+ // Guardian action endpoints — deterministic button-based decisions
712
+ if (endpoint === 'guardian-actions/pending' && req.method === 'GET') return handleGuardianActionsPending(req);
713
+ if (endpoint === 'guardian-actions/decision' && req.method === 'POST') return await handleGuardianActionDecision(req);
714
+
693
715
  // Contacts
694
716
  if (endpoint === 'contacts' && req.method === 'GET') return handleListContacts(url);
695
717
  if (endpoint === 'contacts/merge' && req.method === 'POST') return await handleMergeContacts(req);
@@ -22,6 +22,10 @@ import {
22
22
  revokeMember,
23
23
  upsertMember,
24
24
  } from '../memory/ingress-member-store.js';
25
+ import {
26
+ type InviteRedemptionOutcome,
27
+ redeemInvite as redeemInviteTyped,
28
+ } from './invite-redemption-service.js';
25
29
 
26
30
  // ---------------------------------------------------------------------------
27
31
  // Response shapes — used by both HTTP routes and IPC handlers
@@ -163,6 +167,24 @@ export function redeemIngressInvite(params: {
163
167
  return { ok: true, data: inviteToResponse(result.invite) };
164
168
  }
165
169
 
170
+ // ---------------------------------------------------------------------------
171
+ // Typed invite redemption — preferred entry point for new callers
172
+ // ---------------------------------------------------------------------------
173
+
174
+ export { type InviteRedemptionOutcome } from './invite-redemption-service.js';
175
+
176
+ export function redeemIngressInviteTyped(params: {
177
+ rawToken: string;
178
+ sourceChannel: string;
179
+ externalUserId?: string;
180
+ externalChatId?: string;
181
+ displayName?: string;
182
+ username?: string;
183
+ assistantId?: string;
184
+ }): InviteRedemptionOutcome {
185
+ return redeemInviteTyped(params);
186
+ }
187
+
166
188
  // ---------------------------------------------------------------------------
167
189
  // Member operations
168
190
  // ---------------------------------------------------------------------------
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Typed invite redemption engine.
3
+ *
4
+ * Wraps the low-level invite store primitives with channel-scoped enforcement
5
+ * and a discriminated-union outcome type so callers can handle every case
6
+ * deterministically. The raw token is accepted as input but is never logged,
7
+ * persisted, or returned in the outcome.
8
+ */
9
+
10
+ import { getSqlite } from '../memory/db.js';
11
+ import { findByTokenHash, hashToken, markInviteExpired, recordInviteUse,redeemInvite as storeRedeemInvite } from '../memory/ingress-invite-store.js';
12
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Outcome type
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type InviteRedemptionOutcome =
19
+ | { ok: true; type: 'redeemed'; memberId: string; inviteId: string }
20
+ | { ok: true; type: 'already_member'; memberId: string }
21
+ | { ok: false; reason: 'invalid_token' | 'expired' | 'revoked' | 'max_uses_reached' | 'channel_mismatch' | 'missing_identity' };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Error-string to typed-reason mapping
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const STORE_ERROR_TO_REASON: Record<string, InviteRedemptionOutcome & { ok: false } | undefined> = {
28
+ invite_not_found: { ok: false, reason: 'invalid_token' },
29
+ invite_expired: { ok: false, reason: 'expired' },
30
+ invite_revoked: { ok: false, reason: 'revoked' },
31
+ invite_redeemed: { ok: false, reason: 'max_uses_reached' },
32
+ invite_max_uses_reached: { ok: false, reason: 'max_uses_reached' },
33
+ invite_channel_mismatch: { ok: false, reason: 'channel_mismatch' },
34
+ };
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // redeemInvite
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export function redeemInvite(params: {
41
+ rawToken: string;
42
+ sourceChannel: string;
43
+ externalUserId?: string;
44
+ externalChatId?: string;
45
+ displayName?: string;
46
+ username?: string;
47
+ assistantId?: string;
48
+ }): InviteRedemptionOutcome {
49
+ const { rawToken, sourceChannel, externalUserId, externalChatId, displayName, username, assistantId } = params;
50
+
51
+ if (!externalUserId && !externalChatId) {
52
+ return { ok: false, reason: 'missing_identity' };
53
+ }
54
+
55
+ // Validate the invite token before any membership checks to prevent
56
+ // membership-status probing with arbitrary tokens.
57
+ const tokenHash = hashToken(rawToken);
58
+ const invite = findByTokenHash(tokenHash);
59
+
60
+ if (!invite) {
61
+ return { ok: false, reason: 'invalid_token' };
62
+ }
63
+
64
+ if (invite.status !== 'active') {
65
+ const mapped = STORE_ERROR_TO_REASON[`invite_${invite.status}`];
66
+ if (mapped) return mapped;
67
+ return { ok: false, reason: 'invalid_token' };
68
+ }
69
+
70
+ if (invite.expiresAt <= Date.now()) {
71
+ markInviteExpired(invite.id);
72
+ return { ok: false, reason: 'expired' };
73
+ }
74
+
75
+ if (invite.useCount >= invite.maxUses) {
76
+ return { ok: false, reason: 'max_uses_reached' };
77
+ }
78
+
79
+ // Enforce channel match: the token must belong to the channel the caller
80
+ // is redeeming from.
81
+ if (sourceChannel !== invite.sourceChannel) {
82
+ return { ok: false, reason: 'channel_mismatch' };
83
+ }
84
+
85
+ // Token is valid — now safe to check existing membership without leaking
86
+ // membership status to callers with bogus tokens.
87
+ const existingMember = findMember({
88
+ assistantId: assistantId ?? invite.assistantId,
89
+ sourceChannel,
90
+ externalUserId,
91
+ externalChatId,
92
+ });
93
+
94
+ if (existingMember && existingMember.status === 'active') {
95
+ return { ok: true, type: 'already_member', memberId: existingMember.id };
96
+ }
97
+
98
+ // Blocked members cannot bypass the guardian's explicit block via invite
99
+ // links. Return the same generic failure as an invalid token to avoid
100
+ // leaking membership status to the caller.
101
+ if (existingMember && existingMember.status === 'blocked') {
102
+ return { ok: false, reason: 'invalid_token' };
103
+ }
104
+
105
+ // Inactive member reactivation: when the user already has a member record
106
+ // in a non-active state (revoked/pending), reactivate it via upsertMember
107
+ // and consume an invite use atomically. Falling through to storeRedeemInvite
108
+ // would try to INSERT a new member row, hitting the unique-key constraint
109
+ // on the members table.
110
+ if (existingMember) {
111
+ // Sentinel error used to trigger a transaction rollback when the invite
112
+ // was concurrently revoked/expired between pre-validation and write time.
113
+ const STALE_INVITE = Symbol('stale_invite');
114
+
115
+ let reactivated: ReturnType<typeof upsertMember> | undefined;
116
+ try {
117
+ getSqlite().transaction(() => {
118
+ reactivated = upsertMember({
119
+ assistantId: assistantId ?? invite.assistantId,
120
+ sourceChannel,
121
+ externalUserId,
122
+ externalChatId,
123
+ displayName,
124
+ username,
125
+ status: 'active',
126
+ policy: 'allow',
127
+ inviteId: invite.id,
128
+ });
129
+
130
+ const recorded = recordInviteUse({
131
+ inviteId: invite.id,
132
+ externalUserId,
133
+ externalChatId,
134
+ });
135
+
136
+ // If the invite was revoked/expired between pre-validation and this
137
+ // write, recordInviteUse returns false — throw to roll back the
138
+ // member reactivation so the DB stays consistent.
139
+ if (!recorded) throw STALE_INVITE;
140
+ }).immediate();
141
+ } catch (err) {
142
+ if (err === STALE_INVITE) {
143
+ return { ok: false, reason: 'invalid_token' };
144
+ }
145
+ throw err;
146
+ }
147
+
148
+ return {
149
+ ok: true,
150
+ type: 'redeemed',
151
+ memberId: reactivated!.id,
152
+ inviteId: invite.id,
153
+ };
154
+ }
155
+
156
+ // Delegate to the store-level redeem which handles token lookup, expiry,
157
+ // use-count, and transactional member creation. Channel enforcement is
158
+ // applied by passing sourceChannel so the store checks it.
159
+ const result = storeRedeemInvite({
160
+ rawToken,
161
+ sourceChannel,
162
+ externalUserId,
163
+ externalChatId,
164
+ displayName,
165
+ username,
166
+ });
167
+
168
+ if ('error' in result) {
169
+ const mapped = STORE_ERROR_TO_REASON[result.error];
170
+ if (mapped) return mapped;
171
+ // Fallback for any unrecognized store error
172
+ return { ok: false, reason: 'invalid_token' };
173
+ }
174
+
175
+ return {
176
+ ok: true,
177
+ type: 'redeemed',
178
+ memberId: result.member.id,
179
+ inviteId: result.invite.id,
180
+ };
181
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Deterministic reply templates for invite token redemption outcomes.
3
+ *
4
+ * These messages are returned directly to the user without passing through
5
+ * the LLM pipeline, ensuring consistent and predictable responses for
6
+ * every invite redemption outcome.
7
+ */
8
+
9
+ import type { InviteRedemptionOutcome } from './invite-redemption-service.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Template strings
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const INVITE_REPLY_TEMPLATES = {
16
+ redeemed: "Welcome! You've been granted access via invite link.",
17
+ already_member: 'You already have access.',
18
+ invalid_token: 'This invite link is no longer valid.',
19
+ expired: 'This invite link is no longer valid.',
20
+ revoked: 'This invite link is no longer valid.',
21
+ max_uses_reached: 'This invite link is no longer valid.',
22
+ channel_mismatch: 'This invite link is not valid for this channel.',
23
+ missing_identity: 'Unable to process this invite. Please contact the person who shared it.',
24
+ generic_failure: 'Unable to process this invite. Please contact the person who shared it.',
25
+ } as const;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Outcome-to-reply resolver
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Map an `InviteRedemptionOutcome` to a deterministic reply string.
33
+ */
34
+ export function getInviteRedemptionReply(outcome: InviteRedemptionOutcome): string {
35
+ if (outcome.ok) {
36
+ return INVITE_REPLY_TEMPLATES[outcome.type];
37
+ }
38
+ return INVITE_REPLY_TEMPLATES[outcome.reason] ?? INVITE_REPLY_TEMPLATES.generic_failure;
39
+ }
@@ -179,7 +179,7 @@ export async function handleCancelCall(req: Request, callSessionId: string): Pro
179
179
  * Body: { answer: string }
180
180
  */
181
181
  export async function handleAnswerCall(req: Request, callSessionId: string): Promise<Response> {
182
- let body: { answer?: string };
182
+ let body: { answer?: string; pendingQuestionId?: string };
183
183
  try {
184
184
  body = await req.json() as typeof body;
185
185
  } catch {
@@ -193,6 +193,7 @@ export async function handleAnswerCall(req: Request, callSessionId: string): Pro
193
193
  const result = await answerCall({
194
194
  callSessionId,
195
195
  answer: body.answer ?? '',
196
+ pendingQuestionId: typeof body.pendingQuestionId === 'string' ? body.pendingQuestionId : undefined,
196
197
  });
197
198
 
198
199
  if (!result.ok) {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Route handlers for deterministic guardian action endpoints.
3
+ *
4
+ * These endpoints let desktop clients fetch pending guardian prompts and
5
+ * submit button decisions without relying on text parsing.
6
+ */
7
+ import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
8
+ import {
9
+ getPendingApprovalForRequest,
10
+ listPendingApprovalRequests,
11
+ } from '../../memory/channel-guardian-store.js';
12
+ import type { ApprovalAction } from '../channel-approval-types.js';
13
+ import { handleChannelDecision } from '../channel-approvals.js';
14
+ import type { GuardianDecisionPrompt } from '../guardian-decision-types.js';
15
+ import { buildDecisionActions } from '../guardian-decision-types.js';
16
+ import { httpError } from '../http-errors.js';
17
+ import * as pendingInteractions from '../pending-interactions.js';
18
+ import { handleAccessRequestDecision } from './access-request-decision.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // GET /v1/guardian-actions/pending?conversationId=...
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * List pending guardian decision prompts for a conversation.
26
+ *
27
+ * Returns guardian approval requests (from the channel guardian store) that
28
+ * are still pending, mapped to the GuardianDecisionPrompt shape so clients
29
+ * can render structured button UIs.
30
+ */
31
+ export function handleGuardianActionsPending(req: Request): Response {
32
+ const url = new URL(req.url);
33
+ const conversationId = url.searchParams.get('conversationId');
34
+
35
+ if (!conversationId) {
36
+ return httpError('BAD_REQUEST', 'conversationId query parameter is required', 400);
37
+ }
38
+
39
+ const prompts = listGuardianDecisionPrompts({ conversationId });
40
+ return Response.json({ conversationId, prompts });
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // POST /v1/guardian-actions/decision
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Submit a guardian action decision.
49
+ *
50
+ * Looks up the guardian approval by requestId and applies the decision
51
+ * through the unified guardian decision primitive.
52
+ */
53
+ export async function handleGuardianActionDecision(req: Request): Promise<Response> {
54
+ const body = await req.json() as {
55
+ requestId?: string;
56
+ action?: string;
57
+ conversationId?: string;
58
+ };
59
+
60
+ const { requestId, action, conversationId } = body;
61
+
62
+ if (!requestId || typeof requestId !== 'string') {
63
+ return httpError('BAD_REQUEST', 'requestId is required', 400);
64
+ }
65
+
66
+ if (!action || typeof action !== 'string') {
67
+ return httpError('BAD_REQUEST', 'action is required', 400);
68
+ }
69
+
70
+ const VALID_ACTIONS = new Set<string>(['approve_once', 'approve_always', 'reject']);
71
+ if (!VALID_ACTIONS.has(action)) {
72
+ return httpError('BAD_REQUEST', `Invalid action: ${action}. Must be one of: approve_once, approve_always, reject`, 400);
73
+ }
74
+
75
+ // Try the channel guardian approval store first (tool approval prompts)
76
+ const approval = getPendingApprovalForRequest(requestId);
77
+ if (approval) {
78
+ // Enforce conversationId scoping: reject decisions that target the wrong conversation.
79
+ if (conversationId && conversationId !== approval.conversationId) {
80
+ return httpError('BAD_REQUEST', 'conversationId does not match the approval', 400);
81
+ }
82
+
83
+ // Access request approvals need a separate decision path — they don't have
84
+ // pending interactions and use verification sessions instead.
85
+ if (approval.toolName === 'ingress_access_request') {
86
+ const mappedAction = action === 'reject' ? 'deny' as const : 'approve' as const;
87
+ // Use 'desktop' as the actor identity because this endpoint is
88
+ // unauthenticated — we cannot verify the caller is the assigned
89
+ // guardian, so we record a generic desktop origin instead of
90
+ // falsely attributing the decision to guardianExternalUserId.
91
+ const decisionResult = handleAccessRequestDecision(
92
+ approval,
93
+ mappedAction,
94
+ 'desktop',
95
+ );
96
+ return Response.json({
97
+ applied: decisionResult.type !== 'stale',
98
+ requestId,
99
+ reason: decisionResult.type === 'stale' ? 'stale' : undefined,
100
+ accessRequestResult: decisionResult,
101
+ });
102
+ }
103
+
104
+ // Note: actorExternalUserId is left undefined because the desktop endpoint
105
+ // does not authenticate caller identity. This means scoped grant minting is
106
+ // skipped for button-based decisions — an acceptable trade-off to avoid
107
+ // falsifying audit records with an unverified guardian identity.
108
+ const result = applyGuardianDecision({
109
+ approval,
110
+ decision: { action: action as 'approve_once' | 'approve_always' | 'reject', source: 'plain_text', requestId },
111
+ actorExternalUserId: undefined,
112
+ actorChannel: 'vellum',
113
+ });
114
+ return Response.json({ ...result, requestId: result.requestId ?? requestId });
115
+ }
116
+
117
+ // Fall back to the pending interactions tracker (direct confirmation requests).
118
+ // Route through handleChannelDecision so approve_always properly persists trust rules.
119
+ const interaction = pendingInteractions.get(requestId);
120
+ if (interaction) {
121
+ // Enforce conversationId scoping for interactions too.
122
+ if (conversationId && conversationId !== interaction.conversationId) {
123
+ return httpError('BAD_REQUEST', 'conversationId does not match the interaction', 400);
124
+ }
125
+
126
+ const result = handleChannelDecision(
127
+ interaction.conversationId,
128
+ { action: action as ApprovalAction, source: 'plain_text', requestId },
129
+ );
130
+ return Response.json({ ...result, requestId: result.requestId ?? requestId });
131
+ }
132
+
133
+ return httpError('NOT_FOUND', 'No pending guardian action found for this requestId', 404);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Shared helper: list guardian decision prompts
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Build a list of GuardianDecisionPrompt objects for the given conversation.
142
+ *
143
+ * Aggregates pending guardian approval requests from the channel guardian
144
+ * store and pending confirmation interactions from the pending-interactions
145
+ * tracker, exposing them in a uniform shape that clients can render as
146
+ * structured button UIs.
147
+ */
148
+ export function listGuardianDecisionPrompts(params: {
149
+ conversationId: string;
150
+ }): GuardianDecisionPrompt[] {
151
+ const { conversationId } = params;
152
+ const prompts: GuardianDecisionPrompt[] = [];
153
+
154
+ // 1. Channel guardian approval requests (tool approvals routed to guardians)
155
+ const approvalRequests = listPendingApprovalRequests({
156
+ conversationId,
157
+ status: 'pending',
158
+ }).filter(a => a.expiresAt > Date.now() && a.requestId != null);
159
+
160
+ for (const approval of approvalRequests) {
161
+ const reqId = approval.requestId!;
162
+ prompts.push({
163
+ requestId: reqId,
164
+ requestCode: reqId.slice(0, 6).toUpperCase(),
165
+ state: 'pending',
166
+ questionText: approval.reason ?? `Approve tool: ${approval.toolName ?? 'unknown'}`,
167
+ toolName: approval.toolName ?? null,
168
+ actions: buildDecisionActions({ forGuardianOnBehalf: true }),
169
+ expiresAt: approval.expiresAt,
170
+ conversationId: approval.conversationId,
171
+ callSessionId: null,
172
+ });
173
+ }
174
+
175
+ // 2. Guardian action requests (voice call guardian questions) are intentionally
176
+ // excluded here — resolving them requires the answerCall + resolveGuardianActionRequest
177
+ // flow which is handled by the conversational session-process path, not by the
178
+ // deterministic button decision endpoint.
179
+ // TODO: Surface voice guardian-action requests as read-only informational prompts
180
+ // so desktop clients can see them even though they can't be resolved via buttons.
181
+
182
+ // 3. Pending confirmation interactions (direct tool approval prompts)
183
+ const interactions = pendingInteractions.getByConversation(conversationId);
184
+ for (const interaction of interactions) {
185
+ if (interaction.kind !== 'confirmation' || !interaction.confirmationDetails) continue;
186
+ // Skip if already covered by a channel guardian approval above
187
+ if (prompts.some(p => p.requestId === interaction.requestId)) continue;
188
+
189
+ const details = interaction.confirmationDetails;
190
+ prompts.push({
191
+ requestId: interaction.requestId,
192
+ requestCode: interaction.requestId.slice(0, 6).toUpperCase(),
193
+ state: 'pending',
194
+ questionText: `Approve tool: ${details.toolName}`,
195
+ toolName: details.toolName,
196
+ actions: buildDecisionActions({
197
+ persistentDecisionsAllowed: details.persistentDecisionsAllowed,
198
+ }),
199
+ expiresAt: Date.now() + 300_000,
200
+ conversationId,
201
+ callSessionId: null,
202
+ });
203
+ }
204
+
205
+ return prompts;
206
+ }