@mycadet/assistant 0.12.11 → 0.13.0

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 (176) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  3. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/openapi.yaml +2 -708
  9. package/package.json +2 -3
  10. package/scripts/sync-web-search-catalog.ts +1 -2
  11. package/src/__tests__/cloud-profile-guard.test.ts +2 -2
  12. package/src/__tests__/config-schema.test.ts +6 -38
  13. package/src/__tests__/conversation-queue.test.ts +45 -6
  14. package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
  15. package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
  16. package/src/__tests__/persona-resolver.test.ts +0 -51
  17. package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
  18. package/src/__tests__/run-due-schedules.test.ts +0 -4
  19. package/src/__tests__/schedule-store.test.ts +0 -12
  20. package/src/__tests__/stt-stream-session.test.ts +4 -4
  21. package/src/__tests__/telephony-tts-guard.test.ts +2 -50
  22. package/src/__tests__/voice-config-update.test.ts +12 -167
  23. package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
  24. package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
  25. package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
  26. package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
  27. package/src/calls/resolve-call-tts-provider.ts +6 -25
  28. package/src/calls/telephony-credential-preflight.ts +0 -10
  29. package/src/calls/telephony-tts-capability.ts +1 -17
  30. package/src/cli/commands/tts.ts +1 -2
  31. package/src/cli/index.help.ts +0 -2
  32. package/src/cli/program.ts +0 -2
  33. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
  34. package/src/config/schemas/__tests__/stt.test.ts +10 -14
  35. package/src/config/schemas/services.ts +5 -6
  36. package/src/config/schemas/stt.ts +1 -3
  37. package/src/config/schemas/tts.ts +1 -30
  38. package/src/daemon/daemon-readiness.ts +4 -29
  39. package/src/daemon/lifecycle.ts +0 -31
  40. package/src/daemon/providers-setup.ts +0 -8
  41. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  42. package/src/heartbeat/heartbeat-service.ts +1 -66
  43. package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
  44. package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
  45. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
  46. package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
  47. package/src/live-voice/live-voice-credential-preflight.ts +9 -33
  48. package/src/live-voice/live-voice-session.ts +10 -6
  49. package/src/live-voice/live-voice-tts.ts +1 -8
  50. package/src/plugins/defaults/memory/worker.ts +3 -3
  51. package/src/prompts/persona-resolver.ts +0 -35
  52. package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
  53. package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
  54. package/src/providers/search-provider-catalog.ts +7 -39
  55. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
  56. package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
  57. package/src/providers/speech-to-text/provider-catalog.ts +2 -73
  58. package/src/providers/speech-to-text/resolve.ts +3 -120
  59. package/src/runtime/http-router-types.ts +1 -3
  60. package/src/runtime/http-server.ts +1 -1
  61. package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
  62. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
  63. package/src/runtime/routes/index.ts +0 -4
  64. package/src/runtime/routes/live-voice-routes.test.ts +4 -11
  65. package/src/runtime/routes/live-voice-routes.ts +8 -11
  66. package/src/runtime/routes/secret-routes.ts +0 -6
  67. package/src/runtime/routes/tts-routes.ts +0 -38
  68. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
  69. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
  70. package/src/schedule/schedule-store.ts +0 -7
  71. package/src/schedule/scheduler.ts +0 -5
  72. package/src/schedule/worker.ts +1 -1
  73. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
  74. package/src/stt/daemon-batch-transcriber.ts +4 -38
  75. package/src/stt/types.ts +1 -9
  76. package/src/tools/network/__tests__/web-search.test.ts +3 -250
  77. package/src/tools/network/web-search.ts +4 -165
  78. package/src/tts/__tests__/provider-resolution.test.ts +0 -5
  79. package/src/tts/provider-catalog.ts +0 -4
  80. package/src/tts/types.ts +0 -1
  81. package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
  82. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
  83. package/src/__tests__/migration-export-http.test.ts +0 -557
  84. package/src/__tests__/migration-export-streaming.test.ts +0 -350
  85. package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
  86. package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
  87. package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
  88. package/src/__tests__/migration-import-from-path.test.ts +0 -324
  89. package/src/__tests__/migration-import-from-url.test.ts +0 -588
  90. package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
  91. package/src/__tests__/migration-jobs-status.test.ts +0 -139
  92. package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
  93. package/src/__tests__/migration-transport.test.ts +0 -1089
  94. package/src/__tests__/migration-validate-http.test.ts +0 -634
  95. package/src/__tests__/migration-wizard.test.ts +0 -1395
  96. package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
  97. package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
  98. package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
  99. package/src/__tests__/validation-results-screen.test.ts +0 -1130
  100. package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
  101. package/src/background-wake/background-wake-routes.test.ts +0 -906
  102. package/src/background-wake/cloud-client.test.ts +0 -328
  103. package/src/background-wake/cloud-client.ts +0 -196
  104. package/src/background-wake/next-wake.test.ts +0 -335
  105. package/src/background-wake/next-wake.ts +0 -188
  106. package/src/background-wake/publisher.ts +0 -93
  107. package/src/background-wake/wake-intent-hooks.test.ts +0 -234
  108. package/src/cli/commands/__tests__/backup.test.ts +0 -363
  109. package/src/cli/commands/backup.help.ts +0 -180
  110. package/src/cli/commands/backup.ts +0 -398
  111. package/src/cloud/cloud-speech.test.ts +0 -335
  112. package/src/cloud/cloud-speech.ts +0 -300
  113. package/src/config/cloud-speech-defaults.ts +0 -300
  114. package/src/config/sanitize-for-transfer.ts +0 -111
  115. package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
  116. package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
  117. package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
  118. package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
  119. package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
  120. package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
  121. package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
  122. package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
  123. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
  124. package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
  125. package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
  126. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
  127. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
  128. package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
  129. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
  130. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
  131. package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
  132. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
  133. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
  134. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
  135. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
  136. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
  137. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
  138. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
  139. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
  140. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
  141. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
  142. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
  143. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
  144. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
  145. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
  146. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
  147. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
  148. package/src/runtime/migrations/gcs-signed-url.ts +0 -190
  149. package/src/runtime/migrations/job-registry.ts +0 -297
  150. package/src/runtime/migrations/migration-transport.ts +0 -612
  151. package/src/runtime/migrations/migration-wizard.ts +0 -747
  152. package/src/runtime/migrations/origin-mode.ts +0 -42
  153. package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
  154. package/src/runtime/migrations/staged-import-path.ts +0 -116
  155. package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
  156. package/src/runtime/migrations/validation-results-screen.ts +0 -469
  157. package/src/runtime/migrations/vbundle-builder.ts +0 -1382
  158. package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
  159. package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
  160. package/src/runtime/migrations/vbundle-importer.ts +0 -826
  161. package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
  162. package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
  163. package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
  164. package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
  165. package/src/runtime/migrations/vbundle-validator.ts +0 -724
  166. package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
  167. package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
  168. package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
  169. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
  170. package/src/runtime/routes/background-wake-routes.ts +0 -387
  171. package/src/runtime/routes/migration-routes.ts +0 -2584
  172. package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
  173. package/src/tools/network/cloud-search-proxy.ts +0 -211
  174. package/src/tts/__tests__/cadet-provider.test.ts +0 -253
  175. package/src/tts/providers/cadet-provider.ts +0 -244
  176. package/src/types/tar-stream.d.ts +0 -66
@@ -1,1382 +0,0 @@
1
- /**
2
- * Builds .vbundle archive files for migration export.
3
- *
4
- * A .vbundle is a gzip-compressed tar archive containing:
5
- * - manifest.json: metadata with schema_version, checksums, and bundle info
6
- * - workspace/: the entire $CADET_WORKSPACE_DIR/ directory tree (DB, config,
7
- * skills, prompts, attachments, etc.) — excluding large/regenerable
8
- * dirs (embedding-models/, data/qdrant/)
9
- */
10
-
11
- import { createHash, randomUUID } from "node:crypto";
12
- import {
13
- closeSync,
14
- createWriteStream,
15
- existsSync,
16
- lstatSync,
17
- openSync,
18
- readdirSync,
19
- readFileSync,
20
- readSync,
21
- realpathSync,
22
- } from "node:fs";
23
- import { type FileHandle, open, stat, unlink } from "node:fs/promises";
24
- import { tmpdir } from "node:os";
25
- import { dirname, join, relative, resolve, sep } from "node:path";
26
- import { Readable } from "node:stream";
27
- import { pipeline } from "node:stream/promises";
28
- import { createGzip, gzipSync } from "node:zlib";
29
-
30
- import { sanitizeConfigForTransfer } from "../../config/sanitize-for-transfer.js";
31
- import { trackDaemonActivity } from "../../daemon/activity-trail.js";
32
- import { getLogger } from "../../util/logger.js";
33
- import type { VBundleOriginMode } from "./origin-mode.js";
34
- import {
35
- MANIFEST_MAX_BYTES,
36
- MANIFEST_MAX_ENTRIES,
37
- type ManifestFileEntryType,
38
- type ManifestType,
39
- } from "./vbundle-validator.js";
40
-
41
- // ---------------------------------------------------------------------------
42
- // Public types
43
- // ---------------------------------------------------------------------------
44
-
45
- export interface VBundleFileEntry {
46
- path: string;
47
- data: Uint8Array;
48
- /** When set, `data` is ignored: the entry is emitted as a tar typeflag-2 (symlink) record with empty body, and `linkTarget` is the symlink target encoded relative to the symlink's own directory inside the archive. */
49
- linkTarget?: string;
50
- }
51
-
52
- /** v1 manifest `assistant` block. */
53
- export interface VBundleAssistantInfo {
54
- id: string;
55
- name: string;
56
- runtime_version: string;
57
- }
58
-
59
- /** v1 manifest `origin` block. */
60
- export interface VBundleOriginInfo {
61
- mode: VBundleOriginMode;
62
- platform_version?: string;
63
- hostname?: string;
64
- }
65
-
66
- /** v1 manifest `compatibility` block. */
67
- export interface VBundleCompatibility {
68
- min_runtime_version: string;
69
- max_runtime_version: string | null;
70
- }
71
-
72
- /** v1 manifest `export_options` block. */
73
- export interface VBundleExportOptions {
74
- include_logs: boolean;
75
- include_browser_state: boolean;
76
- include_memory_vectors: boolean;
77
- /**
78
- * Debug profile: the bundle carries `gateway/export.tar.gz`, the gateway's
79
- * database and recent logs, for Cadet staff to inspect. Absent on
80
- * ordinary migration bundles.
81
- */
82
- include_gateway?: boolean;
83
- }
84
-
85
- export interface BuildVBundleOptions {
86
- /** Files to include in the archive. Must include data/db/assistant.db. */
87
- files: VBundleFileEntry[];
88
- /** Identity of the assistant that produced this bundle. */
89
- assistant: VBundleAssistantInfo;
90
- /** Where this bundle was produced. */
91
- origin: VBundleOriginInfo;
92
- /** Runtime-version compatibility window for importers. */
93
- compatibility: VBundleCompatibility;
94
- /** Which optional bundle contents this export carries. */
95
- exportOptions: VBundleExportOptions;
96
- /**
97
- * Whether secrets were stripped from the bundle before archiving.
98
- * Required at the type level — defaulting silently is exactly how the
99
- * prior schema mismatch went unnoticed.
100
- */
101
- secretsRedacted: boolean;
102
- }
103
-
104
- export interface BuildVBundleResult {
105
- /** The complete .vbundle archive as gzipped tar bytes. */
106
- archive: Uint8Array;
107
- /** The manifest that was embedded in the archive. */
108
- manifest: ManifestType;
109
- }
110
-
111
- interface FileMetadata {
112
- archivePath: string;
113
- diskPath: string;
114
- size: number;
115
- }
116
-
117
- /** In-memory entry for data not backed by a file on disk (e.g. credentials). */
118
- interface InMemoryEntry {
119
- archivePath: string;
120
- data: Uint8Array;
121
- size: number;
122
- }
123
-
124
- /** Symlink entry — emitted as a tar typeflag-2 record with empty body. */
125
- interface SymlinkMetadata {
126
- archivePath: string;
127
- linkTarget: string;
128
- size: 0;
129
- }
130
-
131
- /** Union of disk-backed, in-memory, and symlink tar stream entries. */
132
- type TarStreamEntry = FileMetadata | InMemoryEntry | SymlinkMetadata;
133
-
134
- function isInMemoryEntry(entry: TarStreamEntry): entry is InMemoryEntry {
135
- return "data" in entry;
136
- }
137
-
138
- function isSymlinkEntry(entry: TarStreamEntry): entry is SymlinkMetadata {
139
- return "linkTarget" in entry;
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // Hash helpers
144
- // ---------------------------------------------------------------------------
145
-
146
- function sha256Hex(data: Uint8Array | string): string {
147
- return createHash("sha256").update(data).digest("hex");
148
- }
149
-
150
- /**
151
- * Canonicalize a JSON object by sorting keys recursively, then stringify.
152
- * Matches the canonicalization used by vbundle-validator.
153
- */
154
- function canonicalizeJson(obj: unknown): string {
155
- return JSON.stringify(obj, (_key, value) => {
156
- if (value && typeof value === "object" && !Array.isArray(value)) {
157
- const sorted: Record<string, unknown> = {};
158
- for (const k of Object.keys(value as Record<string, unknown>).sort()) {
159
- sorted[k] = (value as Record<string, unknown>)[k];
160
- }
161
- return sorted;
162
- }
163
- return value;
164
- });
165
- }
166
-
167
- // ---------------------------------------------------------------------------
168
- // Tar archive builder (minimal, ustar-compatible)
169
- // ---------------------------------------------------------------------------
170
-
171
- const BLOCK_SIZE = 512;
172
-
173
- function padToBlock(data: Uint8Array): Uint8Array {
174
- const remainder = data.length % BLOCK_SIZE;
175
- if (remainder === 0) {
176
- return data;
177
- }
178
- const padded = new Uint8Array(data.length + (BLOCK_SIZE - remainder));
179
- padded.set(data);
180
- return padded;
181
- }
182
-
183
- function writeOctal(
184
- buf: Uint8Array,
185
- offset: number,
186
- length: number,
187
- value: number,
188
- ): void {
189
- const str = value.toString(8).padStart(length - 1, "0");
190
- for (let i = 0; i < str.length; i++) {
191
- buf[offset + i] = str.charCodeAt(i);
192
- }
193
- buf[offset + length - 1] = 0;
194
- }
195
-
196
- function computeHeaderChecksum(header: Uint8Array): number {
197
- let sum = 0;
198
- for (let i = 0; i < 512; i++) {
199
- if (i >= 148 && i < 156) {
200
- sum += 0x20; // space placeholder per tar spec
201
- } else {
202
- sum += header[i];
203
- }
204
- }
205
- return sum;
206
- }
207
-
208
- /**
209
- * Build a PAX extended header entry for paths that exceed the 100-byte ustar
210
- * limit. The PAX entry is a special tar record (typeflag 'x') whose body
211
- * contains "key=value" records. The following data entry uses a truncated name
212
- * in its ustar header, but tar extractors use the PAX path attribute instead.
213
- */
214
- function createPaxPathEntry(name: string): Uint8Array {
215
- const encoder = new TextEncoder();
216
-
217
- // Build PAX payload: "<length> path=<name>\n"
218
- // The length field includes itself, the space, and the trailing newline.
219
- const record = `path=${name}\n`;
220
- // Start with a guess for the decimal length prefix
221
- let prefix = `${record.length + 2} `; // +2 for prefix digit + space (min)
222
- let full = `${prefix}${record}`;
223
- // Iterate until the length prefix is self-consistent
224
- while (new TextEncoder().encode(full).length !== Number.parseInt(prefix)) {
225
- prefix = `${new TextEncoder().encode(full).length} `;
226
- full = `${prefix}${record}`;
227
- }
228
- const paxData = encoder.encode(full);
229
-
230
- // Build a ustar header for the PAX entry itself
231
- const header = new Uint8Array(BLOCK_SIZE);
232
-
233
- // Use a synthetic name for the PAX header entry
234
- const paxName = encoder.encode("PaxHeader/entry");
235
- header.set(paxName.subarray(0, 100), 0);
236
-
237
- writeOctal(header, 100, 8, 0o644);
238
- writeOctal(header, 108, 8, 0);
239
- writeOctal(header, 116, 8, 0);
240
- writeOctal(header, 124, 12, paxData.length);
241
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
242
-
243
- // Type flag 'x' = PAX extended header for the next entry
244
- header[156] = "x".charCodeAt(0);
245
-
246
- const magic = encoder.encode("ustar\0");
247
- header.set(magic, 257);
248
- header[263] = "0".charCodeAt(0);
249
- header[264] = "0".charCodeAt(0);
250
-
251
- const checksum = computeHeaderChecksum(header);
252
- writeOctal(header, 148, 7, checksum);
253
- header[155] = 0x20;
254
-
255
- const paddedData = padToBlock(paxData);
256
- const result = new Uint8Array(header.length + paddedData.length);
257
- result.set(header, 0);
258
- result.set(paddedData, header.length);
259
- return result;
260
- }
261
-
262
- function createTarEntry(
263
- name: string,
264
- data: Uint8Array,
265
- linkTarget?: string,
266
- ): Uint8Array {
267
- const encoder = new TextEncoder();
268
- const nameBytes = encoder.encode(name);
269
-
270
- // If the name exceeds 100 bytes, emit a PAX extended header first
271
- // so that the full path is preserved in the archive.
272
- const needsPax = nameBytes.length > 100;
273
- const paxEntry = needsPax ? createPaxPathEntry(name) : null;
274
-
275
- const isSymlink = linkTarget !== undefined;
276
- const linkTargetBytes = isSymlink ? encoder.encode(linkTarget) : null;
277
- if (linkTargetBytes && linkTargetBytes.length > 100) {
278
- throw new Error(
279
- `Symlink target "${linkTarget}" is ${linkTargetBytes.length} bytes, exceeding the ustar linkname-field 100-byte limit. The walker should guard against this case before calling createTarEntry.`,
280
- );
281
- }
282
-
283
- const header = new Uint8Array(BLOCK_SIZE);
284
-
285
- // File name (0-99) — truncated if >100 bytes; PAX header carries the full name
286
- header.set(nameBytes.subarray(0, 100), 0);
287
-
288
- // File mode (100-107): 0644
289
- writeOctal(header, 100, 8, 0o644);
290
-
291
- // Owner ID (108-115)
292
- writeOctal(header, 108, 8, 0);
293
-
294
- // Group ID (116-123)
295
- writeOctal(header, 116, 8, 0);
296
-
297
- // File size (124-135) — symlink entries always carry size 0
298
- writeOctal(header, 124, 12, isSymlink ? 0 : data.length);
299
-
300
- // Modification time (136-147)
301
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
302
-
303
- // Type flag (156): regular file ("0") or symlink ("2")
304
- header[156] = (isSymlink ? "2" : "0").charCodeAt(0);
305
-
306
- // Linkname (157-256) — only set for symlinks; null-padded by default
307
- if (linkTargetBytes) {
308
- header.set(linkTargetBytes, 157);
309
- }
310
-
311
- // USTAR magic (257-262)
312
- const magic = encoder.encode("ustar\0");
313
- header.set(magic, 257);
314
-
315
- // USTAR version (263-264)
316
- header[263] = "0".charCodeAt(0);
317
- header[264] = "0".charCodeAt(0);
318
-
319
- // Compute and write checksum (148-155) — must be last so the linkname
320
- // (and every other field) contributes to the sum.
321
- const checksum = computeHeaderChecksum(header);
322
- writeOctal(header, 148, 7, checksum);
323
- header[155] = 0x20; // trailing space
324
-
325
- // Symlink entries are header-only — no body, no padding.
326
- const fileEntry = isSymlink
327
- ? header
328
- : (() => {
329
- const paddedData = padToBlock(data);
330
- const combined = new Uint8Array(header.length + paddedData.length);
331
- combined.set(header, 0);
332
- combined.set(paddedData, header.length);
333
- return combined;
334
- })();
335
-
336
- if (paxEntry) {
337
- const result = new Uint8Array(paxEntry.length + fileEntry.length);
338
- result.set(paxEntry, 0);
339
- result.set(fileEntry, paxEntry.length);
340
- return result;
341
- }
342
-
343
- return fileEntry;
344
- }
345
-
346
- function createTarArchive(
347
- entries: Array<{ name: string; data: Uint8Array; linkTarget?: string }>,
348
- ): Uint8Array {
349
- const parts: Uint8Array[] = [];
350
- for (const entry of entries) {
351
- parts.push(createTarEntry(entry.name, entry.data, entry.linkTarget));
352
- }
353
- // End-of-archive: two zero blocks
354
- parts.push(new Uint8Array(BLOCK_SIZE * 2));
355
-
356
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
357
- const result = new Uint8Array(totalLength);
358
- let offset = 0;
359
- for (const part of parts) {
360
- result.set(part, offset);
361
- offset += part.length;
362
- }
363
- return result;
364
- }
365
-
366
- // ---------------------------------------------------------------------------
367
- // Core builder
368
- // ---------------------------------------------------------------------------
369
-
370
- /**
371
- * Build the v1 manifest object and its serialized JSON bytes for a vbundle.
372
- *
373
- * Shared by the buffered (`buildVBundle`) and streaming
374
- * (`streamExportVBundle`) emit sites so the manifest shape and self-checksum
375
- * computation live in exactly one place.
376
- *
377
- * The checksum is computed over the canonicalized manifest with the
378
- * `checksum` field set to the empty string (per the schema spec) — both
379
- * producers and the validator agree on this exact wire shape.
380
- */
381
- function buildManifestObject(input: {
382
- contents: ManifestFileEntryType[];
383
- assistant: VBundleAssistantInfo;
384
- origin: VBundleOriginInfo;
385
- compatibility: VBundleCompatibility;
386
- exportOptions: VBundleExportOptions;
387
- secretsRedacted: boolean;
388
- now: Date;
389
- }): { manifest: ManifestType; manifestData: Uint8Array } {
390
- const manifestWithEmptyChecksum = {
391
- schema_version: 1 as const,
392
- bundle_id: randomUUID(),
393
- created_at: input.now.toISOString(),
394
- assistant: input.assistant,
395
- origin: input.origin,
396
- compatibility: input.compatibility,
397
- contents: input.contents,
398
- checksum: "",
399
- secrets_redacted: input.secretsRedacted,
400
- export_options: input.exportOptions,
401
- };
402
- const checksum = sha256Hex(canonicalizeJson(manifestWithEmptyChecksum));
403
- const manifest: ManifestType = { ...manifestWithEmptyChecksum, checksum };
404
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
405
- if (
406
- manifest.contents.length > MANIFEST_MAX_ENTRIES ||
407
- manifestData.length > MANIFEST_MAX_BYTES
408
- ) {
409
- getLogger("vbundle-builder").warn(
410
- {
411
- entries: manifest.contents.length,
412
- manifestBytes: manifestData.length,
413
- maxEntries: MANIFEST_MAX_ENTRIES,
414
- maxBytes: MANIFEST_MAX_BYTES,
415
- },
416
- "Bundle manifest exceeds the streaming import limits; teleport and staged-path imports will reject this bundle",
417
- );
418
- }
419
- return { manifest, manifestData };
420
- }
421
-
422
- /**
423
- * Build a .vbundle archive from the given files and metadata.
424
- *
425
- * Generates a valid manifest with SHA-256 checksums for all files and
426
- * a self-referencing `checksum`. The archive is returned
427
- * as gzip-compressed tar bytes.
428
- */
429
- export function buildVBundle(options: BuildVBundleOptions): BuildVBundleResult {
430
- const {
431
- files,
432
- assistant,
433
- origin,
434
- compatibility,
435
- exportOptions,
436
- secretsRedacted,
437
- } = options;
438
-
439
- // Build file entries for the manifest. Symlink entries hash the link target
440
- // string (not the empty data buffer) and declare size_bytes: 0.
441
- const fileEntries: ManifestFileEntryType[] = files.map((f) =>
442
- f.linkTarget !== undefined
443
- ? {
444
- path: f.path,
445
- sha256: sha256Hex(f.linkTarget),
446
- size_bytes: 0,
447
- link_target: f.linkTarget,
448
- }
449
- : {
450
- path: f.path,
451
- sha256: sha256Hex(f.data),
452
- size_bytes: f.data.length,
453
- },
454
- );
455
-
456
- const { manifest, manifestData } = buildManifestObject({
457
- contents: fileEntries,
458
- assistant,
459
- origin,
460
- compatibility,
461
- exportOptions,
462
- secretsRedacted,
463
- now: new Date(),
464
- });
465
-
466
- // Build tar entries: manifest first, then all files. Symlink entries forward
467
- // `linkTarget` so createTarEntry emits a typeflag-2 header; `data` is unused
468
- // in that branch but must still be a valid Uint8Array.
469
- const tarEntries = [
470
- { name: "manifest.json", data: manifestData },
471
- ...files.map((f) =>
472
- f.linkTarget !== undefined
473
- ? { name: f.path, data: new Uint8Array(0), linkTarget: f.linkTarget }
474
- : { name: f.path, data: f.data },
475
- ),
476
- ];
477
-
478
- const tar = createTarArchive(tarEntries);
479
- const archive = gzipSync(tar);
480
-
481
- return { archive, manifest };
482
- }
483
-
484
- // ---------------------------------------------------------------------------
485
- // Directory walker — recursively collects files for archive inclusion
486
- // ---------------------------------------------------------------------------
487
-
488
- interface WalkDirectoryOptions {
489
- /** Include binary files (files containing null bytes). Default: false. */
490
- includeBinary?: boolean;
491
- /** Directory names to skip (matched against relative path from walk root). */
492
- skipDirs?: string[];
493
- /** File names to skip (matched against the entry basename). */
494
- skipFiles?: string[];
495
- }
496
-
497
- /**
498
- * Resolve and classify a symlink encountered during a walk.
499
- *
500
- * Returns one of:
501
- * { kind: "class1", linkTarget } — emit as a tar typeflag-2 entry whose
502
- * `linkname` field holds `linkTarget` (the symlink target encoded as a
503
- * POSIX path relative to the symlink's own directory).
504
- * { kind: "drop", reason } — drop the symlink. Reasons cover broken
505
- * links, targets outside the workspace (class 2), targets inside a
506
- * skipped directory (class 3), directory targets (out of scope), and
507
- * link targets whose UTF-8 encoding exceeds the 100-byte ustar
508
- * `linkname` field limit.
509
- */
510
- type SymlinkClassification =
511
- | { kind: "class1"; linkTarget: string }
512
- | { kind: "drop"; reason: string };
513
-
514
- function classifySymlink(args: {
515
- fullPath: string;
516
- walkRoot: string;
517
- skipDirs: readonly string[];
518
- }): SymlinkClassification {
519
- const { fullPath, walkRoot, skipDirs } = args;
520
-
521
- let absoluteTarget: string;
522
- try {
523
- absoluteTarget = realpathSync(fullPath);
524
- } catch {
525
- return { kind: "drop", reason: "broken symlink (realpath failed)" };
526
- }
527
-
528
- let targetStat;
529
- try {
530
- targetStat = lstatSync(absoluteTarget);
531
- } catch {
532
- return { kind: "drop", reason: "broken symlink (target stat failed)" };
533
- }
534
- if (!targetStat.isFile()) {
535
- return { kind: "drop", reason: "target is not a regular file" };
536
- }
537
-
538
- let dirAbs: string;
539
- try {
540
- dirAbs = realpathSync(walkRoot);
541
- } catch {
542
- dirAbs = resolve(walkRoot);
543
- }
544
- const targetAbs = resolve(absoluteTarget);
545
- const insideWorkspace =
546
- targetAbs === dirAbs || targetAbs.startsWith(dirAbs + sep);
547
- if (!insideWorkspace) {
548
- return { kind: "drop", reason: "target outside workspace" };
549
- }
550
-
551
- const targetRelToWorkspace = relative(dirAbs, targetAbs);
552
- if (
553
- skipDirs.some(
554
- (s) =>
555
- targetRelToWorkspace === s || targetRelToWorkspace.startsWith(s + "/"),
556
- )
557
- ) {
558
- return { kind: "drop", reason: "target inside skipDir" };
559
- }
560
-
561
- // Canonicalize the symlink's parent directory so the relative linkTarget
562
- // computation lines up with `absoluteTarget` (which is canonical from
563
- // realpathSync). On macOS, walking through /var/folders/... and resolving
564
- // the target through /private/var/folders/... would otherwise produce a
565
- // long ../../../private/... path that exceeds the 100-byte ustar limit.
566
- let parentAbs: string;
567
- try {
568
- parentAbs = realpathSync(dirname(fullPath));
569
- } catch {
570
- parentAbs = resolve(dirname(fullPath));
571
- }
572
- const linkTarget = relative(parentAbs, absoluteTarget);
573
- if (new TextEncoder().encode(linkTarget).length > 100) {
574
- return {
575
- kind: "drop",
576
- reason: "encoded link target exceeds 100-byte ustar limit",
577
- };
578
- }
579
-
580
- return { kind: "class1", linkTarget };
581
- }
582
-
583
- /**
584
- * Recursively walk a directory and return all regular files (and bundleable
585
- * symlinks) as VBundleFileEntry objects with paths prefixed by
586
- * `archivePrefix`. Symlinks that resolve to a regular file inside the walk
587
- * root and outside any skipDir are emitted as typeflag-2 entries (data
588
- * empty, `linkTarget` populated). All other symlinks (broken, directory
589
- * target, target outside workspace, target inside skipDir, encoded
590
- * linkTarget over 100 bytes) are reported via the returned `droppedSymlinks`
591
- * array as workspace-relative paths of the symlink itself.
592
- *
593
- * By default, binary files (detected via null-byte heuristic in the first
594
- * 8 KB) are skipped. Pass `includeBinary: true` to include them.
595
- */
596
- export function walkDirectory(
597
- dir: string,
598
- archivePrefix: string,
599
- options: WalkDirectoryOptions = {},
600
- ): { files: VBundleFileEntry[]; droppedSymlinks: string[] } {
601
- const { includeBinary = false, skipDirs = [], skipFiles = [] } = options;
602
- const entries: VBundleFileEntry[] = [];
603
- const droppedSymlinks: string[] = [];
604
-
605
- function walk(currentDir: string): void {
606
- const dirEntries = readdirSync(currentDir, { withFileTypes: true });
607
- for (const entry of dirEntries) {
608
- const fullPath = join(currentDir, entry.name);
609
-
610
- const stat = lstatSync(fullPath);
611
- if (stat.isSymbolicLink()) {
612
- const classification = classifySymlink({
613
- fullPath,
614
- walkRoot: dir,
615
- skipDirs,
616
- });
617
- if (classification.kind === "class1") {
618
- entries.push({
619
- path: `${archivePrefix}/${relative(dir, fullPath)}`,
620
- data: new Uint8Array(0),
621
- linkTarget: classification.linkTarget,
622
- });
623
- } else {
624
- droppedSymlinks.push(relative(dir, fullPath));
625
- }
626
- continue;
627
- }
628
-
629
- if (stat.isDirectory()) {
630
- // Check skip list against the relative path from the walk root
631
- const relDir = relative(dir, fullPath);
632
- if (skipDirs.some((s) => relDir === s || relDir.startsWith(s + "/"))) {
633
- continue;
634
- }
635
- walk(fullPath);
636
- } else if (stat.isFile()) {
637
- // Skip files by basename (e.g. backup key)
638
- if (skipFiles.includes(entry.name)) {
639
- continue;
640
- }
641
-
642
- // Skip SQLite auxiliary files — these are ephemeral and race-prone
643
- // with the live DB connection. The WAL is checkpointed before the
644
- // walk, so the main .db file has all committed rows.
645
- if (
646
- entry.name.endsWith(".db-wal") ||
647
- entry.name.endsWith(".db-shm") ||
648
- entry.name.endsWith(".db-journal")
649
- ) {
650
- continue;
651
- }
652
-
653
- const data = new Uint8Array(readFileSync(fullPath));
654
-
655
- // Skip binary files unless explicitly included
656
- if (!includeBinary) {
657
- const checkLength = Math.min(data.length, 8192);
658
- let isBinary = false;
659
- for (let i = 0; i < checkLength; i++) {
660
- if (data[i] === 0) {
661
- isBinary = true;
662
- break;
663
- }
664
- }
665
- if (isBinary) {
666
- continue;
667
- }
668
- }
669
-
670
- const relativePath = relative(dir, fullPath);
671
- entries.push({
672
- path: `${archivePrefix}/${relativePath}`,
673
- data,
674
- });
675
- }
676
- }
677
- }
678
-
679
- walk(dir);
680
- return { files: entries, droppedSymlinks };
681
- }
682
-
683
- // ---------------------------------------------------------------------------
684
- // Export builder — reads real data from disk
685
- // ---------------------------------------------------------------------------
686
-
687
- export interface BuildExportVBundleOptions {
688
- /** Identity of the assistant that produced this bundle. */
689
- assistant: VBundleAssistantInfo;
690
- /** Where this bundle was produced. */
691
- origin: VBundleOriginInfo;
692
- /** Runtime-version compatibility window for importers. */
693
- compatibility: VBundleCompatibility;
694
- /** Which optional bundle contents this export carries. */
695
- exportOptions: VBundleExportOptions;
696
- /** Whether secrets were stripped from the bundle before archiving. */
697
- secretsRedacted: boolean;
698
- /**
699
- * Absolute path to the workspace directory ($CADET_WORKSPACE_DIR/).
700
- * When provided and exists, the entire directory tree is walked and
701
- * included in the archive under the "workspace/" prefix, skipping
702
- * large/regenerable dirs (embedding-models/, data/qdrant/).
703
- * Binary files (SQLite DB, attachments) are included.
704
- */
705
- workspaceDir?: string;
706
- /**
707
- * Optional callback to checkpoint the WAL before reading the database file.
708
- * In WAL mode, committed rows may live in the -wal file and not yet be
709
- * flushed to the main .db file. Callers should pass a function that runs
710
- * PRAGMA wal_checkpoint(FULL) on the live database connection — FULL
711
- * flushes committed frames to the .db file without the extra WAL restart
712
- * and truncation that TRUNCATE performs.
713
- * Called before the workspace walk so the DB file is up to date.
714
- *
715
- * May return a Promise — `streamExportVBundle` awaits the result so an
716
- * async-dispatched checkpoint (e.g. via `runAsyncSqlite`) does not race
717
- * with the file walk. The synchronous `buildExportVBundle` path does
718
- * not await; pass a sync callback there.
719
- */
720
- checkpoint?: () => void | Promise<void>;
721
- /** Optional credential entries to include in the archive under credentials/ prefix. */
722
- credentials?: Array<{ account: string; value: string }>;
723
- /**
724
- * Extra in-memory entries at exact archive paths, outside `workspace/`.
725
- * The debug profile uses this for `gateway/export.tar.gz`.
726
- */
727
- extraFiles?: Array<{ archivePath: string; data: Uint8Array }>;
728
- }
729
-
730
- /**
731
- * Build a .vbundle archive populated with real assistant data.
732
- *
733
- * Walks the entire workspace directory ($CADET_WORKSPACE_DIR/) and includes
734
- * all files in the archive, skipping only large/regenerable directories
735
- * (embedding-models/, data/qdrant/). Binary files (SQLite DB, attachments)
736
- * are included.
737
- *
738
- * The WAL is checkpointed before the walk so the exported DB file contains
739
- * all committed rows.
740
- */
741
- export function buildExportVBundle(
742
- options: BuildExportVBundleOptions,
743
- ): BuildVBundleResult {
744
- const {
745
- assistant,
746
- origin,
747
- compatibility,
748
- exportOptions,
749
- secretsRedacted,
750
- checkpoint,
751
- workspaceDir,
752
- credentials,
753
- extraFiles,
754
- } = options;
755
-
756
- // Flush WAL to the main database file before reading so the export
757
- // captures all committed rows (SQLite WAL mode keeps recent writes
758
- // in a separate -wal file until checkpoint).
759
- if (checkpoint) {
760
- checkpoint();
761
- }
762
-
763
- const files: VBundleFileEntry[] = [];
764
- for (const extra of extraFiles ?? []) {
765
- files.push({ path: extra.archivePath, data: extra.data });
766
- }
767
-
768
- // Walk the entire workspace directory, including binary files (DB,
769
- // attachments) but skipping large/regenerable subdirectories.
770
- if (
771
- workspaceDir &&
772
- existsSync(workspaceDir) &&
773
- lstatSync(workspaceDir).isDirectory()
774
- ) {
775
- const { files: walkedFiles, droppedSymlinks } = walkDirectory(
776
- workspaceDir,
777
- "workspace",
778
- {
779
- includeBinary: true,
780
- skipDirs: ["embedding-models", "data/qdrant", "signals", "deprecated"],
781
- skipFiles: [".backup.key"],
782
- },
783
- );
784
- files.push(...walkedFiles);
785
- if (droppedSymlinks.length > 0) {
786
- getLogger("vbundle-builder").warn(
787
- { count: droppedSymlinks.length, paths: droppedSymlinks },
788
- `Dropped ${droppedSymlinks.length} symlinks pointing outside workspace or into skipped directories`,
789
- );
790
- }
791
- }
792
-
793
- // Sanitize workspace/config.json to strip environment-specific fields
794
- const configEntry = files.find((f) => f.path === "workspace/config.json");
795
- if (configEntry) {
796
- const configJson = new TextDecoder().decode(configEntry.data);
797
- const sanitized = sanitizeConfigForTransfer(configJson);
798
- configEntry.data = new TextEncoder().encode(sanitized);
799
- }
800
-
801
- // Include credential entries if provided
802
- if (credentials?.length) {
803
- for (const { account, value } of credentials) {
804
- const data = new TextEncoder().encode(value);
805
- files.push({ path: `credentials/${account}`, data });
806
- }
807
- }
808
-
809
- return buildVBundle({
810
- files,
811
- assistant,
812
- origin,
813
- compatibility,
814
- exportOptions,
815
- secretsRedacted,
816
- });
817
- }
818
-
819
- // ---------------------------------------------------------------------------
820
- // Streaming export builder — two-pass approach for bounded memory usage
821
- // ---------------------------------------------------------------------------
822
-
823
- /**
824
- * Walk a directory tree and collect file metadata (paths + sizes) without
825
- * reading file contents into memory. Mirrors `walkDirectory`'s filtering
826
- * logic (SQLite auxiliary skip, binary detection, skipDirs) and symlink
827
- * classification — bundleable symlinks are emitted as `SymlinkMetadata`
828
- * entries; non-bundleable symlinks are reported via `droppedSymlinks`.
829
- */
830
- export function walkDirectoryForMetadata(
831
- dir: string,
832
- archivePrefix: string,
833
- options: WalkDirectoryOptions = {},
834
- ): {
835
- files: FileMetadata[];
836
- symlinks: SymlinkMetadata[];
837
- droppedSymlinks: string[];
838
- } {
839
- const { includeBinary = false, skipDirs = [], skipFiles = [] } = options;
840
- const entries: FileMetadata[] = [];
841
- const symlinks: SymlinkMetadata[] = [];
842
- const droppedSymlinks: string[] = [];
843
-
844
- function walk(currentDir: string): void {
845
- const dirEntries = readdirSync(currentDir, { withFileTypes: true });
846
- for (const entry of dirEntries) {
847
- const fullPath = join(currentDir, entry.name);
848
-
849
- const fileStat = lstatSync(fullPath);
850
- if (fileStat.isSymbolicLink()) {
851
- const classification = classifySymlink({
852
- fullPath,
853
- walkRoot: dir,
854
- skipDirs,
855
- });
856
- if (classification.kind === "class1") {
857
- symlinks.push({
858
- archivePath: `${archivePrefix}/${relative(dir, fullPath)}`,
859
- linkTarget: classification.linkTarget,
860
- size: 0,
861
- });
862
- } else {
863
- droppedSymlinks.push(relative(dir, fullPath));
864
- }
865
- continue;
866
- }
867
-
868
- if (fileStat.isDirectory()) {
869
- // Check skip list against the relative path from the walk root
870
- const relDir = relative(dir, fullPath);
871
- if (skipDirs.some((s) => relDir === s || relDir.startsWith(s + "/"))) {
872
- continue;
873
- }
874
- walk(fullPath);
875
- } else if (fileStat.isFile()) {
876
- // Skip files by basename (e.g. backup key)
877
- if (skipFiles.includes(entry.name)) {
878
- continue;
879
- }
880
-
881
- // Skip SQLite auxiliary files — these are ephemeral and race-prone
882
- if (
883
- entry.name.endsWith(".db-wal") ||
884
- entry.name.endsWith(".db-shm") ||
885
- entry.name.endsWith(".db-journal")
886
- ) {
887
- continue;
888
- }
889
-
890
- // Skip binary files unless explicitly included
891
- if (!includeBinary) {
892
- // Read only the first 8 KB to check for null bytes
893
- const checkLength = Math.min(fileStat.size, 8192);
894
- if (checkLength > 0) {
895
- const buf = Buffer.alloc(checkLength);
896
- const fd = openSync(fullPath, "r");
897
- try {
898
- readSync(fd, buf, 0, checkLength, 0);
899
- } finally {
900
- closeSync(fd);
901
- }
902
- let isBinary = false;
903
- for (let i = 0; i < checkLength; i++) {
904
- if (buf[i] === 0) {
905
- isBinary = true;
906
- break;
907
- }
908
- }
909
- if (isBinary) {
910
- continue;
911
- }
912
- }
913
- }
914
-
915
- const relativePath = relative(dir, fullPath);
916
- entries.push({
917
- archivePath: `${archivePrefix}/${relativePath}`,
918
- diskPath: fullPath,
919
- size: fileStat.size,
920
- });
921
- }
922
- }
923
- }
924
-
925
- walk(dir);
926
- return { files: entries, symlinks, droppedSymlinks };
927
- }
928
-
929
- /**
930
- * Compute SHA-256 hex digest of a file by reading it in bounded chunks —
931
- * never buffers the entire file in memory. When `size` is provided, only
932
- * hashes the first `size` bytes to match what will be archived in the tar
933
- * entry.
934
- */
935
- async function computeFileSha256(
936
- filePath: string,
937
- size?: number,
938
- ): Promise<string> {
939
- const hash = createHash("sha256");
940
- if (size === 0) {
941
- return hash.digest("hex");
942
- }
943
-
944
- // Read through an explicitly-managed FileHandle so the descriptor is
945
- // always released in `finally`. This pass opens every file in the
946
- // workspace, so any per-file descriptor leak here would exhaust the
947
- // daemon's file-descriptor limit (EMFILE). A bounded read loop keeps peak
948
- // memory flat regardless of file size.
949
- const readChunkBytes = 64 * 1024;
950
- const handle = await open(filePath, "r");
951
- try {
952
- const chunk = Buffer.allocUnsafe(readChunkBytes);
953
- let position = 0;
954
- for (;;) {
955
- const remaining = size !== undefined ? size - position : Infinity;
956
- if (remaining <= 0) {
957
- break;
958
- }
959
- const toRead = Math.min(chunk.length, remaining);
960
- const { bytesRead } = await handle.read(chunk, 0, toRead, position);
961
- if (bytesRead === 0) {
962
- break;
963
- }
964
- hash.update(chunk.subarray(0, bytesRead));
965
- position += bytesRead;
966
- }
967
- } finally {
968
- await handle.close();
969
- }
970
- return hash.digest("hex");
971
- }
972
-
973
- /**
974
- * Create just the 512-byte tar header block for a regular file entry.
975
- * Extracted from `createTarEntry` logic — does NOT include data or padding.
976
- *
977
- * When `linkTarget` is provided, the header is emitted as a tar typeflag-2
978
- * (symlink) record: typeflag is "2", the link target is written into the
979
- * `linkname` field (header[157..256], 100-byte limit), and `size` is forced
980
- * to 0 in the header field. Caller is responsible for not yielding any body
981
- * or padding bytes for symlink entries.
982
- */
983
- function createTarHeaderBlock(
984
- name: string,
985
- size: number,
986
- linkTarget?: string,
987
- ): Uint8Array {
988
- const encoder = new TextEncoder();
989
- const nameBytes = encoder.encode(name);
990
-
991
- const header = new Uint8Array(BLOCK_SIZE);
992
-
993
- // File name (0-99) — truncated if >100 bytes
994
- header.set(nameBytes.subarray(0, 100), 0);
995
-
996
- // File mode (100-107): 0644
997
- writeOctal(header, 100, 8, 0o644);
998
-
999
- // Owner ID (108-115)
1000
- writeOctal(header, 108, 8, 0);
1001
-
1002
- // Group ID (116-123)
1003
- writeOctal(header, 116, 8, 0);
1004
-
1005
- // File size (124-135) — symlink entries always declare size=0
1006
- writeOctal(header, 124, 12, linkTarget !== undefined ? 0 : size);
1007
-
1008
- // Modification time (136-147)
1009
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
1010
-
1011
- // Type flag (156): regular file ("0") or symlink ("2")
1012
- header[156] =
1013
- linkTarget !== undefined ? "2".charCodeAt(0) : "0".charCodeAt(0);
1014
-
1015
- // Linkname (157-256, 100 bytes) — only set for symlink entries
1016
- if (linkTarget !== undefined) {
1017
- const linkBytes = encoder.encode(linkTarget);
1018
- if (linkBytes.length > 100) {
1019
- throw new Error(
1020
- `symlink target exceeds 100-byte ustar linkname limit (${linkBytes.length} bytes): ${linkTarget}`,
1021
- );
1022
- }
1023
- header.set(linkBytes, 157);
1024
- }
1025
-
1026
- // USTAR magic (257-262)
1027
- const magic = encoder.encode("ustar\0");
1028
- header.set(magic, 257);
1029
-
1030
- // USTAR version (263-264)
1031
- header[263] = "0".charCodeAt(0);
1032
- header[264] = "0".charCodeAt(0);
1033
-
1034
- // Compute and write checksum (148-155). Must run AFTER linkname is set
1035
- // so the checksum covers the symlink target bytes.
1036
- const checksum = computeHeaderChecksum(header);
1037
- writeOctal(header, 148, 7, checksum);
1038
- header[155] = 0x20; // trailing space
1039
-
1040
- return header;
1041
- }
1042
-
1043
- /**
1044
- * If name exceeds 100 bytes, returns the PAX extended header entry
1045
- * concatenated with the regular header block. Otherwise returns just
1046
- * the header block.
1047
- *
1048
- * `linkTarget` is forwarded to `createTarHeaderBlock` so symlink entries
1049
- * still get a PAX path header for long names while emitting a typeflag-2
1050
- * ustar block.
1051
- */
1052
- function createPaxAndHeaderBlocks(
1053
- name: string,
1054
- size: number,
1055
- linkTarget?: string,
1056
- ): Uint8Array {
1057
- const encoder = new TextEncoder();
1058
- const nameBytes = encoder.encode(name);
1059
- const needsPax = nameBytes.length > 100;
1060
-
1061
- const header = createTarHeaderBlock(name, size, linkTarget);
1062
-
1063
- if (needsPax) {
1064
- const paxEntry = createPaxPathEntry(name);
1065
- const result = new Uint8Array(paxEntry.length + header.length);
1066
- result.set(paxEntry, 0);
1067
- result.set(header, paxEntry.length);
1068
- return result;
1069
- }
1070
-
1071
- return header;
1072
- }
1073
-
1074
- /**
1075
- * Returns zero-filled padding bytes to align data to the tar block boundary.
1076
- */
1077
- function tarPaddingBytes(dataSize: number): Uint8Array {
1078
- const remainder = dataSize % BLOCK_SIZE;
1079
- if (remainder === 0) {
1080
- return new Uint8Array(0);
1081
- }
1082
- return new Uint8Array(BLOCK_SIZE - remainder);
1083
- }
1084
-
1085
- /**
1086
- * Async generator that yields raw tar bytes in order:
1087
- * manifest entry, then each file entry, then end-of-archive marker.
1088
- * Each file is streamed from disk — never fully buffered in memory.
1089
- */
1090
- async function* generateTarStream(
1091
- manifestJson: Uint8Array,
1092
- files: TarStreamEntry[],
1093
- ): AsyncGenerator<Uint8Array> {
1094
- // Manifest entry
1095
- yield createPaxAndHeaderBlocks("manifest.json", manifestJson.length);
1096
- yield manifestJson;
1097
- yield tarPaddingBytes(manifestJson.length);
1098
-
1099
- // File entries
1100
- for (const file of files) {
1101
- if (isSymlinkEntry(file)) {
1102
- // Symlink entry: typeflag-2 header carries the linkname; no body, no
1103
- // padding. Skip the entrySize/body/padding logic entirely so the
1104
- // surrounding stream stays block-aligned.
1105
- yield createPaxAndHeaderBlocks(file.archivePath, 0, file.linkTarget);
1106
- continue;
1107
- }
1108
-
1109
- const entrySize = file.size;
1110
- yield createPaxAndHeaderBlocks(file.archivePath, entrySize);
1111
-
1112
- if (isInMemoryEntry(file)) {
1113
- // In-memory entry — yield data directly
1114
- if (file.size > 0) {
1115
- yield file.data;
1116
- }
1117
- } else {
1118
- // Disk-backed entry — stream from disk
1119
- // Stream exactly file.size bytes from disk. Capping the read at the
1120
- // declared size keeps the tar structure valid even if the file grows
1121
- // between passes (common for log files on active assistants). If the
1122
- // file shrinks below the declared size, zero-pad to maintain block
1123
- // alignment. The WAL checkpoint before export is the primary
1124
- // consistency mechanism for the database.
1125
- let bytesWritten = 0;
1126
- if (entrySize > 0) {
1127
- // Read through an explicitly-managed FileHandle so the descriptor is
1128
- // released in `finally` even when the consumer abandons the generator
1129
- // mid-file. Each read uses a fresh buffer so a yielded chunk is never
1130
- // overwritten by the next read before the consumer drains it.
1131
- let handle: FileHandle | undefined;
1132
- try {
1133
- handle = await open(file.diskPath, "r");
1134
- const readChunkBytes = 64 * 1024;
1135
- while (bytesWritten < entrySize) {
1136
- const toRead = Math.min(readChunkBytes, entrySize - bytesWritten);
1137
- const buf = Buffer.allocUnsafe(toRead);
1138
- const { bytesRead } = await handle.read(
1139
- buf,
1140
- 0,
1141
- toRead,
1142
- bytesWritten,
1143
- );
1144
- if (bytesRead === 0) {
1145
- break;
1146
- }
1147
- bytesWritten += bytesRead;
1148
- yield bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
1149
- }
1150
- } catch {
1151
- // File was deleted or rotated between passes — emit zeros for
1152
- // the full declared size so the tar structure stays valid
1153
- } finally {
1154
- if (handle) {
1155
- await handle.close();
1156
- }
1157
- }
1158
- }
1159
-
1160
- // If the file shrank, pad with zeros in bounded chunks to reach
1161
- // the declared size without a large single allocation
1162
- let remaining = file.size - bytesWritten;
1163
- while (remaining > 0) {
1164
- const chunkSize = Math.min(remaining, 65536);
1165
- yield new Uint8Array(chunkSize);
1166
- remaining -= chunkSize;
1167
- }
1168
- }
1169
-
1170
- yield tarPaddingBytes(entrySize);
1171
- }
1172
-
1173
- // End-of-archive: two zero blocks
1174
- yield new Uint8Array(BLOCK_SIZE * 2);
1175
- }
1176
-
1177
- // ---------------------------------------------------------------------------
1178
- // Streaming export result type
1179
- // ---------------------------------------------------------------------------
1180
-
1181
- export interface StreamExportVBundleResult {
1182
- tempPath: string;
1183
- size: number;
1184
- manifest: ManifestType;
1185
- cleanup: () => Promise<void>;
1186
- }
1187
-
1188
- /**
1189
- * Build a .vbundle archive using a streaming two-pass approach that keeps
1190
- * peak memory usage bounded to ~1 MB regardless of workspace size.
1191
- *
1192
- * Pass 1: Walk directory metadata and compute SHA-256 checksums without
1193
- * loading file contents into memory (builds manifest).
1194
- * Pass 2: Stream tar entries through gzip into a temp file on disk.
1195
- *
1196
- * Returns a result with the temp file path, size, manifest, and a cleanup
1197
- * function to remove the temp file when done.
1198
- */
1199
- export async function streamExportVBundle(
1200
- options: BuildExportVBundleOptions,
1201
- ): Promise<StreamExportVBundleResult> {
1202
- return trackDaemonActivity({ kind: "vbundle_export" }, () =>
1203
- streamExport(options),
1204
- );
1205
- }
1206
-
1207
- async function streamExport(
1208
- options: BuildExportVBundleOptions,
1209
- ): Promise<StreamExportVBundleResult> {
1210
- const {
1211
- assistant,
1212
- origin,
1213
- compatibility,
1214
- exportOptions,
1215
- secretsRedacted,
1216
- checkpoint,
1217
- workspaceDir,
1218
- credentials,
1219
- extraFiles,
1220
- } = options;
1221
-
1222
- // Flush WAL to the main database file before reading. Awaiting allows
1223
- // the callback to dispatch the checkpoint via `runAsyncSqlite` so the
1224
- // daemon event loop stays responsive while SQLite flushes the WAL.
1225
- if (checkpoint) {
1226
- await checkpoint();
1227
- }
1228
-
1229
- const allFileMetadata: FileMetadata[] = [];
1230
- const symlinkEntries: SymlinkMetadata[] = [];
1231
-
1232
- // Walk the entire workspace directory, including binary files
1233
- if (
1234
- workspaceDir &&
1235
- existsSync(workspaceDir) &&
1236
- lstatSync(workspaceDir).isDirectory()
1237
- ) {
1238
- const {
1239
- files: walkedFiles,
1240
- symlinks: walkedSymlinks,
1241
- droppedSymlinks,
1242
- } = walkDirectoryForMetadata(workspaceDir, "workspace", {
1243
- includeBinary: true,
1244
- skipDirs: ["embedding-models", "data/qdrant", "signals", "deprecated"],
1245
- skipFiles: [".backup.key"],
1246
- });
1247
- allFileMetadata.push(...walkedFiles);
1248
- symlinkEntries.push(...walkedSymlinks);
1249
- if (droppedSymlinks.length > 0) {
1250
- getLogger("vbundle-builder").warn(
1251
- { count: droppedSymlinks.length, paths: droppedSymlinks },
1252
- `Dropped ${droppedSymlinks.length} symlinks pointing outside workspace or into skipped directories`,
1253
- );
1254
- }
1255
- }
1256
-
1257
- // Sanitize workspace/config.json: read from disk, sanitize, and replace the
1258
- // disk-backed metadata entry with an in-memory entry so the streaming tar
1259
- // writes sanitized content instead of the raw file.
1260
- const configMetadataIdx = allFileMetadata.findIndex(
1261
- (f) => f.archivePath === "workspace/config.json",
1262
- );
1263
-
1264
- const sanitizedConfigEntries: InMemoryEntry[] = [];
1265
- if (configMetadataIdx !== -1) {
1266
- const configMeta = allFileMetadata[configMetadataIdx];
1267
- const rawConfigData = readFileSync(configMeta.diskPath, "utf8");
1268
- const sanitized = sanitizeConfigForTransfer(rawConfigData);
1269
- const sanitizedData = new TextEncoder().encode(sanitized);
1270
-
1271
- // Remove the disk-backed entry and replace with an in-memory entry
1272
- allFileMetadata.splice(configMetadataIdx, 1);
1273
- sanitizedConfigEntries.push({
1274
- archivePath: "workspace/config.json",
1275
- data: sanitizedData,
1276
- size: sanitizedData.length,
1277
- });
1278
- }
1279
-
1280
- // Build in-memory entries for credentials and any extra files (not disk-backed)
1281
- const inMemoryEntries: InMemoryEntry[] = [];
1282
- for (const extra of extraFiles ?? []) {
1283
- inMemoryEntries.push({
1284
- archivePath: extra.archivePath,
1285
- data: extra.data,
1286
- size: extra.data.length,
1287
- });
1288
- }
1289
- if (credentials?.length) {
1290
- for (const { account, value } of credentials) {
1291
- const data = new TextEncoder().encode(value);
1292
- inMemoryEntries.push({
1293
- archivePath: `credentials/${account}`,
1294
- data,
1295
- size: data.length,
1296
- });
1297
- }
1298
- }
1299
-
1300
- // ------------------------------------------------------------------
1301
- // Pass 1: Compute SHA-256 checksums to build the manifest
1302
- // ------------------------------------------------------------------
1303
-
1304
- const fileEntries: ManifestFileEntryType[] = [];
1305
- for (const file of allFileMetadata) {
1306
- const sha256 = await computeFileSha256(file.diskPath, file.size);
1307
- fileEntries.push({
1308
- path: file.archivePath,
1309
- sha256,
1310
- size_bytes: file.size,
1311
- });
1312
- }
1313
-
1314
- // Add in-memory entries (sanitized config, credentials) to the manifest
1315
- for (const entry of [...sanitizedConfigEntries, ...inMemoryEntries]) {
1316
- const sha256 = sha256Hex(entry.data);
1317
- fileEntries.push({
1318
- path: entry.archivePath,
1319
- sha256,
1320
- size_bytes: entry.size,
1321
- });
1322
- }
1323
-
1324
- // Add symlink entries to the manifest. The sha256 is computed over the
1325
- // link target string (UTF-8 encoded) so the streaming validator can
1326
- // verify the manifest declared the same target the tar header carries.
1327
- // size_bytes is always 0 for symlink entries.
1328
- for (const entry of symlinkEntries) {
1329
- fileEntries.push({
1330
- path: entry.archivePath,
1331
- sha256: sha256Hex(entry.linkTarget),
1332
- size_bytes: 0,
1333
- link_target: entry.linkTarget,
1334
- });
1335
- }
1336
-
1337
- const { manifest, manifestData } = buildManifestObject({
1338
- contents: fileEntries,
1339
- assistant,
1340
- origin,
1341
- compatibility,
1342
- exportOptions,
1343
- secretsRedacted,
1344
- now: new Date(),
1345
- });
1346
-
1347
- // ------------------------------------------------------------------
1348
- // Pass 2: Stream tar through gzip into a temp file
1349
- // ------------------------------------------------------------------
1350
-
1351
- const tempPath = join(tmpdir(), `vbundle-export-${randomUUID()}.tmp`);
1352
-
1353
- const allEntries: TarStreamEntry[] = [
1354
- ...allFileMetadata,
1355
- ...sanitizedConfigEntries,
1356
- ...inMemoryEntries,
1357
- ...symlinkEntries,
1358
- ];
1359
- const tarGenerator = generateTarStream(manifestData, allEntries);
1360
- const tarReadable = Readable.from(tarGenerator);
1361
- const gzipStream = createGzip();
1362
- const writeStream = createWriteStream(tempPath, { mode: 0o600 });
1363
-
1364
- try {
1365
- await pipeline(tarReadable, gzipStream, writeStream);
1366
- } catch (error) {
1367
- await unlink(tempPath).catch(() => {});
1368
- throw error;
1369
- }
1370
-
1371
- const tempStat = await stat(tempPath);
1372
-
1373
- const cleanup = async () => {
1374
- try {
1375
- await unlink(tempPath);
1376
- } catch {
1377
- // Ignore errors during cleanup
1378
- }
1379
- };
1380
-
1381
- return { tempPath, size: tempStat.size, manifest, cleanup };
1382
- }