@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
@@ -47,7 +47,7 @@ export function handleListInvites(url: URL): Response {
47
47
  export async function handleCreateInvite(req: Request): Promise<Response> {
48
48
  const body = (await req.json()) as Record<string, unknown>;
49
49
 
50
- const result = createIngressInvite({
50
+ const result = await createIngressInvite({
51
51
  sourceChannel: body.sourceChannel as string | undefined,
52
52
  note: body.note as string | undefined,
53
53
  maxUses: body.maxUses as number | undefined,
@@ -4,7 +4,11 @@ import {
4
4
  invalidateConfigCache,
5
5
  } from "../../config/loader.js";
6
6
  import { initializeProviders } from "../../providers/registry.js";
7
- import { deleteSecureKey, setSecureKey } from "../../security/secure-keys.js";
7
+ import {
8
+ deleteSecureKeyAsync,
9
+ getSecureKeyAsync,
10
+ setSecureKeyAsync,
11
+ } from "../../security/secure-keys.js";
8
12
  import {
9
13
  assertMetadataWritable,
10
14
  deleteCredentialMetadata,
@@ -48,7 +52,7 @@ export async function handleAddSecret(req: Request): Promise<Response> {
48
52
  400,
49
53
  );
50
54
  }
51
- const stored = setSecureKey(name, value);
55
+ const stored = await setSecureKeyAsync(name, value);
52
56
  if (!stored) {
53
57
  return httpError(
54
58
  "INTERNAL_ERROR",
@@ -75,7 +79,7 @@ export async function handleAddSecret(req: Request): Promise<Response> {
75
79
  const service = name.slice(0, colonIdx);
76
80
  const field = name.slice(colonIdx + 1);
77
81
  const key = `credential:${service}:${field}`;
78
- const stored = setSecureKey(key, value);
82
+ const stored = await setSecureKeyAsync(key, value);
79
83
  if (!stored) {
80
84
  return httpError(
81
85
  "INTERNAL_ERROR",
@@ -128,10 +132,20 @@ export async function handleDeleteSecret(req: Request): Promise<Response> {
128
132
  400,
129
133
  );
130
134
  }
131
- const deleted = deleteSecureKey(name);
132
- if (!deleted) {
135
+ // Check existence first — the broker always returns "deleted" even
136
+ // for keys that don't exist, so we need a pre-check for 404 semantics.
137
+ const existing = await getSecureKeyAsync(name);
138
+ if (existing === undefined) {
133
139
  return httpError("NOT_FOUND", `API key not found: ${name}`, 404);
134
140
  }
141
+ const deleteResult = await deleteSecureKeyAsync(name);
142
+ if (deleteResult === "error") {
143
+ return httpError(
144
+ "INTERNAL_ERROR",
145
+ `Failed to delete API key from secure storage: ${name}`,
146
+ 500,
147
+ );
148
+ }
135
149
  invalidateConfigCache();
136
150
  initializeProviders(getConfig());
137
151
  log.info({ provider: name }, "API key deleted via HTTP");
@@ -151,10 +165,20 @@ export async function handleDeleteSecret(req: Request): Promise<Response> {
151
165
  const field = name.slice(colonIdx + 1);
152
166
  assertMetadataWritable();
153
167
  const key = `credential:${service}:${field}`;
154
- const deleted = deleteSecureKey(key);
155
- if (!deleted) {
168
+ // Check existence first — the broker always returns "deleted" even
169
+ // for keys that don't exist, so we need a pre-check for 404 semantics.
170
+ const existing = await getSecureKeyAsync(key);
171
+ if (existing === undefined) {
156
172
  return httpError("NOT_FOUND", `Credential not found: ${name}`, 404);
157
173
  }
174
+ const deleteResult = await deleteSecureKeyAsync(key);
175
+ if (deleteResult === "error") {
176
+ return httpError(
177
+ "INTERNAL_ERROR",
178
+ `Failed to delete credential from secure storage: ${name}`,
179
+ 500,
180
+ );
181
+ }
158
182
  deleteCredentialMetadata(service, field);
159
183
  log.info({ service, field }, "Credential deleted via HTTP");
160
184
  return Response.json({ success: true, type, name });
@@ -148,6 +148,9 @@ function pruneAssistantPhoneNumbers(
148
148
  */
149
149
  export function handleGetTwilioConfig(): Response {
150
150
  const hasCredentials = hasTwilioCredentials();
151
+ const accountSid = hasCredentials
152
+ ? getSecureKey("credential:twilio:account_sid")
153
+ : undefined;
151
154
  const raw = loadRawConfig();
152
155
  const sms = (raw?.sms ?? {}) as Record<string, unknown>;
153
156
  const phoneNumber = (sms.phoneNumber as string) ?? "";
@@ -155,6 +158,7 @@ export function handleGetTwilioConfig(): Response {
155
158
  return Response.json({
156
159
  success: true,
157
160
  hasCredentials,
161
+ accountSid: accountSid || undefined,
158
162
  phoneNumber: phoneNumber || undefined,
159
163
  });
160
164
  }
@@ -235,7 +239,34 @@ export async function handleSetTwilioCredentials(
235
239
  });
236
240
  }
237
241
 
238
- upsertCredentialMetadata("twilio", "account_sid", {});
242
+ upsertCredentialMetadata("twilio", "account_sid", {
243
+ injectionTemplates: [
244
+ {
245
+ hostPattern: "api.twilio.com",
246
+ injectionType: "header" as const,
247
+ headerName: "Authorization",
248
+ valuePrefix: "Basic ",
249
+ valueTransform: "base64" as const,
250
+ composeWith: {
251
+ service: "twilio",
252
+ field: "auth_token",
253
+ separator: ":",
254
+ },
255
+ },
256
+ {
257
+ hostPattern: "messaging.twilio.com",
258
+ injectionType: "header" as const,
259
+ headerName: "Authorization",
260
+ valuePrefix: "Basic ",
261
+ valueTransform: "base64" as const,
262
+ composeWith: {
263
+ service: "twilio",
264
+ field: "auth_token",
265
+ separator: ":",
266
+ },
267
+ },
268
+ ],
269
+ });
239
270
  upsertCredentialMetadata("twilio", "auth_token", {});
240
271
 
241
272
  return Response.json({ success: true, hasCredentials: true });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Route handlers for usage and cost summary endpoints.
3
+ *
4
+ * GET /v1/usage/totals?from=&to= — aggregate totals for a time range
5
+ * GET /v1/usage/daily?from=&to= — per-day buckets for a time range
6
+ * GET /v1/usage/breakdown?from=&to=&groupBy= — grouped breakdown (actor, provider, model)
7
+ */
8
+
9
+ import {
10
+ getUsageDayBuckets,
11
+ getUsageGroupBreakdown,
12
+ getUsageTotals,
13
+ } from "../../memory/llm-usage-store.js";
14
+ import { httpError } from "../http-errors.js";
15
+ import type { RouteDefinition } from "../http-router.js";
16
+
17
+ const VALID_GROUP_BY = new Set(["actor", "provider", "model"]);
18
+
19
+ /**
20
+ * Parse and validate the `from` and `to` epoch-millis query parameters.
21
+ * Returns the parsed range or an error Response.
22
+ */
23
+ function parseTimeRange(url: URL): { from: number; to: number } | Response {
24
+ const fromRaw = url.searchParams.get("from");
25
+ const toRaw = url.searchParams.get("to");
26
+
27
+ if (!fromRaw || !toRaw) {
28
+ return httpError(
29
+ "BAD_REQUEST",
30
+ 'Missing required query parameters: "from" and "to" (epoch milliseconds)',
31
+ 400,
32
+ );
33
+ }
34
+
35
+ const from = Number(fromRaw);
36
+ const to = Number(toRaw);
37
+
38
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
39
+ return httpError(
40
+ "BAD_REQUEST",
41
+ '"from" and "to" must be valid numbers (epoch milliseconds)',
42
+ 400,
43
+ );
44
+ }
45
+
46
+ if (from > to) {
47
+ return httpError(
48
+ "BAD_REQUEST",
49
+ '"from" must be less than or equal to "to"',
50
+ 400,
51
+ );
52
+ }
53
+
54
+ return { from, to };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Route definitions
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export function usageRouteDefinitions(): RouteDefinition[] {
62
+ return [
63
+ {
64
+ endpoint: "usage/totals",
65
+ method: "GET",
66
+ handler: ({ url }) => {
67
+ const range = parseTimeRange(url);
68
+ if (range instanceof Response) return range;
69
+ const totals = getUsageTotals(range);
70
+ return Response.json(totals);
71
+ },
72
+ },
73
+ {
74
+ endpoint: "usage/daily",
75
+ method: "GET",
76
+ handler: ({ url }) => {
77
+ const range = parseTimeRange(url);
78
+ if (range instanceof Response) return range;
79
+ const buckets = getUsageDayBuckets(range);
80
+ return Response.json({ buckets });
81
+ },
82
+ },
83
+ {
84
+ endpoint: "usage/breakdown",
85
+ method: "GET",
86
+ handler: ({ url }) => {
87
+ const range = parseTimeRange(url);
88
+ if (range instanceof Response) return range;
89
+
90
+ const groupBy = url.searchParams.get("groupBy");
91
+ if (!groupBy) {
92
+ return httpError(
93
+ "BAD_REQUEST",
94
+ 'Missing required query parameter: "groupBy" (one of: actor, provider, model)',
95
+ 400,
96
+ );
97
+ }
98
+ if (!VALID_GROUP_BY.has(groupBy)) {
99
+ return httpError(
100
+ "BAD_REQUEST",
101
+ `Invalid "groupBy" value: "${groupBy}". Must be one of: actor, provider, model`,
102
+ 400,
103
+ );
104
+ }
105
+
106
+ const breakdown = getUsageGroupBreakdown(
107
+ range,
108
+ groupBy as "actor" | "provider" | "model",
109
+ );
110
+ return Response.json({ breakdown });
111
+ },
112
+ },
113
+ ];
114
+ }
@@ -18,6 +18,7 @@ import {
18
18
  listCanonicalGuardianRequests,
19
19
  } from "../memory/canonical-guardian-store.js";
20
20
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
21
+ import type { NotificationSourceChannel } from "../notifications/signal.js";
21
22
  import { getLogger } from "../util/logger.js";
22
23
  import { getGuardianBinding } from "./channel-guardian-service.js";
23
24
  import { GUARDIAN_APPROVAL_TTL_MS } from "./routes/channel-route-shared.js";
@@ -144,7 +145,7 @@ export function createOrReuseToolGrantRequest(
144
145
  // pipeline is preserved.
145
146
  const signalPromise = emitNotificationSignal({
146
147
  sourceEventName: "guardian.question",
147
- sourceChannel,
148
+ sourceChannel: sourceChannel as NotificationSourceChannel,
148
149
  sourceSessionId: conversationId,
149
150
  attentionHints: {
150
151
  requiresAction: true,
@@ -251,22 +251,26 @@ export function setKey(account: string, value: string): boolean {
251
251
  }
252
252
  }
253
253
 
254
+ /** Result of a delete operation — distinguishes success, not-found, and error. */
255
+ export type DeleteKeyResult = "deleted" | "not-found" | "error";
256
+
254
257
  /**
255
258
  * Delete a secret from the encrypted store.
256
- * Returns true on success, false if not found or on failure.
259
+ * Returns `"deleted"` on success, `"not-found"` if the key doesn't exist,
260
+ * or `"error"` on failure.
257
261
  */
258
- export function deleteKey(account: string): boolean {
262
+ export function deleteKey(account: string): DeleteKeyResult {
259
263
  try {
260
264
  const store = readStore();
261
265
  if (!store || !Object.prototype.hasOwnProperty.call(store.entries, account))
262
- return false;
266
+ return "not-found";
263
267
 
264
268
  delete store.entries[account];
265
269
  writeStore(store);
266
- return true;
270
+ return "deleted";
267
271
  } catch (err) {
268
272
  log.debug({ err, account }, "Failed to delete from encrypted store");
269
- return false;
273
+ return "error";
270
274
  }
271
275
  }
272
276
 
@@ -0,0 +1,393 @@
1
+ /**
2
+ * TypeScript client for the keychain broker Unix domain socket protocol.
3
+ *
4
+ * The keychain broker runs inside the macOS app and exposes SecItem*
5
+ * operations over a newline-delimited JSON protocol on a UDS. This client
6
+ * provides a graceful-fallback interface: every public method returns a
7
+ * safe default on failure and never throws.
8
+ *
9
+ * Socket path: read from VELLUM_KEYCHAIN_BROKER_SOCKET env var.
10
+ * Auth token: read from ~/.vellum/protected/keychain-broker.token on first
11
+ * connection, cached for process lifetime.
12
+ */
13
+
14
+ import { randomUUID } from "node:crypto";
15
+ import { readFileSync } from "node:fs";
16
+ import type { Socket } from "node:net";
17
+ import { createConnection } from "node:net";
18
+ import { join } from "node:path";
19
+
20
+ import { pathExists } from "../util/fs.js";
21
+ import { getLogger } from "../util/logger.js";
22
+ import { getRootDir } from "../util/platform.js";
23
+
24
+ const log = getLogger("keychain-broker-client");
25
+
26
+ const REQUEST_TIMEOUT_MS = 5_000;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** Result of a `get()` call. `null` means broker error (caller should fall
33
+ * back); `{ found: false }` means the key doesn't exist in the keychain. */
34
+ export type BrokerGetResult = { found: boolean; value?: string } | null;
35
+
36
+ export interface KeychainBrokerClient {
37
+ isAvailable(): boolean;
38
+ ping(): Promise<{ pong: boolean } | null>;
39
+ get(account: string): Promise<BrokerGetResult>;
40
+ set(account: string, value: string): Promise<boolean>;
41
+ del(account: string): Promise<boolean>;
42
+ list(): Promise<string[]>;
43
+ }
44
+
45
+ interface BrokerRequest {
46
+ v: number;
47
+ id: string;
48
+ method: string;
49
+ token: string;
50
+ params?: Record<string, unknown>;
51
+ }
52
+
53
+ interface BrokerResponse {
54
+ id: string;
55
+ ok: boolean;
56
+ result?: Record<string, unknown>;
57
+ error?: { code: string; message: string };
58
+ }
59
+
60
+ interface PendingRequest {
61
+ resolve: (response: BrokerResponse) => void;
62
+ timer: ReturnType<typeof setTimeout>;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Internal state
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function getTokenPath(): string {
70
+ return join(getRootDir(), "protected", "keychain-broker.token");
71
+ }
72
+
73
+ function getSocketPath(): string | undefined {
74
+ return process.env.VELLUM_KEYCHAIN_BROKER_SOCKET;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Client implementation
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export function createBrokerClient(): KeychainBrokerClient {
82
+ let socket: Socket | null = null;
83
+ /** Promise that resolves when the in-flight connect() completes. */
84
+ let connectPromise: Promise<Socket> | null = null;
85
+ let permanentlyUnavailable = false;
86
+ /** Cached token string, or undefined if not yet successfully read. */
87
+ let cachedToken: string | undefined;
88
+ let hasTriedReconnect = false;
89
+
90
+ /** Buffer for incoming data — responses are newline-delimited JSON. */
91
+ let inBuffer = "";
92
+
93
+ const pending = new Map<string, PendingRequest>();
94
+
95
+ // -------------------------------------------------------------------------
96
+ // Token management
97
+ // -------------------------------------------------------------------------
98
+
99
+ function readToken(): string | null {
100
+ try {
101
+ const tokenPath = getTokenPath();
102
+ if (!pathExists(tokenPath)) return null;
103
+ return readFileSync(tokenPath, "utf-8").trim();
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function getToken(): string | null {
110
+ if (cachedToken !== undefined) return cachedToken;
111
+ const token = readToken();
112
+ // Only cache non-null results so we re-attempt on next call if the
113
+ // token file hasn't appeared yet (startup race).
114
+ if (token) cachedToken = token;
115
+ return token;
116
+ }
117
+
118
+ /** Re-read the token from disk (handles app restart with new token). */
119
+ function refreshToken(): string | null {
120
+ const token = readToken();
121
+ // Update the cache: set to the new value if found, clear if not so
122
+ // subsequent getToken() calls will re-read from disk.
123
+ cachedToken = token ?? undefined;
124
+ return token;
125
+ }
126
+
127
+ // -------------------------------------------------------------------------
128
+ // Socket lifecycle
129
+ // -------------------------------------------------------------------------
130
+
131
+ function handleData(chunk: Buffer | string): void {
132
+ inBuffer += chunk.toString();
133
+ let newlineIdx: number;
134
+ while ((newlineIdx = inBuffer.indexOf("\n")) !== -1) {
135
+ const line = inBuffer.slice(0, newlineIdx).trim();
136
+ inBuffer = inBuffer.slice(newlineIdx + 1);
137
+ if (!line) continue;
138
+
139
+ try {
140
+ const response = JSON.parse(line) as BrokerResponse;
141
+ const entry = pending.get(response.id);
142
+ if (entry) {
143
+ clearTimeout(entry.timer);
144
+ pending.delete(response.id);
145
+ entry.resolve(response);
146
+ }
147
+ } catch {
148
+ log.warn("Received malformed JSON from keychain broker");
149
+ }
150
+ }
151
+ }
152
+
153
+ function cleanupSocket(): void {
154
+ if (socket) {
155
+ socket.removeAllListeners();
156
+ socket.destroy();
157
+ socket = null;
158
+ }
159
+ inBuffer = "";
160
+ // Reject all pending requests
161
+ for (const [id, entry] of pending) {
162
+ clearTimeout(entry.timer);
163
+ entry.resolve({
164
+ id,
165
+ ok: false,
166
+ error: { code: "DISCONNECTED", message: "disconnected" },
167
+ });
168
+ }
169
+ pending.clear();
170
+ }
171
+
172
+ function connect(): Promise<Socket> {
173
+ return new Promise((resolve, reject) => {
174
+ const socketPath = getSocketPath();
175
+ if (!socketPath) {
176
+ reject(new Error("No socket path"));
177
+ return;
178
+ }
179
+
180
+ const sock = createConnection({ path: socketPath });
181
+
182
+ sock.on("connect", () => {
183
+ socket = sock;
184
+ hasTriedReconnect = false;
185
+ resolve(sock);
186
+ });
187
+
188
+ sock.on("error", (err) => {
189
+ log.warn({ err }, "Keychain broker socket error");
190
+ cleanupSocket();
191
+ reject(err);
192
+ });
193
+
194
+ sock.on("close", () => {
195
+ cleanupSocket();
196
+ });
197
+
198
+ sock.on("data", handleData);
199
+ });
200
+ }
201
+
202
+ async function ensureConnected(): Promise<Socket | null> {
203
+ if (permanentlyUnavailable) return null;
204
+ if (socket && !socket.destroyed) return socket;
205
+
206
+ // If a connect() is already in flight, wait for it instead of returning
207
+ // null — this prevents concurrent callers from silently failing.
208
+ if (connectPromise) {
209
+ try {
210
+ return await connectPromise;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ connectPromise = connect();
217
+ try {
218
+ const sock = await connectPromise;
219
+ return sock;
220
+ } catch {
221
+ // First connection failed — try once more
222
+ if (!hasTriedReconnect) {
223
+ hasTriedReconnect = true;
224
+ connectPromise = connect();
225
+ try {
226
+ return await connectPromise;
227
+ } catch {
228
+ // Reconnect also failed — mark unavailable
229
+ log.warn(
230
+ "Keychain broker reconnect failed, marking unavailable for this process",
231
+ );
232
+ permanentlyUnavailable = true;
233
+ return null;
234
+ } finally {
235
+ connectPromise = null;
236
+ }
237
+ }
238
+ permanentlyUnavailable = true;
239
+ return null;
240
+ } finally {
241
+ connectPromise = null;
242
+ }
243
+ }
244
+
245
+ // -------------------------------------------------------------------------
246
+ // Request / response
247
+ // -------------------------------------------------------------------------
248
+
249
+ function sendRequest(request: BrokerRequest): Promise<BrokerResponse> {
250
+ return new Promise((resolve) => {
251
+ if (!socket || socket.destroyed) {
252
+ resolve({
253
+ id: request.id,
254
+ ok: false,
255
+ error: { code: "NOT_CONNECTED", message: "not connected" },
256
+ });
257
+ return;
258
+ }
259
+
260
+ const timer = setTimeout(() => {
261
+ pending.delete(request.id);
262
+ resolve({
263
+ id: request.id,
264
+ ok: false,
265
+ error: { code: "TIMEOUT", message: "timeout" },
266
+ });
267
+ }, REQUEST_TIMEOUT_MS);
268
+
269
+ pending.set(request.id, { resolve, timer });
270
+
271
+ const data = JSON.stringify(request) + "\n";
272
+ socket.write(data, (err) => {
273
+ if (err) {
274
+ clearTimeout(timer);
275
+ pending.delete(request.id);
276
+ resolve({
277
+ id: request.id,
278
+ ok: false,
279
+ error: { code: "WRITE_ERROR", message: "write error" },
280
+ });
281
+ }
282
+ });
283
+ });
284
+ }
285
+
286
+ async function doRequest(
287
+ method: string,
288
+ params: Record<string, unknown> = {},
289
+ ): Promise<BrokerResponse | null> {
290
+ const sock = await ensureConnected();
291
+ if (!sock) return null;
292
+
293
+ const token = getToken();
294
+ if (!token) return null;
295
+
296
+ const id = randomUUID();
297
+ const request: BrokerRequest = {
298
+ v: 1,
299
+ id,
300
+ method,
301
+ token,
302
+ ...(Object.keys(params).length > 0 ? { params } : {}),
303
+ };
304
+ const response = await sendRequest(request);
305
+
306
+ // On UNAUTHORIZED, re-read the token once and retry. This handles
307
+ // the case where the app restarted with a new token while the daemon
308
+ // is still running with the old cached one.
309
+ if (response.error?.code === "UNAUTHORIZED") {
310
+ const newToken = refreshToken();
311
+ if (!newToken || newToken === request.token) return response;
312
+
313
+ const retryRequest: BrokerRequest = {
314
+ ...request,
315
+ id: randomUUID(),
316
+ token: newToken,
317
+ };
318
+ return await sendRequest(retryRequest);
319
+ }
320
+
321
+ return response;
322
+ }
323
+
324
+ // -------------------------------------------------------------------------
325
+ // Public API
326
+ // -------------------------------------------------------------------------
327
+
328
+ return {
329
+ isAvailable(): boolean {
330
+ if (permanentlyUnavailable) return false;
331
+ const socketPath = getSocketPath();
332
+ if (!socketPath) return false;
333
+ return pathExists(getTokenPath());
334
+ },
335
+
336
+ async ping(): Promise<{ pong: boolean } | null> {
337
+ try {
338
+ const response = await doRequest("broker.ping");
339
+ if (!response || !response.ok) return null;
340
+ return {
341
+ pong: !!(response.result as Record<string, unknown> | undefined)
342
+ ?.pong,
343
+ };
344
+ } catch {
345
+ return null;
346
+ }
347
+ },
348
+
349
+ async get(account: string): Promise<BrokerGetResult> {
350
+ try {
351
+ const response = await doRequest("key.get", { account });
352
+ if (!response) return null;
353
+ if (!response.ok) return null;
354
+ const result = response.result as
355
+ | { found?: boolean; value?: string }
356
+ | undefined;
357
+ if (!result) return null;
358
+ return { found: !!result.found, value: result.value };
359
+ } catch {
360
+ return null;
361
+ }
362
+ },
363
+
364
+ async set(account: string, value: string): Promise<boolean> {
365
+ try {
366
+ const response = await doRequest("key.set", { account, value });
367
+ return response?.ok === true;
368
+ } catch {
369
+ return false;
370
+ }
371
+ },
372
+
373
+ async del(account: string): Promise<boolean> {
374
+ try {
375
+ const response = await doRequest("key.delete", { account });
376
+ return response?.ok === true;
377
+ } catch {
378
+ return false;
379
+ }
380
+ },
381
+
382
+ async list(): Promise<string[]> {
383
+ try {
384
+ const response = await doRequest("key.list");
385
+ if (!response || !response.ok) return [];
386
+ const result = response.result as { accounts?: string[] } | undefined;
387
+ return result?.accounts ?? [];
388
+ } catch {
389
+ return [];
390
+ }
391
+ },
392
+ };
393
+ }