@mycadet/assistant 0.12.10 → 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 (180) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.yaml +2 -708
  10. package/package.json +2 -3
  11. package/scripts/sync-web-search-catalog.ts +1 -2
  12. package/src/__tests__/cloud-profile-guard.test.ts +41 -5
  13. package/src/__tests__/config-schema.test.ts +6 -38
  14. package/src/__tests__/conversation-queue.test.ts +45 -6
  15. package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
  16. package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
  17. package/src/__tests__/persona-resolver.test.ts +0 -51
  18. package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
  19. package/src/__tests__/run-due-schedules.test.ts +0 -4
  20. package/src/__tests__/schedule-store.test.ts +0 -12
  21. package/src/__tests__/stt-stream-session.test.ts +4 -4
  22. package/src/__tests__/telephony-tts-guard.test.ts +2 -50
  23. package/src/__tests__/voice-config-update.test.ts +12 -167
  24. package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
  25. package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
  26. package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
  28. package/src/calls/resolve-call-tts-provider.ts +6 -25
  29. package/src/calls/telephony-credential-preflight.ts +0 -10
  30. package/src/calls/telephony-tts-capability.ts +1 -17
  31. package/src/cli/commands/tts.ts +1 -2
  32. package/src/cli/index.help.ts +0 -2
  33. package/src/cli/program.ts +0 -2
  34. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
  35. package/src/config/schemas/__tests__/stt.test.ts +10 -14
  36. package/src/config/schemas/llm.ts +3 -5
  37. package/src/config/schemas/services.ts +5 -6
  38. package/src/config/schemas/stt.ts +1 -3
  39. package/src/config/schemas/tts.ts +1 -30
  40. package/src/daemon/daemon-readiness.ts +4 -29
  41. package/src/daemon/lifecycle.ts +0 -31
  42. package/src/daemon/providers-setup.ts +0 -8
  43. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  44. package/src/heartbeat/heartbeat-service.ts +1 -66
  45. package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
  46. package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
  47. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
  48. package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
  49. package/src/live-voice/live-voice-credential-preflight.ts +9 -33
  50. package/src/live-voice/live-voice-session.ts +10 -6
  51. package/src/live-voice/live-voice-tts.ts +1 -8
  52. package/src/plugins/defaults/memory/worker.ts +3 -3
  53. package/src/prompts/persona-resolver.ts +0 -35
  54. package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
  55. package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
  56. package/src/providers/search-provider-catalog.ts +7 -39
  57. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
  58. package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
  59. package/src/providers/speech-to-text/provider-catalog.ts +2 -73
  60. package/src/providers/speech-to-text/resolve.ts +3 -120
  61. package/src/runtime/http-router-types.ts +1 -3
  62. package/src/runtime/http-server.ts +1 -1
  63. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +13 -11
  64. package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
  65. package/src/runtime/routes/conversation-query-routes.ts +0 -96
  66. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
  67. package/src/runtime/routes/index.ts +0 -4
  68. package/src/runtime/routes/live-voice-routes.test.ts +4 -11
  69. package/src/runtime/routes/live-voice-routes.ts +8 -11
  70. package/src/runtime/routes/secret-routes.ts +0 -6
  71. package/src/runtime/routes/tts-routes.ts +0 -38
  72. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
  73. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
  74. package/src/schedule/schedule-store.ts +0 -7
  75. package/src/schedule/scheduler.ts +0 -5
  76. package/src/schedule/worker.ts +1 -1
  77. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
  78. package/src/stt/daemon-batch-transcriber.ts +4 -38
  79. package/src/stt/types.ts +1 -9
  80. package/src/tools/network/__tests__/web-search.test.ts +3 -250
  81. package/src/tools/network/web-search.ts +4 -165
  82. package/src/tts/__tests__/provider-resolution.test.ts +0 -5
  83. package/src/tts/provider-catalog.ts +0 -4
  84. package/src/tts/types.ts +0 -1
  85. package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
  86. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
  87. package/src/__tests__/migration-export-http.test.ts +0 -557
  88. package/src/__tests__/migration-export-streaming.test.ts +0 -350
  89. package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
  90. package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
  91. package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
  92. package/src/__tests__/migration-import-from-path.test.ts +0 -324
  93. package/src/__tests__/migration-import-from-url.test.ts +0 -588
  94. package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
  95. package/src/__tests__/migration-jobs-status.test.ts +0 -139
  96. package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
  97. package/src/__tests__/migration-transport.test.ts +0 -1089
  98. package/src/__tests__/migration-validate-http.test.ts +0 -634
  99. package/src/__tests__/migration-wizard.test.ts +0 -1395
  100. package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
  101. package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
  102. package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
  103. package/src/__tests__/validation-results-screen.test.ts +0 -1130
  104. package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
  105. package/src/background-wake/background-wake-routes.test.ts +0 -906
  106. package/src/background-wake/cloud-client.test.ts +0 -328
  107. package/src/background-wake/cloud-client.ts +0 -196
  108. package/src/background-wake/next-wake.test.ts +0 -335
  109. package/src/background-wake/next-wake.ts +0 -188
  110. package/src/background-wake/publisher.ts +0 -93
  111. package/src/background-wake/wake-intent-hooks.test.ts +0 -234
  112. package/src/cli/commands/__tests__/backup.test.ts +0 -363
  113. package/src/cli/commands/backup.help.ts +0 -180
  114. package/src/cli/commands/backup.ts +0 -398
  115. package/src/cloud/cloud-speech.test.ts +0 -335
  116. package/src/cloud/cloud-speech.ts +0 -300
  117. package/src/config/cloud-speech-defaults.ts +0 -300
  118. package/src/config/sanitize-for-transfer.ts +0 -111
  119. package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
  120. package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
  121. package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
  122. package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
  123. package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
  124. package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
  125. package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
  126. package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
  127. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
  128. package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
  129. package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
  130. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
  131. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
  132. package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
  133. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
  134. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
  135. package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
  136. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
  137. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
  138. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
  139. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
  140. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
  141. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
  142. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
  143. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
  144. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
  145. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
  146. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
  147. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
  148. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
  149. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
  150. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
  151. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
  152. package/src/runtime/migrations/gcs-signed-url.ts +0 -190
  153. package/src/runtime/migrations/job-registry.ts +0 -297
  154. package/src/runtime/migrations/migration-transport.ts +0 -612
  155. package/src/runtime/migrations/migration-wizard.ts +0 -747
  156. package/src/runtime/migrations/origin-mode.ts +0 -42
  157. package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
  158. package/src/runtime/migrations/staged-import-path.ts +0 -116
  159. package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
  160. package/src/runtime/migrations/validation-results-screen.ts +0 -469
  161. package/src/runtime/migrations/vbundle-builder.ts +0 -1382
  162. package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
  163. package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
  164. package/src/runtime/migrations/vbundle-importer.ts +0 -826
  165. package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
  166. package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
  167. package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
  168. package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
  169. package/src/runtime/migrations/vbundle-validator.ts +0 -724
  170. package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
  171. package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
  172. package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
  173. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
  174. package/src/runtime/routes/background-wake-routes.ts +0 -387
  175. package/src/runtime/routes/migration-routes.ts +0 -2584
  176. package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
  177. package/src/tools/network/cloud-search-proxy.ts +0 -211
  178. package/src/tts/__tests__/cadet-provider.test.ts +0 -253
  179. package/src/tts/providers/cadet-provider.ts +0 -244
  180. package/src/types/tar-stream.d.ts +0 -66
@@ -1,724 +0,0 @@
1
- /**
2
- * Validates .vbundle archive files for migration import/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 workspace directory tree (new format), OR
7
- * data/db/assistant.db + config/settings.json (old format)
8
- *
9
- * Validation steps:
10
- * 1. Archive structure: valid gzip tar with required entries
11
- * 2. Manifest schema: required fields and correct types
12
- * 3. Manifest checksum: SHA-256 of canonicalized JSON matches declared digest
13
- * 4. Per-file content integrity: SHA-256 of each file matches manifest checksums
14
- */
15
-
16
- import { createHash, randomUUID } from "node:crypto";
17
- import { posix } from "node:path";
18
- import { gunzipSync } from "node:zlib";
19
-
20
- import { z } from "zod";
21
-
22
- import { parseUstarSizeField } from "../../archive/ustar-size.js";
23
-
24
- // ---------------------------------------------------------------------------
25
- // Manifest schema (v1)
26
- // ---------------------------------------------------------------------------
27
-
28
- const ManifestFileEntry = z.object({
29
- path: z.string().min(1),
30
- sha256: z.string().regex(/^[0-9a-f]{64}$/),
31
- size_bytes: z.number().int().nonnegative(),
32
- link_target: z.string().min(1).optional(),
33
- });
34
-
35
- const AssistantInfo = z.object({
36
- id: z.string().min(1),
37
- name: z.string().min(1),
38
- runtime_version: z.string().min(1),
39
- });
40
-
41
- const Origin = z.object({
42
- mode: z.enum(["managed", "self-hosted-remote", "self-hosted-local"]),
43
- platform_version: z.string().optional(),
44
- hostname: z.string().optional(),
45
- });
46
-
47
- const Compatibility = z.object({
48
- min_runtime_version: z.string().min(1),
49
- max_runtime_version: z.string().nullable(),
50
- });
51
-
52
- const ExportOptions = z.object({
53
- include_logs: z.boolean(),
54
- include_browser_state: z.boolean(),
55
- include_memory_vectors: z.boolean(),
56
- include_gateway: z.boolean().optional(),
57
- });
58
-
59
- export const ManifestSchema = z
60
- .object({
61
- schema_version: z.literal(1),
62
- bundle_id: z.string().uuid(),
63
- created_at: z.string().datetime({ offset: true }),
64
- assistant: AssistantInfo,
65
- origin: Origin,
66
- compatibility: Compatibility,
67
- contents: z.array(ManifestFileEntry),
68
- checksum: z.string().regex(/^[0-9a-f]{64}$/),
69
- secrets_redacted: z.boolean(),
70
- export_options: ExportOptions,
71
- })
72
- .refine((m) => m.origin.mode !== "managed" || m.secrets_redacted === true, {
73
- message: "secrets_redacted must be true when origin.mode is 'managed'",
74
- path: ["secrets_redacted"],
75
- })
76
- .refine(
77
- (m) =>
78
- m.contents.some(
79
- (f) =>
80
- f.path === "data/db/assistant.db" ||
81
- f.path === "workspace/data/db/assistant.db",
82
- ),
83
- {
84
- message:
85
- "contents must include an entry for data/db/assistant.db (legacy format) or workspace/data/db/assistant.db (current format)",
86
- path: ["contents"],
87
- },
88
- );
89
-
90
- export type ManifestFileEntryType = z.infer<typeof ManifestFileEntry>;
91
- export type ManifestType = z.infer<typeof ManifestSchema>;
92
-
93
- // ---------------------------------------------------------------------------
94
- // Legacy manifest schema (pre-v1, six-field shape)
95
- // ---------------------------------------------------------------------------
96
- //
97
- // Older runtime versions wrote a six-field manifest with `schema_version: "1.0"`,
98
- // `files`, `size` (per-entry), and a self-referencing `manifest_sha256` field.
99
- // Existing on-disk artifacts produced by those versions — backup snapshots,
100
- // cross-version migration bundles — must keep validating after upgrade,
101
- // per AGENTS.md "no silent breaks of persisted state".
102
- //
103
- // We accept legacy bundles via a fallback parse + translator so the rest of
104
- // the validation pipeline (per-file hash + size verification) only ever sees
105
- // the v1 shape.
106
-
107
- const LegacyManifestFileEntry = z.object({
108
- path: z.string(),
109
- sha256: z.string(),
110
- size: z.number().int().nonnegative(),
111
- });
112
-
113
- export const LegacyManifestSchema = z.object({
114
- schema_version: z.string(),
115
- created_at: z.string(),
116
- source: z.string().optional(),
117
- description: z.string().optional(),
118
- files: z.array(LegacyManifestFileEntry),
119
- manifest_sha256: z.string(),
120
- });
121
-
122
- export type LegacyManifestType = z.infer<typeof LegacyManifestSchema>;
123
-
124
- /**
125
- * Recompute the legacy `manifest_sha256` field — strips the field entirely
126
- * (rather than emptying it) before canonicalizing, matching the pre-v1
127
- * producer behavior. Required so legacy bundles whose checksum was computed
128
- * the old way still verify after upgrade.
129
- */
130
- export function computeLegacyManifestSha256(manifest: unknown): string {
131
- const copy = { ...(manifest as Record<string, unknown>) };
132
- delete copy.manifest_sha256;
133
- return sha256Hex(canonicalizeJson(copy));
134
- }
135
-
136
- /**
137
- * Coerce a legacy ISO-ish `created_at` into the v1 datetime regex shape.
138
- * Pre-v1 producers always wrote `new Date().toISOString()`, which already
139
- * has the `Z` suffix the v1 regex requires; this helper is defensive against
140
- * any historical producer that omitted the offset/`Z`.
141
- */
142
- function coerceLegacyCreatedAt(raw: string): string {
143
- // If the string already parses as a Date, keep it as the canonical ISO form.
144
- const parsed = new Date(raw);
145
- if (!Number.isNaN(parsed.getTime())) {
146
- // `toISOString()` always emits the `...Z` form the v1 regex accepts.
147
- return parsed.toISOString();
148
- }
149
- return raw;
150
- }
151
-
152
- /**
153
- * Translate a parsed legacy manifest into a v1 `ManifestType` so the rest of
154
- * the validator pipeline can operate on a uniform shape.
155
- *
156
- * Legacy bundles never carried assistant identity, origin, compatibility, or
157
- * export-option signals; we substitute conservative placeholders that satisfy
158
- * the v1 schema's `.refine()` rules without misrepresenting the source bundle.
159
- */
160
- export function translateLegacyManifest(
161
- legacy: LegacyManifestType,
162
- ): ManifestType {
163
- return {
164
- schema_version: 1,
165
- bundle_id: randomUUID(),
166
- created_at: coerceLegacyCreatedAt(legacy.created_at),
167
- assistant: {
168
- id: "self",
169
- name: "Assistant",
170
- runtime_version: "0.0.0-legacy",
171
- },
172
- // Legacy bundles came from the local self-hosted exporter; the
173
- // conservative default is "self-hosted-local" so the v1 managed/secrets
174
- // refine never trips on a translated legacy bundle.
175
- origin: { mode: "self-hosted-local" },
176
- compatibility: {
177
- min_runtime_version: "0.0.0-legacy",
178
- max_runtime_version: null,
179
- },
180
- contents: legacy.files.map((f) => ({
181
- path: f.path,
182
- sha256: f.sha256,
183
- size_bytes: f.size,
184
- })),
185
- checksum: legacy.manifest_sha256,
186
- secrets_redacted: false,
187
- export_options: {
188
- include_logs: false,
189
- include_browser_state: false,
190
- include_memory_vectors: false,
191
- },
192
- };
193
- }
194
-
195
- // ---------------------------------------------------------------------------
196
- // Validation result types
197
- // ---------------------------------------------------------------------------
198
-
199
- export interface ValidationError {
200
- code: string;
201
- message: string;
202
- path?: string;
203
- }
204
-
205
- export interface VBundleTarEntry {
206
- name: string;
207
- data: Uint8Array;
208
- size: number;
209
- /** Set when the tar entry is typeflag-2 (symlink); carries the link target
210
- * decoded from the ustar linkname field. */
211
- linkname?: string;
212
- }
213
-
214
- export interface VBundleValidationResult {
215
- is_valid: boolean;
216
- errors: ValidationError[];
217
- manifest?: ManifestType;
218
- /** Parsed tar entries — only present when validation succeeds, so callers
219
- * can reuse them without decompressing the archive a second time. */
220
- entries?: Map<string, VBundleTarEntry>;
221
- }
222
-
223
- // ---------------------------------------------------------------------------
224
- // Tar parsing (minimal, spec-compliant for ustar/GNU tar)
225
- // ---------------------------------------------------------------------------
226
-
227
- interface TarEntry {
228
- name: string;
229
- data: Uint8Array;
230
- size: number;
231
- /** Set when the tar entry is typeflag-2 (symlink); carries the link target
232
- * decoded from the ustar linkname field. */
233
- linkname?: string;
234
- }
235
-
236
- /**
237
- * Parse a raw tar archive (uncompressed) into its entries.
238
- * Handles ustar and GNU tar long name extensions.
239
- */
240
- function parseTar(buffer: Uint8Array): TarEntry[] {
241
- const entries: TarEntry[] = [];
242
- let offset = 0;
243
- const BLOCK_SIZE = 512;
244
- let longName: string | null = null;
245
-
246
- while (offset + BLOCK_SIZE <= buffer.length) {
247
- const header = buffer.subarray(offset, offset + BLOCK_SIZE);
248
-
249
- // Check for end-of-archive (two consecutive zero blocks)
250
- if (header.every((b) => b === 0)) {
251
- break;
252
- }
253
-
254
- // Extract file name
255
- let name: string;
256
- if (longName) {
257
- name = longName;
258
- longName = null;
259
- } else {
260
- // POSIX ustar: prefix (345 bytes at 345) + name (100 bytes at 0)
261
- const rawName = decodeNullTerminated(header, 0, 100);
262
- const prefix = decodeNullTerminated(header, 345, 155);
263
- name = prefix ? `${prefix}/${rawName}` : rawName;
264
- }
265
-
266
- // File type (byte 156)
267
- const typeFlag = String.fromCharCode(header[156]);
268
-
269
- const size = parseUstarSizeField(header);
270
-
271
- // Calculate data blocks
272
- const dataBlocks = Math.ceil(size / BLOCK_SIZE);
273
- const dataStart = offset + BLOCK_SIZE;
274
- const data = buffer.subarray(dataStart, dataStart + size);
275
-
276
- // GNU tar long name extension (type 'L')
277
- if (typeFlag === "L") {
278
- longName = new TextDecoder().decode(data).replace(/\0+$/, "");
279
- offset = dataStart + dataBlocks * BLOCK_SIZE;
280
- continue;
281
- }
282
-
283
- // PAX extended header (type 'x') — extract path= attribute for next entry
284
- if (typeFlag === "x") {
285
- const paxText = new TextDecoder().decode(data);
286
- const pathMatch = paxText.match(/\d+ path=([^\n]+)\n/);
287
- if (pathMatch) {
288
- longName = pathMatch[1];
289
- }
290
- offset = dataStart + dataBlocks * BLOCK_SIZE;
291
- continue;
292
- }
293
-
294
- // Symlink (type '2') — empty body regardless of declared size; the link
295
- // target lives in the ustar linkname field (157..256). We preserve the
296
- // tar-declared `size` here (rather than forcing it to 0) so the
297
- // downstream `archiveEntry.size !== 0` check can surface
298
- // `FILE_SIZE_MISMATCH` on malformed symlink headers. The body itself is
299
- // always an empty buffer — symlinks have no data body even if the
300
- // header lies about it.
301
- if (typeFlag === "2") {
302
- const linkname = decodeNullTerminated(header, 157, 100);
303
- entries.push({
304
- name: normalizePath(name),
305
- data: new Uint8Array(0),
306
- size,
307
- linkname,
308
- });
309
- offset = dataStart + dataBlocks * BLOCK_SIZE;
310
- continue;
311
- }
312
-
313
- // Regular file or hard link
314
- if (typeFlag === "0" || typeFlag === "\0" || typeFlag === "") {
315
- entries.push({ name: normalizePath(name), data, size });
316
- }
317
-
318
- offset = dataStart + dataBlocks * BLOCK_SIZE;
319
- }
320
-
321
- return entries;
322
- }
323
-
324
- function decodeNullTerminated(
325
- buf: Uint8Array,
326
- start: number,
327
- maxLen: number,
328
- ): string {
329
- let end = start;
330
- while (end < start + maxLen && buf[end] !== 0) {
331
- end++;
332
- }
333
- return new TextDecoder().decode(buf.subarray(start, end));
334
- }
335
-
336
- function normalizePath(p: string): string {
337
- // Remove leading ./ and trailing /
338
- return p.replace(/^\.\//, "").replace(/\/+$/, "");
339
- }
340
-
341
- // ---------------------------------------------------------------------------
342
- // Hash helpers
343
- // ---------------------------------------------------------------------------
344
-
345
- function sha256Hex(data: Uint8Array | string): string {
346
- return createHash("sha256").update(data).digest("hex");
347
- }
348
-
349
- /**
350
- * Canonicalize a JSON object by sorting keys recursively, then SHA-256 hash it.
351
- * This matches the cloud's canonicalization approach.
352
- */
353
- export function canonicalizeJson(obj: unknown): string {
354
- return JSON.stringify(obj, (_key, value) => {
355
- if (value && typeof value === "object" && !Array.isArray(value)) {
356
- const sorted: Record<string, unknown> = {};
357
- for (const k of Object.keys(value as Record<string, unknown>).sort()) {
358
- sorted[k] = (value as Record<string, unknown>)[k];
359
- }
360
- return sorted;
361
- }
362
- return value;
363
- });
364
- }
365
-
366
- /**
367
- * Recompute the `checksum` field for a manifest object.
368
- *
369
- * The v1 schema spec says the checksum is computed over the canonicalized
370
- * manifest with the `checksum` field set to the empty string (not absent),
371
- * so we replace it before canonicalizing — both producers and validators
372
- * must agree on this exact wire shape. Centralized here so the streaming
373
- * validator and the in-memory validator agree on the exact canonicalization.
374
- */
375
- export function computeManifestChecksum(manifest: unknown): string {
376
- const copy = { ...(manifest as Record<string, unknown>), checksum: "" };
377
- return sha256Hex(canonicalizeJson(copy));
378
- }
379
-
380
- // ---------------------------------------------------------------------------
381
- // Core validation
382
- // ---------------------------------------------------------------------------
383
-
384
- /**
385
- * Hard caps on `manifest.json`, shared by the buffered and streaming
386
- * validators so preflight and import agree on what is acceptable. The two
387
- * are sized together: the manifest carries one `contents` entry (path,
388
- * sha256, size) per bundled file at roughly 150 bytes each, so 200,000
389
- * entries serialize to about 32 MiB.
390
- *
391
- * That absorbs a workspace with a stray dependency install or checked-out
392
- * repo while still bounding the transient memory a hostile "manifest" can
393
- * force the importer to hold (buffer, decoded string, parsed object,
394
- * canonical re-serialization for the self-checksum) and the per-entry work
395
- * it commits to, on a memory-constrained pod.
396
- */
397
- export const MANIFEST_MAX_BYTES = 32 * 1024 * 1024;
398
- export const MANIFEST_MAX_ENTRIES = 200_000;
399
-
400
- export interface ValidateVBundleOptions {
401
- /**
402
- * Reject manifests over `MANIFEST_MAX_BYTES` or `MANIFEST_MAX_ENTRIES`.
403
- * Preflights that precede a streaming import set this so they never
404
- * approve a bundle the import will refuse. The buffered import path
405
- * (`commitImport`) has no such ceilings, so it stays off there to keep
406
- * existing large backups restorable.
407
- */
408
- enforceStreamingLimits?: boolean;
409
- }
410
-
411
- // Only manifest.json is structurally required. The DB and config live under
412
- // workspace/ (new format) or data/db/ + config/ (old format) — both are valid.
413
- const REQUIRED_ENTRIES = ["manifest.json"];
414
-
415
- // 2 GB — must accommodate large but valid migrations from buildExportVBundle()
416
- const MAX_DECOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024;
417
-
418
- /**
419
- * Validate a .vbundle archive from raw bytes.
420
- *
421
- * Performs four validation passes:
422
- * 1. Archive structure (gzip decompression, tar parsing, required entries)
423
- * 2. Manifest schema (Zod validation of manifest.json)
424
- * 3. Manifest checksum (SHA-256 of canonicalized JSON with the `checksum` field set to empty string)
425
- * 4. Per-file content integrity (SHA-256 of each file vs manifest declaration)
426
- */
427
- export function validateVBundle(
428
- data: Uint8Array,
429
- { enforceStreamingLimits = false }: ValidateVBundleOptions = {},
430
- ): VBundleValidationResult {
431
- const errors: ValidationError[] = [];
432
-
433
- // Step 1: Decompress gzip with size cap to prevent zip-bomb DoS
434
- let tarData: Uint8Array;
435
- try {
436
- tarData = gunzipSync(data, { maxOutputLength: MAX_DECOMPRESSED_SIZE });
437
- } catch (err) {
438
- const message =
439
- err instanceof RangeError
440
- ? `Decompressed archive exceeds ${MAX_DECOMPRESSED_SIZE} byte limit`
441
- : `Archive is not a valid gzip file: ${
442
- err instanceof Error ? err.message : String(err)
443
- }`;
444
- const code =
445
- err instanceof RangeError ? "DECOMPRESSED_SIZE_EXCEEDED" : "INVALID_GZIP";
446
- errors.push({ code, message });
447
- return { is_valid: false, errors };
448
- }
449
-
450
- // Step 2: Parse tar
451
- let entries: TarEntry[];
452
- try {
453
- entries = parseTar(tarData);
454
- } catch (err) {
455
- errors.push({
456
- code: "INVALID_TAR",
457
- message: `Archive is not a valid tar file: ${
458
- err instanceof Error ? err.message : String(err)
459
- }`,
460
- });
461
- return { is_valid: false, errors };
462
- }
463
-
464
- // Build a lookup map for entries
465
- const entryMap = new Map<string, TarEntry>();
466
- for (const entry of entries) {
467
- entryMap.set(entry.name, entry);
468
- }
469
-
470
- // Step 3: Check required entries
471
- for (const required of REQUIRED_ENTRIES) {
472
- if (!entryMap.has(required)) {
473
- errors.push({
474
- code: "MISSING_ENTRY",
475
- message: `Required archive entry not found: ${required}`,
476
- path: required,
477
- });
478
- }
479
- }
480
-
481
- // If manifest.json is missing, we cannot proceed with further validation
482
- const manifestEntry = entryMap.get("manifest.json");
483
- if (!manifestEntry) {
484
- return { is_valid: false, errors };
485
- }
486
-
487
- // Step 4: Parse and validate manifest schema
488
- if (
489
- enforceStreamingLimits &&
490
- manifestEntry.data.length > MANIFEST_MAX_BYTES
491
- ) {
492
- errors.push({
493
- code: "MANIFEST_TOO_LARGE",
494
- message: `manifest.json exceeds ${MANIFEST_MAX_BYTES} byte limit (${manifestEntry.data.length} bytes)`,
495
- path: "manifest.json",
496
- });
497
- return { is_valid: false, errors };
498
- }
499
- let manifestRaw: unknown;
500
- try {
501
- manifestRaw = JSON.parse(new TextDecoder().decode(manifestEntry.data));
502
- } catch (err) {
503
- errors.push({
504
- code: "INVALID_MANIFEST_JSON",
505
- message: `manifest.json is not valid JSON: ${
506
- err instanceof Error ? err.message : String(err)
507
- }`,
508
- path: "manifest.json",
509
- });
510
- return { is_valid: false, errors };
511
- }
512
-
513
- // Try the v1 schema first. If that fails, fall back to the legacy six-field
514
- // shape so existing on-disk bundles (backup snapshots, cross-version
515
- // migrations) keep validating after upgrade. AGENTS.md prohibits silent
516
- // breaks of persisted state.
517
- const parseResult = ManifestSchema.safeParse(manifestRaw);
518
- let manifest: ManifestType;
519
-
520
- if (parseResult.success) {
521
- manifest = parseResult.data;
522
-
523
- // Step 5 (v1): Verify manifest checksum — SHA-256 of canonicalized JSON
524
- // with the `checksum` field replaced by an empty string.
525
- const computedChecksum = computeManifestChecksum(manifestRaw);
526
- if (computedChecksum !== manifest.checksum) {
527
- errors.push({
528
- code: "MANIFEST_CHECKSUM_MISMATCH",
529
- message: `Manifest checksum mismatch: expected ${manifest.checksum}, computed ${computedChecksum}`,
530
- path: "manifest.json",
531
- });
532
- }
533
- } else {
534
- const legacyParse = LegacyManifestSchema.safeParse(manifestRaw);
535
- if (!legacyParse.success) {
536
- // Truly malformed — surface the v1 error for the clearer error trail.
537
- for (const issue of parseResult.error.issues) {
538
- errors.push({
539
- code: "MANIFEST_SCHEMA_ERROR",
540
- message: `Manifest validation error at ${issue.path.join(".")}: ${
541
- issue.message
542
- }`,
543
- path: `manifest.json/${issue.path.join(".")}`,
544
- });
545
- }
546
- return { is_valid: false, errors };
547
- }
548
-
549
- // Step 5 (legacy): Verify the legacy `manifest_sha256` using the OLD
550
- // canonicalization (strip the field entirely; do NOT replace with "").
551
- const legacy = legacyParse.data;
552
- const computedLegacyChecksum = computeLegacyManifestSha256(manifestRaw);
553
- if (computedLegacyChecksum !== legacy.manifest_sha256) {
554
- errors.push({
555
- code: "MANIFEST_CHECKSUM_MISMATCH",
556
- message: `Manifest checksum mismatch: expected ${legacy.manifest_sha256}, computed ${computedLegacyChecksum}`,
557
- path: "manifest.json",
558
- });
559
- return { is_valid: false, errors };
560
- }
561
-
562
- // Translate to v1 so the rest of the pipeline (per-file hash + size
563
- // verification, refine rules) sees a uniform shape.
564
- manifest = translateLegacyManifest(legacy);
565
- }
566
-
567
- if (
568
- enforceStreamingLimits &&
569
- manifest.contents.length > MANIFEST_MAX_ENTRIES
570
- ) {
571
- errors.push({
572
- code: "MANIFEST_TOO_MANY_ENTRIES",
573
- message: `manifest.json declares more than ${MANIFEST_MAX_ENTRIES} entries (${manifest.contents.length})`,
574
- path: "manifest.json",
575
- });
576
- return { is_valid: false, errors };
577
- }
578
-
579
- // Step 6: Verify per-file content integrity
580
- const manifestFilePaths = new Set(manifest.contents.map((f) => f.path));
581
-
582
- for (const fileEntry of manifest.contents) {
583
- const archiveEntry = entryMap.get(fileEntry.path);
584
- if (!archiveEntry) {
585
- errors.push({
586
- code: "MISSING_DECLARED_FILE",
587
- message: `File declared in manifest not found in archive: ${fileEntry.path}`,
588
- path: fileEntry.path,
589
- });
590
- continue;
591
- }
592
-
593
- if (fileEntry.link_target !== undefined) {
594
- // Symlink branch: typeflag agreement, linkname agreement, sha over the
595
- // link target string, size==0 on both sides, and path-traversal rejection.
596
- if (archiveEntry.linkname === undefined) {
597
- errors.push({
598
- code: "SYMLINK_TYPEFLAG_MISMATCH",
599
- message: `Manifest declares symlink for ${fileEntry.path} but tar entry is not typeflag-2`,
600
- path: fileEntry.path,
601
- });
602
- continue;
603
- }
604
-
605
- if (archiveEntry.linkname !== fileEntry.link_target) {
606
- errors.push({
607
- code: "LINK_TARGET_MISMATCH",
608
- message: `Symlink linkname mismatch for ${fileEntry.path}: manifest declares "${fileEntry.link_target}", tar carries "${archiveEntry.linkname}"`,
609
- path: fileEntry.path,
610
- });
611
- continue;
612
- }
613
-
614
- if (archiveEntry.size !== 0) {
615
- errors.push({
616
- code: "FILE_SIZE_MISMATCH",
617
- message: `Size mismatch for ${fileEntry.path}: manifest declares ${fileEntry.size_bytes} bytes, archive has ${archiveEntry.size} bytes`,
618
- path: fileEntry.path,
619
- });
620
- }
621
-
622
- if (fileEntry.size_bytes !== 0) {
623
- errors.push({
624
- code: "FILE_SIZE_MISMATCH",
625
- message: `Size mismatch for ${fileEntry.path}: manifest declares ${fileEntry.size_bytes} bytes, archive has ${archiveEntry.size} bytes`,
626
- path: fileEntry.path,
627
- });
628
- }
629
-
630
- const expected = sha256Hex(fileEntry.link_target);
631
- if (expected !== fileEntry.sha256) {
632
- errors.push({
633
- code: "FILE_CHECKSUM_MISMATCH",
634
- message: `Checksum mismatch for ${fileEntry.path}: expected ${fileEntry.sha256}, computed ${expected}`,
635
- path: fileEntry.path,
636
- });
637
- }
638
-
639
- // Absolute POSIX targets are unconstrained by the bundle root — reject
640
- // them up front. The `posix.normalize` guard below only catches
641
- // `..`-based escapes; an absolute path like `/etc/passwd` survives
642
- // normalization unchanged and would otherwise pass.
643
- if (fileEntry.link_target.startsWith("/")) {
644
- errors.push({
645
- code: "SYMLINK_TARGET_ESCAPES_ARCHIVE",
646
- message: `Symlink target is absolute, which escapes the archive root for ${fileEntry.path}: target=${fileEntry.link_target}`,
647
- path: fileEntry.path,
648
- });
649
- } else {
650
- const normalized = posix.normalize(
651
- posix.join(posix.dirname(fileEntry.path), fileEntry.link_target),
652
- );
653
- // Defense-in-depth: also reject if the joined+normalized path is
654
- // absolute, in case `dirname` ever resolves to an absolute root.
655
- if (
656
- normalized.startsWith("../") ||
657
- normalized === ".." ||
658
- normalized.startsWith("/")
659
- ) {
660
- errors.push({
661
- code: "SYMLINK_TARGET_ESCAPES_ARCHIVE",
662
- message: `Symlink target escapes archive root for ${fileEntry.path}: target=${fileEntry.link_target}, normalized=${normalized}`,
663
- path: fileEntry.path,
664
- });
665
- }
666
- }
667
- continue;
668
- }
669
-
670
- if (archiveEntry.linkname !== undefined) {
671
- // Tar carries a typeflag-2 entry but manifest declares a regular file.
672
- errors.push({
673
- code: "SYMLINK_NOT_DECLARED",
674
- message: `Tar entry ${fileEntry.path} is typeflag-2 but manifest does not declare link_target`,
675
- path: fileEntry.path,
676
- });
677
- continue;
678
- }
679
-
680
- // Verify size
681
- if (archiveEntry.size !== fileEntry.size_bytes) {
682
- errors.push({
683
- code: "FILE_SIZE_MISMATCH",
684
- message: `Size mismatch for ${fileEntry.path}: manifest declares ${fileEntry.size_bytes} bytes, archive has ${archiveEntry.size} bytes`,
685
- path: fileEntry.path,
686
- });
687
- }
688
-
689
- // Verify SHA-256
690
- const computedSha256 = sha256Hex(archiveEntry.data);
691
- if (computedSha256 !== fileEntry.sha256) {
692
- errors.push({
693
- code: "FILE_CHECKSUM_MISMATCH",
694
- message: `Checksum mismatch for ${fileEntry.path}: expected ${fileEntry.sha256}, computed ${computedSha256}`,
695
- path: fileEntry.path,
696
- });
697
- }
698
- }
699
-
700
- // Step 7: Ensure every required entry (except manifest.json itself) has a
701
- // checksum in the manifest — presence in the archive alone is not enough.
702
- for (const required of REQUIRED_ENTRIES) {
703
- if (required === "manifest.json") {
704
- continue;
705
- }
706
- if (!entryMap.has(required)) {
707
- continue;
708
- }
709
- if (!manifestFilePaths.has(required)) {
710
- errors.push({
711
- code: "REQUIRED_FILE_NOT_IN_MANIFEST",
712
- message: `Required file ${required} exists in archive but has no checksum entry in manifest.contents`,
713
- path: required,
714
- });
715
- }
716
- }
717
-
718
- return {
719
- is_valid: errors.length === 0,
720
- errors,
721
- manifest: errors.length === 0 ? manifest : undefined,
722
- entries: errors.length === 0 ? entryMap : undefined,
723
- };
724
- }