@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,173 @@
1
+ /**
2
+ * HTTP client for CES credential CRUD endpoints.
3
+ *
4
+ * In containerized mode the assistant cannot access `keys.enc` directly.
5
+ * Instead, the CES sidecar exposes credential management over HTTP and the
6
+ * assistant talks to it via this client.
7
+ *
8
+ * Endpoints (served by `credential-executor/src/http/credential-routes.ts`):
9
+ * - GET /v1/credentials → { accounts: string[] }
10
+ * - GET /v1/credentials/:account → { account, value } | 404
11
+ * - POST /v1/credentials/:account → { ok: true, account }
12
+ * - DELETE /v1/credentials/:account → { ok: true, account } | 404 | 500
13
+ *
14
+ * Auth: Bearer token from `CES_SERVICE_TOKEN` env var.
15
+ * Base URL: `CES_CREDENTIAL_URL` env var (e.g. `http://ces-container:8090`).
16
+ */
17
+
18
+ import { getLogger } from "../util/logger.js";
19
+ import type { CredentialBackend, DeleteResult } from "./credential-backend.js";
20
+
21
+ const log = getLogger("ces-credential-client");
22
+
23
+ const REQUEST_TIMEOUT_MS = 10_000;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Env helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function getBaseUrl(): string | undefined {
30
+ return process.env.CES_CREDENTIAL_URL;
31
+ }
32
+
33
+ function getServiceToken(): string | undefined {
34
+ return process.env.CES_SERVICE_TOKEN;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Internal fetch wrapper
39
+ // ---------------------------------------------------------------------------
40
+
41
+ async function cesRequest(
42
+ method: string,
43
+ path: string,
44
+ body?: unknown,
45
+ ): Promise<Response | null> {
46
+ const baseUrl = getBaseUrl();
47
+ const token = getServiceToken();
48
+ if (!baseUrl || !token) return null;
49
+
50
+ const url = `${baseUrl.replace(/\/+$/, "")}${path}`;
51
+ const headers: Record<string, string> = {
52
+ Authorization: `Bearer ${token}`,
53
+ "Content-Type": "application/json",
54
+ };
55
+
56
+ try {
57
+ return await fetch(url, {
58
+ method,
59
+ headers,
60
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
61
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
62
+ });
63
+ } catch (err) {
64
+ log.warn({ err, method, path }, "CES credential request failed");
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // CesCredentialBackend
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export class CesCredentialBackend implements CredentialBackend {
74
+ readonly name = "ces-http";
75
+
76
+ isAvailable(): boolean {
77
+ return !!getBaseUrl() && !!getServiceToken();
78
+ }
79
+
80
+ async get(account: string): Promise<string | undefined> {
81
+ try {
82
+ const res = await cesRequest(
83
+ "GET",
84
+ `/v1/credentials/${encodeURIComponent(account)}`,
85
+ );
86
+ if (!res) return undefined;
87
+ if (res.status === 404) return undefined;
88
+ if (!res.ok) {
89
+ log.warn(
90
+ { account, status: res.status },
91
+ "CES credential get returned non-OK status",
92
+ );
93
+ return undefined;
94
+ }
95
+ const data = (await res.json()) as { value?: string };
96
+ return data.value;
97
+ } catch (err) {
98
+ log.warn({ err, account }, "CES credential get threw unexpectedly");
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ async set(account: string, value: string): Promise<boolean> {
104
+ try {
105
+ const res = await cesRequest(
106
+ "POST",
107
+ `/v1/credentials/${encodeURIComponent(account)}`,
108
+ { value },
109
+ );
110
+ if (!res) return false;
111
+ if (!res.ok) {
112
+ log.warn(
113
+ { account, status: res.status },
114
+ "CES credential set returned non-OK status",
115
+ );
116
+ return false;
117
+ }
118
+ return true;
119
+ } catch (err) {
120
+ log.warn({ err, account }, "CES credential set threw unexpectedly");
121
+ return false;
122
+ }
123
+ }
124
+
125
+ async delete(account: string): Promise<DeleteResult> {
126
+ try {
127
+ const res = await cesRequest(
128
+ "DELETE",
129
+ `/v1/credentials/${encodeURIComponent(account)}`,
130
+ );
131
+ if (!res) return "error";
132
+ if (res.status === 404) return "not-found";
133
+ if (!res.ok) {
134
+ log.warn(
135
+ { account, status: res.status },
136
+ "CES credential delete returned non-OK status",
137
+ );
138
+ return "error";
139
+ }
140
+ return "deleted";
141
+ } catch (err) {
142
+ log.warn({ err, account }, "CES credential delete threw unexpectedly");
143
+ return "error";
144
+ }
145
+ }
146
+
147
+ async list(): Promise<string[]> {
148
+ try {
149
+ const res = await cesRequest("GET", "/v1/credentials");
150
+ if (!res) return [];
151
+ if (!res.ok) {
152
+ log.warn(
153
+ { status: res.status },
154
+ "CES credential list returned non-OK status",
155
+ );
156
+ return [];
157
+ }
158
+ const data = (await res.json()) as { accounts?: string[] };
159
+ return data.accounts ?? [];
160
+ } catch (err) {
161
+ log.warn({ err }, "CES credential list threw unexpectedly");
162
+ return [];
163
+ }
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Factory
169
+ // ---------------------------------------------------------------------------
170
+
171
+ export function createCesCredentialBackend(): CesCredentialBackend {
172
+ return new CesCredentialBackend();
173
+ }
@@ -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 => {
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { getLogger } from "../util/logger.js";
17
+ import { escapeXmlContent } from "../util/xml.js";
17
18
  import type { InlineCommandExpansion } from "./inline-command-expansions.js";
18
19
  import type { InlineCommandResult } from "./inline-command-runner.js";
19
20
  import { runInlineCommand } from "./inline-command-runner.js";
@@ -91,7 +92,10 @@ export async function renderInlineCommands(
91
92
 
92
93
  let replacement: string;
93
94
  if (commandResult.ok) {
94
- replacement = wrapInXml(expansion.placeholderId, commandResult.output);
95
+ replacement = wrapInXml(
96
+ expansion.placeholderId,
97
+ escapeXmlContent(commandResult.output),
98
+ );
95
99
  expandedCount++;
96
100
  } else {
97
101
  const stub = failureReasonToStub(commandResult);
@@ -37,6 +37,15 @@ const DEFAULT_TIMEOUT_MS = 10_000;
37
37
  /** Maximum output characters before truncation. */
38
38
  const MAX_OUTPUT_CHARS = 20_000;
39
39
 
40
+ /**
41
+ * Maximum bytes to buffer from stdout during streaming. Once this limit is
42
+ * reached we stop accepting data so a long-running command (e.g. `yes`) cannot
43
+ * grow memory unbounded before the timeout fires. Set generously above
44
+ * MAX_OUTPUT_CHARS to account for multi-byte UTF-8 and ANSI sequences that will
45
+ * be stripped before the character-level clamp.
46
+ */
47
+ const MAX_STDOUT_BUFFER_BYTES = MAX_OUTPUT_CHARS * 4;
48
+
40
49
  /**
41
50
  * ANSI escape sequence pattern (covers SGR, cursor movement, erase, etc.).
42
51
  * Matches: ESC[ ... final_byte and ESC] ... ST (OSC sequences).
@@ -115,7 +124,9 @@ export async function runInlineCommand(
115
124
 
116
125
  return new Promise<InlineCommandResult>((resolve) => {
117
126
  let timedOut = false;
127
+ let stdoutCapped = false;
118
128
  const stdoutChunks: Buffer[] = [];
129
+ let stdoutBytes = 0;
119
130
 
120
131
  let child: ReturnType<typeof spawn>;
121
132
  try {
@@ -140,7 +151,19 @@ export async function runInlineCommand(
140
151
  child.kill("SIGKILL");
141
152
  }, timeoutMs);
142
153
 
143
- child.stdout!.on("data", (data: Buffer) => stdoutChunks.push(data));
154
+ child.stdout!.on("data", (data: Buffer) => {
155
+ if (stdoutBytes >= MAX_STDOUT_BUFFER_BYTES) return;
156
+ stdoutChunks.push(data);
157
+ stdoutBytes += data.length;
158
+ if (stdoutBytes >= MAX_STDOUT_BUFFER_BYTES) {
159
+ // Stop reading to release backpressure on the child process.
160
+ // This destroys the read end of the pipe, which may cause the
161
+ // child to receive SIGPIPE and exit with code=null. The
162
+ // stdoutCapped flag lets the close handler treat this as success.
163
+ stdoutCapped = true;
164
+ child.stdout!.destroy();
165
+ }
166
+ });
144
167
 
145
168
  child.on("close", (code) => {
146
169
  clearTimeout(timer);
@@ -157,7 +180,12 @@ export async function runInlineCommand(
157
180
  }
158
181
 
159
182
  // ── Non-zero exit ────────────────────────────────────────────────
160
- if (code !== 0) {
183
+ // When stdout was capped we destroyed the read end of the pipe,
184
+ // which typically causes SIGPIPE — the process is killed by the
185
+ // signal so the exit code is null. Only suppress the error in that
186
+ // specific case; a command that outputs a lot but exits with a
187
+ // genuine non-zero code (e.g. exit 1) should still be an error.
188
+ if (code !== 0 && !(stdoutCapped && code == null)) {
161
189
  log.debug(
162
190
  { command, exitCode: code },
163
191
  "Inline command exited with non-zero code",
@@ -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();