@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.
- package/Dockerfile +18 -27
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/config-watcher.ts +4 -1
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/lifecycle.ts +7 -1
- package/src/daemon/providers-setup.ts +2 -1
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/job-handlers/conversation-starters.ts +24 -36
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/trust-client.ts +343 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +523 -36
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- package/src/runtime/routes/secret-routes.ts +4 -1
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +21 -19
- package/src/util/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- 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
|
-
|
|
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
|
|
203
|
-
|
|
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 {
|
|
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
|
|
81
|
+
const client = await VellumPlatformClient.create();
|
|
83
82
|
|
|
84
|
-
if (!
|
|
83
|
+
if (!client || !client.platformAssistantId) {
|
|
85
84
|
return { ok: true, descriptors: [] };
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
const
|
|
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(
|
|
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
|
-
|
|
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)) {
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -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) {
|
package/src/hooks/manager.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
375
|
+
unlinkSync(this.getPidFilePath());
|
|
370
376
|
} catch {
|
|
371
377
|
// Best-effort
|
|
372
378
|
}
|