@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,826 +0,0 @@
1
- /**
2
- * Commits a validated .vbundle archive to disk.
3
- *
4
- * Given a .vbundle archive, this module:
5
- * 1. Validates the bundle (decompresses and parses once — reuses the entries
6
- * from validation to avoid a second decompression pass)
7
- * 2. Backs up existing files before overwriting
8
- * 3. Writes bundle files to their target disk locations
9
- * 4. Verifies written files match expected checksums (post-write integrity)
10
- * 5. Returns a detailed import report
11
- *
12
- * Backup files are stored alongside the originals with a timestamped suffix.
13
- */
14
-
15
- import { createHash } from "node:crypto";
16
- import {
17
- copyFileSync,
18
- existsSync,
19
- lstatSync,
20
- mkdirSync,
21
- readdirSync,
22
- readFileSync,
23
- readlinkSync,
24
- rmSync,
25
- symlinkSync,
26
- writeFileSync,
27
- } from "node:fs";
28
- import { dirname, join, resolve, sep } from "node:path";
29
-
30
- import { sanitizeConfigForTransfer } from "../../config/sanitize-for-transfer.js";
31
- import { isGuardianPersonaCustomized } from "../../prompts/persona-resolver.js";
32
- import { getLogger } from "../../util/logger.js";
33
- import { APP_VERSION } from "../../version.js";
34
- import type { PathResolver } from "./vbundle-import-analyzer.js";
35
- import type { RuntimeCompatibility } from "./vbundle-import-policy.js";
36
- import * as policy from "./vbundle-import-policy.js";
37
- import { mergeMetadataPreservingCadet } from "./vbundle-metadata-merge.js";
38
- import type { ManifestType, VBundleTarEntry } from "./vbundle-validator.js";
39
- import { validateVBundle } from "./vbundle-validator.js";
40
-
41
- const log = getLogger("vbundle-importer");
42
-
43
- // ---------------------------------------------------------------------------
44
- // Public types
45
- // ---------------------------------------------------------------------------
46
-
47
- export type ImportFileAction = "created" | "overwritten" | "skipped";
48
-
49
- export interface ImportedFileReport {
50
- /** Archive path (e.g. "data/db/assistant.db") */
51
- path: string;
52
- /** Disk path the file was written to */
53
- disk_path: string;
54
- /** What happened to this file */
55
- action: ImportFileAction;
56
- /** Size of the written file in bytes */
57
- size: number;
58
- /** SHA-256 of the written file */
59
- sha256: string;
60
- /** Path to the backup file, if one was created */
61
- backup_path: string | null;
62
- }
63
-
64
- export interface ImportCommitReport {
65
- /** Whether the import succeeded */
66
- success: boolean;
67
- /** Summary of what was imported */
68
- summary: {
69
- total_files: number;
70
- files_created: number;
71
- files_overwritten: number;
72
- files_skipped: number;
73
- backups_created: number;
74
- };
75
- /** Per-file import details */
76
- files: ImportedFileReport[];
77
- /** The manifest from the imported bundle */
78
- manifest: ManifestType;
79
- /** Any integrity warnings (non-fatal) */
80
- warnings: string[];
81
- }
82
-
83
- export type ImportCommitResult =
84
- | { ok: true; report: ImportCommitReport }
85
- | {
86
- ok: false;
87
- reason: "validation_failed";
88
- errors: Array<{ code: string; message: string; path?: string }>;
89
- }
90
- | { ok: false; reason: "extraction_failed"; message: string }
91
- | {
92
- ok: false;
93
- reason: "version_incompatible";
94
- bundle_compat: RuntimeCompatibility;
95
- runtime_version: string;
96
- }
97
- | {
98
- ok: false;
99
- reason: "write_failed";
100
- message: string;
101
- partial_report?: ImportCommitReport;
102
- };
103
-
104
- // ---------------------------------------------------------------------------
105
- // Hash helper
106
- // ---------------------------------------------------------------------------
107
-
108
- function sha256Hex(data: Uint8Array): string {
109
- return createHash("sha256").update(data).digest("hex");
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // Backup helper
114
- // ---------------------------------------------------------------------------
115
-
116
- function generateBackupPath(diskPath: string): string {
117
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
118
- return `${diskPath}.backup-${timestamp}`;
119
- }
120
-
121
- /**
122
- * Defense-in-depth: returns true if `linkTarget`, when resolved relative to
123
- * the symlink's own directory (`dirname(diskPath)`), lands outside the
124
- * supplied `workspaceDir`. The validator (`validateVBundle`) already enforces
125
- * archive-relative containment, but we re-check here so the buffer importer
126
- * is safe even if a caller passes a hand-built `preValidatedManifest`.
127
- *
128
- * Returns false when `workspaceDir` is undefined — the importer is permitted
129
- * to write outside any workspace in that mode (e.g. legacy hooks-only
130
- * imports).
131
- */
132
- function isOutsideWorkspace(
133
- diskPath: string,
134
- linkTarget: string,
135
- workspaceDir: string | undefined,
136
- ): boolean {
137
- if (!workspaceDir) {
138
- return false;
139
- }
140
- const resolved = resolve(dirname(diskPath), linkTarget);
141
- const ws = resolve(workspaceDir);
142
- return resolved !== ws && !resolved.startsWith(ws + sep);
143
- }
144
-
145
- // ---------------------------------------------------------------------------
146
- // Core importer
147
- // ---------------------------------------------------------------------------
148
-
149
- export interface ImportCommitOptions {
150
- /** Raw .vbundle archive bytes — used only when pre-validated data is not provided. */
151
- archiveData: Uint8Array;
152
- /** Resolves archive paths to disk paths */
153
- pathResolver: PathResolver;
154
- /** Pre-validated manifest from a prior validateVBundle call. When provided
155
- * with `preValidatedEntries`, skips internal re-validation to avoid
156
- * holding two copies of decompressed data in memory. */
157
- preValidatedManifest?: ManifestType;
158
- /** Pre-parsed tar entries from a prior validateVBundle call. */
159
- preValidatedEntries?: Map<string, VBundleTarEntry>;
160
- /**
161
- * Absolute path to the workspace directory. When set and the bundle
162
- * contains workspace/ entries, the workspace is cleared (except
163
- * skip dirs) before writing to ensure an exact-match restore.
164
- */
165
- workspaceDir?: string;
166
- }
167
-
168
- /**
169
- * Validate, extract, and write a .vbundle archive to disk.
170
- *
171
- * This is a destructive operation — files on disk will be overwritten.
172
- * Existing files are backed up before being replaced. The bundle is
173
- * re-validated before any state mutation to prevent writing corrupt data.
174
- */
175
- export function commitImport(options: ImportCommitOptions): ImportCommitResult {
176
- const {
177
- archiveData,
178
- pathResolver,
179
- preValidatedManifest,
180
- preValidatedEntries,
181
- workspaceDir,
182
- } = options;
183
-
184
- let manifest: ManifestType;
185
- let entryMap: Map<string, VBundleTarEntry>;
186
-
187
- if (preValidatedManifest && preValidatedEntries) {
188
- // Caller already validated and decompressed — reuse directly
189
- manifest = preValidatedManifest;
190
- entryMap = preValidatedEntries;
191
- } else {
192
- // Validate the bundle (validation before mutation).
193
- // validateVBundle decompresses and parses the tar, returning the entries
194
- // alongside the validation result so we avoid a second decompression.
195
- const validation = validateVBundle(archiveData);
196
- if (!validation.is_valid || !validation.manifest || !validation.entries) {
197
- return {
198
- ok: false,
199
- reason: "validation_failed",
200
- errors: validation.errors,
201
- };
202
- }
203
-
204
- manifest = validation.manifest;
205
- entryMap = validation.entries;
206
- }
207
-
208
- // Defense-in-depth: refuse to import a bundle whose declared compat range
209
- // excludes this runtime BEFORE any state mutation. The cloud-side gate
210
- // is the primary check; this catches legacy bundles whose ExportJob row
211
- // predates PR #5470 (compat columns NULL → platform gate skipped) and
212
- // any caller that bypasses the cloud-issued signed URL flow.
213
- const compatResult = policy.evaluateRuntimeCompatibility(
214
- manifest.compatibility,
215
- APP_VERSION,
216
- );
217
- if (!compatResult.ok) {
218
- return {
219
- ok: false,
220
- reason: "version_incompatible",
221
- bundle_compat: compatResult.bundle_compat,
222
- runtime_version: compatResult.runtime_version,
223
- };
224
- }
225
-
226
- // Directories to preserve when clearing the workspace. Derived from the
227
- // shared WORKSPACE_PRESERVE_PATHS list so the streaming importer's
228
- // carry-over logic and this in-place clear stay in sync.
229
- const { topLevelSkipDirs, dataSubdirSkipDirs } =
230
- policy.partitionWorkspacePreserveSkipDirs();
231
-
232
- // Step 1b: Clear the workspace directory before restore if the bundle
233
- // contains new-format workspace/ entries. This ensures an exact-match
234
- // restore with no stale files left behind. Skips embedding-models/,
235
- // data/qdrant/ (large, regenerable), and data/db/ (critical — prevents
236
- // data loss if the import fails partway or the archive omits the DB).
237
- //
238
- // Only new-format bundles (workspace/ prefix) trigger clearing. Old-format
239
- // bundles (skills/, hooks/, data/db/*, config/*) wrote specific files
240
- // without clearing — preserving that behavior avoids wiping workspace
241
- // data when importing legacy bundles.
242
- //
243
- // Gate on resolution: at least one workspace/ entry must resolve to a
244
- // valid disk path. This prevents path-traversal entries (e.g.
245
- // "workspace/../../etc/passwd") from triggering a workspace purge while
246
- // resolving to nothing.
247
- const hasWorkspaceEntries = manifest.contents.some(
248
- (f) =>
249
- policy.isWorkspaceNamespacedArchivePath(f.path) &&
250
- !!pathResolver.resolve(f.path),
251
- );
252
-
253
- // Capture the target's credential metadata BEFORE the workspace clear
254
- // runs. Step 1b wipes `data/credentials/`, so reading live metadata
255
- // later (during the per-file write loop) would always miss. The merge
256
- // helper needs this content to preserve the target's cloud-identity
257
- // (`cadet:*`) entries across the overwrite.
258
- let liveCredentialMetadataJson: string | null = null;
259
- const credentialMetadataDiskPath = pathResolver.resolve(
260
- policy.CREDENTIAL_METADATA_ARCHIVE_PATH,
261
- );
262
- if (credentialMetadataDiskPath && existsSync(credentialMetadataDiskPath)) {
263
- try {
264
- liveCredentialMetadataJson = readFileSync(
265
- credentialMetadataDiskPath,
266
- "utf-8",
267
- );
268
- } catch (err) {
269
- log.warn(
270
- { err, path: credentialMetadataDiskPath },
271
- "Failed to read live credential metadata before import; cadet:* entries may not be preserved",
272
- );
273
- }
274
- }
275
-
276
- let workspaceWasCleared = false;
277
- if (hasWorkspaceEntries && workspaceDir && existsSync(workspaceDir)) {
278
- try {
279
- // Clear workspace contents selectively, preserving skip dirs
280
- const topEntries = readdirSync(workspaceDir, { withFileTypes: true });
281
- for (const entry of topEntries) {
282
- if (topLevelSkipDirs.has(entry.name)) {
283
- continue;
284
- }
285
-
286
- const entryPath = join(workspaceDir, entry.name);
287
- if (entry.name === "data" && entry.isDirectory()) {
288
- // Inside data/, preserve qdrant/ (large, regenerable) and db/
289
- // (critical user data) but clear everything else
290
- const dataEntries = readdirSync(entryPath, { withFileTypes: true });
291
- for (const dataEntry of dataEntries) {
292
- if (dataSubdirSkipDirs.has(dataEntry.name)) {
293
- continue;
294
- }
295
- rmSync(join(entryPath, dataEntry.name), {
296
- recursive: true,
297
- force: true,
298
- });
299
- }
300
- } else {
301
- rmSync(entryPath, { recursive: true, force: true });
302
- }
303
- }
304
- workspaceWasCleared = true;
305
- } catch (err) {
306
- return {
307
- ok: false,
308
- reason: "write_failed",
309
- message: `Failed to clear workspace directory "${workspaceDir}": ${
310
- err instanceof Error ? err.message : String(err)
311
- }`,
312
- };
313
- }
314
- }
315
-
316
- // Step 2: Write files to disk with backups
317
- const importedFiles: ImportedFileReport[] = [];
318
- const warnings: string[] = [];
319
- let backupsCreated = 0;
320
-
321
- for (const fileEntry of manifest.contents) {
322
- // Credential entries are handled separately by extractCredentialsFromBundle()
323
- // in migration-routes.ts — skip them silently without warnings or skip counts.
324
- if (fileEntry.path.startsWith("credentials/")) {
325
- continue;
326
- }
327
-
328
- const diskPath = pathResolver.resolve(fileEntry.path);
329
-
330
- if (!diskPath) {
331
- // Unknown archive path — skip it. Retired-feature paths (see
332
- // `policy.RETIRED_ARCHIVE_PATHS`) are expected in legacy bundles and
333
- // skip silently so the import report matches preflight; anything else
334
- // unresolvable earns a warning.
335
- importedFiles.push({
336
- path: fileEntry.path,
337
- disk_path: "",
338
- action: "skipped",
339
- size: fileEntry.size_bytes,
340
- sha256: fileEntry.sha256,
341
- backup_path: null,
342
- });
343
- if (
344
- !policy.isRetiredArchivePath(fileEntry.path) &&
345
- !policy.isGatewayArchivePath(fileEntry.path)
346
- ) {
347
- warnings.push(
348
- `Skipped "${fileEntry.path}": no known disk target for this archive path`,
349
- );
350
- }
351
- continue;
352
- }
353
-
354
- // Symlink branch: recreate the entry on disk as a real symlink so the
355
- // post-import workspace mirrors the source's link topology rather than
356
- // duplicating bytes. The validator already enforces archive-relative
357
- // containment, sha256-over-target, and size==0 — we still reapply
358
- // absolute-target and workspace-escape gates here so a hand-built
359
- // `preValidatedManifest` cannot bypass them.
360
- if (fileEntry.link_target !== undefined) {
361
- const archiveEntry = entryMap.get(fileEntry.path);
362
- if (!archiveEntry) {
363
- importedFiles.push({
364
- path: fileEntry.path,
365
- disk_path: diskPath,
366
- action: "skipped",
367
- size: 0,
368
- sha256: fileEntry.sha256,
369
- backup_path: null,
370
- });
371
- warnings.push(
372
- `Skipped "${fileEntry.path}": declared in manifest but not found in archive`,
373
- );
374
- continue;
375
- }
376
-
377
- // Legacy guardian persona (prompts/USER.md) is translated to the
378
- // current guardian's users/<slug>.md by DefaultPathResolver. If the
379
- // bundle ships USER.md as a symlink and the target already holds
380
- // user-authored content, skip rather than clobber — mirrors the
381
- // protection in the regular-file branch below.
382
- if (
383
- policy.isLegacyPersonaArchivePath(fileEntry.path) &&
384
- isGuardianPersonaCustomized(diskPath)
385
- ) {
386
- log.warn(
387
- { archivePath: fileEntry.path, diskPath },
388
- "Skipping legacy prompts/USER.md symlink import: guardian persona is already customized",
389
- );
390
- warnings.push(
391
- `Skipped "${fileEntry.path}": guardian persona at "${diskPath}" is already customized`,
392
- );
393
- importedFiles.push({
394
- path: fileEntry.path,
395
- disk_path: diskPath,
396
- action: "skipped",
397
- size: 0,
398
- sha256: fileEntry.sha256,
399
- backup_path: null,
400
- });
401
- continue;
402
- }
403
-
404
- // Defense-in-depth path-traversal gate.
405
- if (
406
- fileEntry.link_target.startsWith("/") ||
407
- isOutsideWorkspace(diskPath, fileEntry.link_target, workspaceDir)
408
- ) {
409
- importedFiles.push({
410
- path: fileEntry.path,
411
- disk_path: diskPath,
412
- action: "skipped",
413
- size: 0,
414
- sha256: fileEntry.sha256,
415
- backup_path: null,
416
- });
417
- warnings.push(
418
- `Skipped "${fileEntry.path}": symlink target "${fileEntry.link_target}" escapes workspace`,
419
- );
420
- continue;
421
- }
422
-
423
- // Back up an existing entry at diskPath, if any. Use `lstatSync` so we
424
- // detect a pre-existing dangling symlink (which `existsSync` reports
425
- // as missing) — `symlinkSync` would otherwise fail with EEXIST. For
426
- // regular files and resolvable symlinks we copy the file contents into
427
- // the backup, matching the existing contract; for dangling symlinks
428
- // we preserve the linkname via `readlinkSync`+`symlinkSync` so the
429
- // original entry can be inspected after the import. The pre-existing
430
- // entry is removed before `symlinkSync` so the new symlink can land.
431
- let backupPath: string | null = null;
432
- let action: ImportFileAction;
433
- let preExistingEntry = false;
434
- let preExistingIsSymlink = false;
435
- try {
436
- const stats = lstatSync(diskPath);
437
- preExistingEntry = true;
438
- preExistingIsSymlink = stats.isSymbolicLink();
439
- } catch {
440
- // ENOENT — no pre-existing entry at this path.
441
- }
442
- if (preExistingEntry) {
443
- backupPath = generateBackupPath(diskPath);
444
- try {
445
- if (preExistingIsSymlink) {
446
- const oldTarget = readlinkSync(diskPath);
447
- symlinkSync(oldTarget, backupPath);
448
- } else {
449
- copyFileSync(diskPath, backupPath);
450
- }
451
- backupsCreated++;
452
- } catch (err) {
453
- return {
454
- ok: false,
455
- reason: "write_failed",
456
- message: `Failed to back up "${diskPath}": ${
457
- err instanceof Error ? err.message : String(err)
458
- }`,
459
- partial_report: buildPartialReport(
460
- importedFiles,
461
- manifest,
462
- warnings,
463
- backupsCreated,
464
- ),
465
- };
466
- }
467
- action = "overwritten";
468
- try {
469
- rmSync(diskPath, { force: true });
470
- } catch {
471
- /* best effort — symlinkSync below will surface the real error */
472
- }
473
- } else {
474
- action = "created";
475
- }
476
-
477
- // Ensure parent directory exists.
478
- const parentDir = dirname(diskPath);
479
- if (!existsSync(parentDir)) {
480
- try {
481
- mkdirSync(parentDir, { recursive: true });
482
- } catch (err) {
483
- return {
484
- ok: false,
485
- reason: "write_failed",
486
- message: `Failed to create directory "${parentDir}": ${
487
- err instanceof Error ? err.message : String(err)
488
- }`,
489
- partial_report: buildPartialReport(
490
- importedFiles,
491
- manifest,
492
- warnings,
493
- backupsCreated,
494
- ),
495
- };
496
- }
497
- }
498
-
499
- // Create the symlink. The target is stored verbatim — OS symlink
500
- // semantics resolve it relative to the symlink's own directory at
501
- // use time.
502
- try {
503
- symlinkSync(fileEntry.link_target, diskPath);
504
- } catch (err) {
505
- return {
506
- ok: false,
507
- reason: "write_failed",
508
- message: `Failed to create symlink "${diskPath}" -> "${fileEntry.link_target}": ${
509
- err instanceof Error ? err.message : String(err)
510
- }`,
511
- partial_report: buildPartialReport(
512
- importedFiles,
513
- manifest,
514
- warnings,
515
- backupsCreated,
516
- ),
517
- };
518
- }
519
-
520
- importedFiles.push({
521
- path: fileEntry.path,
522
- disk_path: diskPath,
523
- action,
524
- size: 0,
525
- sha256: fileEntry.sha256,
526
- backup_path: backupPath,
527
- });
528
- // Skip the regular-file branches (and the post-write integrity check,
529
- // which would dereference the symlink and read the target's bytes).
530
- continue;
531
- }
532
-
533
- const archiveEntry = entryMap.get(fileEntry.path);
534
- if (!archiveEntry) {
535
- // File declared in manifest but not found in archive — should not
536
- // happen after validation, but guard against it
537
- importedFiles.push({
538
- path: fileEntry.path,
539
- disk_path: diskPath,
540
- action: "skipped",
541
- size: fileEntry.size_bytes,
542
- sha256: fileEntry.sha256,
543
- backup_path: null,
544
- });
545
- warnings.push(
546
- `Skipped "${fileEntry.path}": declared in manifest but not found in archive`,
547
- );
548
- continue;
549
- }
550
-
551
- // Legacy guardian persona (prompts/USER.md) is translated to the
552
- // current guardian's users/<slug>.md by DefaultPathResolver. If
553
- // that target already holds user-authored content, skip rather
554
- // than clobber — the user has curated their persona since the
555
- // bundle was exported.
556
- if (
557
- policy.isLegacyPersonaArchivePath(fileEntry.path) &&
558
- isGuardianPersonaCustomized(diskPath)
559
- ) {
560
- log.warn(
561
- { archivePath: fileEntry.path, diskPath },
562
- "Skipping legacy prompts/USER.md import: guardian persona is already customized",
563
- );
564
- warnings.push(
565
- `Skipped "${fileEntry.path}": guardian persona at "${diskPath}" is already customized`,
566
- );
567
- importedFiles.push({
568
- path: fileEntry.path,
569
- disk_path: diskPath,
570
- action: "skipped",
571
- size: fileEntry.size_bytes,
572
- sha256: fileEntry.sha256,
573
- backup_path: null,
574
- });
575
- continue;
576
- }
577
-
578
- // Determine action and create backup if needed
579
- let backupPath: string | null = null;
580
- let action: ImportFileAction;
581
-
582
- if (existsSync(diskPath)) {
583
- // Back up existing file before overwriting
584
- backupPath = generateBackupPath(diskPath);
585
- try {
586
- copyFileSync(diskPath, backupPath);
587
- backupsCreated++;
588
- } catch (err) {
589
- return {
590
- ok: false,
591
- reason: "write_failed",
592
- message: `Failed to create backup of "${diskPath}": ${
593
- err instanceof Error ? err.message : String(err)
594
- }`,
595
- partial_report: buildPartialReport(
596
- importedFiles,
597
- manifest,
598
- warnings,
599
- backupsCreated,
600
- ),
601
- };
602
- }
603
- action = "overwritten";
604
- } else {
605
- action = "created";
606
- }
607
-
608
- // Ensure parent directory exists
609
- const parentDir = dirname(diskPath);
610
- if (!existsSync(parentDir)) {
611
- try {
612
- mkdirSync(parentDir, { recursive: true });
613
- } catch (err) {
614
- return {
615
- ok: false,
616
- reason: "write_failed",
617
- message: `Failed to create directory "${parentDir}": ${
618
- err instanceof Error ? err.message : String(err)
619
- }`,
620
- partial_report: buildPartialReport(
621
- importedFiles,
622
- manifest,
623
- warnings,
624
- backupsCreated,
625
- ),
626
- };
627
- }
628
- }
629
-
630
- // Sanitize config files to strip environment-specific fields (defense-in-depth)
631
- let dataToWrite: Uint8Array = archiveEntry.data;
632
- if (policy.isConfigArchivePath(fileEntry.path)) {
633
- const configJson = new TextDecoder().decode(archiveEntry.data);
634
- const sanitized = sanitizeConfigForTransfer(configJson);
635
- dataToWrite = new TextEncoder().encode(sanitized);
636
- }
637
-
638
- // Preserve target's `cadet:*` metadata entries across the overwrite.
639
- // Django's post-hatch provisioning writes these on the target via
640
- // POST /v1/secrets; a naive overwrite of the bundle's metadata.json
641
- // would wipe them and break the gateway's cadet credential read.
642
- // We use the snapshot captured BEFORE the workspace clear because
643
- // Step 1b may have already removed the live file.
644
- if (policy.isCredentialMetadataArchivePath(fileEntry.path)) {
645
- const bundleJson = new TextDecoder().decode(archiveEntry.data);
646
- const merged = mergeMetadataPreservingCadet(
647
- bundleJson,
648
- liveCredentialMetadataJson,
649
- );
650
- dataToWrite = new TextEncoder().encode(merged);
651
- }
652
-
653
- // If we're about to replace a SQLite main database file, remove any
654
- // pre-existing `.db-wal`/`.db-shm`/`.db-journal` siblings at the
655
- // target. Those auxiliary files are only valid as a pair with the
656
- // exact `.db` that wrote them; leaving them alongside a replacement
657
- // DB causes SQLite to replay incompatible WAL frames on the first
658
- // open and report "database disk image is malformed". The exporter
659
- // already checkpointed the source WAL into the main DB before the
660
- // bundle was built, so dropping the sibling aux files doesn't lose
661
- // data from the source workspace.
662
- if (diskPath.endsWith(".db")) {
663
- for (const suffix of [".db-wal", ".db-shm", ".db-journal"]) {
664
- const auxPath = `${diskPath.slice(0, -".db".length)}${suffix}`;
665
- try {
666
- rmSync(auxPath, { force: true });
667
- } catch {
668
- /* best effort — if the aux file doesn't exist we're fine */
669
- }
670
- }
671
- }
672
-
673
- // Write the file
674
- try {
675
- writeFileSync(diskPath, dataToWrite);
676
- } catch (err) {
677
- return {
678
- ok: false,
679
- reason: "write_failed",
680
- message: `Failed to write "${diskPath}": ${
681
- err instanceof Error ? err.message : String(err)
682
- }`,
683
- partial_report: buildPartialReport(
684
- importedFiles,
685
- manifest,
686
- warnings,
687
- backupsCreated,
688
- ),
689
- };
690
- }
691
-
692
- // Step 3: Post-write integrity check — verify the written file
693
- // Use the SHA of the data we actually wrote (which may differ from the
694
- // manifest SHA if the config was sanitized during import).
695
- const expectedSha256 = sha256Hex(dataToWrite);
696
- try {
697
- const writtenData = new Uint8Array(readFileSync(diskPath));
698
- const writtenSha256 = sha256Hex(writtenData);
699
-
700
- if (writtenSha256 !== expectedSha256) {
701
- warnings.push(
702
- `Post-write integrity warning for "${fileEntry.path}": ` +
703
- `expected SHA-256 ${expectedSha256}, got ${writtenSha256}`,
704
- );
705
- }
706
- } catch {
707
- warnings.push(
708
- `Could not verify post-write integrity for "${fileEntry.path}"`,
709
- );
710
- }
711
-
712
- importedFiles.push({
713
- path: fileEntry.path,
714
- disk_path: diskPath,
715
- action,
716
- size: dataToWrite.length,
717
- sha256: expectedSha256,
718
- backup_path: backupPath,
719
- });
720
- }
721
-
722
- // If Step 1b actually cleared the workspace AND the bundle did not
723
- // carry a metadata.json entry, the target's cadet:* entries were
724
- // wiped along with the `data/credentials/` directory. Restore them by
725
- // writing a minimal file containing just the preserved entries so the
726
- // gateway can still locate the cloud API key. When Step 1b did NOT
727
- // run (e.g. workspaceDir unset) the live metadata.json is still on
728
- // disk untouched — we must not rewrite it here or we would drop the
729
- // non-cadet entries the caller chose to keep.
730
- const bundleHadMetadata = manifest.contents.some((f) =>
731
- policy.isCredentialMetadataArchivePath(f.path),
732
- );
733
- if (
734
- workspaceWasCleared &&
735
- !bundleHadMetadata &&
736
- liveCredentialMetadataJson &&
737
- credentialMetadataDiskPath
738
- ) {
739
- const merged = mergeMetadataPreservingCadet(
740
- JSON.stringify({ version: 5, credentials: [] }),
741
- liveCredentialMetadataJson,
742
- );
743
- try {
744
- mkdirSync(dirname(credentialMetadataDiskPath), { recursive: true });
745
- writeFileSync(credentialMetadataDiskPath, merged);
746
- } catch (err) {
747
- warnings.push(
748
- `Failed to restore preserved cadet:* credential metadata: ${
749
- err instanceof Error ? err.message : String(err)
750
- }`,
751
- );
752
- }
753
- }
754
-
755
- // Build final report
756
- const report: ImportCommitReport = {
757
- success: true,
758
- summary: {
759
- total_files: importedFiles.length,
760
- files_created: importedFiles.filter((f) => f.action === "created").length,
761
- files_overwritten: importedFiles.filter((f) => f.action === "overwritten")
762
- .length,
763
- files_skipped: importedFiles.filter((f) => f.action === "skipped").length,
764
- backups_created: backupsCreated,
765
- },
766
- files: importedFiles,
767
- manifest,
768
- warnings,
769
- };
770
-
771
- return { ok: true, report };
772
- }
773
-
774
- // ---------------------------------------------------------------------------
775
- // Credential extraction
776
- // ---------------------------------------------------------------------------
777
-
778
- /**
779
- * Extract credential entries from a validated vbundle tar entries map.
780
- *
781
- * Credentials are stored under the `credentials/` prefix in the archive,
782
- * where the remainder of the path is the account name and the entry data
783
- * is the credential value.
784
- */
785
- export function extractCredentialsFromBundle(
786
- entries: Map<string, VBundleTarEntry>,
787
- manifest: ManifestType,
788
- ): Array<{ account: string; value: string }> {
789
- const manifestPaths = new Set(manifest.contents.map((f) => f.path));
790
- const credentials: Array<{ account: string; value: string }> = [];
791
- for (const [path, entry] of entries) {
792
- if (path.startsWith("credentials/") && manifestPaths.has(path)) {
793
- const account = path.slice("credentials/".length);
794
- if (account) {
795
- const value = new TextDecoder().decode(entry.data);
796
- credentials.push({ account, value });
797
- }
798
- }
799
- }
800
- return credentials;
801
- }
802
-
803
- // ---------------------------------------------------------------------------
804
- // Helpers
805
- // ---------------------------------------------------------------------------
806
-
807
- function buildPartialReport(
808
- files: ImportedFileReport[],
809
- manifest: ManifestType,
810
- warnings: string[],
811
- backupsCreated: number,
812
- ): ImportCommitReport {
813
- return {
814
- success: false,
815
- summary: {
816
- total_files: files.length,
817
- files_created: files.filter((f) => f.action === "created").length,
818
- files_overwritten: files.filter((f) => f.action === "overwritten").length,
819
- files_skipped: files.filter((f) => f.action === "skipped").length,
820
- backups_created: backupsCreated,
821
- },
822
- files,
823
- manifest,
824
- warnings,
825
- };
826
- }