@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,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
- }