@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,426 +0,0 @@
1
- /**
2
- * Streaming validation primitives for `.vbundle` archives.
3
- *
4
- * The non-streaming `validateVBundle` decompresses the entire archive into
5
- * memory and walks the tar buffer to compute per-file SHA-256s. That is fine
6
- * for small bundles but peaks at 2x the decompressed size in RAM — an 8 GB
7
- * bundle OOMs a 3 GB pod.
8
- *
9
- * This module lets a caller validate a bundle while streaming:
10
- * - `readAndValidateManifest` consumes the first tar entry (which must be
11
- * `manifest.json`), validates the schema, and verifies the self-referencing
12
- * `checksum` against the canonicalized JSON.
13
- * - `createHashVerifier` returns a passthrough `Transform` that hashes bytes
14
- * flowing through it and errors the pipeline if the final digest or byte
15
- * count does not match the expected values from the manifest.
16
- *
17
- * Together, these let a consumer pipe every subsequent tar entry through a
18
- * hash verifier before writing it to disk, without ever buffering the full
19
- * bundle.
20
- */
21
-
22
- import { createHash } from "node:crypto";
23
- import { posix } from "node:path";
24
- import { Transform, type TransformCallback } from "node:stream";
25
-
26
- import type { StreamedTarEntry } from "./vbundle-tar-stream.js";
27
- import {
28
- computeLegacyManifestSha256,
29
- computeManifestChecksum,
30
- LegacyManifestSchema,
31
- MANIFEST_MAX_BYTES,
32
- MANIFEST_MAX_ENTRIES,
33
- ManifestSchema,
34
- type ManifestType,
35
- translateLegacyManifest,
36
- } from "./vbundle-validator.js";
37
-
38
- // ---------------------------------------------------------------------------
39
- // Public types
40
- // ---------------------------------------------------------------------------
41
-
42
- export interface ManifestReadResult {
43
- manifest: ManifestType;
44
- /**
45
- * Fast lookup from archive path -> expected sha256 + size + linkTarget
46
- * (from manifest.contents). `linkTarget` is non-null only for symlink
47
- * entries — regular file entries carry `null` so callers can branch on
48
- * type without a separate map.
49
- */
50
- expected: Map<
51
- string,
52
- { sha256: string; size: number; linkTarget: string | null }
53
- >;
54
- }
55
-
56
- /**
57
- * All failure modes produced by this module. Every throw/error includes a
58
- * stable `code` string so callers can branch on the failure kind without
59
- * string-matching the message.
60
- */
61
- export class StreamingValidationError extends Error {
62
- public readonly code: string;
63
- public readonly archivePath?: string;
64
-
65
- constructor(code: string, message: string, archivePath?: string) {
66
- super(message);
67
- this.name = "StreamingValidationError";
68
- this.code = code;
69
- if (archivePath !== undefined) {
70
- this.archivePath = archivePath;
71
- }
72
- }
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // Manifest validation
77
- // ---------------------------------------------------------------------------
78
-
79
- /**
80
- * Drain the first tar entry — which MUST be `manifest.json` — and run the
81
- * full manifest-level validation pipeline:
82
- * 1. Entry name check.
83
- * 2. Size cap (`MANIFEST_MAX_BYTES`).
84
- * 3. JSON parse.
85
- * 4. Zod schema validation.
86
- * 5. Self-referencing `checksum` verification against the
87
- * canonicalized JSON (minus that field).
88
- * 6. Entry-count ceiling on `contents` (`maxEntries`, default
89
- * `MANIFEST_MAX_ENTRIES`).
90
- *
91
- * On success, returns the parsed manifest plus a `Map` keyed by archive
92
- * path that callers consult as each subsequent entry streams past.
93
- *
94
- * On failure, throws a `StreamingValidationError` with a distinct `code`
95
- * for every failure mode.
96
- */
97
- export async function readAndValidateManifest(
98
- first: StreamedTarEntry,
99
- options: { maxEntries?: number } = {},
100
- ): Promise<ManifestReadResult> {
101
- const maxEntries = options.maxEntries ?? MANIFEST_MAX_ENTRIES;
102
- if (first.header.name !== "manifest.json") {
103
- // Drain the body so the underlying tar extractor isn't left dangling
104
- // on backpressure before the caller reports the error.
105
- first.body.resume();
106
- throw new StreamingValidationError(
107
- "manifest_not_first",
108
- `Expected manifest.json as the first tar entry, got "${first.header.name}"`,
109
- );
110
- }
111
-
112
- // Drain the entry body into a Buffer, enforcing the size cap as we go.
113
- // The moment we cross the cap we destroy the entry stream — this signals
114
- // the tar extractor (and therefore gunzip + upstream source) to abort,
115
- // so a malicious archive whose "manifest" is a multi-GB decompressed
116
- // stream can't force us to read through all of it before rejecting.
117
- const chunks: Buffer[] = [];
118
- let total = 0;
119
- for await (const chunk of first.body) {
120
- const buf = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
121
- total += buf.length;
122
- if (total > MANIFEST_MAX_BYTES) {
123
- first.body.destroy();
124
- throw new StreamingValidationError(
125
- "manifest_too_large",
126
- `manifest.json exceeds ${MANIFEST_MAX_BYTES} byte limit (read ${total} bytes before aborting)`,
127
- );
128
- }
129
- chunks.push(buf);
130
- }
131
-
132
- const bodyBuf = Buffer.concat(chunks, total);
133
-
134
- let manifestRaw: unknown;
135
- try {
136
- manifestRaw = JSON.parse(bodyBuf.toString("utf8"));
137
- } catch (err) {
138
- throw new StreamingValidationError(
139
- "manifest_malformed",
140
- `manifest.json is not valid JSON: ${
141
- err instanceof Error ? err.message : String(err)
142
- }`,
143
- );
144
- }
145
-
146
- // Try the v1 schema first; fall back to the legacy six-field shape so
147
- // existing on-disk bundles (backup snapshots, cross-version migrations)
148
- // keep streaming-validating after upgrade. AGENTS.md prohibits silent
149
- // breaks of persisted state.
150
- const parseResult = ManifestSchema.safeParse(manifestRaw);
151
- let manifest: ManifestType;
152
-
153
- if (parseResult.success) {
154
- manifest = parseResult.data;
155
- // Recompute the self-referencing checksum using the exact canonicalization
156
- // that vbundle-validator.ts uses. Any drift here would silently reject
157
- // valid bundles produced by buildVBundle.
158
- const computed = computeManifestChecksum(manifestRaw);
159
- if (computed !== manifest.checksum) {
160
- throw new StreamingValidationError(
161
- "manifest_sha256",
162
- `Manifest checksum mismatch: expected ${manifest.checksum}, computed ${computed}`,
163
- );
164
- }
165
- } else {
166
- const legacyParse = LegacyManifestSchema.safeParse(manifestRaw);
167
- if (!legacyParse.success) {
168
- const issues = parseResult.error.issues
169
- .map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
170
- .join("; ");
171
- throw new StreamingValidationError(
172
- "manifest_schema",
173
- `manifest.json failed schema validation: ${issues}`,
174
- );
175
- }
176
- const legacy = legacyParse.data;
177
- // Verify the legacy checksum using the OLD canonicalization (strip the
178
- // field entirely; do NOT replace with "").
179
- const computedLegacy = computeLegacyManifestSha256(manifestRaw);
180
- if (computedLegacy !== legacy.manifest_sha256) {
181
- throw new StreamingValidationError(
182
- "manifest_sha256",
183
- `Manifest checksum mismatch: expected ${legacy.manifest_sha256}, computed ${computedLegacy}`,
184
- );
185
- }
186
- manifest = translateLegacyManifest(legacy);
187
- }
188
-
189
- if (manifest.contents.length > maxEntries) {
190
- throw new StreamingValidationError(
191
- "bundle_too_many_entries",
192
- `bundle contains more than ${maxEntries} entries (declared: ${manifest.contents.length})`,
193
- );
194
- }
195
-
196
- const expected = new Map<
197
- string,
198
- { sha256: string; size: number; linkTarget: string | null }
199
- >();
200
- for (const file of manifest.contents) {
201
- if (expected.has(file.path)) {
202
- throw new StreamingValidationError(
203
- "manifest_duplicate_path",
204
- `Manifest contains duplicate entry for path: ${file.path}`,
205
- );
206
- }
207
- expected.set(file.path, {
208
- sha256: file.sha256,
209
- size: file.size_bytes,
210
- linkTarget: file.link_target ?? null,
211
- });
212
- }
213
-
214
- return { manifest, expected };
215
- }
216
-
217
- // ---------------------------------------------------------------------------
218
- // Symlink entry verifier
219
- // ---------------------------------------------------------------------------
220
-
221
- /** SHA-256 hex digest of UTF-8 bytes / Uint8Array — local mirror of the helper in vbundle-streaming-importer.ts. */
222
- function sha256Hex(data: Uint8Array | string): string {
223
- return createHash("sha256").update(data).digest("hex");
224
- }
225
-
226
- /**
227
- * Verify a streaming tar entry whose manifest declared it as a symlink.
228
- *
229
- * Throws `StreamingValidationError` with stable codes for each failure mode:
230
- * - `entry_size`: tar header declared a non-zero size (symlink bodies must be empty).
231
- * - `symlink_not_declared`: manifest entry didn't carry a `link_target` so the
232
- * tar typeflag-2 record is unauthorized.
233
- * - `link_target_mismatch`: tar header `linkname` doesn't equal the manifest's
234
- * declared `link_target`. Either the bundle was tampered with or the producer
235
- * and writer disagreed.
236
- * - `entry_hash`: manifest's declared sha256 does not match the sha256 of the
237
- * declared link target string. Catches manifest tampering where the attacker
238
- * adjusted `link_target` but forgot to recompute the digest.
239
- * - `symlink_target_escapes_archive`: the resolved link target points outside
240
- * the archive root (e.g. `../../etc/passwd`). Importer would otherwise
241
- * follow the symlink to a privileged path on the host.
242
- *
243
- * On success, drains the entry body via `entry.body.resume()` so the tar
244
- * extractor's `next` callback fires and the parser can advance to the next
245
- * entry — symlink bodies are size=0, but `tar-stream` still emits a Readable
246
- * that needs to be consumed.
247
- */
248
- export function verifySymlinkEntry(input: {
249
- entry: StreamedTarEntry;
250
- expectedEntry: { sha256: string; size: number; linkTarget: string | null };
251
- }): void {
252
- const { entry, expectedEntry } = input;
253
- const archivePath = entry.header.name;
254
-
255
- if (entry.header.size !== 0) {
256
- entry.body.resume();
257
- throw new StreamingValidationError(
258
- "entry_size",
259
- `Symlink entry ${archivePath} declared size ${entry.header.size}; symlink bodies must be empty`,
260
- archivePath,
261
- );
262
- }
263
-
264
- // Reject manifest-declared size_bytes != 0 for symlink entries. The buffered
265
- // validator catches this via FILE_SIZE_MISMATCH; without this guard a
266
- // crafted bundle could declare `link_target` plus `size_bytes: 100` and the
267
- // streaming side would accept (header.size is independent of the manifest's
268
- // size_bytes).
269
- if (expectedEntry.size !== 0) {
270
- entry.body.resume();
271
- throw new StreamingValidationError(
272
- "entry_size",
273
- `Symlink ${archivePath} has non-zero manifest-declared size ${expectedEntry.size} (expected 0)`,
274
- archivePath,
275
- );
276
- }
277
-
278
- if (expectedEntry.linkTarget == null) {
279
- entry.body.resume();
280
- throw new StreamingValidationError(
281
- "symlink_not_declared",
282
- `Tar entry ${archivePath} is a symlink but the manifest did not declare a link_target`,
283
- archivePath,
284
- );
285
- }
286
-
287
- const tarLinkname = entry.header.linkname;
288
- if (tarLinkname !== expectedEntry.linkTarget) {
289
- entry.body.resume();
290
- throw new StreamingValidationError(
291
- "link_target_mismatch",
292
- `Symlink target mismatch for ${archivePath}: tar header linkname=${JSON.stringify(
293
- tarLinkname,
294
- )}, manifest link_target=${JSON.stringify(expectedEntry.linkTarget)}`,
295
- archivePath,
296
- );
297
- }
298
-
299
- const computedSha = sha256Hex(expectedEntry.linkTarget);
300
- if (computedSha !== expectedEntry.sha256) {
301
- entry.body.resume();
302
- throw new StreamingValidationError(
303
- "entry_hash",
304
- `Symlink hash mismatch for ${archivePath}: manifest sha256=${expectedEntry.sha256}, computed over link_target=${computedSha}`,
305
- archivePath,
306
- );
307
- }
308
-
309
- // Absolute targets escape the archive root unconditionally — and would
310
- // bypass the resolution check below because `posix.join("workspace",
311
- // "/etc/passwd")` normalizes the leading `/` away and returns
312
- // `"workspace/etc/passwd"`. Reject these explicitly before resolution so
313
- // a symlink with target `/etc/passwd` cannot be imported as a real
314
- // host-filesystem symlink.
315
- if (expectedEntry.linkTarget.startsWith("/")) {
316
- entry.body.resume();
317
- throw new StreamingValidationError(
318
- "symlink_target_escapes_archive",
319
- `Symlink ${archivePath} has absolute target ${JSON.stringify(
320
- expectedEntry.linkTarget,
321
- )}, which escapes the archive root`,
322
- archivePath,
323
- );
324
- }
325
-
326
- // Path traversal: resolve the symlink target relative to the symlink's own
327
- // directory inside the archive. If the resolved path escapes the archive
328
- // root (begins with `..` or equals `..`), the target points outside the
329
- // bundle — refuse to import. Also reject a resolved path that is itself
330
- // absolute as defense-in-depth (dirname could in theory yield an absolute
331
- // path; cheap to guard).
332
- const resolved = posix.normalize(
333
- posix.join(posix.dirname(archivePath), expectedEntry.linkTarget),
334
- );
335
- if (
336
- resolved === ".." ||
337
- resolved.startsWith("../") ||
338
- resolved.startsWith("/")
339
- ) {
340
- entry.body.resume();
341
- throw new StreamingValidationError(
342
- "symlink_target_escapes_archive",
343
- `Symlink ${archivePath} target ${JSON.stringify(
344
- expectedEntry.linkTarget,
345
- )} resolves to ${resolved}, which escapes the archive root`,
346
- archivePath,
347
- );
348
- }
349
-
350
- // All checks pass — drain the (empty) body so the tar extractor advances.
351
- entry.body.resume();
352
- }
353
-
354
- // ---------------------------------------------------------------------------
355
- // Per-entry hash + size verifier
356
- // ---------------------------------------------------------------------------
357
-
358
- /**
359
- * Create a passthrough `Transform` that:
360
- * - forwards every chunk unchanged (identity transform for correct input),
361
- * - incrementally SHA-256s the byte stream,
362
- * - on `_flush`, errors the pipeline if the final digest or total byte
363
- * count differs from `expected`.
364
- *
365
- * Errors are emitted as `StreamingValidationError` with `code` set to
366
- * `"entry_hash"` or `"entry_size"` and `archivePath` populated so callers
367
- * can surface which file failed.
368
- *
369
- * Consumers should pipe the entry body through this transform before
370
- * writing to disk — that way a bad payload is caught before the byte
371
- * reaches storage rather than after a whole 8 GB write completes.
372
- */
373
- export function createHashVerifier(expected: {
374
- sha256: string;
375
- size: number;
376
- archivePath: string;
377
- }): Transform {
378
- const hash = createHash("sha256");
379
- let bytes = 0;
380
-
381
- return new Transform({
382
- transform(
383
- chunk: Buffer | string,
384
- encoding: BufferEncoding,
385
- callback: TransformCallback,
386
- ) {
387
- try {
388
- const buf =
389
- typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk;
390
- hash.update(buf);
391
- bytes += buf.length;
392
- callback(null, buf);
393
- } catch (err) {
394
- callback(err instanceof Error ? err : new Error(String(err)));
395
- }
396
- },
397
- flush(callback: TransformCallback) {
398
- // Size check first — a wrong size is a sharper signal than a hash
399
- // collision, and a truncated payload frequently triggers both.
400
- if (bytes !== expected.size) {
401
- callback(
402
- new StreamingValidationError(
403
- "entry_size",
404
- `Size mismatch for ${expected.archivePath}: expected ${expected.size} bytes, got ${bytes}`,
405
- expected.archivePath,
406
- ),
407
- );
408
- return;
409
- }
410
-
411
- const digest = hash.digest("hex");
412
- if (digest !== expected.sha256) {
413
- callback(
414
- new StreamingValidationError(
415
- "entry_hash",
416
- `Checksum mismatch for ${expected.archivePath}: expected ${expected.sha256}, computed ${digest}`,
417
- expected.archivePath,
418
- ),
419
- );
420
- return;
421
- }
422
-
423
- callback();
424
- },
425
- });
426
- }
@@ -1,244 +0,0 @@
1
- /**
2
- * Streaming tar reader for `.vbundle` archives.
3
- *
4
- * A `.vbundle` is a gzip-compressed tar. This module gunzips the incoming
5
- * `Readable`, pipes it through `tar-stream`'s push-style extractor, and
6
- * adapts the `(header, stream, next)` event into a consumer-friendly async
7
- * generator that yields one entry at a time.
8
- *
9
- * Memory invariant: each entry's body is surfaced as a `Readable`, never a
10
- * pre-buffered blob. Callers MUST fully consume (or explicitly `resume()`)
11
- * each body stream before advancing the outer generator — otherwise the
12
- * underlying tar extractor will stall waiting on backpressure.
13
- */
14
-
15
- import type { Readable } from "node:stream";
16
- import { createGunzip } from "node:zlib";
17
-
18
- import { extract as tarExtract } from "tar-stream";
19
-
20
- // ---------------------------------------------------------------------------
21
- // Public types
22
- // ---------------------------------------------------------------------------
23
-
24
- /** Subset of tar-stream's header surface that vbundle consumers care about. */
25
- export interface StreamedTarHeader {
26
- name: string;
27
- size: number;
28
- type: "file" | "directory" | "pax-header" | "symlink" | "other";
29
- /** Populated only when `type === "symlink"`; the symlink's target string from the tar header. */
30
- linkname?: string;
31
- }
32
-
33
- export interface StreamedTarEntry {
34
- header: StreamedTarHeader;
35
- /**
36
- * The entry body. Must be fully consumed (drained via for-await, piped,
37
- * or explicitly `.resume()`'d) before the generator is advanced again.
38
- */
39
- body: Readable;
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
- // Implementation
44
- // ---------------------------------------------------------------------------
45
-
46
- interface PendingEntry {
47
- header: StreamedTarHeader;
48
- body: Readable;
49
- next: (err?: Error | null) => void;
50
- }
51
-
52
- function normalizeHeaderType(
53
- type: string | undefined,
54
- ): StreamedTarHeader["type"] {
55
- switch (type) {
56
- case "file":
57
- return "file";
58
- case "directory":
59
- return "directory";
60
- case "pax-header":
61
- return "pax-header";
62
- case "symlink":
63
- return "symlink";
64
- default:
65
- return "other";
66
- }
67
- }
68
-
69
- /**
70
- * Stream a `.vbundle` archive as an async sequence of tar entries.
71
- *
72
- * Errors from the upstream source, gunzip, or tar-stream extractor are
73
- * surfaced by throwing from the generator.
74
- *
75
- * Early termination (caller `break`s or the for-await loop throws) destroys
76
- * the upstream source and the tar extractor so that sockets and file
77
- * descriptors are released promptly.
78
- */
79
- export async function* parseVBundleStream(
80
- source: Readable,
81
- ): AsyncGenerator<StreamedTarEntry, void, void> {
82
- const gunzip = createGunzip();
83
- const extractor = tarExtract();
84
-
85
- // Terminal error captured from any stage in the pipeline. Consumed by the
86
- // generator's internal pump; re-thrown on the next `yield`.
87
- let pipelineError: Error | null = null;
88
- let finished = false;
89
-
90
- // Single-slot mailbox: the extractor pushes one entry here, and the
91
- // generator consumes it. We gate the push-style API by holding the tar
92
- // extractor's `next` callback until the caller is done with the body.
93
- let waiter: {
94
- resolve: (entry: PendingEntry | null) => void;
95
- reject: (err: Error) => void;
96
- } | null = null;
97
- let pending: PendingEntry | null = null;
98
-
99
- function pushEntry(entry: PendingEntry): void {
100
- if (waiter) {
101
- const w = waiter;
102
- waiter = null;
103
- w.resolve(entry);
104
- } else {
105
- pending = entry;
106
- }
107
- }
108
-
109
- function pushError(err: Error): void {
110
- if (pipelineError) {
111
- return;
112
- }
113
- pipelineError = err;
114
- if (waiter) {
115
- const w = waiter;
116
- waiter = null;
117
- w.reject(err);
118
- }
119
- }
120
-
121
- function pushFinish(): void {
122
- finished = true;
123
- if (waiter) {
124
- const w = waiter;
125
- waiter = null;
126
- w.resolve(null);
127
- }
128
- }
129
-
130
- extractor.on("entry", (header, body, next) => {
131
- // Avoid unhandled "error" on body streams destroyed mid-flight; the
132
- // extractor itself propagates the real error to its "error" listener.
133
- body.on("error", () => {});
134
- const normalizedType = normalizeHeaderType(header.type);
135
- const surfaced: StreamedTarHeader = {
136
- name: header.name,
137
- size: header.size,
138
- type: normalizedType,
139
- };
140
- if (normalizedType === "symlink" && header.linkname) {
141
- surfaced.linkname = header.linkname;
142
- }
143
- pushEntry({
144
- header: surfaced,
145
- body,
146
- next,
147
- });
148
- });
149
-
150
- extractor.on("error", (err: Error) => {
151
- pushError(err);
152
- });
153
-
154
- extractor.on("finish", () => {
155
- pushFinish();
156
- });
157
-
158
- gunzip.on("error", (err: Error) => {
159
- pushError(err);
160
- extractor.destroy(err);
161
- });
162
-
163
- source.on("error", (err: Error) => {
164
- pushError(err);
165
- gunzip.destroy(err);
166
- extractor.destroy(err);
167
- });
168
-
169
- // `pipe()` is a no-op on an already-destroyed Readable, so gunzip and
170
- // extractor would never see `end` and `nextEntry()` would await forever.
171
- // Synthesize a terminal error instead.
172
- if (source.destroyed) {
173
- const err = new Error("vbundle source stream was destroyed before parse");
174
- pushError(err);
175
- gunzip.destroy(err);
176
- extractor.destroy(err);
177
- } else {
178
- source.pipe(gunzip).pipe(extractor);
179
- }
180
-
181
- function nextEntry(): Promise<PendingEntry | null> {
182
- if (pipelineError) {
183
- return Promise.reject(pipelineError);
184
- }
185
- if (pending) {
186
- const p = pending;
187
- pending = null;
188
- return Promise.resolve(p);
189
- }
190
- if (finished) {
191
- return Promise.resolve(null);
192
- }
193
- return new Promise<PendingEntry | null>((resolve, reject) => {
194
- waiter = { resolve, reject };
195
- });
196
- }
197
-
198
- try {
199
- while (true) {
200
- const entry = await nextEntry();
201
- if (entry === null) {
202
- return;
203
- }
204
-
205
- let advanced = false;
206
- const advance = (err?: Error | null): void => {
207
- if (advanced) {
208
- return;
209
- }
210
- advanced = true;
211
- entry.next(err ?? null);
212
- };
213
-
214
- // When the consumer finishes (or abandons) the body, release the tar
215
- // extractor so the next entry can flow.
216
- entry.body.once("end", () => advance());
217
- entry.body.once("close", () => advance());
218
- entry.body.once("error", (err: Error) => advance(err));
219
-
220
- try {
221
- yield { header: entry.header, body: entry.body };
222
- } catch (err) {
223
- // Caller threw (or re-threw) inside the for-await loop. Propagate
224
- // after cleanup in the finally below.
225
- advance(err instanceof Error ? err : new Error(String(err)));
226
- throw err;
227
- }
228
-
229
- // If the consumer neither consumed nor destroyed the body, drain it
230
- // for them so the extractor can advance.
231
- if (!advanced) {
232
- entry.body.resume();
233
- }
234
- }
235
- } finally {
236
- // Early termination (break, throw, or natural completion): tear down the
237
- // pipeline so we don't leak the socket/file descriptor underneath.
238
- if (!finished || pipelineError) {
239
- source.destroy();
240
- gunzip.destroy();
241
- extractor.destroy();
242
- }
243
- }
244
- }