@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,473 +0,0 @@
1
- /**
2
- * Tests for the v1-tightened `validateVBundle` schema.
3
- *
4
- * Covers:
5
- * - Accepts a v1 ten-field manifest produced by `buildVBundle`.
6
- * - Rejects every legacy six-field manifest shape.
7
- * - Rejects when `schema_version` is anything other than `1`.
8
- * - Enforces the cloud-mode redaction `.refine()` (`origin.mode === "managed" ⇒ secrets_redacted === true`).
9
- * - Enforces the `data/db/assistant.db`-in-contents `.refine()`.
10
- * - Rejects when the declared `checksum` doesn't match the recomputed self-checksum.
11
- */
12
-
13
- import { createHash } from "node:crypto";
14
- import { gzipSync } from "node:zlib";
15
- import { describe, expect, test } from "bun:test";
16
-
17
- import { buildVBundle } from "../vbundle-builder.js";
18
- import {
19
- canonicalizeJson,
20
- computeLegacyManifestSha256,
21
- computeManifestChecksum,
22
- MANIFEST_MAX_BYTES,
23
- MANIFEST_MAX_ENTRIES,
24
- ManifestSchema,
25
- validateVBundle,
26
- } from "../vbundle-validator.js";
27
- import { buildTestManifest, defaultV1Options } from "./v1-test-helpers.js";
28
-
29
- // ---------------------------------------------------------------------------
30
- // Tar helpers — minimum-viable to wrap a manifest object into a vbundle.
31
- // ---------------------------------------------------------------------------
32
-
33
- const BLOCK = 512;
34
-
35
- function padToBlock(data: Uint8Array): Uint8Array {
36
- const r = data.length % BLOCK;
37
- if (r === 0) {
38
- return data;
39
- }
40
- const out = new Uint8Array(data.length + (BLOCK - r));
41
- out.set(data);
42
- return out;
43
- }
44
-
45
- function writeOctal(
46
- buf: Uint8Array,
47
- offset: number,
48
- length: number,
49
- value: number,
50
- ): void {
51
- const s = value.toString(8).padStart(length - 1, "0");
52
- for (let i = 0; i < s.length; i++) {
53
- buf[offset + i] = s.charCodeAt(i);
54
- }
55
- buf[offset + length - 1] = 0;
56
- }
57
-
58
- function tarEntry(name: string, data: Uint8Array): Uint8Array {
59
- const enc = new TextEncoder();
60
- const header = new Uint8Array(BLOCK);
61
- header.set(enc.encode(name).subarray(0, 100), 0);
62
- writeOctal(header, 100, 8, 0o644);
63
- writeOctal(header, 108, 8, 0);
64
- writeOctal(header, 116, 8, 0);
65
- writeOctal(header, 124, 12, data.length);
66
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
67
- header[156] = "0".charCodeAt(0);
68
- header.set(enc.encode("ustar\0"), 257);
69
- header[263] = "0".charCodeAt(0);
70
- header[264] = "0".charCodeAt(0);
71
- let sum = 0;
72
- for (let i = 0; i < BLOCK; i++) {
73
- sum += i >= 148 && i < 156 ? 0x20 : header[i];
74
- }
75
- writeOctal(header, 148, 7, sum);
76
- header[155] = 0x20;
77
- const padded = padToBlock(data);
78
- const out = new Uint8Array(header.length + padded.length);
79
- out.set(header, 0);
80
- out.set(padded, header.length);
81
- return out;
82
- }
83
-
84
- function tarArchive(
85
- entries: Array<{ name: string; data: Uint8Array }>,
86
- ): Uint8Array {
87
- const parts: Uint8Array[] = entries.map((e) => tarEntry(e.name, e.data));
88
- parts.push(new Uint8Array(BLOCK * 2));
89
- const total = parts.reduce((n, p) => n + p.length, 0);
90
- const out = new Uint8Array(total);
91
- let off = 0;
92
- for (const p of parts) {
93
- out.set(p, off);
94
- off += p.length;
95
- }
96
- return out;
97
- }
98
-
99
- function gzipTarOf(
100
- manifestObj: Record<string, unknown>,
101
- files: Array<{ name: string; data: Uint8Array }> = [],
102
- ): Uint8Array {
103
- const manifestData = new TextEncoder().encode(JSON.stringify(manifestObj));
104
- return gzipSync(
105
- tarArchive([{ name: "manifest.json", data: manifestData }, ...files]),
106
- );
107
- }
108
-
109
- function sha256Hex(data: Uint8Array | string): string {
110
- return createHash("sha256").update(data).digest("hex");
111
- }
112
-
113
- const DB_BYTES = new TextEncoder().encode("db-bytes");
114
-
115
- function v1Skeleton(): Record<string, unknown> {
116
- return {
117
- schema_version: 1,
118
- bundle_id: "00000000-0000-4000-8000-000000000000",
119
- created_at: "2026-04-01T00:00:00Z",
120
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
121
- origin: { mode: "self-hosted-local" },
122
- compatibility: {
123
- min_runtime_version: "0.0.0-test",
124
- max_runtime_version: null,
125
- },
126
- contents: [
127
- {
128
- path: "data/db/assistant.db",
129
- sha256: sha256Hex(DB_BYTES),
130
- size_bytes: DB_BYTES.length,
131
- },
132
- ],
133
- checksum: "",
134
- secrets_redacted: false,
135
- export_options: {
136
- include_logs: false,
137
- include_browser_state: false,
138
- include_memory_vectors: false,
139
- },
140
- };
141
- }
142
-
143
- function withChecksum(
144
- manifest: Record<string, unknown>,
145
- ): Record<string, unknown> {
146
- const checksum = computeManifestChecksum(manifest);
147
- return { ...manifest, checksum };
148
- }
149
-
150
- // ---------------------------------------------------------------------------
151
- // Tests
152
- // ---------------------------------------------------------------------------
153
-
154
- describe("ManifestSchema — v1 acceptance", () => {
155
- test("accepts a freshly-built v1 manifest from buildVBundle", () => {
156
- const { archive } = buildVBundle({
157
- files: [{ path: "data/db/assistant.db", data: DB_BYTES }],
158
- ...defaultV1Options(),
159
- });
160
- const result = validateVBundle(archive);
161
- expect(result.is_valid).toBe(true);
162
- expect(result.manifest?.schema_version).toBe(1);
163
- });
164
-
165
- test("accepts when origin.mode === 'managed' and secrets_redacted === true", () => {
166
- const manifest = withChecksum({
167
- ...v1Skeleton(),
168
- origin: { mode: "managed" },
169
- secrets_redacted: true,
170
- });
171
- const archive = gzipTarOf(manifest, [
172
- { name: "data/db/assistant.db", data: DB_BYTES },
173
- ]);
174
- const result = validateVBundle(archive);
175
- expect(result.is_valid).toBe(true);
176
- });
177
- });
178
-
179
- describe("validateVBundle: streaming manifest limits", () => {
180
- test("rejects a manifest over MANIFEST_MAX_BYTES with MANIFEST_TOO_LARGE", () => {
181
- const oversized = new Uint8Array(MANIFEST_MAX_BYTES + 1).fill(0x20);
182
- const archive = gzipSync(
183
- tarArchive([{ name: "manifest.json", data: oversized }]),
184
- );
185
- const result = validateVBundle(archive, { enforceStreamingLimits: true });
186
- expect(result.is_valid).toBe(false);
187
- expect(result.errors.map((e) => e.code)).toEqual(["MANIFEST_TOO_LARGE"]);
188
- });
189
-
190
- test("rejects a manifest declaring more than MANIFEST_MAX_ENTRIES with MANIFEST_TOO_MANY_ENTRIES", () => {
191
- const skeleton = v1Skeleton();
192
- const dbEntry = (skeleton.contents as Array<Record<string, unknown>>)[0]!;
193
- const extra = Array.from({ length: MANIFEST_MAX_ENTRIES }, (_, i) => ({
194
- ...dbEntry,
195
- path: `workspace/${i}`,
196
- }));
197
- const archive = gzipTarOf(
198
- withChecksum({ ...skeleton, contents: [dbEntry, ...extra] }),
199
- );
200
- const result = validateVBundle(archive, { enforceStreamingLimits: true });
201
- expect(result.is_valid).toBe(false);
202
- expect(result.errors.map((e) => e.code)).toEqual([
203
- "MANIFEST_TOO_MANY_ENTRIES",
204
- ]);
205
- });
206
-
207
- test("does not apply the limits unless enforceStreamingLimits is set", () => {
208
- const skeleton = v1Skeleton();
209
- const dbEntry = (skeleton.contents as Array<Record<string, unknown>>)[0]!;
210
- const extra = Array.from({ length: MANIFEST_MAX_ENTRIES }, (_, i) => ({
211
- ...dbEntry,
212
- path: `workspace/${i}`,
213
- }));
214
- const archive = gzipTarOf(
215
- withChecksum({ ...skeleton, contents: [dbEntry, ...extra] }),
216
- [{ name: "data/db/assistant.db", data: DB_BYTES }],
217
- );
218
- const result = validateVBundle(archive);
219
- expect(result.errors.map((e) => e.code)).not.toContain(
220
- "MANIFEST_TOO_MANY_ENTRIES",
221
- );
222
- });
223
- });
224
-
225
- describe("ManifestSchema — v1 rejection", () => {
226
- test("rejects schema_version !== 1", () => {
227
- const manifest = withChecksum({ ...v1Skeleton(), schema_version: 2 });
228
- const archive = gzipTarOf(manifest, [
229
- { name: "data/db/assistant.db", data: DB_BYTES },
230
- ]);
231
- const result = validateVBundle(archive);
232
- expect(result.is_valid).toBe(false);
233
- });
234
-
235
- test("rejects managed mode with secrets_redacted=false", () => {
236
- const manifest = withChecksum({
237
- ...v1Skeleton(),
238
- origin: { mode: "managed" },
239
- secrets_redacted: false,
240
- });
241
- const archive = gzipTarOf(manifest, [
242
- { name: "data/db/assistant.db", data: DB_BYTES },
243
- ]);
244
- const result = validateVBundle(archive);
245
- expect(result.is_valid).toBe(false);
246
- });
247
-
248
- test("rejects manifest whose contents lacks data/db/assistant.db", () => {
249
- const manifest = withChecksum({
250
- ...v1Skeleton(),
251
- contents: [
252
- {
253
- path: "config/settings.json",
254
- sha256: sha256Hex(new TextEncoder().encode("{}")),
255
- size_bytes: 2,
256
- },
257
- ],
258
- });
259
- const archive = gzipTarOf(manifest);
260
- const result = validateVBundle(archive);
261
- expect(result.is_valid).toBe(false);
262
- });
263
-
264
- test("rejects a checksum that does not match the recomputed self-checksum", () => {
265
- // Build a structurally valid manifest but tamper with `checksum` after
266
- // computing it so the validator's recomputation fails.
267
- const valid = withChecksum(v1Skeleton());
268
- const tampered = {
269
- ...valid,
270
- // Twiddle one byte; still 64 hex chars so the schema accepts it,
271
- // but the recomputation rejects it.
272
- checksum:
273
- (valid.checksum as string).slice(0, -1) +
274
- ((valid.checksum as string).slice(-1) === "0" ? "1" : "0"),
275
- };
276
- const archive = gzipTarOf(tampered, [
277
- { name: "data/db/assistant.db", data: DB_BYTES },
278
- ]);
279
- const result = validateVBundle(archive);
280
- expect(result.is_valid).toBe(false);
281
- expect(
282
- result.errors.some((e) => e.code === "MANIFEST_CHECKSUM_MISMATCH"),
283
- ).toBe(true);
284
- });
285
- });
286
-
287
- // ---------------------------------------------------------------------------
288
- // Legacy six-field manifest fallback — required for backwards compatibility
289
- // with on-disk bundles produced by pre-v1 runtimes (backup snapshots,
290
- // cross-version migration artifacts). AGENTS.md prohibits silent breaks of
291
- // persisted state.
292
- // ---------------------------------------------------------------------------
293
-
294
- function legacySkeleton(
295
- files: Array<{ path: string; sha256: string; size: number }>,
296
- ): Record<string, unknown> {
297
- return {
298
- schema_version: "1.0",
299
- created_at: new Date().toISOString(),
300
- source: "runtime-export",
301
- description: "legacy fixture",
302
- files,
303
- manifest_sha256: "",
304
- };
305
- }
306
-
307
- function withLegacyChecksum(
308
- manifest: Record<string, unknown>,
309
- ): Record<string, unknown> {
310
- // `computeLegacyManifestSha256` strips the field internally before
311
- // canonicalizing, so passing the manifest in unmodified is correct.
312
- const manifest_sha256 = computeLegacyManifestSha256(manifest);
313
- return { ...manifest, manifest_sha256 };
314
- }
315
-
316
- describe("ManifestSchema — legacy fallback (backwards compatibility)", () => {
317
- test("accepts a valid legacy six-field manifest", () => {
318
- const legacy = withLegacyChecksum(
319
- legacySkeleton([
320
- {
321
- path: "data/db/assistant.db",
322
- sha256: sha256Hex(DB_BYTES),
323
- size: DB_BYTES.length,
324
- },
325
- ]),
326
- );
327
- const archive = gzipTarOf(legacy, [
328
- { name: "data/db/assistant.db", data: DB_BYTES },
329
- ]);
330
- const result = validateVBundle(archive);
331
- expect(result.is_valid).toBe(true);
332
- });
333
-
334
- test("rejects a legacy manifest with a wrong manifest_sha256", () => {
335
- const legacy = {
336
- ...legacySkeleton([
337
- {
338
- path: "data/db/assistant.db",
339
- sha256: sha256Hex(DB_BYTES),
340
- size: DB_BYTES.length,
341
- },
342
- ]),
343
- // Deliberately wrong checksum.
344
- manifest_sha256:
345
- "0000000000000000000000000000000000000000000000000000000000000000",
346
- };
347
- const archive = gzipTarOf(legacy, [
348
- { name: "data/db/assistant.db", data: DB_BYTES },
349
- ]);
350
- const result = validateVBundle(archive);
351
- expect(result.is_valid).toBe(false);
352
- expect(
353
- result.errors.some((e) => e.code === "MANIFEST_CHECKSUM_MISMATCH"),
354
- ).toBe(true);
355
- });
356
-
357
- test("translator surfaces a v1 ManifestType to downstream consumers", () => {
358
- const legacy = withLegacyChecksum(
359
- legacySkeleton([
360
- {
361
- path: "data/db/assistant.db",
362
- sha256: sha256Hex(DB_BYTES),
363
- size: DB_BYTES.length,
364
- },
365
- ]),
366
- );
367
- const archive = gzipTarOf(legacy, [
368
- { name: "data/db/assistant.db", data: DB_BYTES },
369
- ]);
370
- const result = validateVBundle(archive);
371
- expect(result.is_valid).toBe(true);
372
- expect(result.manifest).toBeDefined();
373
- // Translation fills in v1 fields and renames legacy fields.
374
- expect(result.manifest?.schema_version).toBe(1);
375
- expect(result.manifest?.contents).toHaveLength(1);
376
- expect(result.manifest?.contents[0]?.path).toBe("data/db/assistant.db");
377
- expect(result.manifest?.contents[0]?.size_bytes).toBe(DB_BYTES.length);
378
- expect(result.manifest?.contents[0]?.sha256).toBe(sha256Hex(DB_BYTES));
379
- // Conservative defaults so the v1 refines never trip.
380
- expect(result.manifest?.origin.mode).toBe("self-hosted-local");
381
- expect(result.manifest?.secrets_redacted).toBe(false);
382
- });
383
-
384
- test("legacy manifest with corrupted file payload still flags FILE_CHECKSUM_MISMATCH", () => {
385
- // Verifies the per-file integrity pipeline runs on translated legacy
386
- // bundles too — translation must preserve `files` → `contents` /
387
- // `size` → `size_bytes` so verification works uniformly.
388
- const legacy = withLegacyChecksum(
389
- legacySkeleton([
390
- {
391
- path: "data/db/assistant.db",
392
- sha256: sha256Hex(DB_BYTES),
393
- size: DB_BYTES.length,
394
- },
395
- ]),
396
- );
397
- const corruptedDb = new TextEncoder().encode("not-the-real-db-bytes");
398
- const archive = gzipTarOf(legacy, [
399
- { name: "data/db/assistant.db", data: corruptedDb },
400
- ]);
401
- const result = validateVBundle(archive);
402
- expect(result.is_valid).toBe(false);
403
- expect(result.errors.some((e) => e.code === "FILE_CHECKSUM_MISMATCH")).toBe(
404
- true,
405
- );
406
- });
407
- });
408
-
409
- describe("ManifestFileEntry — link_target field", () => {
410
- test("accepts manifest entry with link_target", () => {
411
- const manifest = buildTestManifest({
412
- contents: [
413
- {
414
- path: "workspace/data/db/assistant.db",
415
- sha256: sha256Hex(DB_BYTES),
416
- size_bytes: 1,
417
- link_target: "bar.md",
418
- },
419
- ],
420
- });
421
- const result = ManifestSchema.safeParse(manifest);
422
- expect(result.success).toBe(true);
423
- if (result.success) {
424
- expect(result.data.contents[0]?.link_target).toBe("bar.md");
425
- }
426
- });
427
-
428
- test("accepts manifest entry without link_target (backwards-compat)", () => {
429
- const manifest = buildTestManifest({
430
- contents: [
431
- {
432
- path: "workspace/data/db/assistant.db",
433
- sha256: sha256Hex(DB_BYTES),
434
- size_bytes: 1,
435
- },
436
- ],
437
- });
438
- const result = ManifestSchema.safeParse(manifest);
439
- expect(result.success).toBe(true);
440
- });
441
-
442
- test("rejects manifest entry with empty link_target", () => {
443
- const manifest = buildTestManifest({
444
- contents: [
445
- {
446
- path: "workspace/data/db/assistant.db",
447
- sha256: sha256Hex(DB_BYTES),
448
- size_bytes: 1,
449
- link_target: "",
450
- },
451
- ],
452
- });
453
- const result = ManifestSchema.safeParse(manifest);
454
- expect(result.success).toBe(false);
455
- });
456
- });
457
-
458
- // Exercise canonicalizeJson + computeManifestChecksum themselves to keep the
459
- // helpers from rotting if signatures change.
460
- describe("computeManifestChecksum helper", () => {
461
- test("ignores the existing checksum value when computing the canonical form", () => {
462
- const a = withChecksum(v1Skeleton());
463
- const b = withChecksum({ ...v1Skeleton(), checksum: "deadbeef" });
464
- // Both inputs canonicalize to the same form (checksum field replaced by
465
- // empty string), so both produce the same checksum.
466
- expect(a.checksum).toBe(b.checksum);
467
- // Sanity: the same canonicalization explanation as in the validator.
468
- const expected = sha256Hex(
469
- canonicalizeJson({ ...v1Skeleton(), checksum: "" }),
470
- );
471
- expect(a.checksum).toBe(expected);
472
- });
473
- });
@@ -1,190 +0,0 @@
1
- /**
2
- * Validator for Google Cloud Storage signed URLs.
3
- *
4
- * Used by the migrations import path to confirm that a URL the daemon is
5
- * about to `fetch()` is, in fact, a signed GCS object URL (and not an
6
- * attacker-chosen host, scheme, or path).
7
- *
8
- * This is a pure function — no I/O, no side effects. The caller decides
9
- * what to do with the validation result.
10
- *
11
- * What we check:
12
- * - The string parses as a URL.
13
- * - The scheme is `https:` (no `http:`, `file:`, `data:`, etc.).
14
- * - The hostname is exactly `storage.googleapis.com`.
15
- * - No explicit port is present (the default HTTPS port is required;
16
- * WHATWG URL normalizes `:443` to an empty port string for HTTPS).
17
- * - The URL carries a signature query param — `X-Goog-Signature` (GCS
18
- * V4) or `Signature` (GCS V2); for hosts on a non-default allowlist
19
- * (local/minikube SeaweedFS) SigV4's `X-Amz-Signature` is also
20
- * accepted. A URL with no signature param is not a presigned URL and
21
- * is refused.
22
- * - The pathname does not contain `..` segments (traversal guard).
23
- *
24
- * On success we return the hostname and pathname for logging/telemetry.
25
- * We deliberately do NOT return the full URL or the query string,
26
- * because the signature is sensitive and should not end up in logs.
27
- */
28
-
29
- export type GcsUrlValidation =
30
- | { ok: true; host: string; path: string }
31
- | { ok: false; reason: string };
32
-
33
- const EXPECTED_HOST = "storage.googleapis.com";
34
- const DEFAULT_ALLOWED_HOSTS: readonly string[] = [EXPECTED_HOST];
35
-
36
- export interface ValidateGcsSignedUrlOptions {
37
- /**
38
- * Allowlisted hosts. Defaults to `["storage.googleapis.com"]` — the only
39
- * production value. A non-default list (local/minikube via
40
- * `CADET_MIGRATION_IMPORT_ALLOWED_HOSTS`, or tests pointing at a local
41
- * HTTP fixture) relaxes the `scheme` check to accept `http:`, skips the
42
- * explicit-port check, and skips the signature-param requirement for the
43
- * listed hosts. Production code MUST NOT pass a wider list.
44
- */
45
- allowedHosts?: readonly string[];
46
- }
47
-
48
- export function validateGcsSignedUrl(
49
- raw: string,
50
- options?: ValidateGcsSignedUrlOptions,
51
- ): GcsUrlValidation {
52
- let parsed: URL;
53
- try {
54
- parsed = new URL(raw);
55
- } catch {
56
- return { ok: false, reason: "invalid_url" };
57
- }
58
-
59
- const allowedHosts = options?.allowedHosts ?? DEFAULT_ALLOWED_HOSTS;
60
- const isDefaultHostList =
61
- allowedHosts.length === 1 && allowedHosts[0] === EXPECTED_HOST;
62
-
63
- // When the allowlist is the production default (GCS only), require HTTPS.
64
- // When a caller passes a non-default allowlist (tests pointing at a local
65
- // HTTP server), allow http too — same-process tests cannot reasonably
66
- // stand up HTTPS. We still refuse non-http(s) schemes (file:, data:, etc).
67
- if (isDefaultHostList) {
68
- if (parsed.protocol !== "https:") {
69
- return { ok: false, reason: "scheme" };
70
- }
71
- } else {
72
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
73
- return { ok: false, reason: "scheme" };
74
- }
75
- }
76
-
77
- if (!allowedHosts.includes(parsed.hostname)) {
78
- return { ok: false, reason: "host" };
79
- }
80
-
81
- // Reject any explicit port. GCS signed URLs use the default HTTPS
82
- // port (443), and WHATWG URL normalizes `:443` to an empty port
83
- // string for HTTPS — so a correctly-issued signed URL always has
84
- // `parsed.port === ""`. A non-empty port indicates an attacker is
85
- // trying to redirect the fetch to a non-default port (SSRF vector).
86
- //
87
- // For the non-default (test) allowlist, an explicit port is expected
88
- // (local servers bind to arbitrary ephemeral ports), so the check is
89
- // skipped in that case.
90
- if (isDefaultHostList && parsed.port !== "") {
91
- return { ok: false, reason: "port" };
92
- }
93
-
94
- // Every URL must carry a signature param — possession of a complete
95
- // presigned URL is the capability, and this shape check keeps unsigned
96
- // object paths from being fetched/PUT even on allowlisted hosts. GCS
97
- // signs with X-Goog-Signature (V4) or Signature (V2); S3-compatible
98
- // backends behind a non-default allowlist (local/minikube SeaweedFS)
99
- // sign with X-Amz-Signature (SigV4), which is only accepted for
100
- // explicitly allowlisted hosts. The storage backend itself verifies the
101
- // signature; this validator only checks the shape.
102
- const hasV4 = parsed.searchParams.has("X-Goog-Signature");
103
- const hasV2 = parsed.searchParams.has("Signature");
104
- const hasSigV4 =
105
- !isDefaultHostList && parsed.searchParams.has("X-Amz-Signature");
106
- if (!hasV4 && !hasV2 && !hasSigV4) {
107
- return { ok: false, reason: "missing_signature" };
108
- }
109
-
110
- // Defense-in-depth: reject any `..` path segment. The WHATWG URL
111
- // parser silently normalizes `/bucket/../foo` to `/foo`, which would
112
- // hide a traversal attempt — so inspect the *raw* input before
113
- // normalization in addition to the parsed pathname.
114
- if (hasTraversalSegment(parsed.pathname) || hasTraversalInRawPath(raw)) {
115
- return { ok: false, reason: "path_traversal" };
116
- }
117
-
118
- return { ok: true, host: parsed.hostname, path: parsed.pathname };
119
- }
120
-
121
- function hasTraversalSegment(pathname: string): boolean {
122
- for (const segment of pathname.split("/")) {
123
- if (segment === "..") {
124
- return true;
125
- }
126
- }
127
- return false;
128
- }
129
-
130
- /**
131
- * Look for `..` path segments in the raw input before URL normalization.
132
- * We slice off the scheme+authority and stop at the first `?` or `#`,
133
- * then examine each segment split on both `/` and `\` (the WHATWG URL
134
- * parser treats `\` as equivalent to `/` for special schemes like
135
- * `https:`, so a backslash-delimited traversal would be normalized
136
- * away by `new URL()`).
137
- *
138
- * Inside each segment we also handle percent-encoded separators
139
- * (`%2F` = `/`, `%5C` = `\`): we pre-normalize those before the
140
- * top-level split, and we also re-split each decoded segment on `/`
141
- * and `\` to catch cases like `%2e%2e%2fother` where the encoded
142
- * slash hides a traversal within a single raw segment.
143
- */
144
- function hasTraversalInRawPath(raw: string): boolean {
145
- const schemeEnd = raw.indexOf("://");
146
- if (schemeEnd === -1) {
147
- return false;
148
- }
149
- const afterScheme = raw.slice(schemeEnd + 3);
150
- const pathStart = afterScheme.search(/[\/\\]/);
151
- if (pathStart === -1) {
152
- return false;
153
- }
154
- let path = afterScheme.slice(pathStart);
155
- const queryIdx = path.indexOf("?");
156
- if (queryIdx !== -1) {
157
- path = path.slice(0, queryIdx);
158
- }
159
- const hashIdx = path.indexOf("#");
160
- if (hashIdx !== -1) {
161
- path = path.slice(0, hashIdx);
162
- }
163
-
164
- // Normalize percent-encoded forms of `/` and `\` so that encoded
165
- // separators participate in the segment split.
166
- const normalized = path.replace(/%2[fF]/g, "/").replace(/%5[cC]/g, "\\");
167
-
168
- for (const segment of normalized.split(/[\/\\]/)) {
169
- if (segment === "..") {
170
- return true;
171
- }
172
- // Percent-decoded forms of ".." — e.g. "%2E%2E", ".%2e", "%2e.".
173
- // Also re-split on `/` and `\` to catch encoded separators that
174
- // weren't covered by the top-level normalization (e.g. a decoded
175
- // segment `../other`).
176
- let decoded: string;
177
- try {
178
- decoded = decodeURIComponent(segment);
179
- } catch {
180
- // Ignore malformed percent-encoding; URL parser would handle it.
181
- continue;
182
- }
183
- for (const sub of decoded.split(/[\/\\]/)) {
184
- if (sub === "..") {
185
- return true;
186
- }
187
- }
188
- }
189
- return false;
190
- }