@vellumai/assistant 0.5.4 → 0.5.5

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 (59) hide show
  1. package/Dockerfile +18 -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__/credential-security-invariants.test.ts +2 -0
  6. package/src/__tests__/openai-whisper.test.ts +93 -0
  7. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  8. package/src/__tests__/volume-security-guard.test.ts +155 -0
  9. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  10. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  11. package/src/config/env-registry.ts +9 -0
  12. package/src/config/feature-flag-registry.json +8 -0
  13. package/src/credential-execution/managed-catalog.ts +5 -15
  14. package/src/daemon/config-watcher.ts +4 -1
  15. package/src/daemon/daemon-control.ts +7 -0
  16. package/src/daemon/lifecycle.ts +7 -1
  17. package/src/daemon/providers-setup.ts +2 -1
  18. package/src/hooks/manager.ts +7 -0
  19. package/src/instrument.ts +33 -1
  20. package/src/memory/embedding-local.ts +11 -5
  21. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  22. package/src/messaging/provider.ts +9 -0
  23. package/src/messaging/providers/slack/adapter.ts +29 -2
  24. package/src/oauth/connection-resolver.test.ts +22 -18
  25. package/src/oauth/connection-resolver.ts +92 -7
  26. package/src/oauth/platform-connection.test.ts +78 -69
  27. package/src/oauth/platform-connection.ts +12 -19
  28. package/src/permissions/trust-client.ts +343 -0
  29. package/src/permissions/trust-store-interface.ts +105 -0
  30. package/src/permissions/trust-store.ts +523 -36
  31. package/src/platform/client.test.ts +148 -0
  32. package/src/platform/client.ts +71 -0
  33. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  34. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  35. package/src/providers/speech-to-text/resolve.ts +9 -0
  36. package/src/providers/speech-to-text/types.ts +17 -0
  37. package/src/runtime/http-server.ts +2 -2
  38. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  39. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  40. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  41. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  42. package/src/runtime/routes/log-export-routes.ts +1 -0
  43. package/src/runtime/routes/secret-routes.ts +4 -1
  44. package/src/security/ces-credential-client.ts +173 -0
  45. package/src/security/secure-keys.ts +65 -22
  46. package/src/signals/bash.ts +3 -0
  47. package/src/signals/cancel.ts +3 -0
  48. package/src/signals/confirm.ts +3 -0
  49. package/src/signals/conversation-undo.ts +3 -0
  50. package/src/signals/event-stream.ts +7 -0
  51. package/src/signals/shotgun.ts +3 -0
  52. package/src/signals/trust-rule.ts +3 -0
  53. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  54. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  55. package/src/util/device-id.ts +70 -7
  56. package/src/util/logger.ts +35 -9
  57. package/src/util/platform.ts +29 -5
  58. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  59. package/src/workspace/migrations/registry.ts +2 -0
@@ -3,6 +3,7 @@
3
3
  * adapters.
4
4
  *
5
5
  * Backend selection (`resolveBackend`) is the single decision point:
6
+ * - Containerized (IS_CONTAINERIZED + CES_CREDENTIAL_URL set): CES HTTP client.
6
7
  * - Production (VELLUM_DEV unset or "0"): keychain backend when available.
7
8
  * - Dev mode (VELLUM_DEV=1): encrypted file store always.
8
9
  *
@@ -16,7 +17,10 @@ import type {
16
17
  SecureKeyDeleteResult,
17
18
  } from "@vellumai/credential-storage";
18
19
 
20
+ import providerEnvVarsRegistry from "../../../meta/provider-env-vars.json" with { type: "json" };
21
+ import { getIsContainerized } from "../config/env-registry.js";
19
22
  import { getLogger } from "../util/logger.js";
23
+ import { createCesCredentialBackend } from "./ces-credential-client.js";
20
24
  import type { CredentialBackend, DeleteResult } from "./credential-backend.js";
21
25
  import {
22
26
  createEncryptedStoreBackend,
@@ -50,18 +54,39 @@ function getEncryptedStoreBackend(): CredentialBackend {
50
54
 
51
55
  /**
52
56
  * Resolve the primary credential backend for this process.
53
- * Production (VELLUM_DEV unset or "0") uses keychain when available.
54
- * Dev mode (VELLUM_DEV=1) always uses the encrypted file store.
57
+ *
58
+ * Priority:
59
+ * 1. Containerized + CES_CREDENTIAL_URL → CES HTTP client (skip keychain
60
+ * and encrypted store entirely — the sidecar owns credential storage).
61
+ * 2. Production (VELLUM_DEV unset or "0") → keychain when available.
62
+ * 3. Dev mode (VELLUM_DEV=1) → encrypted file store always.
55
63
  *
56
64
  * Once resolved, the backend does not change during the process lifetime.
57
65
  * Call `_resetBackend()` in tests to clear the cached resolution.
58
66
  */
59
67
  function resolveBackend(): CredentialBackend {
60
68
  if (!_resolvedBackend) {
61
- if (process.env.VELLUM_DEV !== "1" && getKeychainBackend().isAvailable()) {
62
- _resolvedBackend = getKeychainBackend();
63
- } else {
64
- _resolvedBackend = getEncryptedStoreBackend();
69
+ if (getIsContainerized() && process.env.CES_CREDENTIAL_URL) {
70
+ const ces = createCesCredentialBackend();
71
+ if (ces.isAvailable()) {
72
+ _resolvedBackend = ces;
73
+ } else {
74
+ log.warn(
75
+ "CES_CREDENTIAL_URL is set but CES backend is not available — " +
76
+ "falling back to local credential store",
77
+ );
78
+ }
79
+ }
80
+
81
+ if (!_resolvedBackend) {
82
+ if (
83
+ process.env.VELLUM_DEV !== "1" &&
84
+ getKeychainBackend().isAvailable()
85
+ ) {
86
+ _resolvedBackend = getKeychainBackend();
87
+ } else {
88
+ _resolvedBackend = getEncryptedStoreBackend();
89
+ }
65
90
  }
66
91
  }
67
92
  return _resolvedBackend;
@@ -70,6 +95,8 @@ function resolveBackend(): CredentialBackend {
70
95
  /**
71
96
  * List all account names across both backends (async).
72
97
  *
98
+ * In CES mode, only the CES backend is queried — there are no local stores.
99
+ *
73
100
  * When the primary backend is the keychain, this merges keys from the keychain
74
101
  * and the encrypted store (for legacy keys that haven't been migrated). The
75
102
  * result is deduplicated. When the primary backend is already the encrypted
@@ -79,6 +106,9 @@ export async function listSecureKeysAsync(): Promise<string[]> {
79
106
  const backend = resolveBackend();
80
107
  const primaryKeys = await backend.list();
81
108
 
109
+ // CES mode — the sidecar is the single source of truth, no local merge.
110
+ if (backend.name === "ces-http") return primaryKeys;
111
+
82
112
  // If primary backend is NOT the encrypted store, also check
83
113
  // the encrypted store for legacy keys that haven't been migrated.
84
114
  if (backend !== getEncryptedStoreBackend()) {
@@ -96,8 +126,10 @@ export async function listSecureKeysAsync(): Promise<string[]> {
96
126
 
97
127
  /**
98
128
  * Retrieve a secret from secure storage. Reads from the primary backend
99
- * first. If the primary backend is the keychain, falls back to the encrypted
100
- * store for legacy keys that haven't been migrated.
129
+ * first. In CES mode, the sidecar is the single source of truth no
130
+ * local fallback. In local mode, if the primary backend is the keychain,
131
+ * falls back to the encrypted store for legacy keys that haven't been
132
+ * migrated.
101
133
  */
102
134
  export async function getSecureKeyAsync(
103
135
  account: string,
@@ -106,6 +138,9 @@ export async function getSecureKeyAsync(
106
138
  const result = await backend.get(account);
107
139
  if (result != null) return result;
108
140
 
141
+ // CES mode — no local fallback.
142
+ if (backend.name === "ces-http") return undefined;
143
+
109
144
  // Legacy fallback: if primary backend is NOT the encrypted store,
110
145
  // check the encrypted store for keys that haven't been migrated.
111
146
  if (backend !== getEncryptedStoreBackend()) {
@@ -135,13 +170,25 @@ export async function setSecureKeyAsync(
135
170
  }
136
171
 
137
172
  /**
138
- * Delete a secret from secure storage. Always attempts deletion on both
139
- * the keychain backend (if available) and the encrypted store backend,
140
- * regardless of routing mode. This cleans up legacy data from both stores.
173
+ * Delete a secret from secure storage.
174
+ *
175
+ * In containerized mode with CES, deletion is routed exclusively through the
176
+ * CES backend — there are no local stores to clean up.
177
+ *
178
+ * In local mode, always attempts deletion on both the keychain backend (if
179
+ * available) and the encrypted store backend, regardless of routing mode.
180
+ * This cleans up legacy data from both stores.
141
181
  */
142
182
  export async function deleteSecureKeyAsync(
143
183
  account: string,
144
184
  ): Promise<DeleteResult> {
185
+ const backend = resolveBackend();
186
+
187
+ // In CES mode, the sidecar is the only store — no local cleanup needed.
188
+ if (backend.name === "ces-http") {
189
+ return backend.delete(account);
190
+ }
191
+
145
192
  const keychain = getKeychainBackend();
146
193
  const enc = getEncryptedStoreBackend();
147
194
 
@@ -164,18 +211,14 @@ export async function deleteSecureKeyAsync(
164
211
  // ---------------------------------------------------------------------------
165
212
 
166
213
  /**
167
- * Env var names keyed by provider. The convention is `<PROVIDER>_API_KEY`.
168
- * Ollama is intentionally omitted it doesn't require an API key.
214
+ * Env var names keyed by provider. Loaded from the shared registry at
215
+ * `meta/provider-env-vars.json` the single source of truth also consumed
216
+ * by the CLI and the macOS client.
217
+ * Ollama is intentionally omitted from the registry — it doesn't require
218
+ * an API key.
169
219
  */
170
- const PROVIDER_ENV_VARS: Record<string, string> = {
171
- anthropic: "ANTHROPIC_API_KEY",
172
- openai: "OPENAI_API_KEY",
173
- gemini: "GEMINI_API_KEY",
174
- fireworks: "FIREWORKS_API_KEY",
175
- openrouter: "OPENROUTER_API_KEY",
176
- brave: "BRAVE_API_KEY",
177
- perplexity: "PERPLEXITY_API_KEY",
178
- };
220
+ const PROVIDER_ENV_VARS: Record<string, string> =
221
+ providerEnvVarsRegistry.providers;
179
222
 
180
223
  /**
181
224
  * Retrieve a provider API key, checking secure storage first and falling
@@ -20,6 +20,7 @@ import { spawn } from "node:child_process";
20
20
  import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
 
23
+ import { getIsContainerized } from "../config/env-registry.js";
23
24
  import { getLogger } from "../util/logger.js";
24
25
  import { getSignalsDir, getWorkspaceDir } from "../util/platform.js";
25
26
 
@@ -65,6 +66,8 @@ function writeResult(requestId: string, result: BashSignalResult): void {
65
66
  * when a matching signal file is created or modified.
66
67
  */
67
68
  export function handleBashSignal(filename: string): void {
69
+ if (getIsContainerized()) return;
70
+
68
71
  if (!isDebugMode()) {
69
72
  log.warn(
70
73
  { filename },
@@ -13,6 +13,7 @@
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
 
16
+ import { getIsContainerized } from "../config/env-registry.js";
16
17
  import { getLogger } from "../util/logger.js";
17
18
  import { getSignalsDir } from "../util/platform.js";
18
19
 
@@ -39,6 +40,8 @@ export function registerCancelCallback(cb: CancelCallback): void {
39
40
  * Called by ConfigWatcher when the signal file is written or modified.
40
41
  */
41
42
  export function handleCancelSignal(): void {
43
+ if (getIsContainerized()) return;
44
+
42
45
  try {
43
46
  const content = readFileSync(join(getSignalsDir(), "cancel"), "utf-8");
44
47
  const parsed = JSON.parse(content) as { conversationId?: string };
@@ -10,6 +10,7 @@
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
+ import { getIsContainerized } from "../config/env-registry.js";
13
14
  import type { UserDecision } from "../permissions/types.js";
14
15
  import * as pendingInteractions from "../runtime/pending-interactions.js";
15
16
  import { getLogger } from "../util/logger.js";
@@ -37,6 +38,8 @@ function isUserDecision(value: string): value is UserDecision {
37
38
  * Called by ConfigWatcher when the signal file is written or modified.
38
39
  */
39
40
  export function handleConfirmationSignal(): void {
41
+ if (getIsContainerized()) return;
42
+
40
43
  try {
41
44
  const content = readFileSync(join(getSignalsDir(), "confirm"), "utf-8");
42
45
  const parsed = JSON.parse(content) as {
@@ -16,6 +16,7 @@
16
16
  import { readFileSync, writeFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
 
19
+ import { getIsContainerized } from "../config/env-registry.js";
19
20
  import { getLogger } from "../util/logger.js";
20
21
  import { getSignalsDir } from "../util/platform.js";
21
22
 
@@ -46,6 +47,8 @@ export function registerConversationUndoCallback(cb: UndoCallback): void {
46
47
  * file is written.
47
48
  */
48
49
  export async function handleConversationUndoSignal(): Promise<void> {
50
+ if (getIsContainerized()) return;
51
+
49
52
  const resultPath = join(getSignalsDir(), "conversation-undo.result");
50
53
 
51
54
  const writeResult = (
@@ -24,6 +24,7 @@ import {
24
24
  } from "node:fs";
25
25
  import { join } from "node:path";
26
26
 
27
+ import { getIsContainerized } from "../config/env-registry.js";
27
28
  import type { AssistantEvent } from "../runtime/assistant-event.js";
28
29
  import { getSignalsDir } from "../util/platform.js";
29
30
 
@@ -86,6 +87,8 @@ export function appendEventToStream(
86
87
  conversationId: string,
87
88
  event: AssistantEvent,
88
89
  ): void {
90
+ if (getIsContainerized()) return;
91
+
89
92
  const dirs = getSubscriberDirs(conversationId);
90
93
  if (dirs.length === 0) return;
91
94
 
@@ -130,6 +133,10 @@ export function watchEventStream(
130
133
  conversationId: string,
131
134
  callback: (event: AssistantEvent) => void,
132
135
  ): EventStreamWatcher {
136
+ if (getIsContainerized()) {
137
+ return { dispose() {} };
138
+ }
139
+
133
140
  const parentDir = eventsDir();
134
141
  mkdirSync(parentDir, { recursive: true });
135
142
  const subDir = join(parentDir, `${conversationId}.${process.pid}`);
@@ -15,6 +15,7 @@ import crypto from "node:crypto";
15
15
  import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
 
18
+ import { getIsContainerized } from "../config/env-registry.js";
18
19
  import {
19
20
  fireWatchCompletionNotifier,
20
21
  fireWatchStartNotifier,
@@ -54,6 +55,8 @@ function writeResult(requestId: string, result: ShotgunResult): void {
54
55
  * when a matching signal file is created or modified.
55
56
  */
56
57
  export function handleShotgunSignal(filename: string): void {
58
+ if (getIsContainerized()) return;
59
+
57
60
  const signalPath = join(getSignalsDir(), filename);
58
61
  let raw: string;
59
62
  try {
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
+ import { getIsContainerized } from "../config/env-registry.js";
15
16
  import { addRule } from "../permissions/trust-store.js";
16
17
  import * as pendingInteractions from "../runtime/pending-interactions.js";
17
18
  import { getTool } from "../tools/registry.js";
@@ -27,6 +28,8 @@ const VALID_TRUST_DECISIONS: ReadonlySet<string> = new Set(["allow", "deny"]);
27
28
  * Called by ConfigWatcher when the signal file is written or modified.
28
29
  */
29
30
  export function handleTrustRuleSignal(): void {
31
+ if (getIsContainerized()) return;
32
+
30
33
  const resultPath = join(getSignalsDir(), "trust-rule.result");
31
34
 
32
35
  const writeError = (requestId: string | undefined, error: string): void => {
@@ -41,14 +41,12 @@ mock.module("../memory/turn-events-store.js", () => ({
41
41
  queryUnreportedTurnEvents: mockQueryUnreportedTurnEvents,
42
42
  }));
43
43
 
44
- const mockResolveManagedProxyContext = mock(async () => ({
45
- enabled: false,
46
- platformBaseUrl: "",
47
- assistantApiKey: "",
48
- }));
44
+ let mockPlatformClient: Record<string, unknown> | null = null;
49
45
 
50
- mock.module("../providers/managed-proxy/context.js", () => ({
51
- resolveManagedProxyContext: mockResolveManagedProxyContext,
46
+ mock.module("../platform/client.js", () => ({
47
+ VellumPlatformClient: {
48
+ create: async () => mockPlatformClient,
49
+ },
52
50
  }));
53
51
 
54
52
  const mockGetTelemetryPlatformUrl = mock(() => "https://platform.vellum.ai");
@@ -142,7 +140,7 @@ beforeEach(() => {
142
140
  mockQueryUnreportedUsageEvents.mockReset();
143
141
  mockQueryUnreportedTurnEvents.mockReset();
144
142
  mockQueryUnreportedTurnEvents.mockReturnValue([]);
145
- mockResolveManagedProxyContext.mockReset();
143
+ mockPlatformClient = null;
146
144
  mockGetTelemetryPlatformUrl.mockReset();
147
145
  mockGetTelemetryAppToken.mockReset();
148
146
  mockGetDeviceId.mockReset();
@@ -156,11 +154,6 @@ beforeEach(() => {
156
154
 
157
155
  // Defaults
158
156
  mockGetMemoryCheckpoint.mockReturnValue(null);
159
- mockResolveManagedProxyContext.mockResolvedValue({
160
- enabled: false,
161
- platformBaseUrl: "",
162
- assistantApiKey: "",
163
- });
164
157
  mockGetTelemetryPlatformUrl.mockReturnValue("https://platform.vellum.ai");
165
158
  mockGetTelemetryAppToken.mockReturnValue("default-test-token");
166
159
 
@@ -179,37 +172,31 @@ afterEach(() => {
179
172
  // ---------------------------------------------------------------------------
180
173
 
181
174
  describe("UsageTelemetryReporter", () => {
182
- test("authenticated flush uses Api-Key header and proxy URL", async () => {
183
- mockResolveManagedProxyContext.mockResolvedValue({
184
- enabled: true,
185
- platformBaseUrl: "https://test.vellum.ai",
186
- assistantApiKey: "test-key",
175
+ test("authenticated flush uses client.fetch with platform path", async () => {
176
+ const clientFetchMock = mock(async (_path: string, _init?: RequestInit) => {
177
+ return new Response('{"accepted":2}', { status: 200 });
187
178
  });
179
+ mockPlatformClient = {
180
+ baseUrl: "https://test.vellum.ai",
181
+ assistantApiKey: "test-key",
182
+ platformAssistantId: "asst-123",
183
+ fetch: clientFetchMock,
184
+ };
188
185
  const events = [makeUsageEvent(), makeUsageEvent()];
189
186
  mockQueryUnreportedUsageEvents.mockReturnValue(events);
190
- mockFetch.mockImplementation(() =>
191
- Promise.resolve(
192
- new Response(`{"accepted":${events.length}}`, { status: 200 }),
193
- ),
194
- );
195
187
 
196
188
  const reporter = new UsageTelemetryReporter();
197
189
  await reporter.flush();
198
190
 
199
- expect(mockFetch).toHaveBeenCalledTimes(1);
200
- const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
201
- expect(url).toBe("https://test.vellum.ai/v1/telemetry/ingest/");
202
- expect((opts.headers as Record<string, string>)["Authorization"]).toBe(
203
- "Api-Key test-key",
204
- );
191
+ expect(clientFetchMock).toHaveBeenCalledTimes(1);
192
+ const [path] = clientFetchMock.mock.calls[0] as [string, RequestInit];
193
+ expect(path).toBe("/v1/telemetry/ingest/");
194
+ // globalThis.fetch should NOT have been called directly
195
+ expect(mockFetch).not.toHaveBeenCalled();
205
196
  });
206
197
 
207
198
  test("anonymous flush uses X-Telemetry-Token and default URL", async () => {
208
- mockResolveManagedProxyContext.mockResolvedValue({
209
- enabled: false,
210
- platformBaseUrl: "",
211
- assistantApiKey: "",
212
- });
199
+ mockPlatformClient = null;
213
200
  mockGetTelemetryPlatformUrl.mockReturnValue("https://platform.test.ai");
214
201
  mockGetTelemetryAppToken.mockReturnValue("anon-token");
215
202
 
@@ -375,9 +362,9 @@ describe("UsageTelemetryReporter", () => {
375
362
  (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
376
363
  );
377
364
 
378
- // Top-level: installation_id, app_version, and events array (no turn_events key)
365
+ // Top-level: installation_id, assistant_version, and events array (no turn_events key)
379
366
  expect(body.installation_id).toBe("test-device-id");
380
- expect(body.app_version).toBe("1.2.3-test");
367
+ expect(body.assistant_version).toBe("1.2.3-test");
381
368
  expect(Array.isArray(body.events)).toBe(true);
382
369
  expect(body.events.length).toBe(1);
383
370
  expect(body.turn_events).toBeUndefined();
@@ -22,7 +22,7 @@ import {
22
22
  import { queryUnreportedLifecycleEvents } from "../memory/lifecycle-events-store.js";
23
23
  import { queryUnreportedUsageEvents } from "../memory/llm-usage-store.js";
24
24
  import { queryUnreportedTurnEvents } from "../memory/turn-events-store.js";
25
- import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
25
+ import { VellumPlatformClient } from "../platform/client.js";
26
26
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
27
27
  import { getExternalAssistantId } from "../runtime/auth/external-assistant-id.js";
28
28
  import { getDeviceId } from "../util/device-id.js";
@@ -139,22 +139,11 @@ export class UsageTelemetryReporter {
139
139
  return;
140
140
 
141
141
  // Resolve auth context — skip flush when neither auth mode is viable
142
- const proxyCtx = await resolveManagedProxyContext();
143
- if (!proxyCtx.enabled && !getTelemetryAppToken()) {
142
+ const client = await VellumPlatformClient.create();
143
+ if (!client && !getTelemetryAppToken()) {
144
144
  return;
145
145
  }
146
146
 
147
- let url: string;
148
- let authHeaders: Record<string, string>;
149
-
150
- if (proxyCtx.enabled) {
151
- url = `${proxyCtx.platformBaseUrl}${TELEMETRY_PATH}`;
152
- authHeaders = { Authorization: `Api-Key ${proxyCtx.assistantApiKey}` };
153
- } else {
154
- url = `${getTelemetryPlatformUrl()}${TELEMETRY_PATH}`;
155
- authHeaders = { "X-Telemetry-Token": getTelemetryAppToken() };
156
- }
157
-
158
147
  // Build payload
159
148
  const typedEvents: TelemetryEvent[] = [
160
149
  ...events.map(
@@ -195,26 +184,39 @@ export class UsageTelemetryReporter {
195
184
  const payload = {
196
185
  device_id: getDeviceId(),
197
186
  assistant_id: assistantId,
198
- app_version: APP_VERSION,
187
+ assistant_version: APP_VERSION,
199
188
  ...(organizationId ? { organization_id: organizationId } : {}),
200
189
  ...(userId ? { user_id: userId } : {}),
201
190
  events: typedEvents,
202
191
  };
203
192
 
204
193
  // Send
205
- const resp = await fetch(url, {
194
+ const fetchInit: RequestInit = {
206
195
  method: "POST",
207
196
  headers: {
208
197
  "Content-Type": "application/json",
209
- ...authHeaders,
210
198
  },
211
199
  body: JSON.stringify(payload),
212
- });
200
+ };
201
+
202
+ let resp: Response;
203
+ if (client) {
204
+ resp = await client.fetch(TELEMETRY_PATH, fetchInit);
205
+ } else {
206
+ const url = `${getTelemetryPlatformUrl()}${TELEMETRY_PATH}`;
207
+ resp = await fetch(url, {
208
+ ...fetchInit,
209
+ headers: {
210
+ "Content-Type": "application/json",
211
+ "X-Telemetry-Token": getTelemetryAppToken(),
212
+ },
213
+ });
214
+ }
213
215
 
214
216
  if (!resp.ok) {
215
217
  await resp.text(); // consume body to release connection
216
218
  log.warn(
217
- { status: resp.status, url },
219
+ { status: resp.status },
218
220
  "Usage telemetry POST failed — will retry next cycle",
219
221
  );
220
222
  return;
@@ -6,8 +6,10 @@
6
6
  * extensible for future per-device metadata.
7
7
  *
8
8
  * Path resolution:
9
- * - Containerized (IS_CONTAINERIZED=true): uses BASE_DATA_DIR, which maps to a
10
- * persistent volume. Each container is effectively its own "device."
9
+ * - Containerized (IS_CONTAINERIZED=true): uses /home/assistant (the assistant
10
+ * user's persistent home dir) so device.json lives on the assistant's own
11
+ * filesystem rather than the shared data volume. Falls back to BASE_DATA_DIR
12
+ * for migration from the old location.
11
13
  * - Local (single or multi-instance): uses homedir() so all instances on the
12
14
  * same machine share a single device ID, even when BASE_DATA_DIR is set to
13
15
  * an instance-scoped directory.
@@ -31,18 +33,34 @@ let cached: string | undefined;
31
33
  /**
32
34
  * Resolve the base directory for device.json.
33
35
  *
34
- * In containerized environments, BASE_DATA_DIR points to a persistent volume
35
- * and homedir() is ephemeral, so we must use BASE_DATA_DIR.
36
+ * In containerized environments, device.json is stored under /home/assistant
37
+ * (the assistant user's persistent home dir) rather than on the shared data
38
+ * volume. Device ID is assistant-specific state that doesn't need to be shared.
36
39
  * In local environments (including multi-instance), homedir() is stable and
37
40
  * shared across instances, giving a true per-machine device ID.
38
41
  */
39
42
  export function getDeviceIdBaseDir(): string {
40
43
  if (getIsContainerized()) {
41
- return getBaseDataDir() || homedir();
44
+ return "/home/assistant";
42
45
  }
43
46
  return homedir();
44
47
  }
45
48
 
49
+ /**
50
+ * Resolve the legacy base directory for device.json migration.
51
+ *
52
+ * Returns the old containerized path (BASE_DATA_DIR) so we can fall back to
53
+ * reading device.json from the shared volume if it hasn't been migrated yet.
54
+ * Returns undefined when not containerized or when no legacy path exists.
55
+ */
56
+ function getLegacyDeviceIdBaseDir(): string | undefined {
57
+ if (!getIsContainerized()) {
58
+ return undefined;
59
+ }
60
+ const baseDataDir = getBaseDataDir();
61
+ return baseDataDir || undefined;
62
+ }
63
+
46
64
  /**
47
65
  * Get the stable device ID for this machine.
48
66
  *
@@ -78,10 +96,55 @@ export function getDeviceId(): string {
78
96
  }
79
97
  }
80
98
  } catch (err) {
81
- log.warn({ err }, "Failed to read device.json — generating new device ID");
99
+ log.warn({ err }, "Failed to read device.json — checking legacy path");
100
+ }
101
+
102
+ // Migration fallback: check the legacy location (shared volume) if the new
103
+ // location doesn't have a valid device.json yet.
104
+ const legacyBase = getLegacyDeviceIdBaseDir();
105
+ if (legacyBase) {
106
+ const legacyPath = join(legacyBase, ".vellum", "device.json");
107
+ try {
108
+ if (existsSync(legacyPath)) {
109
+ const raw = JSON.parse(readFileSync(legacyPath, "utf-8"));
110
+ if (
111
+ raw &&
112
+ typeof raw === "object" &&
113
+ typeof raw.deviceId === "string" &&
114
+ raw.deviceId.length > 0
115
+ ) {
116
+ cached = raw.deviceId as string;
117
+ log.info(
118
+ { deviceId: cached },
119
+ "Resolved device ID from legacy device.json — will persist to new location",
120
+ );
121
+ // Persist to the new location so future reads don't need the fallback
122
+ try {
123
+ mkdirSync(vellumDir, { recursive: true });
124
+ writeFileSync(
125
+ filePath,
126
+ JSON.stringify({ deviceId: cached }, null, 2) + "\n",
127
+ { mode: 0o644 },
128
+ );
129
+ log.info("Migrated device.json to new location");
130
+ } catch (writeErr) {
131
+ log.warn(
132
+ { err: writeErr },
133
+ "Failed to migrate device.json to new location",
134
+ );
135
+ }
136
+ return cached;
137
+ }
138
+ }
139
+ } catch (err) {
140
+ log.warn(
141
+ { err },
142
+ "Failed to read legacy device.json — generating new device ID",
143
+ );
144
+ }
82
145
  }
83
146
 
84
- // Either the file doesn't exist, or deviceId was missing/empty.
147
+ // Either the file doesn't exist at either location, or deviceId was missing/empty.
85
148
  // Generate a new UUID and persist it.
86
149
  try {
87
150
  mkdirSync(vellumDir, { recursive: true });
@@ -76,20 +76,44 @@ let rootLogger: pino.Logger | null = null;
76
76
  let activeLogDate: string | null = null;
77
77
  let activeLogFileConfig: LogFileConfig | null = null;
78
78
 
79
+ function resolveLogDir(config: LogFileConfig): string | undefined {
80
+ if (!config.dir) return undefined;
81
+
82
+ if (!existsSync(config.dir)) {
83
+ try {
84
+ mkdirSync(config.dir, { recursive: true });
85
+ } catch (err) {
86
+ if (getIsContainerized()) {
87
+ // Config has a host-specific path that can't be created inside the
88
+ // container (e.g. /Users/…). Fall back to the default log directory.
89
+ const fallback = join(getLogPath(), "..");
90
+ console.warn(
91
+ `[logger] Configured logFile.dir "${config.dir}" cannot be created ` +
92
+ `in container (${(err as Error).message}). Falling back to "${fallback}".`,
93
+ );
94
+ if (!existsSync(fallback)) {
95
+ mkdirSync(fallback, { recursive: true });
96
+ }
97
+ return fallback;
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ return config.dir;
104
+ }
105
+
79
106
  function buildRotatingLogger(config: LogFileConfig): pino.Logger {
80
- if (!config.dir) {
107
+ const dir = resolveLogDir(config);
108
+ if (!dir) {
81
109
  return pino(
82
110
  { name: "assistant", serializers: logSerializers },
83
111
  pinoPretty(prettyOpts({ destination: 1 })),
84
112
  );
85
113
  }
86
114
 
87
- if (!existsSync(config.dir)) {
88
- mkdirSync(config.dir, { recursive: true });
89
- }
90
-
91
115
  const today = formatDate(new Date());
92
- const filePath = logFilePathForDate(config.dir, new Date());
116
+ const filePath = logFilePathForDate(dir, new Date());
93
117
  const fileDest = pino.destination({
94
118
  dest: filePath,
95
119
  sync: false,
@@ -107,7 +131,7 @@ function buildRotatingLogger(config: LogFileConfig): pino.Logger {
107
131
  );
108
132
 
109
133
  activeLogDate = today;
110
- activeLogFileConfig = config;
134
+ activeLogFileConfig = { ...config, dir };
111
135
 
112
136
  // When stdout is not a TTY (e.g. desktop app redirects to a hatch log file),
113
137
  // write to the rotating file only — the hatch log already captured early
@@ -144,8 +168,10 @@ function ensureCurrentDate(): void {
144
168
  export function initLogger(config: LogFileConfig): void {
145
169
  rootLogger = buildRotatingLogger(config);
146
170
 
147
- if (config.dir && config.retentionDays > 0) {
148
- const removed = pruneOldLogFiles(config.dir, config.retentionDays);
171
+ // Use the resolved dir (may differ from config.dir when containerized)
172
+ const resolvedDir = activeLogFileConfig?.dir;
173
+ if (resolvedDir && config.retentionDays > 0) {
174
+ const removed = pruneOldLogFiles(resolvedDir, config.retentionDays);
149
175
  if (removed > 0) {
150
176
  rootLogger.info(
151
177
  { removed, retentionDays: config.retentionDays },