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