@vellumai/assistant 0.4.3 → 0.4.4

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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -1,10 +1,9 @@
1
- import { v4 as uuid } from 'uuid';
1
+ import type { ServerMessage } from '../../daemon/ipc-contract.js';
2
+ import { browserManager, SCREENCAST_HEIGHT, SCREENCAST_WIDTH } from './browser-manager.js';
2
3
 
3
- import type { BrowserFrame, BrowserViewSurfaceData, ServerMessage } from '../../daemon/ipc-contract.js';
4
- import { browserManager, SCREENCAST_HEIGHT,SCREENCAST_WIDTH } from './browser-manager.js';
5
-
6
- // Track active screencast sessions
7
- const activeScreencasts = new Map<string, { surfaceId: string }>();
4
+ // Track which sessions have an active browser page (no PiP surface — the user
5
+ // watches the actual browser window directly).
6
+ const activeBrowserSessions = new Set<string>();
8
7
 
9
8
  // Registry of sendToClient callbacks per session
10
9
  const sessionSenders = new Map<string, (msg: ServerMessage) => void>();
@@ -32,118 +31,55 @@ function getSender(sessionId: string): ((msg: ServerMessage) => void) | undefine
32
31
 
33
32
  export async function ensureScreencast(
34
33
  sessionId: string,
35
- sendToClient: (msg: ServerMessage) => void,
34
+ _sendToClient: (msg: ServerMessage) => void,
36
35
  ): Promise<void> {
37
- if (activeScreencasts.has(sessionId)) return;
36
+ if (activeBrowserSessions.has(sessionId)) return;
38
37
 
39
- const surfaceId = uuid();
40
- activeScreencasts.set(sessionId, { surfaceId });
38
+ activeBrowserSessions.add(sessionId);
41
39
 
42
40
  try {
43
- // Get current page info
44
- const page = await browserManager.getOrCreateSessionPage(sessionId);
45
- const currentUrl = page.url();
46
- const title = await page.title();
47
-
48
- // Send surface show
49
- sendToClient({
50
- type: 'ui_surface_show',
51
- sessionId,
52
- surfaceId,
53
- surfaceType: 'browser_view',
54
- title: 'Browser',
55
- data: {
56
- sessionId,
57
- currentUrl: currentUrl || 'about:blank',
58
- status: 'idle',
59
- pages: [{ id: sessionId, title: title || 'New Tab', url: currentUrl || 'about:blank', active: true }],
60
- } satisfies BrowserViewSurfaceData,
61
- display: 'panel',
62
- });
63
-
64
- // Start CDP screencast
65
- await browserManager.startScreencast(sessionId, (frame) => {
66
- sendToClient({
67
- type: 'browser_frame',
68
- sessionId,
69
- surfaceId,
70
- frame: frame.data,
71
- metadata: frame.metadata,
72
- } satisfies BrowserFrame);
73
- });
41
+ // Ensure the page exists (may trigger browser launch/connect)
42
+ await browserManager.getOrCreateSessionPage(sessionId);
43
+
44
+ // No PiP surface or CDP screencast — the user watches the actual
45
+ // browser window directly (positioned in top-right via positionWindowSidebar).
74
46
  } catch (err) {
75
- // Dismiss the surface we already showed so the client doesn't have an orphaned panel
76
- sendToClient({
77
- type: 'ui_surface_dismiss',
78
- sessionId,
79
- surfaceId,
80
- });
81
47
  // Roll back so future calls can retry
82
- activeScreencasts.delete(sessionId);
48
+ activeBrowserSessions.delete(sessionId);
83
49
  throw err;
84
50
  }
85
51
  }
86
52
 
87
53
  export function updateBrowserStatus(
88
54
  sessionId: string,
89
- sendToClient: (msg: ServerMessage) => void,
90
- status: 'navigating' | 'idle' | 'interacting',
91
- actionText?: string,
92
- currentUrl?: string,
55
+ _sendToClient: (msg: ServerMessage) => void,
56
+ _status: 'navigating' | 'idle' | 'interacting',
57
+ _actionText?: string,
58
+ _currentUrl?: string,
93
59
  ): void {
94
- const state = activeScreencasts.get(sessionId);
95
- if (!state) return;
96
-
97
- const update: Record<string, unknown> = { status };
98
- if (actionText !== undefined) update.actionText = actionText;
99
- if (currentUrl !== undefined) update.currentUrl = currentUrl;
100
-
101
- sendToClient({
102
- type: 'ui_surface_update',
103
- sessionId,
104
- surfaceId: state.surfaceId,
105
- data: update,
106
- });
60
+ // No-op: PiP surface was removed so there is no ui_surface to update.
61
+ // The function signature is preserved to avoid churn at callsites.
62
+ if (!activeBrowserSessions.has(sessionId)) return;
107
63
  }
108
64
 
109
65
  export async function updatePagesList(
110
66
  sessionId: string,
111
- sendToClient: (msg: ServerMessage) => void,
67
+ _sendToClient: (msg: ServerMessage) => void,
112
68
  ): Promise<void> {
113
- const state = activeScreencasts.get(sessionId);
114
- if (!state) return;
115
-
116
- const page = await browserManager.getOrCreateSessionPage(sessionId);
117
- const currentUrl = page.url();
118
- const title = await page.title();
119
-
120
- sendToClient({
121
- type: 'ui_surface_update',
122
- sessionId,
123
- surfaceId: state.surfaceId,
124
- data: {
125
- currentUrl,
126
- pages: [{ id: sessionId, title: title || 'Untitled', url: currentUrl, active: true }],
127
- },
128
- });
69
+ // No-op: PiP surface was removed so there is no ui_surface to update.
70
+ if (!activeBrowserSessions.has(sessionId)) return;
129
71
  }
130
72
 
131
73
  export async function stopBrowserScreencast(
132
74
  sessionId: string,
133
- sendToClient: (msg: ServerMessage) => void,
75
+ _sendToClient: (msg: ServerMessage) => void,
134
76
  ): Promise<void> {
135
- const state = activeScreencasts.get(sessionId);
136
- if (!state) return;
77
+ if (!activeBrowserSessions.has(sessionId)) return;
137
78
 
79
+ // Safe no-op if CDP screencast was never started
138
80
  await browserManager.stopScreencast(sessionId);
139
81
 
140
- sendToClient({
141
- type: 'ui_surface_dismiss',
142
- sessionId,
143
- surfaceId: state.surfaceId,
144
- });
145
-
146
- activeScreencasts.delete(sessionId);
82
+ activeBrowserSessions.delete(sessionId);
147
83
  }
148
84
 
149
85
  export async function getElementBounds(
@@ -175,44 +111,24 @@ export async function getElementBounds(
175
111
 
176
112
  export function updateHighlights(
177
113
  sessionId: string,
178
- sendToClient: (msg: ServerMessage) => void,
179
- highlights: Array<{ x: number; y: number; w: number; h: number; label: string }>,
114
+ _sendToClient: (msg: ServerMessage) => void,
115
+ _highlights: Array<{ x: number; y: number; w: number; h: number; label: string }>,
180
116
  ): void {
181
- const state = activeScreencasts.get(sessionId);
182
- if (!state) return;
183
- sendToClient({
184
- type: 'ui_surface_update',
185
- sessionId,
186
- surfaceId: state.surfaceId,
187
- data: { highlights },
188
- });
117
+ // No-op: PiP surface was removed so there is no ui_surface to update.
118
+ if (!activeBrowserSessions.has(sessionId)) return;
189
119
  }
190
120
 
191
121
  export async function stopAllScreencasts(): Promise<void> {
192
- const entries = Array.from(activeScreencasts.entries());
193
- for (const [sessionId, state] of entries) {
122
+ for (const sessionId of activeBrowserSessions) {
194
123
  try {
195
124
  await browserManager.stopScreencast(sessionId);
196
125
  } catch { /* best-effort */ }
197
- const sender = sessionSenders.get(sessionId);
198
- if (sender) {
199
- sender({
200
- type: 'ui_surface_dismiss',
201
- sessionId,
202
- surfaceId: state.surfaceId,
203
- });
204
- }
205
126
  }
206
- activeScreencasts.clear();
127
+ activeBrowserSessions.clear();
207
128
  }
208
129
 
209
130
  export function isScreencastActive(sessionId: string): boolean {
210
- return activeScreencasts.has(sessionId);
131
+ return activeBrowserSessions.has(sessionId);
211
132
  }
212
133
 
213
134
  export { getSender };
214
-
215
- export function getScreencastSurfaceId(sessionId: string): string | null {
216
- const state = activeScreencasts.get(sessionId);
217
- return state?.surfaceId ?? null;
218
- }
@@ -207,9 +207,14 @@ export class PermissionChecker {
207
207
  }
208
208
 
209
209
  if (response.decision === 'always_deny') {
210
- const ruleSaved = !!(persistentDecisionsAllowed && response.selectedPattern && response.selectedScope);
210
+ // For non-scoped tools (empty scopeOptions), default to 'everywhere' since
211
+ // the client has no scope picker and will send undefined.
212
+ const effectiveDenyScope = scopeOptions.length === 0
213
+ ? (response.selectedScope ?? 'everywhere')
214
+ : response.selectedScope;
215
+ const ruleSaved = !!(persistentDecisionsAllowed && response.selectedPattern && effectiveDenyScope);
211
216
  if (ruleSaved) {
212
- addRule(name, response.selectedPattern!, response.selectedScope!, 'deny');
217
+ addRule(name, response.selectedPattern!, effectiveDenyScope!, 'deny');
213
218
  }
214
219
  const denialReason = ruleSaved ? 'Permission denied by user (rule saved)' : 'Permission denied by user';
215
220
  const denialMessage = ruleSaved
@@ -237,7 +242,6 @@ export class PermissionChecker {
237
242
  persistentDecisionsAllowed
238
243
  && (response.decision === 'always_allow' || response.decision === 'always_allow_high_risk')
239
244
  && response.selectedPattern
240
- && response.selectedScope
241
245
  ) {
242
246
  const ruleOptions: {
243
247
  allowHighRisk?: boolean;
@@ -253,7 +257,14 @@ export class PermissionChecker {
253
257
  }
254
258
 
255
259
  const hasOptions = Object.keys(ruleOptions).length > 0;
256
- addRule(name, response.selectedPattern, response.selectedScope, 'allow', 100, hasOptions ? ruleOptions : undefined);
260
+ // Only default to 'everywhere' for non-scoped tools (empty scopeOptions).
261
+ // For scoped tools, require an explicit scope to prevent silent permission widening.
262
+ const effectiveScope = scopeOptions.length === 0
263
+ ? (response.selectedScope ?? 'everywhere')
264
+ : response.selectedScope;
265
+ if (effectiveScope) {
266
+ addRule(name, response.selectedPattern, effectiveScope, 'allow', 100, hasOptions ? ruleOptions : undefined);
267
+ }
257
268
  }
258
269
 
259
270
  return { allowed: true, decision, riskLevel };
@@ -1,4 +1,5 @@
1
1
  import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
2
+ import { getCanonicalGuardianRequest, updateCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
2
3
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
3
4
  import { createOrReuseToolGrantRequest } from '../runtime/tool-grant-request-helper.js';
4
5
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
@@ -11,6 +12,112 @@ import type { ExecutionTarget, Tool, ToolContext, ToolExecutionResult, ToolLifec
11
12
 
12
13
  const log = getLogger('tool-approval-handler');
13
14
 
15
+ /** Default polling interval for inline grant wait (ms). */
16
+ export const TC_GRANT_WAIT_INTERVAL_MS = 500;
17
+ /** Default maximum wait time for inline grant wait (ms). */
18
+ export const TC_GRANT_WAIT_MAX_MS = 60_000;
19
+
20
+ /**
21
+ * Inline wait result for trusted-contact grant polling.
22
+ * - `granted`: a grant was minted and consumed within the wait window.
23
+ * - `denied`: the guardian explicitly rejected the request.
24
+ * - `timeout`: the wait budget expired without a decision.
25
+ * - `aborted`: the session was cancelled during the wait.
26
+ * - `escalation_failed`: the grant request could not be created.
27
+ */
28
+ export type InlineGrantWaitOutcome =
29
+ | { outcome: 'granted'; grant: { id: string } }
30
+ | { outcome: 'denied'; requestId: string }
31
+ | { outcome: 'timeout'; requestId: string }
32
+ | { outcome: 'aborted' }
33
+ | { outcome: 'escalation_failed'; reason: string };
34
+
35
+ /**
36
+ * Wait bounded for a guardian to approve a tool grant request and for the
37
+ * resulting grant to become consumable. Polls both the canonical request
38
+ * status (to detect early rejection) and the grant store (to detect approval
39
+ * and atomically consume the grant).
40
+ *
41
+ * Only called for trusted_contact actors with valid guardian bindings.
42
+ */
43
+ export async function waitForInlineGrant(
44
+ escalationRequestId: string,
45
+ consumeParams: Parameters<typeof consumeGrantForInvocation>[0],
46
+ options?: { maxWaitMs?: number; intervalMs?: number; signal?: AbortSignal },
47
+ ): Promise<InlineGrantWaitOutcome> {
48
+ const maxWait = options?.maxWaitMs ?? TC_GRANT_WAIT_MAX_MS;
49
+ const interval = options?.intervalMs ?? TC_GRANT_WAIT_INTERVAL_MS;
50
+ const signal = options?.signal;
51
+ const deadline = Date.now() + maxWait;
52
+
53
+ log.info(
54
+ {
55
+ event: 'tc_inline_grant_wait_start',
56
+ escalationRequestId,
57
+ toolName: consumeParams.toolName,
58
+ maxWaitMs: maxWait,
59
+ intervalMs: interval,
60
+ },
61
+ 'Starting inline wait for guardian grant decision',
62
+ );
63
+
64
+ while (Date.now() < deadline) {
65
+ if (signal?.aborted) {
66
+ return { outcome: 'aborted' };
67
+ }
68
+
69
+ await new Promise((resolve) => setTimeout(resolve, interval));
70
+
71
+ if (signal?.aborted) {
72
+ return { outcome: 'aborted' };
73
+ }
74
+
75
+ // Check if the canonical request was rejected — exit early without
76
+ // waiting for the full timeout.
77
+ const request = getCanonicalGuardianRequest(escalationRequestId);
78
+ if (request && request.status === 'denied') {
79
+ log.info(
80
+ {
81
+ event: 'tc_inline_grant_wait_denied',
82
+ escalationRequestId,
83
+ toolName: consumeParams.toolName,
84
+ elapsedMs: maxWait - (deadline - Date.now()),
85
+ },
86
+ 'Guardian denied tool grant request during inline wait',
87
+ );
88
+ return { outcome: 'denied', requestId: escalationRequestId };
89
+ }
90
+
91
+ // Try to consume the grant — if the guardian approved, the canonical
92
+ // decision primitive will have minted a scoped grant by now.
93
+ const grantResult = await consumeGrantForInvocation(consumeParams, { maxWaitMs: 0 });
94
+ if (grantResult.ok) {
95
+ log.info(
96
+ {
97
+ event: 'tc_inline_grant_wait_granted',
98
+ escalationRequestId,
99
+ toolName: consumeParams.toolName,
100
+ grantId: grantResult.grant.id,
101
+ elapsedMs: maxWait - (deadline - Date.now()),
102
+ },
103
+ 'Grant found during inline wait — tool execution proceeding',
104
+ );
105
+ return { outcome: 'granted', grant: { id: grantResult.grant.id } };
106
+ }
107
+ }
108
+
109
+ log.info(
110
+ {
111
+ event: 'tc_inline_grant_wait_timeout',
112
+ escalationRequestId,
113
+ toolName: consumeParams.toolName,
114
+ maxWaitMs: maxWait,
115
+ },
116
+ 'Inline grant wait timed out — no guardian decision within budget',
117
+ );
118
+ return { outcome: 'timeout', requestId: escalationRequestId };
119
+ }
120
+
14
121
  function isUntrustedGuardianTrustClass(role: ToolContext['guardianTrustClass']): boolean {
15
122
  return role === 'trusted_contact' || role === 'unknown';
16
123
  }
@@ -40,12 +147,26 @@ export type PreExecutionGateResult =
40
147
  | { allowed: true; tool: Tool; grantConsumed?: boolean }
41
148
  | { allowed: false; result: ToolExecutionResult };
42
149
 
150
+ /** Configuration for the inline grant wait behavior. */
151
+ export interface InlineGrantWaitConfig {
152
+ /** Maximum time to wait for guardian approval (ms). Defaults to TC_GRANT_WAIT_MAX_MS. */
153
+ maxWaitMs?: number;
154
+ /** Polling interval during the wait (ms). Defaults to TC_GRANT_WAIT_INTERVAL_MS. */
155
+ intervalMs?: number;
156
+ }
157
+
43
158
  /**
44
159
  * Handles pre-execution approval gates: abort checks, guardian policy,
45
160
  * allowed-tool-set gating, and task-run preflight checks.
46
161
  * These run before the interactive permission prompt flow.
47
162
  */
48
163
  export class ToolApprovalHandler {
164
+ private inlineGrantWaitConfig: InlineGrantWaitConfig;
165
+
166
+ constructor(config?: { inlineGrantWait?: InlineGrantWaitConfig }) {
167
+ this.inlineGrantWaitConfig = config?.inlineGrantWait ?? {};
168
+ }
169
+
49
170
  /**
50
171
  * Evaluate all pre-execution approval gates for a tool invocation.
51
172
  * Returns the resolved Tool if all gates pass, or an early-return
@@ -267,12 +388,15 @@ export class ToolApprovalHandler {
267
388
  return { allowed: false, result: { content: 'Cancelled', isError: true } };
268
389
  }
269
390
 
270
- // No matching grant or race condition — deny.
391
+ // No matching grant or race condition — deny or wait inline.
271
392
  //
272
- // For verified non-guardian actors with sufficient context, escalate to
273
- // the guardian by creating a canonical tool_grant_request. Unverified
274
- // actors remain fail-closed with no escalation.
275
- let escalationMessage: string | undefined;
393
+ // For verified non-guardian actors (trusted_contact) with sufficient
394
+ // context, escalate to the guardian by creating a canonical
395
+ // tool_grant_request. Then wait bounded for the grant to become
396
+ // available this lets the tool call succeed inline after guardian
397
+ // approval without the requester having to retry manually.
398
+ //
399
+ // Unverified actors remain fail-closed with no escalation or wait.
276
400
  if (
277
401
  context.guardianTrustClass === 'trusted_contact'
278
402
  && context.assistantId
@@ -292,24 +416,124 @@ export class ToolApprovalHandler {
292
416
  questionText: `Trusted contact is requesting permission to use "${name}"`,
293
417
  });
294
418
 
295
- if ('created' in escalation) {
296
- const codeSuffix = escalation.requestCode
297
- ? ` (request code: ${escalation.requestCode})`
298
- : '';
299
- escalationMessage = `Permission denied for "${name}": this action requires guardian approval. `
300
- + `A request has been sent to the guardian${codeSuffix}. `
301
- + `Please retry after the guardian approves.`;
302
- } else if ('deduped' in escalation) {
419
+ // Only wait inline if the escalation succeeded (created or deduped).
420
+ // If escalation failed (no binding, missing identity), fall through
421
+ // to the generic denial path.
422
+ if ('created' in escalation || 'deduped' in escalation) {
423
+ // Stamp the canonical request so the approval resolver knows an
424
+ // inline consumer is waiting. Without this, the resolver would
425
+ // send a stale "please retry" notification even though the
426
+ // original invocation is about to resume inline.
427
+ updateCanonicalGuardianRequest(escalation.requestId, {
428
+ followupState: 'inline_wait_active:' + Date.now(),
429
+ });
430
+
431
+ const waitResult = await waitForInlineGrant(
432
+ escalation.requestId,
433
+ deferredConsumeParams!,
434
+ {
435
+ maxWaitMs: this.inlineGrantWaitConfig.maxWaitMs,
436
+ intervalMs: this.inlineGrantWaitConfig.intervalMs,
437
+ signal: context.signal,
438
+ },
439
+ );
440
+
441
+ if (waitResult.outcome === 'granted') {
442
+ // Clear the inline-wait stamp now that the grant has been consumed.
443
+ updateCanonicalGuardianRequest(escalation.requestId, {
444
+ followupState: null,
445
+ });
446
+ log.info({
447
+ toolName: name,
448
+ sessionId: context.sessionId,
449
+ conversationId: context.conversationId,
450
+ trustClass: context.guardianTrustClass,
451
+ executionTarget,
452
+ grantId: waitResult.grant.id,
453
+ escalationRequestId: escalation.requestId,
454
+ }, 'Inline grant wait succeeded — allowing trusted contact tool invocation');
455
+ return { allowed: true, tool, grantConsumed: true };
456
+ }
457
+
458
+ if (waitResult.outcome === 'aborted') {
459
+ // Clear the inline-wait stamp so a later guardian approval
460
+ // (if the request is still pending) will send the retry notification.
461
+ updateCanonicalGuardianRequest(escalation.requestId, {
462
+ followupState: null,
463
+ });
464
+ const durationMs = Date.now() - startTime;
465
+ emitLifecycleEvent({
466
+ type: 'error',
467
+ toolName: name,
468
+ executionTarget,
469
+ input,
470
+ workingDir: context.workingDir,
471
+ sessionId: context.sessionId,
472
+ conversationId: context.conversationId,
473
+ requestId: context.requestId,
474
+ riskLevel,
475
+ decision: 'error',
476
+ durationMs,
477
+ errorMessage: 'Cancelled',
478
+ isExpected: true,
479
+ errorCategory: 'tool_failure',
480
+ });
481
+ return { allowed: false, result: { content: 'Cancelled', isError: true } };
482
+ }
483
+
484
+ // Clear the inline-wait stamp so a later guardian approval
485
+ // (if the request is still pending after timeout) will send
486
+ // the retry notification as expected.
487
+ updateCanonicalGuardianRequest(escalation.requestId, {
488
+ followupState: null,
489
+ });
490
+
303
491
  const codeSuffix = escalation.requestCode
304
492
  ? ` (request code: ${escalation.requestCode})`
305
493
  : '';
306
- escalationMessage = `Permission denied for "${name}": guardian approval is already pending${codeSuffix}. `
307
- + `Please retry after the guardian approves.`;
494
+
495
+ let escalationMessage: string;
496
+ if (waitResult.outcome === 'denied') {
497
+ escalationMessage = `Permission denied for "${name}": the guardian rejected the request${codeSuffix}.`;
498
+ } else {
499
+ // timeout
500
+ escalationMessage = `Permission denied for "${name}": guardian approval was not received in time${codeSuffix}. `
501
+ + `Please retry after the guardian approves.`;
502
+ }
503
+
504
+ log.warn({
505
+ toolName: name,
506
+ sessionId: context.sessionId,
507
+ conversationId: context.conversationId,
508
+ trustClass: context.guardianTrustClass,
509
+ executionTarget,
510
+ reason: 'guardian_approval_required',
511
+ grantMissReason: grantResult.reason,
512
+ waitOutcome: waitResult.outcome,
513
+ escalationRequestId: escalation.requestId,
514
+ }, 'Inline grant wait ended without approval — denying trusted contact tool invocation');
515
+ const durationMs = Date.now() - startTime;
516
+ emitLifecycleEvent({
517
+ type: 'permission_denied',
518
+ toolName: name,
519
+ executionTarget,
520
+ input,
521
+ workingDir: context.workingDir,
522
+ sessionId: context.sessionId,
523
+ conversationId: context.conversationId,
524
+ requestId: context.requestId,
525
+ riskLevel,
526
+ decision: 'deny',
527
+ reason: escalationMessage,
528
+ durationMs,
529
+ });
530
+ return { allowed: false, result: { content: escalationMessage, isError: true } };
308
531
  }
309
- // If escalation.failed, fall through to generic denial message.
532
+ // escalation.failed fall through to generic denial.
310
533
  }
311
534
 
312
- const reason = escalationMessage ?? guardianApprovalDeniedMessage(context.guardianTrustClass, name);
535
+ // Unknown/unverified actors or escalation failures — generic denial.
536
+ const reason = guardianApprovalDeniedMessage(context.guardianTrustClass, name);
313
537
  log.warn({
314
538
  toolName: name,
315
539
  sessionId: context.sessionId,
@@ -318,7 +542,7 @@ export class ToolApprovalHandler {
318
542
  executionTarget,
319
543
  reason: 'guardian_approval_required',
320
544
  grantMissReason: grantResult.reason,
321
- escalated: !!escalationMessage,
545
+ escalated: false,
322
546
  }, 'Guardian approval gate blocked untrusted actor tool invocation (no matching grant)');
323
547
  const durationMs = Date.now() - startTime;
324
548
  emitLifecycleEvent({