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