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