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