@vellumai/assistant 0.4.35 → 0.4.37

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 (239) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +44 -49
  3. package/README.md +32 -20
  4. package/docs/architecture/keychain-broker.md +186 -0
  5. package/docs/architecture/security.md +110 -116
  6. package/docs/runbook-trusted-contacts.md +2 -2
  7. package/docs/skills.md +25 -25
  8. package/package.json +5 -2
  9. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
  10. package/src/__tests__/actor-token-service.test.ts +1 -0
  11. package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
  14. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  15. package/src/__tests__/bundle-scanner.test.ts +1 -1
  16. package/src/__tests__/channel-guardian.test.ts +102 -102
  17. package/src/__tests__/channel-invite-transport.test.ts +155 -256
  18. package/src/__tests__/channel-readiness-routes.test.ts +336 -0
  19. package/src/__tests__/checker.test.ts +6 -6
  20. package/src/__tests__/chrome-cdp.test.ts +350 -0
  21. package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
  22. package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
  23. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
  24. package/src/__tests__/config-loader-migration.test.ts +85 -0
  25. package/src/__tests__/conversation-pairing.test.ts +370 -5
  26. package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
  27. package/src/__tests__/credential-broker-server-use.test.ts +1 -10
  28. package/src/__tests__/credential-security-e2e.test.ts +7 -1
  29. package/src/__tests__/credential-security-invariants.test.ts +14 -20
  30. package/src/__tests__/credential-vault-unit.test.ts +1 -11
  31. package/src/__tests__/credential-vault.test.ts +5 -19
  32. package/src/__tests__/credentials-cli.test.ts +814 -0
  33. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
  34. package/src/__tests__/email-invite-adapter.test.ts +78 -0
  35. package/src/__tests__/email-service-config-fallback.test.ts +102 -0
  36. package/src/__tests__/encrypted-store.test.ts +6 -6
  37. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  38. package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
  39. package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
  40. package/src/__tests__/guardian-outbound-http.test.ts +53 -47
  41. package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
  42. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
  43. package/src/__tests__/handlers-telegram-config.test.ts +8 -2
  44. package/src/__tests__/handlers-twitter-config.test.ts +2 -2
  45. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
  46. package/src/__tests__/ingress-reconcile.test.ts +6 -0
  47. package/src/__tests__/intent-routing.test.ts +23 -4
  48. package/src/__tests__/invite-routes-http.test.ts +12 -0
  49. package/src/__tests__/ipc-snapshot.test.ts +8 -2
  50. package/src/__tests__/keychain-broker-client.test.ts +543 -0
  51. package/src/__tests__/llm-usage-store.test.ts +344 -0
  52. package/src/__tests__/mcp-client-auth.test.ts +2 -2
  53. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
  54. package/src/__tests__/migration-transport.test.ts +49 -0
  55. package/src/__tests__/notification-broadcaster.test.ts +205 -5
  56. package/src/__tests__/notification-deep-link.test.ts +365 -1
  57. package/src/__tests__/oauth-connect-handler.test.ts +2 -2
  58. package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
  59. package/src/__tests__/proxy-approval-callback.test.ts +1 -1
  60. package/src/__tests__/recording-handler.test.ts +1 -1
  61. package/src/__tests__/recording-intent-handler.test.ts +6 -1
  62. package/src/__tests__/recording-state-machine.test.ts +1 -1
  63. package/src/__tests__/relay-server.test.ts +9 -1
  64. package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
  65. package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
  66. package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
  67. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
  68. package/src/__tests__/secret-onetime-send.test.ts +8 -2
  69. package/src/__tests__/secure-keys.test.ts +175 -216
  70. package/src/__tests__/session-confirmation-signals.test.ts +1 -1
  71. package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
  72. package/src/__tests__/session-queue.test.ts +2 -1
  73. package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
  74. package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
  75. package/src/__tests__/skill-feature-flags.test.ts +12 -9
  76. package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
  77. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  78. package/src/__tests__/skills.test.ts +34 -4
  79. package/src/__tests__/slack-channel-config.test.ts +2 -2
  80. package/src/__tests__/system-prompt.test.ts +26 -4
  81. package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
  82. package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  84. package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
  85. package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
  86. package/src/__tests__/twitter-auth-handler.test.ts +2 -2
  87. package/src/__tests__/twitter-oauth-client.test.ts +1 -1
  88. package/src/__tests__/usage-routes.test.ts +339 -0
  89. package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
  90. package/src/agent/loop.ts +3 -0
  91. package/src/amazon/checkout.ts +0 -1
  92. package/src/approvals/guardian-request-resolvers.ts +9 -1
  93. package/src/bundler/app-bundler.ts +28 -12
  94. package/src/bundler/bundle-scanner.ts +1 -1
  95. package/src/bundler/bundle-signer.ts +3 -3
  96. package/src/bundler/manifest.ts +1 -1
  97. package/src/bundler/signature-verifier.ts +3 -3
  98. package/src/channels/config.ts +1 -1
  99. package/src/cli/AGENTS.md +63 -0
  100. package/src/cli/__tests__/notifications.test.ts +470 -0
  101. package/src/cli/amazon.ts +344 -167
  102. package/src/cli/audit.ts +85 -0
  103. package/src/cli/autonomy.ts +369 -0
  104. package/src/cli/channels.ts +51 -0
  105. package/src/cli/completions.ts +208 -0
  106. package/src/cli/config.ts +220 -0
  107. package/src/cli/contacts.ts +471 -0
  108. package/src/cli/credentials.ts +564 -0
  109. package/src/cli/default-action.ts +14 -0
  110. package/src/cli/dev.ts +131 -0
  111. package/src/cli/doctor.ts +398 -0
  112. package/src/cli/email.ts +494 -0
  113. package/src/cli/influencer.ts +72 -0
  114. package/src/cli/integrations.ts +248 -57
  115. package/src/cli/keys.ts +114 -0
  116. package/src/cli/map.ts +46 -54
  117. package/src/cli/mcp.ts +111 -3
  118. package/src/cli/{config-commands.ts → memory.ts} +134 -245
  119. package/src/cli/notifications.ts +407 -0
  120. package/src/cli/program.ts +65 -0
  121. package/src/cli/reference.ts +48 -0
  122. package/src/cli/sequence.ts +154 -0
  123. package/src/cli/sessions.ts +262 -0
  124. package/src/cli/trust.ts +175 -0
  125. package/src/cli/twitter.ts +323 -106
  126. package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
  127. package/src/config/bundled-skills/amazon/SKILL.md +2 -2
  128. package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
  129. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
  130. package/src/config/bundled-skills/contacts/SKILL.md +178 -10
  131. package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
  132. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
  133. package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
  134. package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
  135. package/src/config/bundled-tool-registry.ts +2 -0
  136. package/src/config/core-schema.ts +7 -0
  137. package/src/config/feature-flag-registry.json +16 -0
  138. package/src/config/loader.ts +26 -0
  139. package/src/config/schema.ts +4 -0
  140. package/src/config/skill-state.ts +0 -13
  141. package/src/config/system-prompt.ts +27 -0
  142. package/src/contacts/contact-store.ts +25 -0
  143. package/src/daemon/computer-use-session.ts +1 -1
  144. package/src/daemon/handlers/apps.ts +1 -0
  145. package/src/daemon/handlers/config-channels.ts +3 -3
  146. package/src/daemon/handlers/config-dispatch.ts +29 -0
  147. package/src/daemon/handlers/config-inbox.ts +4 -3
  148. package/src/daemon/handlers/config.ts +3 -43
  149. package/src/daemon/handlers/contacts.ts +34 -0
  150. package/src/daemon/handlers/index.ts +17 -3
  151. package/src/daemon/handlers/session-user-message.ts +7 -0
  152. package/src/daemon/handlers/sessions.ts +21 -2
  153. package/src/daemon/handlers/shared.ts +17 -0
  154. package/src/daemon/ipc-contract/apps.ts +2 -0
  155. package/src/daemon/ipc-contract/computer-use.ts +9 -0
  156. package/src/daemon/ipc-contract/contacts.ts +3 -3
  157. package/src/daemon/ipc-contract/inbox.ts +2 -0
  158. package/src/daemon/ipc-contract/messages.ts +4 -0
  159. package/src/daemon/ipc-contract/sessions.ts +8 -0
  160. package/src/daemon/ipc-contract-inventory.json +1 -0
  161. package/src/daemon/lifecycle.ts +0 -5
  162. package/src/daemon/ride-shotgun-handler.ts +139 -25
  163. package/src/daemon/session-agent-loop-handlers.ts +100 -0
  164. package/src/daemon/session-agent-loop.ts +72 -0
  165. package/src/daemon/session-tool-setup.ts +7 -0
  166. package/src/daemon/session.ts +23 -1
  167. package/src/daemon/tool-side-effects.ts +39 -1
  168. package/src/email/service.ts +59 -2
  169. package/src/index.ts +2 -60
  170. package/src/mcp/mcp-oauth-provider.ts +90 -8
  171. package/src/media/app-icon-generator.ts +86 -0
  172. package/src/memory/db-init.ts +11 -0
  173. package/src/memory/llm-usage-store.ts +186 -0
  174. package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
  175. package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
  176. package/src/memory/migrations/index.ts +2 -0
  177. package/src/memory/schema-migration.ts +1 -0
  178. package/src/memory/shared-app-links-store.ts +1 -1
  179. package/src/messaging/registry.ts +27 -0
  180. package/src/notifications/README.md +79 -70
  181. package/src/notifications/broadcaster.ts +2 -1
  182. package/src/notifications/conversation-pairing.ts +147 -13
  183. package/src/notifications/copy-composer.ts +7 -3
  184. package/src/notifications/destination-resolver.ts +14 -1
  185. package/src/notifications/emit-signal.ts +3 -2
  186. package/src/notifications/signal.ts +105 -1
  187. package/src/notifications/types.ts +16 -0
  188. package/src/permissions/checker.ts +29 -3
  189. package/src/permissions/prompter.ts +11 -3
  190. package/src/runtime/access-request-helper.ts +2 -1
  191. package/src/runtime/auth/route-policy.ts +7 -1
  192. package/src/runtime/channel-invite-transport.ts +40 -63
  193. package/src/runtime/channel-invite-transports/email.ts +13 -39
  194. package/src/runtime/channel-invite-transports/slack.ts +5 -34
  195. package/src/runtime/channel-invite-transports/sms.ts +8 -29
  196. package/src/runtime/channel-invite-transports/telegram.ts +69 -28
  197. package/src/runtime/channel-invite-transports/voice.ts +0 -7
  198. package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
  199. package/src/runtime/channel-readiness-service.ts +202 -45
  200. package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
  201. package/src/runtime/guardian-outbound-actions.ts +8 -5
  202. package/src/runtime/http-server.ts +2 -0
  203. package/src/runtime/invite-instruction-generator.ts +178 -0
  204. package/src/runtime/invite-service.ts +22 -25
  205. package/src/runtime/migrations/migration-transport.ts +13 -0
  206. package/src/runtime/routes/app-routes.ts +1 -1
  207. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
  208. package/src/runtime/routes/channel-readiness-routes.ts +30 -11
  209. package/src/runtime/routes/contact-routes.ts +54 -26
  210. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
  211. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
  212. package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
  213. package/src/runtime/routes/integration-routes.ts +1 -1
  214. package/src/runtime/routes/invite-routes.ts +1 -1
  215. package/src/runtime/routes/secret-routes.ts +31 -7
  216. package/src/runtime/routes/twilio-routes.ts +32 -1
  217. package/src/runtime/routes/usage-routes.ts +114 -0
  218. package/src/runtime/tool-grant-request-helper.ts +2 -1
  219. package/src/security/encrypted-store.ts +9 -5
  220. package/src/security/keychain-broker-client.ts +393 -0
  221. package/src/security/secure-keys.ts +106 -321
  222. package/src/tools/apps/executors.ts +73 -0
  223. package/src/tools/browser/auto-navigate.ts +15 -6
  224. package/src/tools/browser/chrome-cdp.ts +211 -0
  225. package/src/tools/browser/network-recorder.test.ts +83 -0
  226. package/src/tools/browser/network-recorder.ts +8 -7
  227. package/src/tools/browser/x-auto-navigate.ts +12 -6
  228. package/src/tools/credentials/policy-types.ts +24 -0
  229. package/src/tools/credentials/vault.ts +22 -27
  230. package/src/tools/network/script-proxy/session-manager.ts +47 -3
  231. package/src/tools/permission-checker.ts +1 -0
  232. package/src/tools/types.ts +2 -0
  233. package/src/tools/ui-surface/definitions.ts +1 -2
  234. package/src/tools/watch/watch-state.ts +2 -0
  235. package/src/__tests__/key-migration.test.ts +0 -240
  236. package/src/__tests__/keychain.test.ts +0 -286
  237. package/src/cli/core-commands.ts +0 -899
  238. package/src/security/keychain-to-encrypted-migration.ts +0 -66
  239. package/src/security/keychain.ts +0 -490
@@ -1,390 +1,175 @@
1
1
  /**
2
- * Unified secure key storage — tries OS keychain first, falls back to
3
- * encrypted-at-rest file storage.
2
+ * Unified secure key storage — routes through the keychain broker when
3
+ * available (macOS app embedded), with transparent fallback to the
4
+ * encrypted-at-rest file store.
4
5
  *
5
- * Provides the same get/set/delete/list interface used by both backends.
6
- * Backend selection is cached after the first call for the process lifetime.
6
+ * Async variants try the broker first; sync variants always use the
7
+ * encrypted store (startup code paths cannot do async I/O).
7
8
  */
8
9
 
9
- import { getLogger } from "../util/logger.js";
10
- import { isMacOS } from "../util/platform.js";
11
10
  import * as encryptedStore from "./encrypted-store.js";
12
- import * as keychain from "./keychain.js";
11
+ import type { KeychainBrokerClient } from "./keychain-broker-client.js";
12
+ import { createBrokerClient } from "./keychain-broker-client.js";
13
13
 
14
- const log = getLogger("secure-keys");
14
+ let _broker: KeychainBrokerClient | undefined;
15
15
 
16
- type Backend = "keychain" | "encrypted" | null;
17
- let resolvedBackend: Backend | undefined;
18
- /** True when backend was downgraded from keychain to encrypted at runtime. */
19
- let downgradedFromKeychain = false;
20
- /** Keys known to not exist in keychain — avoids repeated subprocess calls on misses. */
21
- const keychainMissCache = new Set<string>();
22
-
23
- function getBackend(): Backend {
24
- if (resolvedBackend !== undefined) return resolvedBackend;
25
-
26
- // On macOS, skip keychain probing and use encrypted file storage directly
27
- // to avoid repeated Keychain Access authorization prompts. Mark as
28
- // downgraded so getSecureKey/getSecureKeyAsync still check keychain as a
29
- // fallback for secrets stored before this switch.
30
- if (isMacOS()) {
31
- log.debug(
32
- "macOS detected, using encrypted file storage (skipping keychain)",
33
- );
34
- resolvedBackend = "encrypted";
35
- downgradedFromKeychain = true;
36
- return resolvedBackend;
37
- }
38
-
39
- if (keychain.isKeychainAvailable()) {
40
- log.debug("Using OS keychain for secure key storage");
41
- resolvedBackend = "keychain";
42
- } else {
43
- log.debug("OS keychain unavailable, using encrypted file storage");
44
- resolvedBackend = "encrypted";
45
- }
46
- return resolvedBackend;
47
- }
48
-
49
- async function getBackendAsync(): Promise<Backend> {
50
- if (resolvedBackend !== undefined) return resolvedBackend;
51
-
52
- // On macOS, skip keychain probing and use encrypted file storage directly
53
- // to avoid repeated Keychain Access authorization prompts. Mark as
54
- // downgraded so getSecureKey/getSecureKeyAsync still check keychain as a
55
- // fallback for secrets stored before this switch.
56
- if (isMacOS()) {
57
- log.debug(
58
- "macOS detected, using encrypted file storage (skipping keychain)",
59
- );
60
- resolvedBackend = "encrypted";
61
- downgradedFromKeychain = true;
62
- return resolvedBackend;
63
- }
64
-
65
- if (await keychain.isKeychainAvailableAsync()) {
66
- log.debug("Using OS keychain for secure key storage");
67
- resolvedBackend = "keychain";
68
- } else {
69
- log.debug("OS keychain unavailable, using encrypted file storage");
70
- resolvedBackend = "encrypted";
71
- }
72
- return resolvedBackend;
16
+ function getBroker(): KeychainBrokerClient {
17
+ if (!_broker) _broker = createBrokerClient();
18
+ return _broker;
73
19
  }
74
20
 
75
- /**
76
- * Try a keychain operation; on failure, permanently downgrade to encrypted
77
- * backend and retry. This handles systems where the keychain CLI exists
78
- * but is unusable at runtime (headless/locked sessions).
79
- */
80
- function withKeychainFallback<T>(
81
- keychainFn: () => T,
82
- encryptedFn: () => T,
83
- fallbackValue: T,
84
- ): T {
85
- const backend = getBackend();
86
- if (backend === "encrypted") return encryptedFn();
87
- if (backend !== "keychain") return fallbackValue;
88
-
89
- const result = keychainFn();
90
- // keychain.setKey/deleteKey return false on failure.
91
- // We downgrade on failures (false) to switch to encrypted backend.
92
- if (result === false) {
93
- log.warn(
94
- "Keychain operation failed at runtime, falling back to encrypted file storage",
95
- );
96
- resolvedBackend = "encrypted";
97
- downgradedFromKeychain = true;
98
- return encryptedFn();
99
- }
100
- return result;
101
- }
21
+ // ---------------------------------------------------------------------------
22
+ // Sync variants encrypted store only (startup / sync call sites)
23
+ // ---------------------------------------------------------------------------
102
24
 
103
25
  /**
104
- * Retrieve a secret from secure storage.
26
+ * Retrieve a secret from secure storage (sync — encrypted store only).
105
27
  * Returns `undefined` if the key doesn't exist or on error.
106
28
  */
107
29
  export function getSecureKey(account: string): string | undefined {
108
- const backend = getBackend();
109
- if (backend === "keychain") {
110
- try {
111
- return keychain.getKey(account) ?? undefined;
112
- } catch {
113
- // Keychain runtime error on read — downgrade to encrypted store
114
- log.warn(
115
- "Keychain read failed at runtime, falling back to encrypted file storage",
116
- );
117
- resolvedBackend = "encrypted";
118
- downgradedFromKeychain = true;
119
- return encryptedStore.getKey(account);
120
- }
121
- }
122
- if (backend === "encrypted") {
123
- const value = encryptedStore.getKey(account);
124
- // After a runtime downgrade, keys may still exist in the keychain.
125
- // Try keychain read as fallback so pre-downgrade keys remain accessible.
126
- if (
127
- value === undefined &&
128
- downgradedFromKeychain &&
129
- !keychainMissCache.has(account)
130
- ) {
131
- try {
132
- const keychainValue = keychain.getKey(account) ?? undefined;
133
- if (keychainValue === undefined) {
134
- keychainMissCache.add(account);
135
- }
136
- return keychainValue;
137
- } catch {
138
- return undefined;
139
- }
140
- }
141
- return value;
142
- }
143
- return undefined;
30
+ return encryptedStore.getKey(account);
144
31
  }
145
32
 
146
33
  /**
147
- * Store a secret in secure storage.
34
+ * Store a secret in secure storage (sync — encrypted store only).
148
35
  * Returns `true` on success, `false` on failure.
149
36
  */
150
37
  export function setSecureKey(account: string, value: string): boolean {
151
- const result = withKeychainFallback(
152
- () => keychain.setKey(account, value),
153
- () => encryptedStore.setKey(account, value),
154
- false,
155
- );
156
- // When writing to the encrypted store after a keychain downgrade, clean up
157
- // any stale keychain entry so the gateway's credential-reader (which tries
158
- // keychain first) does not read an outdated value.
159
- if (result && downgradedFromKeychain && getBackend() === "encrypted") {
160
- keychainMissCache.delete(account);
161
- try {
162
- // Only attempt deletion if the key actually exists in keychain to
163
- // avoid spawning a subprocess on every write.
164
- if (keychain.getKey(account) != null) {
165
- keychain.deleteKey(account);
166
- }
167
- } catch {
168
- /* best-effort */
169
- }
170
- }
171
- return result;
38
+ return encryptedStore.setKey(account, value);
172
39
  }
173
40
 
41
+ /** Result of a delete operation — distinguishes success, not-found, and error. */
42
+ export type DeleteResult = "deleted" | "not-found" | "error";
43
+
174
44
  /**
175
- * Delete a secret from secure storage.
176
- * Returns `true` on success, `false` if not found or on error.
45
+ * Delete a secret from secure storage (sync — encrypted store only).
46
+ * Returns `"deleted"` on success, `"not-found"` if key doesn't exist,
47
+ * or `"error"` on failure.
177
48
  */
178
- export function deleteSecureKey(account: string): boolean {
179
- const backend = getBackend();
180
- if (backend === "encrypted") {
181
- const result = encryptedStore.deleteKey(account);
182
- // After a runtime downgrade, keys may still exist in the keychain.
183
- // Attempt cleanup and return true if either backend had the key.
184
- if (downgradedFromKeychain) {
185
- keychainMissCache.delete(account);
186
- const keychainResult = keychain.deleteKey(account);
187
- return result || keychainResult;
188
- }
189
- return result;
190
- }
191
- if (backend !== "keychain") return false;
192
-
193
- // keychain.deleteKey returns false for both "not found" and "runtime error".
194
- // Check existence first so a missing key doesn't spuriously downgrade the
195
- // backend — saveConfig routinely deletes keys for unset providers.
196
- // getKey now returns null for "not found" and throws on runtime errors.
197
- try {
198
- if (keychain.getKey(account) == null) {
199
- return false;
200
- }
201
- } catch {
202
- // Keychain runtime error — fall through to withKeychainFallback which
203
- // will handle the downgrade when deleteKey also fails.
204
- }
205
-
206
- return withKeychainFallback(
207
- () => keychain.deleteKey(account),
208
- () => encryptedStore.deleteKey(account),
209
- false,
210
- );
49
+ export function deleteSecureKey(account: string): DeleteResult {
50
+ return encryptedStore.deleteKey(account);
211
51
  }
212
52
 
213
53
  /**
214
- * List all account names in secure storage.
215
- * Only supported by the encrypted backend; keychain returns empty array.
216
- * Throws if the store file exists but cannot be read (encrypted backend).
54
+ * List all account names in secure storage (sync — encrypted store only).
55
+ * Throws if the store file exists but cannot be read.
217
56
  */
218
57
  export function listSecureKeys(): string[] {
219
- const backend = getBackend();
220
- if (backend === "encrypted") return encryptedStore.listKeys();
221
- // OS keychains don't provide a list API scoped to our service
222
- return [];
58
+ return encryptedStore.listKeys();
223
59
  }
224
60
 
61
+ // ---------------------------------------------------------------------------
62
+ // Backend introspection
63
+ // ---------------------------------------------------------------------------
64
+
225
65
  /**
226
66
  * Return the currently resolved backend type.
227
- * Useful for feature-gating behaviour that only works on certain backends.
67
+ * Returns `"broker"` when the keychain broker is reachable, `"encrypted"` otherwise.
228
68
  */
229
- export function getBackendType(): "keychain" | "encrypted" | null {
230
- return getBackend();
69
+ export function getBackendType(): "broker" | "encrypted" | null {
70
+ return getBroker().isAvailable() ? "broker" : "encrypted";
231
71
  }
232
72
 
233
73
  /**
234
74
  * Whether the backend was downgraded from keychain to encrypted at runtime.
235
- * When true, credentials may still be readable from keychain via fallback
236
- * even though the active backend is encrypted.
75
+ * Always returns false now that keychain CLI is removed.
237
76
  */
238
77
  export function isDowngradedFromKeychain(): boolean {
239
- return downgradedFromKeychain;
78
+ return false;
240
79
  }
241
80
 
242
81
  // ---------------------------------------------------------------------------
243
- // Async variants — non-blocking alternatives that avoid blocking the event
244
- // loop during keychain operations. Preferred for non-startup code paths.
82
+ // Async variants — try broker first, fall back to encrypted store
245
83
  // ---------------------------------------------------------------------------
246
84
 
247
85
  /**
248
- * Async version of `getSecureKey` retrieve a secret without blocking.
86
+ * Async version of `getSecureKey`. When the broker is available it is
87
+ * queried first. A `null` return from the broker means error (fall back
88
+ * to encrypted store). A `{ found: false }` also falls back to the
89
+ * encrypted store — keys may exist only in `keys.enc` (e.g. written
90
+ * while the broker was unavailable or via sync `setSecureKey`).
249
91
  */
250
92
  export async function getSecureKeyAsync(
251
93
  account: string,
252
94
  ): Promise<string | undefined> {
253
- const backend = await getBackendAsync();
254
- if (backend === "keychain") {
255
- try {
256
- return (await keychain.getKeyAsync(account)) ?? undefined;
257
- } catch {
258
- log.warn(
259
- "Keychain read failed at runtime, falling back to encrypted file storage",
260
- );
261
- resolvedBackend = "encrypted";
262
- downgradedFromKeychain = true;
263
- return encryptedStore.getKey(account);
264
- }
265
- }
266
- if (backend === "encrypted") {
267
- const value = encryptedStore.getKey(account);
268
- if (
269
- value === undefined &&
270
- downgradedFromKeychain &&
271
- !keychainMissCache.has(account)
272
- ) {
273
- try {
274
- const keychainValue =
275
- (await keychain.getKeyAsync(account)) ?? undefined;
276
- if (keychainValue === undefined) {
277
- keychainMissCache.add(account);
278
- }
279
- return keychainValue;
280
- } catch {
281
- return undefined;
282
- }
283
- }
284
- return value;
285
- }
286
- return undefined;
95
+ const broker = getBroker();
96
+ if (broker.isAvailable()) {
97
+ const result = await broker.get(account);
98
+ // null = broker error, fall back to encrypted store
99
+ if (result == null) return encryptedStore.getKey(account);
100
+ // Broker found the key — use it
101
+ if (result.found) return result.value;
102
+ // Broker says not found — check encrypted store as fallback
103
+ return encryptedStore.getKey(account);
104
+ }
105
+ return encryptedStore.getKey(account);
287
106
  }
288
107
 
289
108
  /**
290
- * Async version of `setSecureKey` store a secret without blocking.
109
+ * Async version of `setSecureKey`. When the broker is available the key
110
+ * is written there **and** to the encrypted store so that sync callers
111
+ * have a consistent view. Returns `true` only when both stores succeed.
112
+ *
113
+ * If the broker is available but `broker.set()` fails we return `false`
114
+ * immediately — falling through to an encrypted-store-only write would
115
+ * leave the broker with stale data that async readers would still see.
291
116
  */
292
117
  export async function setSecureKeyAsync(
293
118
  account: string,
294
119
  value: string,
295
120
  ): Promise<boolean> {
296
- const backend = await getBackendAsync();
297
- if (backend === "encrypted") {
298
- const result = encryptedStore.setKey(account, value);
299
- // Clean up stale keychain entry (mirrors setSecureKey logic).
300
- if (result && downgradedFromKeychain) {
301
- keychainMissCache.delete(account);
302
- try {
303
- // Only attempt deletion if the key actually exists in keychain to
304
- // avoid spawning a subprocess on every write.
305
- const exists = await keychain.getKeyAsync(account);
306
- if (exists != null) {
307
- await keychain.deleteKeyAsync(account);
308
- }
309
- } catch {
310
- /* best-effort */
311
- }
312
- }
313
- return result;
314
- }
315
- if (backend !== "keychain") return false;
316
-
317
- const result = await keychain.setKeyAsync(account, value);
318
- if (result === false) {
319
- log.warn(
320
- "Keychain operation failed at runtime, falling back to encrypted file storage",
321
- );
322
- resolvedBackend = "encrypted";
323
- downgradedFromKeychain = true;
324
- const fallbackResult = encryptedStore.setKey(account, value);
325
- // Clean up stale keychain entry after runtime downgrade
326
- if (fallbackResult) {
327
- keychainMissCache.delete(account);
328
- try {
329
- const exists = await keychain.getKeyAsync(account);
330
- if (exists != null) {
331
- await keychain.deleteKeyAsync(account);
332
- }
333
- } catch {
334
- /* best-effort */
335
- }
336
- }
337
- return fallbackResult;
338
- }
339
- return result;
121
+ const broker = getBroker();
122
+ if (broker.isAvailable()) {
123
+ const brokerOk = await broker.set(account, value);
124
+ if (!brokerOk) return false;
125
+ // Broker succeeded also persist to encrypted store for sync callers.
126
+ const encOk = encryptedStore.setKey(account, value);
127
+ return encOk;
128
+ }
129
+ return encryptedStore.setKey(account, value);
340
130
  }
341
131
 
342
132
  /**
343
- * Async version of `deleteSecureKey` delete a secret without blocking.
133
+ * Async version of `deleteSecureKey`. When the broker is available the
134
+ * key is deleted there **and** from the encrypted store so that sync
135
+ * callers have a consistent view.
136
+ *
137
+ * Returns `"deleted"` when the key was removed, `"not-found"` when it
138
+ * didn't exist (idempotent), or `"error"` on a real backend failure.
139
+ *
140
+ * If the broker is available but `broker.del()` fails we return `"error"`
141
+ * immediately — falling through to an encrypted-store-only delete would
142
+ * leave the broker with the key, and async readers would still see it.
344
143
  */
345
- export async function deleteSecureKeyAsync(account: string): Promise<boolean> {
346
- const backend = await getBackendAsync();
347
- if (backend === "encrypted") {
348
- const result = encryptedStore.deleteKey(account);
349
- if (downgradedFromKeychain) {
350
- keychainMissCache.delete(account);
351
- const keychainResult = await keychain.deleteKeyAsync(account);
352
- return result || keychainResult;
353
- }
354
- return result;
355
- }
356
- if (backend !== "keychain") return false;
357
-
358
- try {
359
- if ((await keychain.getKeyAsync(account)) == null) {
360
- return false;
361
- }
362
- } catch {
363
- // fall through
364
- }
365
-
366
- const result = await keychain.deleteKeyAsync(account);
367
- if (result === false) {
368
- log.warn(
369
- "Keychain operation failed at runtime, falling back to encrypted file storage",
370
- );
371
- resolvedBackend = "encrypted";
372
- downgradedFromKeychain = true;
373
- return encryptedStore.deleteKey(account);
374
- }
375
- return result;
144
+ export async function deleteSecureKeyAsync(
145
+ account: string,
146
+ ): Promise<DeleteResult> {
147
+ const broker = getBroker();
148
+ if (broker.isAvailable()) {
149
+ const brokerOk = await broker.del(account);
150
+ if (!brokerOk) return "error";
151
+ // Broker succeeded — also remove from encrypted store for sync callers.
152
+ const encResult = encryptedStore.deleteKey(account);
153
+ // Broker deletion succeeded; encrypted-store "not-found" is fine
154
+ // (key may only exist in the broker).
155
+ if (encResult === "error") return "error";
156
+ return "deleted";
157
+ }
158
+ return encryptedStore.deleteKey(account);
376
159
  }
377
160
 
378
- /** @internal Test-only: reset the cached backend so it's re-evaluated. */
161
+ // ---------------------------------------------------------------------------
162
+ // Test helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** @internal Test-only: reset the cached broker so it's re-created. */
379
166
  export function _resetBackend(): void {
380
- resolvedBackend = undefined;
381
- downgradedFromKeychain = false;
382
- keychainMissCache.clear();
167
+ _broker = undefined;
383
168
  }
384
169
 
385
170
  /** @internal Test-only: force a specific backend. Pass `undefined` to reset. */
386
- export function _setBackend(backend: Backend | undefined): void {
387
- resolvedBackend = backend;
388
- downgradedFromKeychain = false;
389
- keychainMissCache.clear();
171
+ export function _setBackend(
172
+ _backend: "keychain" | "encrypted" | "broker" | null | undefined,
173
+ ): void {
174
+ // No-op — kept for test compatibility.
390
175
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { setHomeBaseAppLink } from "../../home-base/app-link-store.js";
12
+ import { generateAppIcon } from "../../media/app-icon-generator.js";
12
13
  import type { AppDefinition } from "../../memory/app-store.js";
13
14
  import type { EditEngineResult } from "../../memory/app-store.js";
14
15
 
@@ -40,6 +41,7 @@ export interface AppStoreWriter {
40
41
  createApp(params: {
41
42
  name: string;
42
43
  description?: string;
44
+ icon?: string;
43
45
  schemaJson: string;
44
46
  htmlDefinition: string;
45
47
  pages?: Record<string, string>;
@@ -139,9 +141,15 @@ export async function executeAppCreate(
139
141
  }
140
142
  }
141
143
 
144
+ // Extract icon from preview if provided — only persist emoji-like values,
145
+ // not URLs which would render as raw strings in UI and bundle manifests.
146
+ const rawIcon = preview?.icon as string | undefined;
147
+ const icon = rawIcon && !rawIcon.startsWith("http") ? rawIcon : undefined;
148
+
142
149
  const app = store.createApp({
143
150
  name,
144
151
  description,
152
+ icon,
145
153
  schemaJson,
146
154
  htmlDefinition,
147
155
  pages,
@@ -405,3 +413,68 @@ export function executeAppFileWrite(
405
413
  status: input.status,
406
414
  };
407
415
  }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // app_generate_icon
419
+ // ---------------------------------------------------------------------------
420
+
421
+ export interface AppGenerateIconInput {
422
+ app_id: string;
423
+ description?: string;
424
+ }
425
+
426
+ export async function executeAppGenerateIcon(
427
+ input: AppGenerateIconInput,
428
+ store: AppStoreReader,
429
+ ): Promise<ExecutorResult> {
430
+ const app = store.getApp(input.app_id);
431
+ if (!app) {
432
+ return {
433
+ content: JSON.stringify({ error: `App '${input.app_id}' not found` }),
434
+ isError: true,
435
+ };
436
+ }
437
+
438
+ // Generate to a temp path first, then swap on success to avoid
439
+ // destroying an existing icon if generation fails.
440
+ const { existsSync, renameSync, unlinkSync } = await import("node:fs");
441
+ const { join } = await import("node:path");
442
+ const { getAppsDir } = await import("../../memory/app-store.js");
443
+ const iconPath = join(getAppsDir(), input.app_id, "icon.png");
444
+ const tempPath = join(getAppsDir(), input.app_id, "icon.tmp.png");
445
+
446
+ // Temporarily move existing icon aside so generateAppIcon doesn't skip
447
+ if (existsSync(iconPath)) {
448
+ renameSync(iconPath, tempPath);
449
+ }
450
+
451
+ await generateAppIcon(
452
+ input.app_id,
453
+ app.name,
454
+ input.description ?? app.description,
455
+ );
456
+
457
+ if (existsSync(iconPath)) {
458
+ // Success — clean up the old icon backup
459
+ if (existsSync(tempPath)) {
460
+ unlinkSync(tempPath);
461
+ }
462
+ return {
463
+ content: JSON.stringify({ generated: true, appId: input.app_id }),
464
+ isError: false,
465
+ };
466
+ }
467
+
468
+ // Generation failed — restore the previous icon if we had one
469
+ if (existsSync(tempPath)) {
470
+ renameSync(tempPath, iconPath);
471
+ }
472
+
473
+ return {
474
+ content: JSON.stringify({
475
+ error:
476
+ "Icon generation failed. Make sure a Gemini API key is configured in Settings.",
477
+ }),
478
+ isError: true,
479
+ };
480
+ }
@@ -9,7 +9,7 @@ import { getLogger } from "../../util/logger.js";
9
9
 
10
10
  const log = getLogger("auto-navigate");
11
11
 
12
- const CDP_BASE = "http://localhost:9222";
12
+ const DEFAULT_CDP_BASE = "http://localhost:9222";
13
13
  const MAX_PAGES = 10;
14
14
  const PAGE_WAIT_MS = 2500;
15
15
  const SCROLL_WAIT_MS = 1000;
@@ -80,23 +80,32 @@ export interface AutoNavProgress {
80
80
  visitedCount?: number;
81
81
  }
82
82
 
83
+ export interface AutoNavOptions {
84
+ abortSignal?: { aborted: boolean };
85
+ onProgress?: (p: AutoNavProgress) => void;
86
+ cdpBaseUrl?: string;
87
+ }
88
+
83
89
  /**
84
90
  * Navigate Chrome through a domain's pages to trigger API calls.
85
91
  * Discovers internal links from the DOM and visits up to ~15 unique paths.
86
92
  *
87
93
  * @param domain The domain to crawl (e.g. "example.com").
88
- * @param abortSignal Optional signal to stop navigation early.
89
- * @param onProgress Optional callback for live progress updates.
94
+ * @param options Optional configuration for abort, progress, and CDP base URL.
90
95
  * @returns List of visited page URLs.
91
96
  */
92
97
  export async function autoNavigate(
93
98
  domain: string,
94
- abortSignal?: { aborted: boolean },
95
- onProgress?: (p: AutoNavProgress) => void,
99
+ options?: AutoNavOptions,
96
100
  ): Promise<string[]> {
101
+ const {
102
+ abortSignal,
103
+ onProgress,
104
+ cdpBaseUrl = DEFAULT_CDP_BASE,
105
+ } = options ?? {};
97
106
  let wsUrl: string | null = null;
98
107
  try {
99
- const res = await fetch(`${CDP_BASE}/json/list`);
108
+ const res = await fetch(`${cdpBaseUrl}/json/list`);
100
109
  if (!res.ok) {
101
110
  log.warn("CDP not available for auto-navigation");
102
111
  return [];