@vellumai/assistant 0.5.4 → 0.5.6

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 (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. package/src/memory/schema/memory-brief.ts +0 -55
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mutable mock state
5
+ // ---------------------------------------------------------------------------
6
+
7
+ let mockManagedProxyCtx = {
8
+ enabled: false,
9
+ platformBaseUrl: "",
10
+ assistantApiKey: "",
11
+ };
12
+ let mockAssistantId = "";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Module mocks
16
+ // ---------------------------------------------------------------------------
17
+
18
+ mock.module("../providers/managed-proxy/context.js", () => ({
19
+ resolveManagedProxyContext: async () => mockManagedProxyCtx,
20
+ }));
21
+
22
+ mock.module("../config/env.js", () => ({
23
+ getPlatformAssistantId: () => mockAssistantId,
24
+ }));
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Import under test (after mocks)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import { VellumPlatformClient } from "./client.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tests
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe("VellumPlatformClient", () => {
37
+ let originalFetch: typeof globalThis.fetch;
38
+
39
+ beforeEach(() => {
40
+ originalFetch = globalThis.fetch;
41
+ mockManagedProxyCtx = {
42
+ enabled: true,
43
+ platformBaseUrl: "https://platform.example.com",
44
+ assistantApiKey: "sk-test-key",
45
+ };
46
+ mockAssistantId = "asst-123";
47
+ });
48
+
49
+ afterEach(() => {
50
+ globalThis.fetch = originalFetch;
51
+ });
52
+
53
+ describe("create()", () => {
54
+ test("returns a client when all prerequisites are met", async () => {
55
+ const client = await VellumPlatformClient.create();
56
+ expect(client).not.toBeNull();
57
+ expect(client!.baseUrl).toBe("https://platform.example.com");
58
+ expect(client!.assistantApiKey).toBe("sk-test-key");
59
+ expect(client!.platformAssistantId).toBe("asst-123");
60
+ });
61
+
62
+ test("returns null when managed proxy is not enabled", async () => {
63
+ mockManagedProxyCtx = {
64
+ enabled: false,
65
+ platformBaseUrl: "",
66
+ assistantApiKey: "",
67
+ };
68
+
69
+ const client = await VellumPlatformClient.create();
70
+ expect(client).toBeNull();
71
+ });
72
+
73
+ test("returns client with empty assistantId when assistant ID is missing", async () => {
74
+ mockAssistantId = "";
75
+
76
+ const client = await VellumPlatformClient.create();
77
+ expect(client).not.toBeNull();
78
+ expect(client!.platformAssistantId).toBe("");
79
+ });
80
+
81
+ test("strips trailing slash from platform base URL", async () => {
82
+ mockManagedProxyCtx.platformBaseUrl = "https://platform.example.com///";
83
+
84
+ const client = await VellumPlatformClient.create();
85
+ expect(client!.baseUrl).toBe("https://platform.example.com");
86
+ });
87
+ });
88
+
89
+ describe("fetch()", () => {
90
+ test("prepends base URL to path", async () => {
91
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
92
+ expect(String(url)).toBe(
93
+ "https://platform.example.com/v1/some/endpoint/",
94
+ );
95
+ return new Response("ok", { status: 200 });
96
+ }) as unknown as typeof globalThis.fetch;
97
+
98
+ const client = await VellumPlatformClient.create();
99
+ await client!.fetch("/v1/some/endpoint/");
100
+ });
101
+
102
+ test("injects Api-Key auth header", async () => {
103
+ globalThis.fetch = mock(
104
+ async (_url: string | URL | Request, init?: RequestInit) => {
105
+ const headers = new Headers(init?.headers);
106
+ expect(headers.get("Authorization")).toBe("Api-Key sk-test-key");
107
+ return new Response("ok", { status: 200 });
108
+ },
109
+ ) as unknown as typeof globalThis.fetch;
110
+
111
+ const client = await VellumPlatformClient.create();
112
+ await client!.fetch("/v1/test/");
113
+ });
114
+
115
+ test("preserves caller-provided headers", async () => {
116
+ globalThis.fetch = mock(
117
+ async (_url: string | URL | Request, init?: RequestInit) => {
118
+ const headers = new Headers(init?.headers);
119
+ expect(headers.get("Content-Type")).toBe("application/json");
120
+ expect(headers.get("Authorization")).toBe("Api-Key sk-test-key");
121
+ return new Response("ok", { status: 200 });
122
+ },
123
+ ) as unknown as typeof globalThis.fetch;
124
+
125
+ const client = await VellumPlatformClient.create();
126
+ await client!.fetch("/v1/test/", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ });
130
+ });
131
+
132
+ test("passes through request init options", async () => {
133
+ globalThis.fetch = mock(
134
+ async (_url: string | URL | Request, init?: RequestInit) => {
135
+ expect(init?.method).toBe("POST");
136
+ expect(init?.body).toBe('{"key":"value"}');
137
+ return new Response("ok", { status: 200 });
138
+ },
139
+ ) as unknown as typeof globalThis.fetch;
140
+
141
+ const client = await VellumPlatformClient.create();
142
+ await client!.fetch("/v1/test/", {
143
+ method: "POST",
144
+ body: '{"key":"value"}',
145
+ });
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Centralized platform API client.
3
+ *
4
+ * Owns managed proxy context resolution, prerequisite validation, and
5
+ * authenticated fetch for all platform API calls that use Api-Key auth.
6
+ */
7
+
8
+ import { getPlatformAssistantId } from "../config/env.js";
9
+ import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
10
+
11
+ export class VellumPlatformClient {
12
+ private readonly platformBaseUrl: string;
13
+ private readonly apiKey: string;
14
+ private readonly assistantId: string;
15
+
16
+ private constructor(
17
+ platformBaseUrl: string,
18
+ apiKey: string,
19
+ assistantId: string,
20
+ ) {
21
+ this.platformBaseUrl = platformBaseUrl.replace(/\/+$/, "");
22
+ this.apiKey = apiKey;
23
+ this.assistantId = assistantId;
24
+ }
25
+
26
+ /**
27
+ * Create a platform client by resolving managed proxy context.
28
+ *
29
+ * Returns `null` when auth prerequisites are missing (not logged in, no API
30
+ * key). The assistant ID is resolved but not required — callers that need it
31
+ * should check `platformAssistantId` themselves.
32
+ */
33
+ static async create(): Promise<VellumPlatformClient | null> {
34
+ const ctx = await resolveManagedProxyContext();
35
+ if (!ctx.enabled) return null;
36
+
37
+ const assistantId = getPlatformAssistantId();
38
+
39
+ return new VellumPlatformClient(
40
+ ctx.platformBaseUrl,
41
+ ctx.assistantApiKey,
42
+ assistantId,
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Authenticated fetch against the platform API.
48
+ *
49
+ * Prepends `platformBaseUrl` to `path` and injects the `Api-Key` auth header.
50
+ * Callers handle response parsing and domain-specific error mapping.
51
+ */
52
+ async fetch(path: string, init?: RequestInit): Promise<Response> {
53
+ const url = `${this.platformBaseUrl}${path}`;
54
+ const headers = new Headers(init?.headers);
55
+ headers.set("Authorization", `Api-Key ${this.apiKey}`);
56
+
57
+ return fetch(url, { ...init, headers });
58
+ }
59
+
60
+ get baseUrl(): string {
61
+ return this.platformBaseUrl;
62
+ }
63
+
64
+ get assistantApiKey(): string {
65
+ return this.apiKey;
66
+ }
67
+
68
+ get platformAssistantId(): string {
69
+ return this.assistantId;
70
+ }
71
+ }
@@ -0,0 +1,190 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Module mocks (must precede dynamic imports)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ let mockOpenAIKey: string | undefined;
8
+
9
+ mock.module("../../security/secure-keys.js", () => ({
10
+ getProviderKeyAsync: async (provider: string) =>
11
+ provider === "openai" ? mockOpenAIKey : undefined,
12
+ }));
13
+
14
+ // Dynamic import so the mock is in effect when resolve.ts loads.
15
+ const { resolveSpeechToTextProvider } = await import("./resolve.js");
16
+
17
+ import { OpenAIWhisperProvider } from "./openai-whisper.js";
18
+
19
+ const TEST_API_KEY = "sk-test-key-for-unit-tests";
20
+
21
+ describe("OpenAIWhisperProvider", () => {
22
+ let originalFetch: typeof globalThis.fetch;
23
+
24
+ beforeEach(() => {
25
+ originalFetch = globalThis.fetch;
26
+ });
27
+
28
+ afterEach(() => {
29
+ globalThis.fetch = originalFetch;
30
+ });
31
+
32
+ test("successful transcription returns text", async () => {
33
+ globalThis.fetch = (async (
34
+ _url: string | URL | Request,
35
+ _init?: RequestInit,
36
+ ) => {
37
+ return new Response(JSON.stringify({ text: " Hello, world! " }), {
38
+ status: 200,
39
+ headers: { "Content-Type": "application/json" },
40
+ });
41
+ }) as typeof fetch;
42
+
43
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
44
+ const result = await provider.transcribe(
45
+ Buffer.from("fake-audio"),
46
+ "audio/ogg",
47
+ );
48
+
49
+ expect(result).toEqual({ text: "Hello, world!" });
50
+ });
51
+
52
+ test("API error throws with status and partial body", async () => {
53
+ const errorBody = JSON.stringify({
54
+ error: {
55
+ message: "Invalid API key provided",
56
+ type: "invalid_request_error",
57
+ },
58
+ });
59
+
60
+ globalThis.fetch = (async (
61
+ _url: string | URL | Request,
62
+ _init?: RequestInit,
63
+ ) => {
64
+ return new Response(errorBody, {
65
+ status: 401,
66
+ headers: { "Content-Type": "application/json" },
67
+ });
68
+ }) as typeof fetch;
69
+
70
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
71
+
72
+ await expect(
73
+ provider.transcribe(Buffer.from("fake-audio"), "audio/wav"),
74
+ ).rejects.toThrow("Whisper API error (401)");
75
+ });
76
+
77
+ test("sends correct FormData structure (model=whisper-1, file blob with correct MIME)", async () => {
78
+ let capturedBody: FormData | undefined;
79
+ let capturedHeaders: HeadersInit | undefined;
80
+
81
+ globalThis.fetch = (async (
82
+ url: string | URL | Request,
83
+ init?: RequestInit,
84
+ ) => {
85
+ capturedBody = init?.body as FormData;
86
+ capturedHeaders = init?.headers;
87
+ expect(url).toBe("https://api.openai.com/v1/audio/transcriptions");
88
+ expect(init?.method).toBe("POST");
89
+
90
+ return new Response(JSON.stringify({ text: "transcribed" }), {
91
+ status: 200,
92
+ headers: { "Content-Type": "application/json" },
93
+ });
94
+ }) as typeof fetch;
95
+
96
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
97
+ await provider.transcribe(Buffer.from("fake-audio"), "audio/mpeg");
98
+
99
+ // Verify authorization header
100
+ expect(capturedHeaders).toEqual({
101
+ Authorization: `Bearer ${TEST_API_KEY}`,
102
+ });
103
+
104
+ // Verify FormData contents
105
+ expect(capturedBody).toBeInstanceOf(FormData);
106
+ const model = capturedBody!.get("model");
107
+ expect(model).toBe("whisper-1");
108
+
109
+ const file = capturedBody!.get("file");
110
+ expect(file).toBeInstanceOf(Blob);
111
+ expect((file as Blob).type).toBe("audio/mpeg");
112
+ });
113
+
114
+ test("returns empty text when API returns empty text field", async () => {
115
+ globalThis.fetch = (async () => {
116
+ return new Response(JSON.stringify({ text: "" }), {
117
+ status: 200,
118
+ headers: { "Content-Type": "application/json" },
119
+ });
120
+ }) as unknown as typeof fetch;
121
+
122
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
123
+ const result = await provider.transcribe(
124
+ Buffer.from("silence"),
125
+ "audio/wav",
126
+ );
127
+
128
+ expect(result).toEqual({ text: "" });
129
+ });
130
+
131
+ test("returns empty text when API response has no text property", async () => {
132
+ globalThis.fetch = (async () => {
133
+ return new Response(JSON.stringify({}), {
134
+ status: 200,
135
+ headers: { "Content-Type": "application/json" },
136
+ });
137
+ }) as unknown as typeof fetch;
138
+
139
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
140
+ const result = await provider.transcribe(
141
+ Buffer.from("silence"),
142
+ "audio/wav",
143
+ );
144
+
145
+ expect(result).toEqual({ text: "" });
146
+ });
147
+
148
+ test("error body is truncated to 300 characters", async () => {
149
+ const longBody = "x".repeat(500);
150
+
151
+ globalThis.fetch = (async () => {
152
+ return new Response(longBody, { status: 500 });
153
+ }) as unknown as typeof fetch;
154
+
155
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
156
+
157
+ try {
158
+ await provider.transcribe(Buffer.from("audio"), "audio/wav");
159
+ expect.unreachable("should have thrown");
160
+ } catch (err) {
161
+ const msg = (err as Error).message;
162
+ expect(msg).toContain("Whisper API error (500)");
163
+ // The body portion should be at most 300 chars
164
+ const bodyPart = msg.replace("Whisper API error (500): ", "");
165
+ expect(bodyPart.length).toBeLessThanOrEqual(300);
166
+ }
167
+ });
168
+ });
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // resolveSpeechToTextProvider
172
+ // ---------------------------------------------------------------------------
173
+
174
+ describe("resolveSpeechToTextProvider", () => {
175
+ beforeEach(() => {
176
+ mockOpenAIKey = undefined;
177
+ });
178
+
179
+ test("returns null when no OpenAI key is configured", async () => {
180
+ mockOpenAIKey = undefined;
181
+ const provider = await resolveSpeechToTextProvider();
182
+ expect(provider).toBeNull();
183
+ });
184
+
185
+ test("returns an OpenAIWhisperProvider when key exists", async () => {
186
+ mockOpenAIKey = "sk-real-key";
187
+ const provider = await resolveSpeechToTextProvider();
188
+ expect(provider).toBeInstanceOf(OpenAIWhisperProvider);
189
+ });
190
+ });
@@ -0,0 +1,68 @@
1
+ import type { SpeechToTextProvider, SpeechToTextResult } from "./types.js";
2
+
3
+ const WHISPER_API_URL = "https://api.openai.com/v1/audio/transcriptions";
4
+ const DEFAULT_TIMEOUT_MS = 60_000;
5
+
6
+ /**
7
+ * Derive a filename extension from a MIME type so the Whisper API can detect
8
+ * the audio format. Falls back to "audio" when the MIME type is unrecognised.
9
+ */
10
+ function extensionFromMime(mimeType: string): string {
11
+ const map: Record<string, string> = {
12
+ "audio/wav": "wav",
13
+ "audio/x-wav": "wav",
14
+ "audio/mpeg": "mp3",
15
+ "audio/mp3": "mp3",
16
+ "audio/ogg": "ogg",
17
+ "audio/opus": "opus",
18
+ "audio/webm": "webm",
19
+ "audio/mp4": "m4a",
20
+ "audio/x-m4a": "m4a",
21
+ "audio/flac": "flac",
22
+ };
23
+ const base = mimeType.split(";")[0].trim().toLowerCase();
24
+ return map[base] ?? "audio";
25
+ }
26
+
27
+ export class OpenAIWhisperProvider implements SpeechToTextProvider {
28
+ private readonly apiKey: string;
29
+
30
+ constructor(apiKey: string) {
31
+ this.apiKey = apiKey;
32
+ }
33
+
34
+ async transcribe(
35
+ audio: Buffer,
36
+ mimeType: string,
37
+ signal?: AbortSignal,
38
+ ): Promise<SpeechToTextResult> {
39
+ const ext = extensionFromMime(mimeType);
40
+
41
+ const formData = new FormData();
42
+ formData.append(
43
+ "file",
44
+ new Blob([new Uint8Array(audio)], { type: mimeType }),
45
+ `audio.${ext}`,
46
+ );
47
+ formData.append("model", "whisper-1");
48
+
49
+ const effectiveSignal = signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
50
+
51
+ const response = await fetch(WHISPER_API_URL, {
52
+ method: "POST",
53
+ headers: { Authorization: `Bearer ${this.apiKey}` },
54
+ body: formData,
55
+ signal: effectiveSignal,
56
+ });
57
+
58
+ if (!response.ok) {
59
+ const body = await response.text().catch(() => "");
60
+ throw new Error(
61
+ `Whisper API error (${response.status}): ${body.slice(0, 300)}`,
62
+ );
63
+ }
64
+
65
+ const result = (await response.json()) as { text?: string };
66
+ return { text: result.text?.trim() ?? "" };
67
+ }
68
+ }
@@ -0,0 +1,9 @@
1
+ import { getProviderKeyAsync } from "../../security/secure-keys.js";
2
+ import { OpenAIWhisperProvider } from "./openai-whisper.js";
3
+ import type { SpeechToTextProvider } from "./types.js";
4
+
5
+ export async function resolveSpeechToTextProvider(): Promise<SpeechToTextProvider | null> {
6
+ const apiKey = await getProviderKeyAsync("openai");
7
+ if (!apiKey) return null;
8
+ return new OpenAIWhisperProvider(apiKey);
9
+ }
@@ -0,0 +1,17 @@
1
+ export interface SpeechToTextResult {
2
+ text: string;
3
+ }
4
+
5
+ export interface SpeechToTextProvider {
6
+ /**
7
+ * Transcribe audio from a Buffer.
8
+ * @param audio - Raw audio data (WAV, OGG, MP3, etc.)
9
+ * @param mimeType - MIME type of the audio data
10
+ * @param signal - Optional abort signal for cancellation
11
+ */
12
+ transcribe(
13
+ audio: Buffer,
14
+ mimeType: string,
15
+ signal?: AbortSignal,
16
+ ): Promise<SpeechToTextResult>;
17
+ }
@@ -176,6 +176,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
176
176
 
177
177
  // Settings / integrations / identity
178
178
  { endpoint: "identity", scopes: ["settings.read"] },
179
+ { endpoint: "identity/intro", scopes: ["settings.read"] },
179
180
  { endpoint: "brain-graph", scopes: ["settings.read"] },
180
181
  { endpoint: "brain-graph-ui", scopes: ["settings.read"] },
181
182
  { endpoint: "contacts", scopes: ["settings.read"] },
@@ -347,6 +348,10 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
347
348
  { endpoint: "config/embeddings:GET", scopes: ["settings.read"] },
348
349
  { endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
349
350
 
351
+ // Permissions config
352
+ { endpoint: "config/permissions/skip:GET", scopes: ["settings.read"] },
353
+ { endpoint: "config/permissions/skip:PUT", scopes: ["settings.write"] },
354
+
350
355
  // Conversation management
351
356
  { endpoint: "conversations:DELETE", scopes: ["chat.write"] },
352
357
  { endpoint: "conversations/wipe", scopes: ["chat.write"] },
@@ -355,6 +360,9 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
355
360
  // Conversation search
356
361
  { endpoint: "conversations/search", scopes: ["chat.read"] },
357
362
 
363
+ // Conversation starters
364
+ { endpoint: "conversation-starters", scopes: ["chat.read"] },
365
+
358
366
  // Message content
359
367
  { endpoint: "messages/content", scopes: ["chat.read"] },
360
368
  { endpoint: "messages/llm-context", scopes: ["chat.read"] },
@@ -498,3 +506,9 @@ for (const endpoint of INTERNAL_ENDPOINTS) {
498
506
  allowedPrincipalTypes: ["svc_gateway"],
499
507
  });
500
508
  }
509
+
510
+ // Admin control-plane endpoints: gateway-only
511
+ registerPolicy("admin/upgrade-broadcast", {
512
+ requiredScopes: ["internal.write"],
513
+ allowedPrincipalTypes: ["svc_gateway"],
514
+ });
@@ -28,6 +28,22 @@ import type { ScopeProfile, TokenAudience, TokenClaims } from "./types.js";
28
28
 
29
29
  const log = getLogger("token-service");
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Bootstrap sentinel error
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Thrown when the gateway's signing-key bootstrap endpoint returns 403,
37
+ * indicating that bootstrap has already completed (daemon restart case).
38
+ * The caller should fall back to loading the key from disk.
39
+ */
40
+ export class BootstrapAlreadyCompleted extends Error {
41
+ constructor() {
42
+ super("Gateway signing key bootstrap already completed");
43
+ this.name = "BootstrapAlreadyCompleted";
44
+ }
45
+ }
46
+
31
47
  // ---------------------------------------------------------------------------
32
48
  // Signing key management
33
49
  // ---------------------------------------------------------------------------
@@ -78,6 +94,123 @@ export function loadOrCreateSigningKey(): Buffer {
78
94
  return newKey;
79
95
  }
80
96
 
97
+ /**
98
+ * Fetch the shared signing key from the gateway's bootstrap endpoint.
99
+ *
100
+ * Used in Docker mode where the gateway owns the signing key and the daemon
101
+ * must fetch it at startup. Retries up to 30 times with 1s intervals to
102
+ * tolerate gateway startup delays.
103
+ *
104
+ * @returns A 32-byte Buffer containing the signing key.
105
+ * @throws {BootstrapAlreadyCompleted} If the gateway returns 403 (bootstrap
106
+ * already completed — daemon restart case). Caller should fall back to
107
+ * loading the key from disk.
108
+ * @throws {Error} If the gateway is unreachable after all retry attempts.
109
+ */
110
+ export async function fetchSigningKeyFromGateway(): Promise<Buffer> {
111
+ const gatewayUrl = process.env.GATEWAY_INTERNAL_URL;
112
+ if (!gatewayUrl) {
113
+ throw new Error("GATEWAY_INTERNAL_URL not set — cannot fetch signing key");
114
+ }
115
+
116
+ const maxAttempts = 30;
117
+ const intervalMs = 1000;
118
+
119
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
120
+ let resp: Response | undefined;
121
+ try {
122
+ resp = await fetch(`${gatewayUrl}/internal/signing-key-bootstrap`, {
123
+ signal: AbortSignal.timeout(5000),
124
+ });
125
+ } catch (err) {
126
+ log.warn(
127
+ { err, attempt },
128
+ "Signing key bootstrap: connection failed, retrying",
129
+ );
130
+ await Bun.sleep(intervalMs);
131
+ continue;
132
+ }
133
+
134
+ if (resp.ok) {
135
+ const body = (await resp.json()) as { key: string };
136
+ const keyBuf = Buffer.from(body.key, "hex");
137
+ if (keyBuf.length !== 32) {
138
+ throw new Error(`Invalid signing key length: ${keyBuf.length}`);
139
+ }
140
+ log.info("Signing key fetched from gateway bootstrap endpoint");
141
+ return keyBuf;
142
+ }
143
+
144
+ if (resp.status === 403) {
145
+ // Bootstrap already completed — fall through to file-based load.
146
+ // This happens on daemon restart when the gateway lockfile persists.
147
+ log.info(
148
+ "Gateway signing key bootstrap already completed — loading from disk",
149
+ );
150
+ throw new BootstrapAlreadyCompleted();
151
+ }
152
+
153
+ log.warn(
154
+ { status: resp.status, attempt },
155
+ "Signing key bootstrap: gateway not ready, retrying",
156
+ );
157
+
158
+ await Bun.sleep(intervalMs);
159
+ }
160
+
161
+ throw new Error("Signing key bootstrap: timed out waiting for gateway");
162
+ }
163
+
164
+ /**
165
+ * Persist a signing key to disk using an atomic-write pattern.
166
+ * Used after fetching the key from the gateway so daemon restarts can
167
+ * load it from disk when the gateway returns 403.
168
+ */
169
+ function persistSigningKey(key: Buffer): void {
170
+ const keyPath = getSigningKeyPath();
171
+ const dir = dirname(keyPath);
172
+ if (!existsSync(dir)) {
173
+ mkdirSync(dir, { recursive: true });
174
+ }
175
+ const tmpPath = keyPath + ".tmp." + process.pid;
176
+ writeFileSync(tmpPath, key, { mode: 0o600 });
177
+ renameSync(tmpPath, keyPath);
178
+ chmodSync(keyPath, 0o600);
179
+ }
180
+
181
+ /**
182
+ * Resolve the signing key for the current environment.
183
+ *
184
+ * In Docker mode (IS_CONTAINERIZED=true + GATEWAY_INTERNAL_URL set), fetches
185
+ * the key from the gateway's bootstrap endpoint and persists it locally for
186
+ * restart resilience. On daemon restart (gateway returns 403), falls back to
187
+ * loading the key from disk.
188
+ *
189
+ * In local mode, delegates to the existing file-based loadOrCreateSigningKey().
190
+ */
191
+ export async function resolveSigningKey(): Promise<Buffer> {
192
+ const isContainerized = process.env.IS_CONTAINERIZED === "true";
193
+ const gatewayUrl = process.env.GATEWAY_INTERNAL_URL;
194
+
195
+ if (isContainerized && gatewayUrl) {
196
+ try {
197
+ const key = await fetchSigningKeyFromGateway();
198
+ // Persist locally so daemon restarts (where gateway returns 403) load from disk.
199
+ persistSigningKey(key);
200
+ return key;
201
+ } catch (err) {
202
+ if (err instanceof BootstrapAlreadyCompleted) {
203
+ // Gateway already bootstrapped (daemon restart) — load from disk.
204
+ return loadOrCreateSigningKey();
205
+ }
206
+ throw err;
207
+ }
208
+ }
209
+
210
+ // Local mode: use file-based load/create (unchanged behavior).
211
+ return loadOrCreateSigningKey();
212
+ }
213
+
81
214
  function getSigningKey(): Buffer {
82
215
  if (!_authSigningKey) {
83
216
  if (process.env.NODE_ENV === "test") {