@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -1107,6 +1107,126 @@ describe("session-agent-loop", () => {
1107
1107
  });
1108
1108
  });
1109
1109
 
1110
+ describe("stale pending surface cleanup", () => {
1111
+ test("auto-completes non-dynamic_page pending surfaces on regular user message", async () => {
1112
+ const events: ServerMessage[] = [];
1113
+
1114
+ const ctx = makeCtx();
1115
+ // Pre-populate a stale pending table surface
1116
+ ctx.pendingSurfaceActions.set("stale-table-1", { surfaceType: "table" });
1117
+ ctx.pendingSurfaceActions.set("stale-form-1", { surfaceType: "form" });
1118
+ // dynamic_page should be preserved
1119
+ ctx.pendingSurfaceActions.set("page-1", { surfaceType: "dynamic_page" });
1120
+
1121
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg), {
1122
+ isUserMessage: true,
1123
+ });
1124
+
1125
+ // The stale table and form surfaces should have been auto-completed
1126
+ const completeEvents = events.filter(
1127
+ (e) => e.type === "ui_surface_complete",
1128
+ );
1129
+ expect(completeEvents).toHaveLength(2);
1130
+ for (const evt of completeEvents) {
1131
+ const typed = evt as { surfaceId: string; summary: string };
1132
+ expect(typed.summary).toBe("Dismissed");
1133
+ expect(["stale-table-1", "stale-form-1"]).toContain(typed.surfaceId);
1134
+ }
1135
+
1136
+ // dynamic_page should still be pending
1137
+ expect(ctx.pendingSurfaceActions.has("page-1")).toBe(true);
1138
+ expect(ctx.pendingSurfaceActions.has("stale-table-1")).toBe(false);
1139
+ expect(ctx.pendingSurfaceActions.has("stale-form-1")).toBe(false);
1140
+ });
1141
+
1142
+ test("does not auto-complete surfaces when request is a surface action", async () => {
1143
+ const events: ServerMessage[] = [];
1144
+
1145
+ const ctx = makeCtx();
1146
+ ctx.pendingSurfaceActions.set("active-table-1", { surfaceType: "table" });
1147
+ // Mark the request ID as a surface action response
1148
+ ctx.currentRequestId = "surface-action-req";
1149
+ ctx.surfaceActionRequestIds.add("surface-action-req");
1150
+
1151
+ await runAgentLoopImpl(
1152
+ ctx,
1153
+ "[User action on table surface]",
1154
+ "msg-1",
1155
+ (msg) => events.push(msg),
1156
+ { isUserMessage: true },
1157
+ );
1158
+
1159
+ // No ui_surface_complete should have been emitted
1160
+ const completeEvents = events.filter(
1161
+ (e) => e.type === "ui_surface_complete",
1162
+ );
1163
+ expect(completeEvents).toHaveLength(0);
1164
+ // The pending surface should still be there
1165
+ expect(ctx.pendingSurfaceActions.has("active-table-1")).toBe(true);
1166
+ });
1167
+
1168
+ test("no-op when no pending surfaces exist", async () => {
1169
+ const events: ServerMessage[] = [];
1170
+
1171
+ const ctx = makeCtx();
1172
+ // No pending surfaces
1173
+
1174
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg), {
1175
+ isUserMessage: true,
1176
+ });
1177
+
1178
+ const completeEvents = events.filter(
1179
+ (e) => e.type === "ui_surface_complete",
1180
+ );
1181
+ expect(completeEvents).toHaveLength(0);
1182
+ });
1183
+
1184
+ test("does not auto-complete surfaces for internal/subagent turns (no isUserMessage)", async () => {
1185
+ const events: ServerMessage[] = [];
1186
+
1187
+ const ctx = makeCtx();
1188
+ ctx.pendingSurfaceActions.set("active-table-1", { surfaceType: "table" });
1189
+ ctx.pendingSurfaceActions.set("active-form-1", { surfaceType: "form" });
1190
+
1191
+ // Internal turn: no isUserMessage option
1192
+ await runAgentLoopImpl(ctx, "subagent notification", "msg-1", (msg) =>
1193
+ events.push(msg),
1194
+ );
1195
+
1196
+ // No ui_surface_complete should have been emitted
1197
+ const completeEvents = events.filter(
1198
+ (e) => e.type === "ui_surface_complete",
1199
+ );
1200
+ expect(completeEvents).toHaveLength(0);
1201
+ // Pending surfaces should still be there
1202
+ expect(ctx.pendingSurfaceActions.has("active-table-1")).toBe(true);
1203
+ expect(ctx.pendingSurfaceActions.has("active-form-1")).toBe(true);
1204
+ });
1205
+
1206
+ test("finally block still runs if onEvent throws during stale surface dismissal", async () => {
1207
+ let _eventCount = 0;
1208
+ const ctx = makeCtx();
1209
+ ctx.pendingSurfaceActions.set("stale-table-1", { surfaceType: "table" });
1210
+
1211
+ const throwingOnEvent = (msg: ServerMessage) => {
1212
+ _eventCount++;
1213
+ if (msg.type === "ui_surface_complete") {
1214
+ throw new Error("onEvent sink failed");
1215
+ }
1216
+ };
1217
+
1218
+ // The error from onEvent should be caught by the try/catch,
1219
+ // and the finally block should still clean up session state
1220
+ await runAgentLoopImpl(ctx, "hello", "msg-1", throwingOnEvent, {
1221
+ isUserMessage: true,
1222
+ });
1223
+
1224
+ expect(ctx.processing).toBe(false);
1225
+ expect(ctx.abortController).toBeNull();
1226
+ expect(ctx.currentRequestId).toBeUndefined();
1227
+ });
1228
+ });
1229
+
1110
1230
  describe("error-only response with no assistant text", () => {
1111
1231
  test("synthesizes error assistant message when provider returns no response", async () => {
1112
1232
  const events: ServerMessage[] = [];
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ clearAll,
5
+ clearMode,
6
+ getEffectiveMode,
7
+ hasActiveOverride,
8
+ setThreadMode,
9
+ setTimedMode,
10
+ } from '../runtime/session-approval-overrides.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Tests
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe('session-approval-overrides', () => {
17
+ afterEach(() => {
18
+ clearAll();
19
+ });
20
+
21
+ // -----------------------------------------------------------------------
22
+ // setThreadMode / getEffectiveMode
23
+ // -----------------------------------------------------------------------
24
+ describe('thread mode', () => {
25
+ test('setThreadMode stores a thread override', () => {
26
+ setThreadMode('conv-1');
27
+ const mode = getEffectiveMode('conv-1');
28
+ expect(mode).not.toBeNull();
29
+ expect(mode!.kind).toBe('thread');
30
+ });
31
+
32
+ test('thread mode persists across multiple reads', () => {
33
+ setThreadMode('conv-1');
34
+ expect(getEffectiveMode('conv-1')).not.toBeNull();
35
+ expect(getEffectiveMode('conv-1')).not.toBeNull();
36
+ });
37
+
38
+ test('thread mode is scoped to a specific conversationId', () => {
39
+ setThreadMode('conv-1');
40
+ expect(getEffectiveMode('conv-1')).not.toBeNull();
41
+ expect(getEffectiveMode('conv-2')).toBeNull();
42
+ });
43
+ });
44
+
45
+ // -----------------------------------------------------------------------
46
+ // setTimedMode / getEffectiveMode
47
+ // -----------------------------------------------------------------------
48
+ describe('timed mode', () => {
49
+ test('setTimedMode stores a timed override with default 10-minute TTL', () => {
50
+ const before = Date.now();
51
+ setTimedMode('conv-1');
52
+ const after = Date.now();
53
+
54
+ const mode = getEffectiveMode('conv-1');
55
+ expect(mode).not.toBeNull();
56
+ expect(mode!.kind).toBe('timed');
57
+
58
+ const timed = mode!;
59
+ if (timed.kind === 'timed') {
60
+ const expectedMin = before + 10 * 60 * 1000;
61
+ const expectedMax = after + 10 * 60 * 1000;
62
+ expect(timed.expiresAt).toBeGreaterThanOrEqual(expectedMin);
63
+ expect(timed.expiresAt).toBeLessThanOrEqual(expectedMax);
64
+ }
65
+ });
66
+
67
+ test('setTimedMode accepts a custom duration', () => {
68
+ const before = Date.now();
69
+ setTimedMode('conv-1', 5000);
70
+ const after = Date.now();
71
+
72
+ const mode = getEffectiveMode('conv-1');
73
+ expect(mode).not.toBeNull();
74
+ const timed = mode!;
75
+ if (timed.kind === 'timed') {
76
+ expect(timed.expiresAt).toBeGreaterThanOrEqual(before + 5000);
77
+ expect(timed.expiresAt).toBeLessThanOrEqual(after + 5000);
78
+ }
79
+ });
80
+
81
+ test('timed mode expires after TTL elapses', async () => {
82
+ setTimedMode('conv-1', 1); // 1ms TTL
83
+ // Small delay to ensure expiry
84
+ await new Promise((resolve) => setTimeout(resolve, 5));
85
+ expect(getEffectiveMode('conv-1')).toBeNull();
86
+ });
87
+
88
+ test('expired timed mode is lazily cleaned up on read', async () => {
89
+ setTimedMode('conv-1', 1);
90
+ await new Promise((resolve) => setTimeout(resolve, 5));
91
+
92
+ // First read triggers cleanup and returns null
93
+ expect(getEffectiveMode('conv-1')).toBeNull();
94
+ // Subsequent read also returns null (entry was removed)
95
+ expect(getEffectiveMode('conv-1')).toBeNull();
96
+ });
97
+ });
98
+
99
+ // -----------------------------------------------------------------------
100
+ // Mode replacement
101
+ // -----------------------------------------------------------------------
102
+ describe('mode replacement', () => {
103
+ test('setTimedMode replaces an existing thread mode', () => {
104
+ setThreadMode('conv-1');
105
+ setTimedMode('conv-1', 60_000);
106
+
107
+ const mode = getEffectiveMode('conv-1');
108
+ expect(mode).not.toBeNull();
109
+ expect(mode!.kind).toBe('timed');
110
+ });
111
+
112
+ test('setThreadMode replaces an existing timed mode', () => {
113
+ setTimedMode('conv-1', 60_000);
114
+ setThreadMode('conv-1');
115
+
116
+ const mode = getEffectiveMode('conv-1');
117
+ expect(mode).not.toBeNull();
118
+ expect(mode!.kind).toBe('thread');
119
+ });
120
+ });
121
+
122
+ // -----------------------------------------------------------------------
123
+ // clearMode
124
+ // -----------------------------------------------------------------------
125
+ describe('clearMode', () => {
126
+ test('clearMode removes a thread override', () => {
127
+ setThreadMode('conv-1');
128
+ clearMode('conv-1');
129
+ expect(getEffectiveMode('conv-1')).toBeNull();
130
+ });
131
+
132
+ test('clearMode removes a timed override', () => {
133
+ setTimedMode('conv-1', 60_000);
134
+ clearMode('conv-1');
135
+ expect(getEffectiveMode('conv-1')).toBeNull();
136
+ });
137
+
138
+ test('clearMode is a no-op for unknown conversationId', () => {
139
+ // Should not throw
140
+ clearMode('nonexistent');
141
+ expect(getEffectiveMode('nonexistent')).toBeNull();
142
+ });
143
+ });
144
+
145
+ // -----------------------------------------------------------------------
146
+ // hasActiveOverride
147
+ // -----------------------------------------------------------------------
148
+ describe('hasActiveOverride', () => {
149
+ test('returns false when no override is set', () => {
150
+ expect(hasActiveOverride('conv-1')).toBe(false);
151
+ });
152
+
153
+ test('returns true for active thread override', () => {
154
+ setThreadMode('conv-1');
155
+ expect(hasActiveOverride('conv-1')).toBe(true);
156
+ });
157
+
158
+ test('returns true for active timed override', () => {
159
+ setTimedMode('conv-1', 60_000);
160
+ expect(hasActiveOverride('conv-1')).toBe(true);
161
+ });
162
+
163
+ test('returns false after timed override expires', async () => {
164
+ setTimedMode('conv-1', 1);
165
+ await new Promise((resolve) => setTimeout(resolve, 5));
166
+ expect(hasActiveOverride('conv-1')).toBe(false);
167
+ });
168
+
169
+ test('returns false after clearMode', () => {
170
+ setThreadMode('conv-1');
171
+ clearMode('conv-1');
172
+ expect(hasActiveOverride('conv-1')).toBe(false);
173
+ });
174
+ });
175
+
176
+ // -----------------------------------------------------------------------
177
+ // clearAll
178
+ // -----------------------------------------------------------------------
179
+ describe('clearAll', () => {
180
+ test('clearAll removes all overrides', () => {
181
+ setThreadMode('conv-1');
182
+ setTimedMode('conv-2', 60_000);
183
+ setThreadMode('conv-3');
184
+
185
+ clearAll();
186
+
187
+ expect(getEffectiveMode('conv-1')).toBeNull();
188
+ expect(getEffectiveMode('conv-2')).toBeNull();
189
+ expect(getEffectiveMode('conv-3')).toBeNull();
190
+ });
191
+
192
+ test('clearAll is safe to call on empty store', () => {
193
+ // Should not throw
194
+ clearAll();
195
+ expect(hasActiveOverride('anything')).toBe(false);
196
+ });
197
+ });
198
+
199
+ // -----------------------------------------------------------------------
200
+ // getEffectiveMode with no override
201
+ // -----------------------------------------------------------------------
202
+ test('getEffectiveMode returns null for unknown conversationId', () => {
203
+ expect(getEffectiveMode('nonexistent')).toBeNull();
204
+ });
205
+ });
@@ -180,4 +180,42 @@ describe('task_progress surface compatibility', () => {
180
180
  expect(templateData.status).toBe('completed');
181
181
  expect(Array.isArray(templateData.steps)).toBe(true);
182
182
  });
183
+
184
+ test('ui_show rejects new interactive surface when a non-dynamic_page pending surface exists', async () => {
185
+ const sent: ServerMessage[] = [];
186
+ const ctx = makeContext(sent);
187
+
188
+ // Pre-populate a pending table surface (simulates a previously shown interactive surface)
189
+ ctx.pendingSurfaceActions.set('stale-surface-1', { surfaceType: 'table' });
190
+
191
+ const result = await surfaceProxyResolver(ctx, 'ui_show', {
192
+ surface_type: 'table',
193
+ title: 'New Table',
194
+ data: { columns: [], rows: [] },
195
+ actions: [{ id: 'archive', label: 'Archive' }],
196
+ });
197
+
198
+ expect(result.isError).toBe(true);
199
+ expect(result.content).toContain('Another interactive surface is already awaiting user input');
200
+ // The stale entry should still be present (guard only rejects, doesn't clean up)
201
+ expect(ctx.pendingSurfaceActions.has('stale-surface-1')).toBe(true);
202
+ });
203
+
204
+ test('ui_show allows new interactive surface when only dynamic_page surfaces are pending', async () => {
205
+ const sent: ServerMessage[] = [];
206
+ const ctx = makeContext(sent);
207
+
208
+ // dynamic_page pending entries should not block new interactive surfaces
209
+ ctx.pendingSurfaceActions.set('page-1', { surfaceType: 'dynamic_page' });
210
+
211
+ const result = await surfaceProxyResolver(ctx, 'ui_show', {
212
+ surface_type: 'table',
213
+ title: 'Email Table',
214
+ data: { columns: [], rows: [] },
215
+ actions: [{ id: 'archive', label: 'Archive' }],
216
+ });
217
+
218
+ expect(result.isError).toBe(false);
219
+ expect(sent.some(m => m.type === 'ui_surface_show')).toBe(true);
220
+ });
183
221
  });
@@ -51,8 +51,8 @@
51
51
  import type { ExtensionCommand, ExtensionResponse } from '../browser-extension-relay/protocol.js';
52
52
  import { extensionRelayServer } from '../browser-extension-relay/server.js';
53
53
  import { getGatewayInternalBaseUrl } from '../config/env.js';
54
+ import { isSigningKeyInitialized, mintEdgeRelayToken } from '../runtime/auth/token-service.js';
54
55
  import type { ExtractedCredential } from '../tools/browser/network-recording-types.js';
55
- import { readHttpToken } from '../util/platform.js';
56
56
  import {
57
57
  type AmazonSession,
58
58
  loadSession,
@@ -75,11 +75,14 @@ export async function sendRelayCommand(command: Record<string, unknown>): Promis
75
75
  return extensionRelayServer.sendCommand(command as Omit<ExtensionCommand, 'id'>);
76
76
  }
77
77
 
78
- // Fall back to HTTP relay endpoint on the daemon
79
- const token = readHttpToken();
80
- if (!token) {
81
- throw new Error('Browser extension relay is not connected and no HTTP token found. Is the daemon running?');
78
+ // Fall back to HTTP relay endpoint via the gateway.
79
+ // The gateway validates edge JWTs (aud=vellum-gateway) and mints an
80
+ // exchange token for the runtime. Without the signing key (CLI
81
+ // out-of-process), we cannot mint JWTs at all.
82
+ if (!isSigningKeyInitialized()) {
83
+ throw new Error('Auth signing key not initialized — browser-relay commands require the daemon to be running');
82
84
  }
85
+ const token = mintEdgeRelayToken();
83
86
 
84
87
  const resp = await fetch(`${getGatewayInternalBaseUrl()}/v1/browser-relay/command`, {
85
88
  method: 'POST',
@@ -162,10 +162,11 @@ export interface ApplyGuardianDecisionParams {
162
162
  export function applyGuardianDecision(params: ApplyGuardianDecisionParams): ApplyGuardianDecisionResult {
163
163
  const { approval, decision, actorExternalUserId, actorChannel, decisionContext } = params;
164
164
 
165
- // Guardians cannot approve_always on behalf of requesters -- downgrade.
166
- const effectiveDecision: ApprovalDecisionResult = decision.action === 'approve_always'
167
- ? { ...decision, action: 'approve_once' }
168
- : decision;
165
+ // Guardians cannot grant broad allow modes on behalf of requesters -- downgrade.
166
+ const effectiveDecision: ApprovalDecisionResult =
167
+ decision.action === 'approve_always' || decision.action === 'approve_10m' || decision.action === 'approve_thread'
168
+ ? { ...decision, action: 'approve_once' }
169
+ : decision;
169
170
 
170
171
  // Capture pending approval info before handleChannelDecision resolves
171
172
  // (and removes) the pending interaction. Needed for grant minting.
@@ -282,6 +283,8 @@ export function mintCanonicalRequestGrant(params: {
282
283
  /** Valid actions for canonical guardian decisions. */
283
284
  const VALID_CANONICAL_ACTIONS: ReadonlySet<ApprovalAction> = new Set([
284
285
  'approve_once',
286
+ 'approve_10m',
287
+ 'approve_thread',
285
288
  'approve_always',
286
289
  'reject',
287
290
  ]);
@@ -404,11 +407,13 @@ export async function applyCanonicalGuardianDecision(
404
407
  return { applied: false, reason: 'expired' };
405
408
  }
406
409
 
407
- // 3. Downgrade approve_always to approve_once for guardian-on-behalf requests.
408
- // Guardians cannot permanently allowlist tools on behalf of requesters.
409
- const effectiveAction: ApprovalAction = action === 'approve_always'
410
- ? 'approve_once'
411
- : action;
410
+ // 3. Downgrade approve_always and temporary modes to approve_once for
411
+ // guardian-on-behalf requests. Guardians cannot grant broad allow modes
412
+ // (permanent, timed, or thread-scoped) on behalf of requesters.
413
+ const effectiveAction: ApprovalAction =
414
+ action === 'approve_always' || action === 'approve_10m' || action === 'approve_thread'
415
+ ? 'approve_once'
416
+ : action;
412
417
 
413
418
  // 4. CAS resolve: atomically transition from 'pending' to terminal status
414
419
  const targetStatus: CanonicalRequestStatus = effectiveAction === 'reject'
@@ -18,6 +18,7 @@ import { upsertMember } from '../memory/ingress-member-store.js';
18
18
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
19
19
  import { addRule } from '../permissions/trust-store.js';
20
20
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
21
+ import { mintDaemonDeliveryToken } from '../runtime/auth/token-service.js';
21
22
  import type { ApprovalAction } from '../runtime/channel-approval-types.js';
22
23
  import { createOutboundSession } from '../runtime/channel-guardian-service.js';
23
24
  import { deliverChannelReply } from '../runtime/gateway-client.js';
@@ -25,7 +26,6 @@ import * as pendingInteractions from '../runtime/pending-interactions.js';
25
26
  import { getTool } from '../tools/registry.js';
26
27
  import { TC_GRANT_WAIT_MAX_MS } from '../tools/tool-approval-handler.js';
27
28
  import { getLogger } from '../util/logger.js';
28
- import { readHttpToken } from '../util/platform.js';
29
29
 
30
30
  const log = getLogger('guardian-request-resolvers');
31
31
 
@@ -313,7 +313,7 @@ const accessRequestResolver: GuardianRequestResolver = {
313
313
  const decidedByExternalUserId = ctx.actor.externalUserId ?? '';
314
314
  const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
315
315
  const desktopDeliverUrl = resolveDeliverCallbackUrlForChannel(channel);
316
- const desktopBearerToken = readHttpToken() ?? undefined;
316
+ const desktopBearerToken = mintDaemonDeliveryToken();
317
317
 
318
318
  if (decision.action === 'reject') {
319
319
  log.info(
@@ -19,9 +19,9 @@ import {
19
19
  } from '../memory/canonical-guardian-store.js';
20
20
  import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
21
21
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
22
+ import { mintDaemonDeliveryToken } from '../runtime/auth/token-service.js';
22
23
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
23
24
  import { getLogger } from '../util/logger.js';
24
- import { readHttpToken } from '../util/platform.js';
25
25
  import { getMaxCallDurationMs, getSilenceTimeoutMs, getUserConsultationTimeoutMs } from './call-constants.js';
26
26
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
27
27
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
@@ -949,7 +949,7 @@ export class CallController {
949
949
  canonicalDeliveries,
950
950
  this.assistantId,
951
951
  getGatewayInternalBaseUrl(),
952
- readHttpToken() ?? undefined,
952
+ mintDaemonDeliveryToken(),
953
953
  ).catch((err) => {
954
954
  log.error(
955
955
  { err, callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
@@ -9,8 +9,8 @@
9
9
  import { getCallWelcomeGreeting, getRuntimeProxyBearerToken } from '../config/env.js';
10
10
  import { loadConfig } from '../config/loader.js';
11
11
  import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
12
+ import { mintEdgeRelayToken } from '../runtime/auth/token-service.js';
12
13
  import { getLogger } from '../util/logger.js';
13
- import { readHttpToken } from '../util/platform.js';
14
14
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
15
15
  import { createInboundVoiceSession } from './call-domain.js';
16
16
  import { logDeadLetterEvent } from './call-recovery.js';
@@ -264,7 +264,7 @@ function buildVoiceWebhookTwiml(
264
264
 
265
265
  // Use the same token resolution the gateway uses for runtimeProxyBearerToken:
266
266
  // env var override first, then the on-disk http-token file.
267
- const relayToken = getRuntimeProxyBearerToken() ?? readHttpToken() ?? undefined;
267
+ const relayToken = getRuntimeProxyBearerToken() ?? mintEdgeRelayToken();
268
268
 
269
269
  // Propagate guardianVerificationSessionId as a TwiML <Parameter> for
270
270
  // observability. This is not the sole source of truth; the relay
package/src/cli/mcp.ts CHANGED
@@ -12,15 +12,15 @@ import { getCliLogger } from '../util/logger.js';
12
12
 
13
13
  const log = getCliLogger('cli');
14
14
 
15
- const HEALTH_CHECK_TIMEOUT_MS = 10_000;
15
+ export const HEALTH_CHECK_TIMEOUT_MS = 10_000;
16
16
 
17
- async function checkServerHealth(serverId: string, config: McpServerConfig): Promise<string> {
17
+ export async function checkServerHealth(serverId: string, config: McpServerConfig, timeoutMs = HEALTH_CHECK_TIMEOUT_MS): Promise<string> {
18
18
  const client = new McpClient(serverId, { quiet: true });
19
19
  try {
20
20
  await Promise.race([
21
21
  client.connect(config.transport),
22
22
  new Promise<never>((_, reject) => {
23
- const t = setTimeout(() => reject(new Error('timeout')), HEALTH_CHECK_TIMEOUT_MS);
23
+ const t = setTimeout(() => reject(new Error('timeout')), timeoutMs);
24
24
  if (typeof t === 'object' && 'unref' in t) t.unref();
25
25
  }),
26
26
  ]);
package/src/cli.ts CHANGED
@@ -232,6 +232,12 @@ export async function startCli(): Promise<void> {
232
232
  }
233
233
  process.stdout.write(`\u2502\n`);
234
234
  process.stdout.write(`\u2502 [a] Allow once\n`);
235
+ if (req.temporaryOptionsAvailable?.includes('allow_10m')) {
236
+ process.stdout.write(`\u2502 [t] Allow 10m\n`);
237
+ }
238
+ if (req.temporaryOptionsAvailable?.includes('allow_thread')) {
239
+ process.stdout.write(`\u2502 [T] Allow Thread\n`);
240
+ }
235
241
  process.stdout.write(`\u2502 [d] Deny once\n`);
236
242
  if (req.allowlistOptions.length > 0 && req.scopeOptions.length > 0) {
237
243
  process.stdout.write(`\u2502 [A] Allowlist...\n`);
@@ -277,6 +283,24 @@ export async function startCli(): Promise<void> {
277
283
  return;
278
284
  }
279
285
 
286
+ if (choice === 't' && trimmed === 't' && req.temporaryOptionsAvailable?.includes('allow_10m')) {
287
+ send({
288
+ type: 'confirmation_response',
289
+ requestId: req.requestId,
290
+ decision: 'allow_10m',
291
+ });
292
+ return;
293
+ }
294
+
295
+ if (trimmed === 'T' && req.temporaryOptionsAvailable?.includes('allow_thread')) {
296
+ send({
297
+ type: 'confirmation_response',
298
+ requestId: req.requestId,
299
+ decision: 'allow_thread',
300
+ });
301
+ return;
302
+ }
303
+
280
304
  if (choice === 'd') {
281
305
  send({
282
306
  type: 'confirmation_response',