@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -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 +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -17,6 +17,8 @@ const log = getLogger('runtime-http');
17
17
  export interface PairingHandlerContext {
18
18
  pairingStore: PairingStore;
19
19
  bearerToken: string | undefined;
20
+ /** Feature-flag client token to include in pairing approval responses so iOS can PATCH flags. */
21
+ featureFlagToken: string | undefined;
20
22
  pairingBroadcast?: (msg: ServerMessage) => void;
21
23
  }
22
24
 
@@ -90,6 +92,7 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont
90
92
  bearerToken: ctx.bearerToken,
91
93
  gatewayUrl: entry.gatewayUrl,
92
94
  localLanUrl: entry.localLanUrl,
95
+ ...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
93
96
  });
94
97
  }
95
98
 
@@ -138,6 +141,7 @@ export function handlePairingStatus(url: URL, ctx: PairingHandlerContext): Respo
138
141
  bearerToken: entry.bearerToken,
139
142
  gatewayUrl: entry.gatewayUrl,
140
143
  localLanUrl: entry.localLanUrl,
144
+ ...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
141
145
  });
142
146
  }
143
147
 
@@ -17,9 +17,9 @@ import {
17
17
  pbkdf2Sync,
18
18
  randomBytes,
19
19
  } from 'node:crypto';
20
- import { chmodSync,readFileSync, writeFileSync } from 'node:fs';
20
+ import { chmodSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
21
  import { hostname, userInfo } from 'node:os';
22
- import { dirname,join } from 'node:path';
22
+ import { dirname, join } from 'node:path';
23
23
 
24
24
  import { ensureDir,pathExists } from '../util/fs.js';
25
25
  import { getLogger } from '../util/logger.js';
@@ -94,24 +94,40 @@ function deriveKey(salt: Buffer): Buffer {
94
94
 
95
95
  /**
96
96
  * Read result: distinguishes "file missing" from "file corrupt/unreadable".
97
- * - `null`: file does not exist (safe to create)
97
+ * - `null`: file does not exist or was corrupt (backed up and removed)
98
98
  * - `StoreFile`: successfully parsed
99
- * - throws: file exists but cannot be parsed (corrupt/invalid)
99
+ * - throws: transient I/O error from readFileSync (EACCES, EMFILE, EIO, etc.)
100
100
  */
101
101
  function readStore(): StoreFile | null {
102
102
  const path = getStorePath();
103
103
  if (!pathExists(path)) return null;
104
104
 
105
+ // Read outside the parse try/catch so transient filesystem errors (EACCES,
106
+ // EMFILE, EIO) propagate to callers instead of triggering corruption recovery.
105
107
  const raw = readFileSync(path, 'utf-8');
106
- const parsed = JSON.parse(raw);
107
- if (parsed.version !== 1 || typeof parsed.salt !== 'string' || typeof parsed.entries !== 'object') {
108
- throw new Error('Encrypted store has invalid format');
108
+
109
+ try {
110
+ const parsed = JSON.parse(raw);
111
+ if (parsed.version !== 1 || typeof parsed.salt !== 'string' || typeof parsed.entries !== 'object') {
112
+ throw new Error('Encrypted store has invalid format');
113
+ }
114
+ // Use null-prototype object for entries to prevent prototype pollution
115
+ const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
116
+ Object.assign(safeEntries, parsed.entries);
117
+ parsed.entries = safeEntries;
118
+ return parsed as StoreFile;
119
+ } catch (err) {
120
+ // Corrupted or invalid store file — back it up and start fresh so the
121
+ // daemon doesn't crash on every credential access.
122
+ const backupPath = `${path}.corrupt.${Date.now()}`;
123
+ log.error({ err, backupPath }, 'Encrypted store is corrupt — backing up and resetting');
124
+ try {
125
+ renameSync(path, backupPath);
126
+ } catch (renameErr) {
127
+ log.warn({ err: renameErr }, 'Failed to back up corrupt store file');
128
+ }
129
+ return null;
109
130
  }
110
- // Use null-prototype object for entries to prevent prototype pollution
111
- const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
112
- Object.assign(safeEntries, parsed.entries);
113
- parsed.entries = safeEntries;
114
- return parsed as StoreFile;
115
131
  }
116
132
 
117
133
  function writeStore(store: StoreFile): void {
@@ -123,11 +139,9 @@ function writeStore(store: StoreFile): void {
123
139
  }
124
140
 
125
141
  function getOrCreateStore(): StoreFile {
126
- const path = getStorePath();
127
- if (pathExists(path)) {
128
- // File exists — must be parseable, otherwise fail to prevent data loss
129
- return readStore()!;
130
- }
142
+ const existing = readStore();
143
+ if (existing) return existing;
144
+
131
145
  const salt = randomBytes(SALT_LENGTH);
132
146
  const entries: Record<string, EncryptedEntry> = Object.create(null);
133
147
  const store: StoreFile = {
@@ -4,17 +4,22 @@
4
4
  * - macOS: uses the `security` CLI to interact with Keychain
5
5
  * - Linux: uses `secret-tool` (libsecret) for GNOME/KDE keyrings
6
6
  *
7
- * All operations are synchronous to match the config loader's sync API.
7
+ * Sync variants (getKey, setKey, deleteKey) match the config loader's sync API.
8
+ * Async variants (getKeyAsync, setKeyAsync, deleteKeyAsync) avoid blocking
9
+ * the event loop and should be preferred for non-startup code paths.
10
+ *
8
11
  * Callers should check `isKeychainAvailable()` before use and fall back
9
12
  * to encrypted-at-rest storage when the keychain is not accessible.
10
13
  */
11
14
 
12
- import { execFileSync } from 'node:child_process';
15
+ import { execFile, execFileSync } from 'node:child_process';
16
+ import { promisify } from 'node:util';
13
17
 
14
18
  import { getLogger } from '../util/logger.js';
15
19
  import { isLinux,isMacOS } from '../util/platform.js';
16
20
 
17
21
  const log = getLogger('keychain');
22
+ const execFileAsync = promisify(execFile);
18
23
 
19
24
  const SERVICE_NAME = 'vellum-assistant';
20
25
 
@@ -67,6 +72,29 @@ export function isKeychainAvailable(): boolean {
67
72
  }
68
73
  }
69
74
 
75
+ /** Async version of `isKeychainAvailable` — probes without blocking the event loop. */
76
+ export async function isKeychainAvailableAsync(): Promise<boolean> {
77
+ try {
78
+ if (deps.isMacOS()) {
79
+ await execFileAsync('security', ['list-keychains'], {
80
+ timeout: 5000,
81
+ });
82
+ return true;
83
+ }
84
+
85
+ if (deps.isLinux()) {
86
+ await execFileAsync('which', ['secret-tool'], {
87
+ timeout: 5000,
88
+ });
89
+ return true;
90
+ }
91
+
92
+ return false;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
70
98
  /**
71
99
  * Retrieve a secret from the OS keychain.
72
100
  * Returns `null` if the key doesn't exist.
@@ -251,3 +279,149 @@ function linuxDeleteKey(account: string): boolean {
251
279
  return false;
252
280
  }
253
281
  }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Async variants — non-blocking alternatives to the sync functions above.
285
+ // Preferred for non-startup code paths to avoid blocking the event loop.
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Async version of `getKey` — retrieve a secret without blocking the event loop.
290
+ * Returns `null` if the key doesn't exist.
291
+ * Throws on runtime errors (keychain unavailable, locked, etc.).
292
+ */
293
+ export async function getKeyAsync(account: string): Promise<string | null> {
294
+ if (deps.isMacOS()) return macosGetKeyAsync(account);
295
+ if (deps.isLinux()) return linuxGetKeyAsync(account);
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Async version of `setKey` — store a secret without blocking the event loop.
301
+ * Returns true on success, false on failure.
302
+ */
303
+ export async function setKeyAsync(account: string, value: string): Promise<boolean> {
304
+ try {
305
+ if (deps.isMacOS()) return await macosSetKeyAsync(account, value);
306
+ if (deps.isLinux()) return await linuxSetKeyAsync(account, value);
307
+ return false;
308
+ } catch (err) {
309
+ log.warn({ err, account }, 'Failed to write to keychain');
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Async version of `deleteKey` — delete a secret without blocking the event loop.
316
+ * Returns true on success, false if not found or on failure.
317
+ */
318
+ export async function deleteKeyAsync(account: string): Promise<boolean> {
319
+ try {
320
+ if (deps.isMacOS()) return await macosDeleteKeyAsync(account);
321
+ if (deps.isLinux()) return await linuxDeleteKeyAsync(account);
322
+ return false;
323
+ } catch (err) {
324
+ log.debug({ err, account }, 'Failed to delete from keychain');
325
+ return false;
326
+ }
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Async macOS Keychain
331
+ // ---------------------------------------------------------------------------
332
+
333
+ async function macosGetKeyAsync(account: string): Promise<string | null> {
334
+ try {
335
+ const { stdout } = await execFileAsync('security', [
336
+ 'find-generic-password',
337
+ '-s', SERVICE_NAME,
338
+ '-a', account,
339
+ '-w',
340
+ ], { timeout: 5000 });
341
+ return stdout.replace(/\n$/, '') || null;
342
+ } catch (err: unknown) {
343
+ // Exit code 44 = item not found — return null.
344
+ if (err && typeof err === 'object' && 'code' in err && (err as { code: number }).code === 44) {
345
+ return null;
346
+ }
347
+ throw err;
348
+ }
349
+ }
350
+
351
+ async function macosSetKeyAsync(account: string, value: string): Promise<boolean> {
352
+ try {
353
+ await execFileAsync('security', [
354
+ 'add-generic-password',
355
+ '-s', SERVICE_NAME,
356
+ '-a', account,
357
+ '-w', value,
358
+ '-U',
359
+ ], { timeout: 5000 });
360
+ return true;
361
+ } catch {
362
+ return false;
363
+ }
364
+ }
365
+
366
+ async function macosDeleteKeyAsync(account: string): Promise<boolean> {
367
+ try {
368
+ await execFileAsync('security', [
369
+ 'delete-generic-password',
370
+ '-s', SERVICE_NAME,
371
+ '-a', account,
372
+ ], { timeout: 5000 });
373
+ return true;
374
+ } catch {
375
+ return false;
376
+ }
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Async Linux via `secret-tool`
381
+ // ---------------------------------------------------------------------------
382
+
383
+ async function linuxGetKeyAsync(account: string): Promise<string | null> {
384
+ try {
385
+ const { stdout } = await execFileAsync('secret-tool', [
386
+ 'lookup',
387
+ 'service', SERVICE_NAME,
388
+ 'account', account,
389
+ ], { timeout: 5000 });
390
+ return stdout.replace(/\n$/, '') || null;
391
+ } catch (err: unknown) {
392
+ if (err && typeof err === 'object' && 'code' in err && (err as { code: number }).code === 1) {
393
+ const stderr = String((err as { stderr?: unknown }).stderr ?? '').trim();
394
+ if (stderr.length > 0) throw err;
395
+ return null;
396
+ }
397
+ throw err;
398
+ }
399
+ }
400
+
401
+ async function linuxSetKeyAsync(account: string, value: string): Promise<boolean> {
402
+ // secret-tool reads the secret from stdin
403
+ return new Promise<boolean>((resolve) => {
404
+ const child = execFile('secret-tool', [
405
+ 'store',
406
+ '--label', `${SERVICE_NAME}: ${account}`,
407
+ 'service', SERVICE_NAME,
408
+ 'account', account,
409
+ ], { timeout: 5000 }, (err) => {
410
+ resolve(!err);
411
+ });
412
+ child.stdin?.end(value);
413
+ });
414
+ }
415
+
416
+ async function linuxDeleteKeyAsync(account: string): Promise<boolean> {
417
+ try {
418
+ await execFileAsync('secret-tool', [
419
+ 'clear',
420
+ 'service', SERVICE_NAME,
421
+ 'account', account,
422
+ ], { timeout: 5000 });
423
+ return true;
424
+ } catch {
425
+ return false;
426
+ }
427
+ }
@@ -30,6 +30,19 @@ function getBackend(): Backend {
30
30
  return resolvedBackend;
31
31
  }
32
32
 
33
+ async function getBackendAsync(): Promise<Backend> {
34
+ if (resolvedBackend !== undefined) return resolvedBackend;
35
+
36
+ if (await keychain.isKeychainAvailableAsync()) {
37
+ log.debug('Using OS keychain for secure key storage');
38
+ resolvedBackend = 'keychain';
39
+ } else {
40
+ log.debug('OS keychain unavailable, using encrypted file storage');
41
+ resolvedBackend = 'encrypted';
42
+ }
43
+ return resolvedBackend;
44
+ }
45
+
33
46
  /**
34
47
  * Try a keychain operation; on failure, permanently downgrade to encrypted
35
48
  * backend and retry. This handles systems where the keychain CLI exists
@@ -167,6 +180,90 @@ export function isDowngradedFromKeychain(): boolean {
167
180
  return downgradedFromKeychain;
168
181
  }
169
182
 
183
+ // ---------------------------------------------------------------------------
184
+ // Async variants — non-blocking alternatives that avoid blocking the event
185
+ // loop during keychain operations. Preferred for non-startup code paths.
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Async version of `getSecureKey` — retrieve a secret without blocking.
190
+ */
191
+ export async function getSecureKeyAsync(account: string): Promise<string | undefined> {
192
+ const backend = await getBackendAsync();
193
+ if (backend === 'keychain') {
194
+ try {
195
+ return (await keychain.getKeyAsync(account)) ?? undefined;
196
+ } catch {
197
+ log.warn('Keychain read failed at runtime, falling back to encrypted file storage');
198
+ resolvedBackend = 'encrypted';
199
+ downgradedFromKeychain = true;
200
+ return encryptedStore.getKey(account);
201
+ }
202
+ }
203
+ if (backend === 'encrypted') {
204
+ const value = encryptedStore.getKey(account);
205
+ if (value === undefined && downgradedFromKeychain) {
206
+ try {
207
+ return (await keychain.getKeyAsync(account)) ?? undefined;
208
+ } catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ return value;
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ /**
218
+ * Async version of `setSecureKey` — store a secret without blocking.
219
+ */
220
+ export async function setSecureKeyAsync(account: string, value: string): Promise<boolean> {
221
+ const backend = await getBackendAsync();
222
+ if (backend === 'encrypted') return encryptedStore.setKey(account, value);
223
+ if (backend !== 'keychain') return false;
224
+
225
+ const result = await keychain.setKeyAsync(account, value);
226
+ if (result === false) {
227
+ log.warn('Keychain operation failed at runtime, falling back to encrypted file storage');
228
+ resolvedBackend = 'encrypted';
229
+ downgradedFromKeychain = true;
230
+ return encryptedStore.setKey(account, value);
231
+ }
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Async version of `deleteSecureKey` — delete a secret without blocking.
237
+ */
238
+ export async function deleteSecureKeyAsync(account: string): Promise<boolean> {
239
+ const backend = await getBackendAsync();
240
+ if (backend === 'encrypted') {
241
+ const result = encryptedStore.deleteKey(account);
242
+ if (downgradedFromKeychain) {
243
+ await keychain.deleteKeyAsync(account); // best-effort
244
+ }
245
+ return result;
246
+ }
247
+ if (backend !== 'keychain') return false;
248
+
249
+ try {
250
+ if ((await keychain.getKeyAsync(account)) == null) {
251
+ return false;
252
+ }
253
+ } catch {
254
+ // fall through
255
+ }
256
+
257
+ const result = await keychain.deleteKeyAsync(account);
258
+ if (result === false) {
259
+ log.warn('Keychain operation failed at runtime, falling back to encrypted file storage');
260
+ resolvedBackend = 'encrypted';
261
+ downgradedFromKeychain = true;
262
+ return encryptedStore.deleteKey(account);
263
+ }
264
+ return result;
265
+ }
266
+
170
267
  /** @internal Test-only: reset the cached backend so it's re-evaluated. */
171
268
  export function _resetBackend(): void {
172
269
  resolvedBackend = undefined;
@@ -27,7 +27,7 @@ export function canonicalJsonSerialize(value: unknown): string {
27
27
  }
28
28
 
29
29
  function sortKeysDeep(value: unknown): unknown {
30
- if (value === null || value === undefined) return value;
30
+ if (value == null) return value;
31
31
 
32
32
  if (Array.isArray(value)) {
33
33
  return value.map(sortKeysDeep);
@@ -12,7 +12,7 @@ import {
12
12
  import type { ToolContext, ToolExecutionResult } from '../types.js';
13
13
  import { detectAuthChallenge, detectCaptchaChallenge, formatAuthChallenge } from './auth-detector.js';
14
14
  import type { PageResponse,RouteHandler } from './browser-manager.js';
15
- import { browserManager } from './browser-manager.js';
15
+ import { browserManager, SCREENCAST_HEIGHT,SCREENCAST_WIDTH } from './browser-manager.js';
16
16
  import {
17
17
  ensureScreencast,
18
18
  getElementBounds,
@@ -770,7 +770,7 @@ export async function executeBrowserScroll(
770
770
  if (bounds) {
771
771
  // Convert screencast coords back to page coords for mouse.move
772
772
  const result = await page.evaluate(`(() => ({ vw: window.innerWidth, vh: window.innerHeight }))()`) as { vw: number; vh: number };
773
- const scale = Math.min(1280 / result.vw, 960 / result.vh);
773
+ const scale = Math.min(SCREENCAST_WIDTH / result.vw, SCREENCAST_HEIGHT / result.vh);
774
774
  const pageX = (bounds.x + bounds.w / 2) / scale;
775
775
  const pageY = (bounds.y + bounds.h / 2) / scale;
776
776
  await page.mouse.move(pageX, pageY);
@@ -10,6 +10,11 @@ import { checkBrowserRuntime } from './runtime-check.js';
10
10
 
11
11
  const log = getLogger('browser-manager');
12
12
 
13
+ // Screencast capture dimensions — used by coordinate math across the browser module
14
+ // to map between page coordinates and screencast-frame coordinates.
15
+ export const SCREENCAST_WIDTH = 800;
16
+ export const SCREENCAST_HEIGHT = 600;
17
+
13
18
  function getDownloadsDir(): string {
14
19
  const dir = join(getDataDir(), 'browser-downloads');
15
20
  mkdirSync(dir, { recursive: true });
@@ -20,6 +25,7 @@ export type DownloadInfo = { path: string; filename: string };
20
25
 
21
26
  type BrowserContext = {
22
27
  newPage(): Promise<Page>;
28
+ pages?(): Page[];
23
29
  close(): Promise<void>;
24
30
  };
25
31
 
@@ -253,32 +259,11 @@ class BrowserManager {
253
259
  }
254
260
  }
255
261
 
256
- // If a client is connected, launch headed Chromium (minimized) so the user
257
- // can interact directly when handoff triggers (e.g. CAPTCHAs).
258
- // The window stays offscreen until bringToFront() is called during handoff.
259
- const hasSender = !!(invokingSessionId && this.sessionSenders.get(invokingSessionId));
260
- if (hasSender && this._browserMode === 'headless') {
261
- try {
262
- const pw2 = await import('playwright');
263
- const headedBrowser = await pw2.chromium.launch({
264
- channel: 'chrome',
265
- headless: false,
266
- args: [
267
- '--window-position=-32000,-32000',
268
- '--window-size=1,1',
269
- '--disable-blink-features=AutomationControlled',
270
- ],
271
- });
272
- const ctx = headedBrowser.contexts()[0] || await headedBrowser.newContext();
273
- this.cdpBrowser = headedBrowser as unknown as typeof this.cdpBrowser;
274
- this._browserLaunched = true;
275
- this.setBrowserMode('cdp');
276
- await this.initBrowserCdpSession();
277
- log.info('Launched headed Chromium (minimized) for interactive handoff support');
278
- return ctx as unknown as BrowserContext;
279
- } catch (err2) {
280
- log.warn({ err: err2 }, 'Headed Chromium launch failed, falling back to headless');
281
- }
262
+ if (invokingSessionId && this.sessionSenders.get(invokingSessionId) && this._browserMode === 'headless') {
263
+ log.info(
264
+ { sessionId: invokingSessionId },
265
+ 'CDP unavailable/declined; staying in headless mode (no visible browser window will be auto-launched)',
266
+ );
282
267
  }
283
268
  }
284
269
 
@@ -380,7 +365,26 @@ class BrowserManager {
380
365
  this.snapshotMaps.delete(sessionId);
381
366
  await this.stopScreencast(sessionId);
382
367
 
383
- const page = await context.newPage();
368
+ let page: Page | undefined;
369
+
370
+ // In connectOverCDP mode, Chrome often starts with a pre-opened blank tab.
371
+ // Only reuse blank/new-tab pages to avoid hijacking active user tabs, which
372
+ // could cause user-visible disruption or data loss when the session closes.
373
+ if (this._browserMode === 'cdp' && !this._browserLaunched && typeof context.pages === 'function') {
374
+ const BLANK_TAB_URLS = new Set(['about:blank', 'chrome://newtab/', 'chrome://new-tab-page/']);
375
+ const claimedPages = new Set(this.pages.values());
376
+ const reusable = context.pages().find((p) => {
377
+ if (p.isClosed() || claimedPages.has(p)) return false;
378
+ const url = p.url();
379
+ return BLANK_TAB_URLS.has(url) || url === '';
380
+ });
381
+ if (reusable) {
382
+ page = reusable;
383
+ log.debug({ sessionId, url: reusable.url() }, 'Reusing blank CDP tab instead of creating a new page');
384
+ }
385
+ }
386
+
387
+ page ??= await context.newPage();
384
388
  this.pages.set(sessionId, page);
385
389
  this.rawPages.set(sessionId, page);
386
390
 
@@ -509,17 +513,27 @@ class BrowserManager {
509
513
  this.cdpSessions.set(sessionId, cdp);
510
514
  this.screencastCallbacks.set(sessionId, onFrame);
511
515
 
516
+ // Keep screencast intentionally low-frequency to avoid Chrome renderer /
517
+ // WindowServer spikes while users type in interactive auth flows.
518
+ const MIN_FRAME_INTERVAL_MS = 1000;
519
+ let lastFrameTime = 0;
520
+
512
521
  cdp.on('Page.screencastFrame', (params) => {
513
- onFrame({ data: params.data as string, metadata: params.metadata as ScreencastFrameMetadata });
522
+ const now = Date.now();
523
+ if (now - lastFrameTime >= MIN_FRAME_INTERVAL_MS) {
524
+ lastFrameTime = now;
525
+ onFrame({ data: params.data as string, metadata: params.metadata as ScreencastFrameMetadata });
526
+ }
527
+ // Always ack so CDP continues delivering frames (otherwise it stalls)
514
528
  silentlyWithLog(cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }), 'screencast frame ack');
515
529
  });
516
530
 
517
531
  await cdp.send('Page.startScreencast', {
518
532
  format: 'jpeg',
519
- quality: 60,
520
- maxWidth: 1280,
521
- maxHeight: 960,
522
- everyNthFrame: 1,
533
+ quality: 30,
534
+ maxWidth: SCREENCAST_WIDTH,
535
+ maxHeight: SCREENCAST_HEIGHT,
536
+ everyNthFrame: 4,
523
537
  });
524
538
  }
525
539
 
@@ -1,7 +1,7 @@
1
1
  import { v4 as uuid } from 'uuid';
2
2
 
3
3
  import type { BrowserFrame, BrowserViewSurfaceData, ServerMessage } from '../../daemon/ipc-contract.js';
4
- import { browserManager } from './browser-manager.js';
4
+ import { browserManager, SCREENCAST_HEIGHT,SCREENCAST_WIDTH } from './browser-manager.js';
5
5
 
6
6
  // Track active screencast sessions
7
7
  const activeScreencasts = new Map<string, { surfaceId: string }>();
@@ -161,7 +161,7 @@ export async function getElementBounds(
161
161
  })()
162
162
  `) as { x: number; y: number; w: number; h: number; vw: number; vh: number } | null;
163
163
  if (!result) return null;
164
- const scale = Math.min(1280 / result.vw, 960 / result.vh);
164
+ const scale = Math.min(SCREENCAST_WIDTH / result.vw, SCREENCAST_HEIGHT / result.vh);
165
165
  return {
166
166
  x: result.x * scale,
167
167
  y: result.y * scale,
@@ -23,7 +23,7 @@ export async function executeCallStart(
23
23
  return {
24
24
  content: [
25
25
  'Error: A guardian voice verification call is already active for this number.',
26
- 'Use the guardian outbound verification flow (`/v1/integrations/guardian/outbound/start` or `/resend`) and wait for completion before using `call_start`.',
26
+ 'Use the guardian outbound verification flow via the gateway API (`/v1/integrations/guardian/outbound/start` or `/resend`) and wait for completion before using `call_start`.',
27
27
  ].join(' '),
28
28
  isError: true,
29
29
  };
@@ -58,7 +58,7 @@ export class ToolExecutor {
58
58
 
59
59
  // Run pre-execution approval gates (abort, parental controls, guardian
60
60
  // policy, allowed-tool-set, task-run preflight, tool registry lookup).
61
- const gateResult = this.approvalHandler.checkPreExecutionGates(
61
+ const gateResult = await this.approvalHandler.checkPreExecutionGates(
62
62
  name, input, context, executionTarget, riskLevel, startTime,
63
63
  (event) => emitLifecycleEvent(context, event),
64
64
  );
@@ -70,24 +70,29 @@ export class ToolExecutor {
70
70
  const tool = gateResult.tool;
71
71
 
72
72
  try {
73
- // Check permissions via the extracted PermissionChecker
74
- const permResult = await this.permissionChecker.checkPermission(
75
- name,
76
- input,
77
- tool,
78
- context,
79
- executionTarget,
80
- (event) => emitLifecycleEvent(context, event),
81
- sanitizeToolInput,
82
- startTime,
83
- computePreviewDiff,
84
- );
73
+ // A consumed scoped grant is a complete authorization — skip the
74
+ // interactive permission/prompt flow so non-interactive sessions
75
+ // don't auto-deny prompt-gated tools and burn the one-time grant.
76
+ if (!gateResult.grantConsumed) {
77
+ // Check permissions via the extracted PermissionChecker
78
+ const permResult = await this.permissionChecker.checkPermission(
79
+ name,
80
+ input,
81
+ tool,
82
+ context,
83
+ executionTarget,
84
+ (event) => emitLifecycleEvent(context, event),
85
+ sanitizeToolInput,
86
+ startTime,
87
+ computePreviewDiff,
88
+ );
85
89
 
86
- riskLevel = permResult.riskLevel;
87
- decision = permResult.decision;
90
+ riskLevel = permResult.riskLevel;
91
+ decision = permResult.decision;
88
92
 
89
- if (!permResult.allowed) {
90
- return { content: permResult.content, isError: true };
93
+ if (!permResult.allowed) {
94
+ return { content: permResult.content, isError: true };
95
+ }
91
96
  }
92
97
 
93
98
  const hookResult = await getHookManager().trigger('pre-tool-execute', {