@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,1176 +0,0 @@
1
- /**
2
- * HTTP-layer integration tests for POST /v1/migrations/import.
3
- *
4
- * Tests cover:
5
- * - Success: valid .vbundle writes files to disk and returns import report
6
- * - Validation failures: invalid bundle returns success: false with errors
7
- * - Request errors: empty body, invalid multipart
8
- * - Backup creation: existing files are backed up before overwriting
9
- * - Post-write integrity: written files match expected checksums
10
- * - Skipped files: unknown archive paths are reported as skipped
11
- * - Auth: route policy enforcement (settings.write scope required)
12
- * - Integration: existing routes are unaffected by the new endpoint
13
- */
14
- import { createHash, randomUUID } from "node:crypto";
15
- import {
16
- existsSync,
17
- mkdirSync,
18
- readdirSync,
19
- readFileSync,
20
- rmSync,
21
- unlinkSync,
22
- writeFileSync,
23
- } from "node:fs";
24
- import { join } from "node:path";
25
- import { gzipSync } from "node:zlib";
26
- import {
27
- afterEach,
28
- beforeAll,
29
- beforeEach,
30
- describe,
31
- expect,
32
- test,
33
- } from "bun:test";
34
-
35
- /** Convert a Uint8Array to an ArrayBuffer for BodyInit compatibility. */
36
- function toArrayBuffer(data: Uint8Array): ArrayBuffer {
37
- return data.buffer.slice(
38
- data.byteOffset,
39
- data.byteOffset + data.byteLength,
40
- ) as ArrayBuffer;
41
- }
42
-
43
- const testDir = process.env.CADET_WORKSPACE_DIR!;
44
- const testDbDir = join(testDir, "data", "db");
45
- const testDbPath = join(testDbDir, "assistant.db");
46
- const testConfigPath = join(testDir, "config.json");
47
-
48
- process.env.DISABLE_HTTP_AUTH = "true";
49
-
50
- import { DefaultPathResolver } from "../runtime/migrations/vbundle-import-analyzer.js";
51
- import { commitImport } from "../runtime/migrations/vbundle-importer.js";
52
- import { handleMigrationImport } from "../runtime/routes/migration-routes.js";
53
- import { callHandler } from "./helpers/call-route-handler.js";
54
-
55
- // Test fixture data
56
- const EXISTING_DB_DATA = new Uint8Array([
57
- 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74,
58
- 0x20, 0x33, 0x00,
59
- ]);
60
- const EXISTING_CONFIG = { provider: "anthropic", model: "test-model" };
61
-
62
- beforeAll(() => {
63
- mkdirSync(testDbDir, { recursive: true });
64
- writeFileSync(testDbPath, EXISTING_DB_DATA);
65
- writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2));
66
- });
67
-
68
- // Restore test files before each test so mutations from previous tests
69
- // do not leak across test cases.
70
- beforeEach(() => {
71
- mkdirSync(testDbDir, { recursive: true });
72
- writeFileSync(testDbPath, EXISTING_DB_DATA);
73
- writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2));
74
- });
75
-
76
- // Clean up backup files after each test
77
- afterEach(() => {
78
- try {
79
- const entries = readdirSync(testDbDir);
80
- for (const entry of entries) {
81
- if (entry.includes(".backup-")) {
82
- unlinkSync(join(testDbDir, entry));
83
- }
84
- }
85
- const parentEntries = readdirSync(testDir);
86
- for (const entry of parentEntries) {
87
- if (entry.includes(".backup-")) {
88
- unlinkSync(join(testDir, entry));
89
- }
90
- }
91
- } catch {
92
- /* best effort */
93
- }
94
- });
95
-
96
- // ---------------------------------------------------------------------------
97
- // Tar archive builder helpers (mirrors validate/preflight tests)
98
- // ---------------------------------------------------------------------------
99
-
100
- const BLOCK_SIZE = 512;
101
-
102
- function padToBlock(data: Uint8Array): Uint8Array {
103
- const remainder = data.length % BLOCK_SIZE;
104
- if (remainder === 0) {
105
- return data;
106
- }
107
- const padded = new Uint8Array(data.length + (BLOCK_SIZE - remainder));
108
- padded.set(data);
109
- return padded;
110
- }
111
-
112
- function writeOctal(
113
- buf: Uint8Array,
114
- offset: number,
115
- length: number,
116
- value: number,
117
- ): void {
118
- const str = value.toString(8).padStart(length - 1, "0");
119
- for (let i = 0; i < str.length; i++) {
120
- buf[offset + i] = str.charCodeAt(i);
121
- }
122
- buf[offset + length - 1] = 0;
123
- }
124
-
125
- function computeHeaderChecksum(header: Uint8Array): number {
126
- let sum = 0;
127
- for (let i = 0; i < 512; i++) {
128
- if (i >= 148 && i < 156) {
129
- sum += 0x20;
130
- } else {
131
- sum += header[i];
132
- }
133
- }
134
- return sum;
135
- }
136
-
137
- function createTarEntry(name: string, data: Uint8Array): Uint8Array {
138
- const header = new Uint8Array(BLOCK_SIZE);
139
- const encoder = new TextEncoder();
140
-
141
- const nameBytes = encoder.encode(name);
142
- header.set(nameBytes.subarray(0, 100), 0);
143
-
144
- writeOctal(header, 100, 8, 0o644);
145
- writeOctal(header, 108, 8, 0);
146
- writeOctal(header, 116, 8, 0);
147
- writeOctal(header, 124, 12, data.length);
148
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
149
- header[156] = "0".charCodeAt(0);
150
-
151
- const magic = encoder.encode("ustar\0");
152
- header.set(magic, 257);
153
- header[263] = "0".charCodeAt(0);
154
- header[264] = "0".charCodeAt(0);
155
-
156
- const checksum = computeHeaderChecksum(header);
157
- writeOctal(header, 148, 7, checksum);
158
- header[155] = 0x20;
159
-
160
- const paddedData = padToBlock(data);
161
- const result = new Uint8Array(header.length + paddedData.length);
162
- result.set(header, 0);
163
- result.set(paddedData, header.length);
164
- return result;
165
- }
166
-
167
- function createTarArchive(
168
- entries: Array<{ name: string; data: Uint8Array }>,
169
- ): Uint8Array {
170
- const parts: Uint8Array[] = [];
171
- for (const entry of entries) {
172
- parts.push(createTarEntry(entry.name, entry.data));
173
- }
174
- parts.push(new Uint8Array(BLOCK_SIZE * 2));
175
-
176
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
177
- const result = new Uint8Array(totalLength);
178
- let offset = 0;
179
- for (const part of parts) {
180
- result.set(part, offset);
181
- offset += part.length;
182
- }
183
- return result;
184
- }
185
-
186
- function sha256Hex(data: Uint8Array | string): string {
187
- return createHash("sha256").update(data).digest("hex");
188
- }
189
-
190
- function canonicalizeJson(obj: unknown): string {
191
- return JSON.stringify(obj, (_key, value) => {
192
- if (value && typeof value === "object" && !Array.isArray(value)) {
193
- const sorted: Record<string, unknown> = {};
194
- for (const k of Object.keys(value as Record<string, unknown>).sort()) {
195
- sorted[k] = (value as Record<string, unknown>)[k];
196
- }
197
- return sorted;
198
- }
199
- return value;
200
- });
201
- }
202
-
203
- interface VBundleFile {
204
- path: string;
205
- data: Uint8Array;
206
- }
207
-
208
- /**
209
- * Build a v1-shape vbundle archive for HTTP/import tests.
210
- *
211
- * Mirrors the v1 ten-field manifest that `buildVBundle()` produces, so the
212
- * fixtures here pass the same `ManifestSchema` zod validation the real
213
- * importer applies. The manifest's `checksum` is computed against the
214
- * canonical JSON with `checksum` set to "" (matches `computeManifestChecksum`
215
- * in vbundle-validator.ts).
216
- *
217
- * Adds a synthetic `data/db/assistant.db` entry to `contents` when the caller
218
- * doesn't supply one, satisfying the schema's `.refine()` constraint that
219
- * every bundle must reference an assistant.db file.
220
- */
221
- function createValidVBundle(
222
- files?: VBundleFile[],
223
- overrides?: Partial<{
224
- bundle_id: string;
225
- origin_mode: "managed" | "self-hosted-remote" | "self-hosted-local";
226
- secrets_redacted: boolean;
227
- min_runtime_version: string;
228
- max_runtime_version: string | null;
229
- }>,
230
- ): Uint8Array {
231
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
232
- const bundleFiles = files ?? [{ path: "data/db/assistant.db", data: dbData }];
233
-
234
- // v1 schema requires contents to include data/db/assistant.db (legacy) or
235
- // workspace/data/db/assistant.db (current). If the caller's files don't
236
- // satisfy that refine, inject a synthetic legacy-path db entry — this
237
- // matches the pattern used in vbundle-streaming-importer.test.ts.
238
- const hasDbEntry = bundleFiles.some(
239
- (f) =>
240
- f.path === "data/db/assistant.db" ||
241
- f.path === "workspace/data/db/assistant.db",
242
- );
243
- const allFiles: VBundleFile[] = hasDbEntry
244
- ? bundleFiles
245
- : [
246
- { path: "data/db/assistant.db", data: new Uint8Array() },
247
- ...bundleFiles,
248
- ];
249
-
250
- const contents = allFiles.map((f) => ({
251
- path: f.path,
252
- sha256: sha256Hex(f.data),
253
- size_bytes: f.data.length,
254
- }));
255
-
256
- const manifestWithoutChecksum = {
257
- schema_version: 1 as const,
258
- bundle_id: overrides?.bundle_id ?? randomUUID(),
259
- created_at: new Date().toISOString(),
260
- assistant: {
261
- id: "self",
262
- name: "Test",
263
- runtime_version: "0.0.0-test",
264
- },
265
- origin: {
266
- mode: overrides?.origin_mode ?? "self-hosted-local",
267
- },
268
- compatibility: {
269
- min_runtime_version: overrides?.min_runtime_version ?? "0.0.0-test",
270
- max_runtime_version:
271
- overrides?.max_runtime_version === undefined
272
- ? null
273
- : overrides.max_runtime_version,
274
- },
275
- contents,
276
- checksum: "",
277
- secrets_redacted: overrides?.secrets_redacted ?? false,
278
- export_options: {
279
- include_logs: false,
280
- include_browser_state: false,
281
- include_memory_vectors: false,
282
- },
283
- };
284
-
285
- const checksum = sha256Hex(canonicalizeJson(manifestWithoutChecksum));
286
- const manifest = { ...manifestWithoutChecksum, checksum };
287
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
288
-
289
- const tarEntries = [
290
- { name: "manifest.json", data: manifestData },
291
- ...allFiles.map((f) => ({ name: f.path, data: f.data })),
292
- ];
293
-
294
- const tar = createTarArchive(tarEntries);
295
- return gzipSync(tar);
296
- }
297
-
298
- // ---------------------------------------------------------------------------
299
- // Import commit report types (for type-safe assertions)
300
- // ---------------------------------------------------------------------------
301
-
302
- interface ImportCommitResponse {
303
- success: boolean;
304
- summary: {
305
- total_files: number;
306
- files_created: number;
307
- files_overwritten: number;
308
- files_skipped: number;
309
- backups_created: number;
310
- };
311
- files: Array<{
312
- path: string;
313
- disk_path: string;
314
- action: string;
315
- size: number;
316
- sha256: string;
317
- backup_path: string | null;
318
- }>;
319
- manifest: Record<string, unknown>;
320
- warnings: string[];
321
- }
322
-
323
- interface ImportValidationFailureResponse {
324
- success: boolean;
325
- reason: string;
326
- errors: Array<{ code: string; message: string; path?: string }>;
327
- }
328
-
329
- // ---------------------------------------------------------------------------
330
- // HTTP handler tests: success cases
331
- // ---------------------------------------------------------------------------
332
-
333
- describe("handleMigrationImport", () => {
334
- test("POST with valid vbundle returns 200 with import report", async () => {
335
- const newDbData = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
336
- const vbundle = createValidVBundle([
337
- { path: "data/db/assistant.db", data: newDbData },
338
- ]);
339
- const req = new Request("http://localhost/v1/migrations/import", {
340
- method: "POST",
341
- headers: { "Content-Type": "application/octet-stream" },
342
- body: toArrayBuffer(vbundle),
343
- });
344
-
345
- const res = await callHandler(handleMigrationImport, req);
346
- const body = (await res.json()) as ImportCommitResponse;
347
-
348
- expect(res.status).toBe(200);
349
- expect(body.success).toBe(true);
350
- expect(body.summary).toBeDefined();
351
- expect(body.summary.total_files).toBeGreaterThan(0);
352
- expect(body.files).toBeDefined();
353
- expect(body.files.length).toBeGreaterThan(0);
354
- expect(body.manifest).toBeDefined();
355
- });
356
-
357
- test("writes bundle data to disk", async () => {
358
- const newDbData = new Uint8Array([0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad]);
359
- const vbundle = createValidVBundle([
360
- { path: "data/db/assistant.db", data: newDbData },
361
- ]);
362
- const req = new Request("http://localhost/v1/migrations/import", {
363
- method: "POST",
364
- headers: { "Content-Type": "application/octet-stream" },
365
- body: toArrayBuffer(vbundle),
366
- });
367
-
368
- await callHandler(handleMigrationImport, req);
369
-
370
- // Verify the file was actually written to disk
371
- const writtenData = new Uint8Array(readFileSync(testDbPath));
372
- expect(writtenData).toEqual(newDbData);
373
- });
374
-
375
- test("workspace is cleared before restore — preserved dirs are overwritten", async () => {
376
- // New-format bundles (workspace/ prefix) trigger workspace clearing, but
377
- // data/db/ is preserved during clearing to avoid destroying the database
378
- // if the import fails partway. The DB file is overwritten (with backup).
379
- const newDbData = new Uint8Array([0x01, 0x02, 0x03]);
380
- const vbundle = createValidVBundle([
381
- { path: "workspace/data/db/assistant.db", data: newDbData },
382
- ]);
383
- const req = new Request("http://localhost/v1/migrations/import", {
384
- method: "POST",
385
- headers: { "Content-Type": "application/octet-stream" },
386
- body: toArrayBuffer(vbundle),
387
- });
388
-
389
- const res = await callHandler(handleMigrationImport, req);
390
- const body = (await res.json()) as ImportCommitResponse;
391
-
392
- expect(body.success).toBe(true);
393
- expect(body.summary.backups_created).toBe(1);
394
-
395
- const dbFile = body.files.find(
396
- (f) => f.path === "workspace/data/db/assistant.db",
397
- );
398
- expect(dbFile).toBeDefined();
399
- expect(dbFile!.action).toBe("overwritten");
400
- });
401
-
402
- test("workspace clear: preserved dirs overwritten, cleared dirs created", async () => {
403
- // data/db/ is preserved during workspace clearing → DB is "overwritten".
404
- // config.json lives at the workspace root which IS cleared → "created".
405
- const newDbData = new Uint8Array([0xaa, 0xbb]);
406
- const newConfigData = new TextEncoder().encode('{"provider":"openai"}');
407
- const vbundle = createValidVBundle([
408
- { path: "workspace/data/db/assistant.db", data: newDbData },
409
- { path: "workspace/config.json", data: newConfigData },
410
- ]);
411
- const req = new Request("http://localhost/v1/migrations/import", {
412
- method: "POST",
413
- headers: { "Content-Type": "application/octet-stream" },
414
- body: toArrayBuffer(vbundle),
415
- });
416
-
417
- const res = await callHandler(handleMigrationImport, req);
418
- const body = (await res.json()) as ImportCommitResponse;
419
-
420
- expect(body.success).toBe(true);
421
-
422
- const dbFile = body.files.find(
423
- (f) => f.path === "workspace/data/db/assistant.db",
424
- );
425
- const configFile = body.files.find(
426
- (f) => f.path === "workspace/config.json",
427
- );
428
-
429
- // data/db/ preserved during clearing → overwritten; config.json cleared → created
430
- expect(dbFile!.action).toBe("overwritten");
431
- expect(configFile!.action).toBe("created");
432
- });
433
-
434
- test("summary counts match file details", async () => {
435
- const newDbData = new Uint8Array([0xdd, 0xee, 0xff]);
436
- const newConfigData = new TextEncoder().encode('{"provider":"test"}');
437
- const vbundle = createValidVBundle([
438
- { path: "data/db/assistant.db", data: newDbData },
439
- { path: "config/settings.json", data: newConfigData },
440
- ]);
441
- const req = new Request("http://localhost/v1/migrations/import", {
442
- method: "POST",
443
- headers: { "Content-Type": "application/octet-stream" },
444
- body: toArrayBuffer(vbundle),
445
- });
446
-
447
- const res = await callHandler(handleMigrationImport, req);
448
- const body = (await res.json()) as ImportCommitResponse;
449
-
450
- expect(body.success).toBe(true);
451
- expect(body.summary.total_files).toBe(body.files.length);
452
-
453
- const created = body.files.filter((f) => f.action === "created").length;
454
- const overwritten = body.files.filter(
455
- (f) => f.action === "overwritten",
456
- ).length;
457
- const skipped = body.files.filter((f) => f.action === "skipped").length;
458
-
459
- expect(body.summary.files_created).toBe(created);
460
- expect(body.summary.files_overwritten).toBe(overwritten);
461
- expect(body.summary.files_skipped).toBe(skipped);
462
- });
463
-
464
- test("includes manifest in response", async () => {
465
- const fixedBundleId = "11111111-2222-4333-8444-555555555555";
466
- const vbundle = createValidVBundle(undefined, {
467
- bundle_id: fixedBundleId,
468
- });
469
- const req = new Request("http://localhost/v1/migrations/import", {
470
- method: "POST",
471
- headers: { "Content-Type": "application/octet-stream" },
472
- body: toArrayBuffer(vbundle),
473
- });
474
-
475
- const res = await callHandler(handleMigrationImport, req);
476
- const body = (await res.json()) as ImportCommitResponse;
477
-
478
- expect(body.manifest).toBeDefined();
479
- expect(body.manifest.schema_version).toBe(1);
480
- expect(body.manifest.bundle_id).toBe(fixedBundleId);
481
- expect(body.manifest.assistant).toBeDefined();
482
- expect(body.manifest.origin).toEqual({ mode: "self-hosted-local" });
483
- });
484
-
485
- test("POST with multipart form data works", async () => {
486
- const vbundle = createValidVBundle();
487
- const formData = new FormData();
488
- formData.append("file", new Blob([toArrayBuffer(vbundle)]), "test.vbundle");
489
-
490
- const req = new Request("http://localhost/v1/migrations/import", {
491
- method: "POST",
492
- body: formData,
493
- });
494
-
495
- const res = await callHandler(handleMigrationImport, req);
496
- const body = (await res.json()) as ImportCommitResponse;
497
-
498
- expect(res.status).toBe(200);
499
- expect(body.success).toBe(true);
500
- });
501
-
502
- test("skips files with unknown archive paths", async () => {
503
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
504
- const extraData = new Uint8Array([0x01, 0x02]);
505
- const vbundle = createValidVBundle([
506
- { path: "data/db/assistant.db", data: dbData },
507
- { path: "unknown/extra-file.bin", data: extraData },
508
- ]);
509
- const req = new Request("http://localhost/v1/migrations/import", {
510
- method: "POST",
511
- headers: { "Content-Type": "application/octet-stream" },
512
- body: toArrayBuffer(vbundle),
513
- });
514
-
515
- const res = await callHandler(handleMigrationImport, req);
516
- const body = (await res.json()) as ImportCommitResponse;
517
-
518
- expect(body.success).toBe(true);
519
- expect(body.summary.files_skipped).toBe(1);
520
-
521
- const skippedFile = body.files.find(
522
- (f) => f.path === "unknown/extra-file.bin",
523
- );
524
- expect(skippedFile).toBeDefined();
525
- expect(skippedFile!.action).toBe("skipped");
526
- expect(body.warnings.length).toBeGreaterThan(0);
527
- });
528
- });
529
-
530
- // ---------------------------------------------------------------------------
531
- // HTTP handler tests: validation failure cases
532
- // ---------------------------------------------------------------------------
533
-
534
- describe("handleMigrationImport — validation failures", () => {
535
- test("invalid gzip returns success: false with validation errors", async () => {
536
- const req = new Request("http://localhost/v1/migrations/import", {
537
- method: "POST",
538
- headers: { "Content-Type": "application/octet-stream" },
539
- body: toArrayBuffer(new Uint8Array([0xde, 0xad, 0xbe, 0xef])),
540
- });
541
-
542
- const res = await callHandler(handleMigrationImport, req);
543
- const body = (await res.json()) as ImportValidationFailureResponse;
544
-
545
- expect(res.status).toBe(200);
546
- expect(body.success).toBe(false);
547
- expect(body.reason).toBe("validation_failed");
548
- expect(body.errors.length).toBeGreaterThan(0);
549
- expect(body.errors[0].code).toBe("INVALID_GZIP");
550
- });
551
-
552
- test("missing manifest returns success: false", async () => {
553
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
554
- const tar = createTarArchive([
555
- { name: "data/db/assistant.db", data: dbData },
556
- ]);
557
- const vbundle = gzipSync(tar);
558
-
559
- const req = new Request("http://localhost/v1/migrations/import", {
560
- method: "POST",
561
- headers: { "Content-Type": "application/octet-stream" },
562
- body: toArrayBuffer(vbundle),
563
- });
564
-
565
- const res = await callHandler(handleMigrationImport, req);
566
- const body = (await res.json()) as ImportValidationFailureResponse;
567
-
568
- expect(res.status).toBe(200);
569
- expect(body.success).toBe(false);
570
- expect(body.reason).toBe("validation_failed");
571
- });
572
-
573
- test("empty body returns 400", async () => {
574
- const req = new Request("http://localhost/v1/migrations/import", {
575
- method: "POST",
576
- headers: { "Content-Type": "application/octet-stream" },
577
- body: toArrayBuffer(new Uint8Array(0)),
578
- });
579
-
580
- const res = await callHandler(handleMigrationImport, req);
581
- const body = (await res.json()) as {
582
- error: { code: string; message: string };
583
- };
584
-
585
- expect(res.status).toBe(400);
586
- expect(body.error.code).toBe("BAD_REQUEST");
587
- });
588
-
589
- test("multipart without file field returns 400", async () => {
590
- const formData = new FormData();
591
- formData.append("notfile", "some text");
592
-
593
- const req = new Request("http://localhost/v1/migrations/import", {
594
- method: "POST",
595
- body: formData,
596
- });
597
-
598
- const res = await callHandler(handleMigrationImport, req);
599
- const body = (await res.json()) as { error: { code: string } };
600
-
601
- expect(res.status).toBe(400);
602
- expect(body.error.code).toBe("BAD_REQUEST");
603
- });
604
-
605
- test("does not modify disk when validation fails", async () => {
606
- // Save original data
607
- const originalDb = new Uint8Array(readFileSync(testDbPath));
608
- const originalConfig = readFileSync(testConfigPath, "utf8");
609
-
610
- // Send invalid bundle
611
- const req = new Request("http://localhost/v1/migrations/import", {
612
- method: "POST",
613
- headers: { "Content-Type": "application/octet-stream" },
614
- body: toArrayBuffer(new Uint8Array([0xde, 0xad, 0xbe, 0xef])),
615
- });
616
-
617
- await callHandler(handleMigrationImport, req);
618
-
619
- // Verify disk was not modified
620
- const currentDb = new Uint8Array(readFileSync(testDbPath));
621
- const currentConfig = readFileSync(testConfigPath, "utf8");
622
-
623
- expect(currentDb).toEqual(originalDb);
624
- expect(currentConfig).toBe(originalConfig);
625
- });
626
- });
627
-
628
- // ---------------------------------------------------------------------------
629
- // HTTP handler tests: version_incompatible (Codex P2 regression)
630
- //
631
- // Pin: a bundle whose min_runtime_version exceeds APP_VERSION must surface as
632
- // a 4xx user-actionable response (422 Unprocessable Entity), not a 500
633
- // InternalError. Body mirrors the cloud's PR #5470 response shape so
634
- // clients can render the same UX regardless of which gate (platform or
635
- // runtime) rejected the bundle.
636
- // ---------------------------------------------------------------------------
637
-
638
- describe("handleMigrationImport — version_incompatible", () => {
639
- test("incompatible bundle returns 422 with structured body, not 500", async () => {
640
- const vbundle = createValidVBundle(undefined, {
641
- min_runtime_version: "99.0.0",
642
- });
643
- const req = new Request("http://localhost/v1/migrations/import", {
644
- method: "POST",
645
- headers: { "Content-Type": "application/octet-stream" },
646
- body: toArrayBuffer(vbundle),
647
- });
648
-
649
- const res = await callHandler(handleMigrationImport, req);
650
- const body = (await res.json()) as {
651
- error: {
652
- code: string;
653
- message: string;
654
- details?: {
655
- reason: string;
656
- bundle_compat: {
657
- min_runtime_version: string;
658
- max_runtime_version: string | null;
659
- };
660
- runtime_version: string;
661
- };
662
- };
663
- };
664
-
665
- expect(res.status).toBe(422);
666
- expect(body.error.code).toBe("UNPROCESSABLE_ENTITY");
667
- expect(body.error.message).toContain("99.0.0");
668
- expect(body.error.details).toBeDefined();
669
- expect(body.error.details!.reason).toBe("version_incompatible");
670
- expect(body.error.details!.bundle_compat.min_runtime_version).toBe(
671
- "99.0.0",
672
- );
673
- expect(body.error.details!.bundle_compat.max_runtime_version).toBeNull();
674
- expect(typeof body.error.details!.runtime_version).toBe("string");
675
- });
676
-
677
- test("incompatible bundle does not modify disk", async () => {
678
- const originalDb = new Uint8Array(readFileSync(testDbPath));
679
- const originalConfig = readFileSync(testConfigPath, "utf8");
680
-
681
- const vbundle = createValidVBundle(undefined, {
682
- min_runtime_version: "99.0.0",
683
- });
684
- const req = new Request("http://localhost/v1/migrations/import", {
685
- method: "POST",
686
- headers: { "Content-Type": "application/octet-stream" },
687
- body: toArrayBuffer(vbundle),
688
- });
689
-
690
- await callHandler(handleMigrationImport, req);
691
-
692
- const currentDb = new Uint8Array(readFileSync(testDbPath));
693
- const currentConfig = readFileSync(testConfigPath, "utf8");
694
-
695
- expect(currentDb).toEqual(originalDb);
696
- expect(currentConfig).toBe(originalConfig);
697
- });
698
-
699
- // Regression: the route handler pre-checks runtime-version compat using
700
- // `validation.manifest.compatibility` before calling `resetDb()` and
701
- // `commitImport()`. If a future refactor reorders the close to come
702
- // before the gate, an incompatible bundle would still 422 (commitImport
703
- // has its own defense-in-depth gate) but it would unnecessarily close
704
- // and reopen the live SQLite singleton on every rejected import.
705
- //
706
- // We can't easily spy on `resetDb` here without mocking `db-connection.js`
707
- // module-wide (which other tests in this file rely on for real). The
708
- // semantic regression — disk unchanged + 422 — is covered by the two
709
- // tests above. This test is a marker so future readers know the pre-
710
- // check intent; if it ever starts failing, recheck `handleMigrationImport`
711
- // ordering against the comment at line 861-862 ("Validate the bundle
712
- // before closing the DB to avoid an unnecessary close/reopen cycle when
713
- // the bundle is invalid").
714
- test("incompatible bundle short-circuits before resetDb (intent marker)", async () => {
715
- const vbundle = createValidVBundle(undefined, {
716
- min_runtime_version: "99.0.0",
717
- });
718
- const req = new Request("http://localhost/v1/migrations/import", {
719
- method: "POST",
720
- headers: { "Content-Type": "application/octet-stream" },
721
- body: toArrayBuffer(vbundle),
722
- });
723
-
724
- const res = await callHandler(handleMigrationImport, req);
725
- expect(res.status).toBe(422);
726
- });
727
- });
728
-
729
- // ---------------------------------------------------------------------------
730
- // commitImport unit tests
731
- // ---------------------------------------------------------------------------
732
-
733
- describe("commitImport", () => {
734
- test("returns ok: true with report on success", () => {
735
- const newDbData = new Uint8Array([0xfa, 0xce]);
736
- const vbundle = createValidVBundle([
737
- { path: "data/db/assistant.db", data: newDbData },
738
- ]);
739
-
740
- const resolver = new DefaultPathResolver(testDir);
741
- const result = commitImport({
742
- archiveData: vbundle,
743
- pathResolver: resolver,
744
- });
745
-
746
- expect(result.ok).toBe(true);
747
- if (result.ok) {
748
- expect(result.report.success).toBe(true);
749
- expect(result.report.files.length).toBeGreaterThan(0);
750
- }
751
- });
752
-
753
- test("returns validation_failed for invalid bundles", () => {
754
- const resolver = new DefaultPathResolver(testDir);
755
- const result = commitImport({
756
- archiveData: new Uint8Array([0xba, 0xad]),
757
- pathResolver: resolver,
758
- });
759
-
760
- expect(result.ok).toBe(false);
761
- if (!result.ok) {
762
- expect(result.reason).toBe("validation_failed");
763
- if (result.reason === "validation_failed") {
764
- expect(result.errors.length).toBeGreaterThan(0);
765
- }
766
- }
767
- });
768
-
769
- test("creates parent directories if they do not exist", () => {
770
- // Use a workspace that does not exist yet
771
- const nonexistentWorkspace = join(testDir, "new-workspace");
772
- const expectedDbPath = join(
773
- nonexistentWorkspace,
774
- "data",
775
- "db",
776
- "assistant.db",
777
- );
778
-
779
- const dbData = new Uint8Array([0x01, 0x02, 0x03]);
780
- const vbundle = createValidVBundle([
781
- { path: "data/db/assistant.db", data: dbData },
782
- ]);
783
-
784
- const resolver = new DefaultPathResolver(nonexistentWorkspace);
785
- const result = commitImport({
786
- archiveData: vbundle,
787
- pathResolver: resolver,
788
- });
789
-
790
- expect(result.ok).toBe(true);
791
- if (result.ok) {
792
- expect(result.report.files[0].action).toBe("created");
793
- expect(existsSync(expectedDbPath)).toBe(true);
794
- }
795
-
796
- // Clean up
797
- rmSync(nonexistentWorkspace, { recursive: true, force: true });
798
- });
799
-
800
- test("post-write integrity: written file SHA-256 matches expected", () => {
801
- const newDbData = new Uint8Array([0xab, 0xcd, 0xef]);
802
- const expectedSha256 = sha256Hex(newDbData);
803
-
804
- const vbundle = createValidVBundle([
805
- { path: "data/db/assistant.db", data: newDbData },
806
- ]);
807
-
808
- const resolver = new DefaultPathResolver(testDir);
809
- const result = commitImport({
810
- archiveData: vbundle,
811
- pathResolver: resolver,
812
- });
813
-
814
- expect(result.ok).toBe(true);
815
- if (result.ok) {
816
- // No integrity warnings should be present
817
- const integrityWarnings = result.report.warnings.filter((w) =>
818
- w.includes("integrity"),
819
- );
820
- expect(integrityWarnings).toHaveLength(0);
821
-
822
- // Verify the file on disk matches
823
- const writtenData = new Uint8Array(readFileSync(testDbPath));
824
- const writtenSha256 = sha256Hex(writtenData);
825
- expect(writtenSha256).toBe(expectedSha256);
826
- }
827
- });
828
-
829
- test("handles multiple files (db + config)", () => {
830
- const newDbData = new Uint8Array([0x11, 0x22, 0x33]);
831
- const newConfigData = new TextEncoder().encode('{"model":"claude"}');
832
-
833
- const vbundle = createValidVBundle([
834
- { path: "data/db/assistant.db", data: newDbData },
835
- { path: "config/settings.json", data: newConfigData },
836
- ]);
837
-
838
- const resolver = new DefaultPathResolver(testDir);
839
- const result = commitImport({
840
- archiveData: vbundle,
841
- pathResolver: resolver,
842
- });
843
-
844
- expect(result.ok).toBe(true);
845
- if (result.ok) {
846
- expect(result.report.summary.total_files).toBe(2);
847
-
848
- // Verify both files were written
849
- const writtenDb = new Uint8Array(readFileSync(testDbPath));
850
- expect(writtenDb).toEqual(newDbData);
851
-
852
- const writtenConfig = readFileSync(testConfigPath, "utf8");
853
- expect(writtenConfig).toBe(
854
- JSON.stringify({ model: "claude" }, null, 2) + "\n",
855
- );
856
- }
857
- });
858
- });
859
-
860
- // ---------------------------------------------------------------------------
861
- // Workspace clearing tests
862
- // ---------------------------------------------------------------------------
863
-
864
- describe("commitImport — workspace clearing", () => {
865
- const skillsDir = join(testDir, "skills");
866
- const hooksDir = join(testDir, "hooks");
867
-
868
- afterEach(() => {
869
- // Restore test fixture files for subsequent tests
870
- mkdirSync(testDbDir, { recursive: true });
871
- writeFileSync(testDbPath, EXISTING_DB_DATA);
872
- writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2));
873
- // Clean up skills/hooks dirs
874
- for (const dir of [skillsDir, hooksDir]) {
875
- if (existsSync(dir)) {
876
- rmSync(dir, { recursive: true, force: true });
877
- }
878
- }
879
- });
880
-
881
- test("clears stale files via workspace clearing (new-format workspace/ entries)", () => {
882
- mkdirSync(join(skillsDir, "stale-skill"), { recursive: true });
883
- writeFileSync(join(skillsDir, "stale-skill", "SKILL.md"), "stale");
884
-
885
- const skillData = new TextEncoder().encode("# New Skill");
886
- const vbundle = createValidVBundle([
887
- { path: "workspace/skills/new-skill/SKILL.md", data: skillData },
888
- ]);
889
-
890
- const resolver = new DefaultPathResolver(testDir);
891
- const result = commitImport({
892
- archiveData: vbundle,
893
- pathResolver: resolver,
894
- workspaceDir: testDir,
895
- });
896
-
897
- expect(result.ok).toBe(true);
898
- if (!result.ok) {
899
- return;
900
- }
901
-
902
- // New skill written
903
- expect(existsSync(join(skillsDir, "new-skill", "SKILL.md"))).toBe(true);
904
- expect(readFileSync(join(skillsDir, "new-skill", "SKILL.md"), "utf8")).toBe(
905
- "# New Skill",
906
- );
907
-
908
- // Stale skill removed by workspace clearing
909
- expect(existsSync(join(skillsDir, "stale-skill"))).toBe(false);
910
- });
911
-
912
- test("old-format skills/ entries do not trigger workspace clearing", () => {
913
- mkdirSync(join(skillsDir, "stale-skill"), { recursive: true });
914
- writeFileSync(join(skillsDir, "stale-skill", "SKILL.md"), "stale");
915
-
916
- const skillData = new TextEncoder().encode("# New Skill");
917
- const vbundle = createValidVBundle([
918
- { path: "skills/new-skill/SKILL.md", data: skillData },
919
- ]);
920
-
921
- const resolver = new DefaultPathResolver(testDir);
922
- const result = commitImport({
923
- archiveData: vbundle,
924
- pathResolver: resolver,
925
- workspaceDir: testDir,
926
- });
927
-
928
- expect(result.ok).toBe(true);
929
- if (!result.ok) {
930
- return;
931
- }
932
-
933
- // New skill written
934
- expect(existsSync(join(skillsDir, "new-skill", "SKILL.md"))).toBe(true);
935
-
936
- // Stale skill survives — old-format bundles don't trigger workspace clearing
937
- expect(existsSync(join(skillsDir, "stale-skill", "SKILL.md"))).toBe(true);
938
- });
939
-
940
- test("hooks/ entries import to hooksDir (not workspace/hooks/)", () => {
941
- // Use a separate hooks dir outside the workspace, like production layout
942
- const externalHooksDir = join(testDir, ".hooks-external");
943
- mkdirSync(externalHooksDir, { recursive: true });
944
-
945
- const hookData = new TextEncoder().encode("#!/bin/sh\necho new");
946
- const vbundle = createValidVBundle([
947
- { path: "hooks/new-hook/hook.sh", data: hookData },
948
- ]);
949
-
950
- const resolver = new DefaultPathResolver(testDir, externalHooksDir);
951
- const result = commitImport({
952
- archiveData: vbundle,
953
- pathResolver: resolver,
954
- workspaceDir: testDir,
955
- });
956
-
957
- expect(result.ok).toBe(true);
958
- if (!result.ok) {
959
- return;
960
- }
961
-
962
- // Hook written to the external hooks dir, not workspace/hooks/
963
- expect(existsSync(join(externalHooksDir, "new-hook", "hook.sh"))).toBe(
964
- true,
965
- );
966
- expect(
967
- readFileSync(join(externalHooksDir, "new-hook", "hook.sh"), "utf8"),
968
- ).toBe("#!/bin/sh\necho new");
969
-
970
- // Cleanup
971
- rmSync(externalHooksDir, { recursive: true, force: true });
972
- });
973
-
974
- test("without workspaceDir, no clearing happens", () => {
975
- mkdirSync(join(skillsDir, "existing-skill"), { recursive: true });
976
- writeFileSync(
977
- join(skillsDir, "existing-skill", "SKILL.md"),
978
- "should survive",
979
- );
980
-
981
- const vbundle = createValidVBundle([
982
- {
983
- path: "skills/new-skill/SKILL.md",
984
- data: new TextEncoder().encode("new"),
985
- },
986
- ]);
987
-
988
- const resolver = new DefaultPathResolver(testDir);
989
- // No workspaceDir — no clearing
990
- const result = commitImport({
991
- archiveData: vbundle,
992
- pathResolver: resolver,
993
- });
994
-
995
- expect(result.ok).toBe(true);
996
-
997
- // Existing skill survives (no workspace clearing without workspaceDir)
998
- expect(existsSync(join(skillsDir, "existing-skill", "SKILL.md"))).toBe(
999
- true,
1000
- );
1001
- });
1002
- });
1003
-
1004
- // ---------------------------------------------------------------------------
1005
- // Config sanitization tests
1006
- // ---------------------------------------------------------------------------
1007
-
1008
- describe("commitImport — config sanitization", () => {
1009
- test("imported workspace/config.json has environment-specific fields stripped", () => {
1010
- const configWithEnvFields = {
1011
- provider: "anthropic",
1012
- model: "test-model",
1013
- ingress: {
1014
- publicBaseUrl: "https://my-tunnel.example.com",
1015
- enabled: true,
1016
- port: 8080,
1017
- },
1018
- daemon: {
1019
- autoStart: true,
1020
- logLevel: "debug",
1021
- },
1022
- skills: {
1023
- load: {
1024
- extraDirs: ["/home/user/custom-skills"],
1025
- autoReload: true,
1026
- },
1027
- },
1028
- memory: { enabled: true },
1029
- };
1030
- const configData = new TextEncoder().encode(
1031
- JSON.stringify(configWithEnvFields, null, 2),
1032
- );
1033
-
1034
- const vbundle = createValidVBundle([
1035
- { path: "workspace/config.json", data: configData },
1036
- ]);
1037
-
1038
- const resolver = new DefaultPathResolver(testDir);
1039
- const result = commitImport({
1040
- archiveData: vbundle,
1041
- pathResolver: resolver,
1042
- workspaceDir: testDir,
1043
- });
1044
-
1045
- expect(result.ok).toBe(true);
1046
- if (!result.ok) {
1047
- return;
1048
- }
1049
-
1050
- // Read the written config from disk and verify sanitization
1051
- const writtenConfig = JSON.parse(readFileSync(testConfigPath, "utf8"));
1052
-
1053
- // Environment-specific fields should be stripped/reset
1054
- expect(writtenConfig.ingress.publicBaseUrl).toBe("");
1055
- expect(writtenConfig.ingress.enabled).toBeUndefined();
1056
- expect(writtenConfig.daemon).toBeUndefined();
1057
- expect(writtenConfig.skills.load.extraDirs).toEqual([]);
1058
-
1059
- // Non-environment-specific fields should be preserved
1060
- expect(writtenConfig.provider).toBe("anthropic");
1061
- expect(writtenConfig.model).toBe("test-model");
1062
- expect(writtenConfig.ingress.port).toBe(8080);
1063
- expect(writtenConfig.skills.load.autoReload).toBe(true);
1064
- expect(writtenConfig.memory.enabled).toBe(true);
1065
- });
1066
-
1067
- test("imported config/settings.json (legacy) has environment-specific fields stripped", () => {
1068
- const configWithEnvFields = {
1069
- provider: "openai",
1070
- daemon: { autoStart: false },
1071
- ingress: {
1072
- publicBaseUrl: "https://old-tunnel.example.com",
1073
- enabled: false,
1074
- },
1075
- skills: { load: { extraDirs: ["/legacy/skills"] } },
1076
- };
1077
- const configData = new TextEncoder().encode(
1078
- JSON.stringify(configWithEnvFields, null, 2),
1079
- );
1080
-
1081
- const vbundle = createValidVBundle([
1082
- { path: "config/settings.json", data: configData },
1083
- ]);
1084
-
1085
- const resolver = new DefaultPathResolver(testDir);
1086
- const result = commitImport({
1087
- archiveData: vbundle,
1088
- pathResolver: resolver,
1089
- });
1090
-
1091
- expect(result.ok).toBe(true);
1092
- if (!result.ok) {
1093
- return;
1094
- }
1095
-
1096
- const writtenConfig = JSON.parse(readFileSync(testConfigPath, "utf8"));
1097
-
1098
- expect(writtenConfig.ingress.publicBaseUrl).toBe("");
1099
- expect(writtenConfig.ingress.enabled).toBeUndefined();
1100
- expect(writtenConfig.daemon).toBeUndefined();
1101
- expect(writtenConfig.skills.load.extraDirs).toEqual([]);
1102
- expect(writtenConfig.provider).toBe("openai");
1103
- });
1104
- });
1105
-
1106
- // ---------------------------------------------------------------------------
1107
- // Auth policy registration tests
1108
- // ---------------------------------------------------------------------------
1109
-
1110
- describe("route policy registration", () => {
1111
- test("migrations/import policy requires settings.write scope", async () => {
1112
- const { ROUTES } = await import("../runtime/routes/index.js");
1113
- const policy =
1114
- ROUTES.find((r) => r.endpoint === "migrations/import")?.policy ?? null;
1115
-
1116
- expect(policy).not.toBeNull();
1117
- expect(policy?.requiredScopes).toContain("settings.write");
1118
- expect(policy?.allowedPrincipalTypes).toContain("actor");
1119
- expect(policy?.allowedPrincipalTypes).toContain("svc_gateway");
1120
- expect(policy?.allowedPrincipalTypes).toContain("local");
1121
- });
1122
-
1123
- test("import policy matches export/preflight but differs from validate on scopes", async () => {
1124
- const { ROUTES } = await import("../runtime/routes/index.js");
1125
- const importPolicy =
1126
- ROUTES.find((r) => r.endpoint === "migrations/import")?.policy ?? null;
1127
- const validatePolicy =
1128
- ROUTES.find((r) => r.endpoint === "migrations/validate")?.policy ?? null;
1129
- const exportPolicy =
1130
- ROUTES.find((r) => r.endpoint === "migrations/export")?.policy ?? null;
1131
- const preflightPolicy =
1132
- ROUTES.find((r) => r.endpoint === "migrations/import-preflight")
1133
- ?.policy ?? null;
1134
-
1135
- // import, export, and preflight all require settings.write
1136
- expect(importPolicy!.requiredScopes).toEqual(exportPolicy!.requiredScopes);
1137
- expect(importPolicy!.requiredScopes).toEqual(
1138
- preflightPolicy!.requiredScopes,
1139
- );
1140
- expect(importPolicy!.requiredScopes).toEqual(["settings.write"]);
1141
- // validate is read-only so requires settings.read
1142
- expect(validatePolicy!.requiredScopes).toEqual(["settings.read"]);
1143
- // all share the same principal types
1144
- expect(importPolicy!.allowedPrincipalTypes).toEqual(
1145
- validatePolicy!.allowedPrincipalTypes,
1146
- );
1147
- });
1148
- });
1149
-
1150
- // ---------------------------------------------------------------------------
1151
- // Integration: existing routes unaffected
1152
- // ---------------------------------------------------------------------------
1153
-
1154
- describe("integration: existing routes unaffected", () => {
1155
- test("GET /v1/health still works", async () => {
1156
- const { handleDetailedHealth } =
1157
- await import("../runtime/routes/identity-routes.js");
1158
- const res = await handleDetailedHealth();
1159
- const body = (await res.json()) as Record<string, unknown>;
1160
-
1161
- expect(res.status).toBe(200);
1162
- expect(body.status).toBe("healthy");
1163
- });
1164
-
1165
- test("validate, export, and import-preflight handlers still importable", async () => {
1166
- const {
1167
- handleMigrationValidate,
1168
- handleMigrationExport,
1169
- handleMigrationImportPreflight,
1170
- } = await import("../runtime/routes/migration-routes.js");
1171
-
1172
- expect(typeof handleMigrationValidate).toBe("function");
1173
- expect(typeof handleMigrationExport).toBe("function");
1174
- expect(typeof handleMigrationImportPreflight).toBe("function");
1175
- });
1176
- });