@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
@@ -0,0 +1,155 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ /**
6
+ * Guard test: assistant source code must not directly access files in the
7
+ * `protected/` directory (`trust.json`, `keys.enc`, `store.key`,
8
+ * `actor-token-signing-key`). In containerized (Docker) mode these files
9
+ * live outside the assistant's data volume and are managed by the gateway.
10
+ *
11
+ * All access must go through the appropriate abstraction layer:
12
+ * - Trust rules: trust-store.ts / trust-client.ts (file vs gateway backend)
13
+ * - Credentials: encrypted-store.ts / ces-credential-client.ts
14
+ * - Signing keys: secure-keys.ts / credential-backend.ts
15
+ *
16
+ * Only the abstraction-layer files themselves (and tests) are allowed to
17
+ * reference the raw file paths / helper functions.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Allowed files — abstraction layers that legitimately access protected/ files
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const ALLOWED_FILES = new Set([
25
+ // Trust store backends
26
+ "assistant/src/permissions/trust-store.ts",
27
+ "assistant/src/permissions/trust-client.ts",
28
+ "assistant/src/permissions/trust-store-interface.ts",
29
+ // Credential / encrypted store backends
30
+ "assistant/src/security/encrypted-store.ts",
31
+ "assistant/src/security/secure-keys.ts",
32
+ "assistant/src/security/credential-backend.ts",
33
+ "assistant/src/security/ces-credential-client.ts",
34
+ // Token service owns the signing key lifecycle
35
+ "assistant/src/runtime/auth/token-service.ts",
36
+ // CLI commands that run outside Docker (doctor diagnostics, trust management)
37
+ "assistant/src/cli/commands/doctor.ts",
38
+ "assistant/src/cli/commands/trust.ts",
39
+ // Auth middleware documentation comment (not a file access)
40
+ "assistant/src/runtime/auth/middleware.ts",
41
+ ]);
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Patterns that indicate direct access to protected directory files
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Each entry is a `git grep -E` pattern and a human-readable description
49
+ * for the error message.
50
+ */
51
+ const GUARDED_PATTERNS: Array<{ pattern: string; description: string }> = [
52
+ {
53
+ pattern: "protected/trust\\.json",
54
+ description: "direct reference to protected/trust.json",
55
+ },
56
+ {
57
+ pattern: "protected/keys\\.enc",
58
+ description: "direct reference to protected/keys.enc",
59
+ },
60
+ {
61
+ pattern: "protected/store\\.key",
62
+ description: "direct reference to protected/store.key",
63
+ },
64
+ {
65
+ pattern: "actor-token-signing-key",
66
+ description: "direct reference to actor-token-signing-key file",
67
+ },
68
+ {
69
+ pattern: "\\bgetTrustPath\\b",
70
+ description: "use of getTrustPath() (trust-store internal)",
71
+ },
72
+ {
73
+ pattern: "\\bgetStoreKeyPath\\b",
74
+ description: "use of getStoreKeyPath() (encrypted-store internal)",
75
+ },
76
+ ];
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function getRepoRoot(): string {
83
+ return join(process.cwd(), "..");
84
+ }
85
+
86
+ function isTestFile(filePath: string): boolean {
87
+ return (
88
+ filePath.includes("/__tests__/") ||
89
+ filePath.endsWith(".test.ts") ||
90
+ filePath.endsWith(".test.js") ||
91
+ filePath.endsWith(".spec.ts") ||
92
+ filePath.endsWith(".spec.js")
93
+ );
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Tests
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe("volume security: protected directory access guard", () => {
101
+ for (const { pattern, description } of GUARDED_PATTERNS) {
102
+ test(`no ${description} outside allowed files`, () => {
103
+ const repoRoot = getRepoRoot();
104
+
105
+ let grepOutput = "";
106
+ try {
107
+ grepOutput = execFileSync(
108
+ "git",
109
+ [
110
+ "grep",
111
+ "-lE",
112
+ pattern,
113
+ "--",
114
+ "assistant/src/**/*.ts",
115
+ "assistant/src/*.ts",
116
+ ],
117
+ { encoding: "utf-8", cwd: repoRoot },
118
+ ).trim();
119
+ } catch (err) {
120
+ // Exit code 1 means no matches — happy path
121
+ if ((err as { status?: number }).status === 1) {
122
+ return;
123
+ }
124
+ throw err;
125
+ }
126
+
127
+ const files = grepOutput.split("\n").filter((f) => f.length > 0);
128
+ const violations = files.filter(
129
+ (f) => !isTestFile(f) && !ALLOWED_FILES.has(f),
130
+ );
131
+
132
+ if (violations.length > 0) {
133
+ const message = [
134
+ `Found assistant source files with ${description}.`,
135
+ "",
136
+ "In containerized (Docker) mode, the protected/ directory is not",
137
+ "accessible to the assistant. All access to protected files must go",
138
+ "through the abstraction layers:",
139
+ " - Trust rules: trust-store.ts / trust-client.ts",
140
+ " - Credentials: encrypted-store.ts / ces-credential-client.ts",
141
+ " - Signing keys: secure-keys.ts / credential-backend.ts",
142
+ "",
143
+ "If this file is a new abstraction backend, add it to ALLOWED_FILES",
144
+ "in this guard test. Otherwise, use the appropriate abstraction layer",
145
+ "or gate the access behind !getIsContainerized().",
146
+ "",
147
+ "Violations:",
148
+ ...violations.map((f) => ` - ${f}`),
149
+ ].join("\n");
150
+
151
+ expect(violations, message).toEqual([]);
152
+ }
153
+ });
154
+ }
155
+ });
@@ -136,6 +136,7 @@ export async function getProviderConnection(
136
136
  provider: MessagingProvider,
137
137
  account?: string,
138
138
  ): Promise<OAuthConnection | string> {
139
+ if (provider.resolveConnection) return provider.resolveConnection(account);
139
140
  if (await provider.isConnected?.()) return "";
140
141
  return resolveOAuthConnection(provider.credentialService, { account });
141
142
  }
@@ -10,6 +10,7 @@ import {
10
10
  import { tmpdir } from "node:os";
11
11
  import { extname, join } from "node:path";
12
12
 
13
+ import { OpenAIWhisperProvider } from "../../../../providers/speech-to-text/openai-whisper.js";
13
14
  import { getProviderKeyAsync } from "../../../../security/secure-keys.js";
14
15
  import type {
15
16
  ToolContext,
@@ -168,12 +169,19 @@ async function transcribeViaApi(
168
169
  apiKey: string,
169
170
  context: ToolContext,
170
171
  ): Promise<string> {
172
+ const provider = new OpenAIWhisperProvider(apiKey);
171
173
  const duration = await getAudioDuration(audioPath);
172
174
  const fileSize = Bun.file(audioPath).size;
173
175
 
174
176
  // If small enough, send directly
175
177
  if (fileSize <= WHISPER_API_MAX_BYTES) {
176
- return await whisperApiRequest(audioPath, apiKey);
178
+ const audioBuffer = await readFile(audioPath);
179
+ const result = await provider.transcribe(
180
+ audioBuffer,
181
+ "audio/wav",
182
+ AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
183
+ );
184
+ return result.text;
177
185
  }
178
186
 
179
187
  // Split into chunks for large files
@@ -199,8 +207,13 @@ async function transcribeViaApi(
199
207
  for (let i = 0; i < chunks.length; i++) {
200
208
  if (context.signal?.aborted) throw new Error("Cancelled");
201
209
  context.onOutput?.(` Transcribing chunk ${i + 1}/${chunks.length}...\n`);
202
- const text = await whisperApiRequest(chunks[i], apiKey);
203
- if (text) parts.push(text);
210
+ const audioBuffer = await readFile(chunks[i]);
211
+ const result = await provider.transcribe(
212
+ audioBuffer,
213
+ "audio/wav",
214
+ AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
215
+ );
216
+ if (result.text) parts.push(result.text);
204
217
  }
205
218
 
206
219
  return parts.join(" ");
@@ -213,40 +226,6 @@ async function transcribeViaApi(
213
226
  }
214
227
  }
215
228
 
216
- async function whisperApiRequest(
217
- audioPath: string,
218
- apiKey: string,
219
- ): Promise<string> {
220
- const audioData = await readFile(audioPath);
221
- const formData = new FormData();
222
- formData.append(
223
- "file",
224
- new Blob([audioData], { type: "audio/wav" }),
225
- "audio.wav",
226
- );
227
- formData.append("model", "whisper-1");
228
-
229
- const response = await fetch(
230
- "https://api.openai.com/v1/audio/transcriptions",
231
- {
232
- method: "POST",
233
- headers: { Authorization: `Bearer ${apiKey}` },
234
- body: formData,
235
- signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
236
- },
237
- );
238
-
239
- if (!response.ok) {
240
- const body = await response.text().catch(() => "");
241
- throw new Error(
242
- `Whisper API error (${response.status}): ${body.slice(0, 300)}`,
243
- );
244
- }
245
-
246
- const result = (await response.json()) as { text?: string };
247
- return result.text?.trim() ?? "";
248
- }
249
-
250
229
  // ---------------------------------------------------------------------------
251
230
  // Local mode - whisper.cpp
252
231
  // ---------------------------------------------------------------------------
@@ -54,6 +54,15 @@ export function getIsContainerized(): boolean {
54
54
  return flag("IS_CONTAINERIZED");
55
55
  }
56
56
 
57
+ /**
58
+ * WORKSPACE_DIR — string, default: undefined
59
+ * When set, overrides the default workspace directory. Used in containerized
60
+ * deployments where the workspace is a separate volume.
61
+ */
62
+ export function getWorkspaceDirOverride(): string | undefined {
63
+ return str("WORKSPACE_DIR");
64
+ }
65
+
57
66
  // ── Known env var names ──────────────────────────────────────────────────────
58
67
 
59
68
  /**
@@ -288,6 +288,14 @@
288
288
  "label": "Inline Skill Command Expansion",
289
289
  "description": "Enable secure inline skill command expansion via !`command` syntax, with version-pinned approval and sandboxed execution at skill load time",
290
290
  "defaultEnabled": true
291
+ },
292
+ {
293
+ "id": "channel-voice-transcription",
294
+ "scope": "assistant",
295
+ "key": "feature_flags.channel-voice-transcription.enabled",
296
+ "label": "Channel Voice Transcription",
297
+ "description": "Auto-transcribe voice/audio messages received from channels (Telegram, WhatsApp) before processing",
298
+ "defaultEnabled": true
291
299
  }
292
300
  ]
293
301
  }
@@ -11,8 +11,7 @@
11
11
 
12
12
  import { platformOAuthHandle } from "@vellumai/ces-contracts";
13
13
 
14
- import { getPlatformAssistantId } from "../config/env.js";
15
- import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
14
+ import { VellumPlatformClient } from "../platform/client.js";
16
15
  import { getLogger } from "../util/logger.js";
17
16
 
18
17
  const log = getLogger("managed-catalog");
@@ -79,25 +78,18 @@ export interface FetchManagedCatalogResult {
79
78
  * error message that never contains secret material.
80
79
  */
81
80
  export async function fetchManagedCatalog(): Promise<FetchManagedCatalogResult> {
82
- const ctx = await resolveManagedProxyContext();
81
+ const client = await VellumPlatformClient.create();
83
82
 
84
- if (!ctx.enabled) {
83
+ if (!client || !client.platformAssistantId) {
85
84
  return { ok: true, descriptors: [] };
86
85
  }
87
86
 
88
- const assistantId = getPlatformAssistantId();
89
- if (!assistantId) {
90
- log.warn("PLATFORM_ASSISTANT_ID not set; cannot fetch managed catalog");
91
- return { ok: true, descriptors: [] };
92
- }
93
-
94
- const url = `${ctx.platformBaseUrl}/v1/assistants/${encodeURIComponent(assistantId)}/oauth/managed/catalog/`;
87
+ const path = `/v1/assistants/${encodeURIComponent(client.platformAssistantId)}/oauth/managed/catalog/`;
95
88
 
96
89
  try {
97
- const response = await fetch(url, {
90
+ const response = await client.fetch(path, {
98
91
  method: "GET",
99
92
  headers: {
100
- Authorization: `Api-Key ${ctx.assistantApiKey}`,
101
93
  Accept: "application/json",
102
94
  },
103
95
  });
@@ -139,8 +131,6 @@ export async function fetchManagedCatalog(): Promise<FetchManagedCatalogResult>
139
131
  return { ok: true, descriptors };
140
132
  } catch (err) {
141
133
  const message = err instanceof Error ? err.message : String(err);
142
- // Ensure the error message does not leak secrets — strip any URL params
143
- // that might contain tokens (defensive, since we use Api-Key header).
144
134
  const safeMessage = message.replace(
145
135
  /Api-Key\s+\S+/gi,
146
136
  "Api-Key [REDACTED]",
@@ -12,6 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
+ import { getIsContainerized } from "../config/env-registry.js";
15
16
  import { getConfig, invalidateConfigCache } from "../config/loader.js";
16
17
  import { clearEmbeddingBackendCache } from "../memory/embedding-backend.js";
17
18
  import { clearCache as clearTrustCache } from "../permissions/trust-store.js";
@@ -209,7 +210,9 @@ export class ConfigWatcher {
209
210
  );
210
211
  }
211
212
 
212
- this.startSignalsWatcher();
213
+ if (!getIsContainerized()) {
214
+ this.startSignalsWatcher();
215
+ }
213
216
  this.startSkillsWatchers(onConversationEvict);
214
217
  }
215
218
 
@@ -11,6 +11,7 @@ import {
11
11
  import { join, resolve } from "node:path";
12
12
 
13
13
  import { getRuntimeHttpHost, getRuntimeHttpPort } from "../config/env.js";
14
+ import { getIsContainerized } from "../config/env-registry.js";
14
15
  import { DaemonError } from "../util/errors.js";
15
16
  import { getLogger } from "../util/logger.js";
16
17
  import {
@@ -157,6 +158,7 @@ export async function isHttpHealthy(): Promise<boolean> {
157
158
  }
158
159
 
159
160
  function readPid(): number | null {
161
+ if (getIsContainerized()) return null; // Docker manages process lifecycle
160
162
  const pidPath = getPidPath();
161
163
  if (!existsSync(pidPath)) return null;
162
164
  try {
@@ -168,10 +170,12 @@ function readPid(): number | null {
168
170
  }
169
171
 
170
172
  export function writePid(pid: number): void {
173
+ if (getIsContainerized()) return; // Docker manages process lifecycle
171
174
  writeFileSync(getPidPath(), String(pid));
172
175
  }
173
176
 
174
177
  export function cleanupPidFile(): void {
178
+ if (getIsContainerized()) return; // Docker manages process lifecycle
175
179
  const pidPath = getPidPath();
176
180
  if (existsSync(pidPath)) {
177
181
  unlinkSync(pidPath);
@@ -181,6 +185,7 @@ export function cleanupPidFile(): void {
181
185
  /** Only remove the PID file if it belongs to the given process. Prevents a
182
186
  * failing second startup from deleting the PID of an already-running daemon. */
183
187
  export function cleanupPidFileIfOwner(ownerPid: number): void {
188
+ if (getIsContainerized()) return; // Docker manages process lifecycle
184
189
  const currentPid = readPid();
185
190
  if (currentPid === ownerPid) {
186
191
  cleanupPidFile();
@@ -188,6 +193,7 @@ export function cleanupPidFileIfOwner(ownerPid: number): void {
188
193
  }
189
194
 
190
195
  export function isDaemonRunning(): boolean {
196
+ if (getIsContainerized()) return true; // Container orchestrator manages lifecycle
191
197
  const pid = readPid();
192
198
  if (pid == null) return false;
193
199
  if (!isProcessRunning(pid)) {
@@ -201,6 +207,7 @@ export async function getDaemonStatus(): Promise<{
201
207
  running: boolean;
202
208
  pid?: number;
203
209
  }> {
210
+ if (getIsContainerized()) return { running: true, pid: process.pid }; // Container orchestrator manages lifecycle
204
211
  const pid = readPid();
205
212
  if (pid == null) return { running: false };
206
213
  if (!isProcessRunning(pid)) {
@@ -20,7 +20,7 @@ import { loadConfig } from "../config/loader.js";
20
20
  import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
21
21
  import { getHookManager } from "../hooks/manager.js";
22
22
  import { installTemplates } from "../hooks/templates.js";
23
- import { closeSentry, initSentry } from "../instrument.js";
23
+ import { closeSentry, initSentry, setSentryDeviceId } from "../instrument.js";
24
24
  import { disableLogfire, initLogfire } from "../logfire.js";
25
25
  import { getMcpServerManager } from "../mcp/manager.js";
26
26
  import * as attachmentsStore from "../memory/attachments-store.js";
@@ -65,6 +65,7 @@ import { RuntimeHttpServer } from "../runtime/http-server.js";
65
65
  import { startScheduler } from "../schedule/scheduler.js";
66
66
  import { seedCatalogSkillMemories } from "../skills/skill-memory.js";
67
67
  import { UsageTelemetryReporter } from "../telemetry/usage-telemetry-reporter.js";
68
+ import { getDeviceId } from "../util/device-id.js";
68
69
  import { getLogger, initLogger } from "../util/logger.js";
69
70
  import {
70
71
  ensureDataDir,
@@ -179,6 +180,11 @@ export async function runDaemon(): Promise<void> {
179
180
  await runWorkspaceMigrations(getWorkspaceDir(), WORKSPACE_MIGRATIONS);
180
181
  log.info("Daemon startup: workspace migrations complete");
181
182
 
183
+ // Now that workspace migrations have run (including 003-seed-device-id
184
+ // which may copy the legacy installationId into device.json), it is safe
185
+ // to read the device ID and set the Sentry tag.
186
+ setSentryDeviceId(getDeviceId());
187
+
182
188
  // Purge private (temporary) conversations from the previous daemon run.
183
189
  // These are ephemeral by design and should not survive daemon restarts.
184
190
  const { count: purgedCount, deletedMemory } = purgePrivateConversations();
@@ -5,7 +5,7 @@ import {
5
5
  setPlatformUserId,
6
6
  } from "../config/env.js";
7
7
  import type { AssistantConfig } from "../config/types.js";
8
- import { setSentryOrganizationId } from "../instrument.js";
8
+ import { setSentryOrganizationId, setSentryUserId } from "../instrument.js";
9
9
  import { getMcpServerManager } from "../mcp/manager.js";
10
10
  import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
11
11
  import { slackProvider as slackMessagingProvider } from "../messaging/providers/slack/adapter.js";
@@ -91,6 +91,7 @@ export async function initializeProvidersAndTools(
91
91
  const trimmed = persisted?.trim();
92
92
  if (trimmed) {
93
93
  setPlatformUserId(trimmed);
94
+ setSentryUserId(trimmed);
94
95
  log.info("Rehydrated platform user ID from credential store");
95
96
  }
96
97
  } catch (err) {
@@ -1,5 +1,6 @@
1
1
  import { type FSWatcher, watch } from "node:fs";
2
2
 
3
+ import { getIsContainerized } from "../config/env-registry.js";
3
4
  import { Debouncer } from "../util/debounce.js";
4
5
  import { pathExists } from "../util/fs.js";
5
6
  import { getLogger } from "../util/logger.js";
@@ -32,6 +33,10 @@ export class HookManager {
32
33
  private readonly debouncer = new Debouncer(500);
33
34
 
34
35
  initialize(): void {
36
+ if (getIsContainerized()) {
37
+ log.info("Hooks disabled in containerized mode");
38
+ return;
39
+ }
35
40
  this.hooks = discoverHooks();
36
41
  this.buildEventIndex();
37
42
  const enabled = this.hooks.filter((h) => h.enabled).length;
@@ -107,6 +112,7 @@ export class HookManager {
107
112
  }
108
113
 
109
114
  reload(): void {
115
+ if (getIsContainerized()) return;
110
116
  this.hooks = discoverHooks();
111
117
  this.buildEventIndex();
112
118
  const enabled = this.hooks.filter((h) => h.enabled).length;
@@ -114,6 +120,7 @@ export class HookManager {
114
120
  }
115
121
 
116
122
  watch(): void {
123
+ if (getIsContainerized()) return;
117
124
  const hooksDir = getHooksDir();
118
125
  if (!pathExists(hooksDir)) return;
119
126
 
package/src/instrument.ts CHANGED
@@ -2,7 +2,11 @@ import { arch, hostname, platform, release } from "node:os";
2
2
 
3
3
  import * as Sentry from "@sentry/node";
4
4
 
5
- import { getPlatformOrganizationId, getSentryDsn } from "./config/env.js";
5
+ import {
6
+ getPlatformOrganizationId,
7
+ getPlatformUserId,
8
+ getSentryDsn,
9
+ } from "./config/env.js";
6
10
  import { APP_VERSION, COMMIT_SHA } from "./version.js";
7
11
 
8
12
  /** Patterns that match sensitive data in Sentry event values. */
@@ -51,6 +55,7 @@ export function initSentry(): void {
51
55
  initialScope: {
52
56
  tags: {
53
57
  commit: COMMIT_SHA,
58
+ assistant_version: APP_VERSION,
54
59
  os_platform: platform(),
55
60
  os_release: release(),
56
61
  os_arch: arch(),
@@ -58,9 +63,14 @@ export function initSentry(): void {
58
63
  runtime: "bun",
59
64
  runtime_version:
60
65
  typeof Bun !== "undefined" ? Bun.version : process.version,
66
+ // NOTE: device_id is NOT set here. It is deferred to setSentryDeviceId()
67
+ // which is called after workspace migrations run, so that migration
68
+ // 003-seed-device-id can copy the legacy installationId into device.json
69
+ // before getDeviceId() eagerly creates a new random UUID.
61
70
  ...(getPlatformOrganizationId()
62
71
  ? { organization_id: getPlatformOrganizationId() }
63
72
  : {}),
73
+ ...(getPlatformUserId() ? { user_id: getPlatformUserId() } : {}),
64
74
  },
65
75
  },
66
76
  beforeSend(event) {
@@ -109,6 +119,28 @@ export function setSentryOrganizationId(
109
119
  Sentry.setTag("organization_id", organizationId || undefined);
110
120
  }
111
121
 
122
+ /**
123
+ * Set (or clear) the user_id tag on the global Sentry scope.
124
+ *
125
+ * Called after the platform user ID is rehydrated from the credential
126
+ * store or updated at runtime so that every subsequent Sentry event
127
+ * includes the user context.
128
+ */
129
+ export function setSentryUserId(userId: string | undefined): void {
130
+ Sentry.setTag("user_id", userId || undefined);
131
+ }
132
+
133
+ /**
134
+ * Set the device_id tag on the global Sentry scope.
135
+ *
136
+ * Called after workspace migrations complete so that migration
137
+ * 003-seed-device-id has a chance to copy the legacy installationId
138
+ * into device.json before getDeviceId() is invoked.
139
+ */
140
+ export function setSentryDeviceId(deviceId: string): void {
141
+ Sentry.setTag("device_id", deviceId);
142
+ }
143
+
112
144
  // ── Dynamic conversation-scoped Sentry tags ─────────────────────────
113
145
  //
114
146
  // These tags change per conversation turn and are set on the current
@@ -1,6 +1,7 @@
1
1
  import { existsSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
+ import { getIsContainerized } from "../config/env-registry.js";
4
5
  import { getLogger } from "../util/logger.js";
5
6
  import { getEmbeddingModelsDir, getRootDir } from "../util/platform.js";
6
7
  import { PromiseGuard } from "../util/promise-guard.js";
@@ -353,12 +354,17 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
353
354
 
354
355
  private static readonly PID_FILENAME = "embed-worker.pid";
355
356
 
357
+ /** PID files are process-local state — store in /tmp when containerized to keep shared volumes clean. */
358
+ private getPidFilePath(): string {
359
+ if (getIsContainerized()) {
360
+ return join("/tmp", LocalEmbeddingBackend.PID_FILENAME);
361
+ }
362
+ return join(getRootDir(), LocalEmbeddingBackend.PID_FILENAME);
363
+ }
364
+
356
365
  private writePidFile(pid: number): void {
357
366
  try {
358
- writeFileSync(
359
- join(getRootDir(), LocalEmbeddingBackend.PID_FILENAME),
360
- String(pid),
361
- );
367
+ writeFileSync(this.getPidFilePath(), String(pid));
362
368
  } catch {
363
369
  // Best-effort — doesn't affect functionality
364
370
  }
@@ -366,7 +372,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
366
372
 
367
373
  private removePidFile(): void {
368
374
  try {
369
- unlinkSync(join(getRootDir(), LocalEmbeddingBackend.PID_FILENAME));
375
+ unlinkSync(this.getPidFilePath());
370
376
  } catch {
371
377
  // Best-effort
372
378
  }