@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,1823 +0,0 @@
1
- /**
2
- * Tests for `streamCommitImport` — the streaming `.vbundle` importer.
3
- *
4
- * Covered:
5
- * - Happy path: multi-file bundle lands in workspace; report shape matches
6
- * buffer-based `commitImport`.
7
- * - Manifest-first-failure: non-manifest first entry → validation_failed,
8
- * temp dir cleaned, real workspace untouched.
9
- * - Mid-stream hash failure: tampered manifest sha → validation_failed,
10
- * temp dir cleaned, real workspace untouched.
11
- * - Missing entry: manifest declares a file that's absent from the tar →
12
- * validation_failed with offending path surfaced.
13
- * - Extra entry (manifest_mismatch): tar carries a file the manifest does
14
- * not declare → validation_failed.
15
- * - Memory ceiling: 100 MB fixture streams through without pushing heap
16
- * past ~64 MB, proving we're not buffering the whole bundle.
17
- * - Sanity parity: buffer-based `commitImport` and `streamCommitImport`
18
- * produce report objects with the same field shape for the same input.
19
- */
20
-
21
- import { createHash } from "node:crypto";
22
- import {
23
- createReadStream,
24
- existsSync,
25
- mkdirSync,
26
- mkdtempSync,
27
- readdirSync,
28
- readFileSync,
29
- realpathSync,
30
- rmSync,
31
- writeFileSync,
32
- } from "node:fs";
33
- import { tmpdir } from "node:os";
34
- import { join } from "node:path";
35
- import { Readable } from "node:stream";
36
- import { gunzipSync, gzipSync } from "node:zlib";
37
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
38
-
39
- import { APP_VERSION } from "../../../version.js";
40
- import { buildVBundle } from "../vbundle-builder.js";
41
- import { DefaultPathResolver } from "../vbundle-import-analyzer.js";
42
- import { LEGACY_RUNTIME_VERSION_SENTINEL } from "../vbundle-import-policy.js";
43
- import { commitImport } from "../vbundle-importer.js";
44
- import {
45
- IMPORT_BACKUP_PREFIX,
46
- IMPORT_TEMP_PREFIX,
47
- streamCommitImport,
48
- } from "../vbundle-streaming-importer.js";
49
- import { canonicalizeJson } from "../vbundle-validator.js";
50
- import { defaultV1Options } from "./v1-test-helpers.js";
51
-
52
- /**
53
- * Fixed "customized" guardian persona content used by the USER.md-skip
54
- * test. Has user-authored content past the bare scaffold, so
55
- * `isGuardianPersonaCustomized` returns true.
56
- */
57
- const CUSTOMIZED_PERSONA_FIXTURE = `_ Lines starting with _ are comments - they won't appear in the system prompt
58
-
59
- # User Profile
60
-
61
- - Preferred name/reference: Real User
62
- - Pronouns: she/her
63
- - Locale: en-US
64
- - Work role: Staff Engineer
65
- - Goals: Ship drop-user-md
66
- - Hobbies/fun: Reading papers
67
- - Daily tools: Terminal, Cadet
68
- `;
69
-
70
- // ---------------------------------------------------------------------------
71
- // Fixture helpers
72
- // ---------------------------------------------------------------------------
73
-
74
- /**
75
- * Build a temp workspace dir whose parent we own, so the atomic-swap rename
76
- * (`workspaceDir` → `workspaceDir.pre-import-<ts>`) stays inside the test
77
- * sandbox instead of polluting $TMPDIR with stale siblings.
78
- */
79
- function freshWorkspace(): string {
80
- const parent = realpathSync(
81
- mkdtempSync(join(tmpdir(), "vbundle-stream-import-")),
82
- );
83
- const workspaceDir = join(parent, "workspace");
84
- // Don't mkdir — leaving it absent lets us verify "real workspace untouched"
85
- // semantics clearly. Individual tests that need an existing workspace
86
- // create it themselves.
87
- return workspaceDir;
88
- }
89
-
90
- function readableFrom(buf: Uint8Array): Readable {
91
- return Readable.from([Buffer.from(buf)]);
92
- }
93
-
94
- function sha256Hex(data: Uint8Array | string): string {
95
- return createHash("sha256").update(data).digest("hex");
96
- }
97
-
98
- /**
99
- * Strip a specific ustar entry from an already-built archive. Keeps the
100
- * manifest (first entry) intact and drops the entry whose name matches
101
- * `entryName`. Assumes no PAX/longname entries precede the target.
102
- */
103
- function removeEntry(archive: Uint8Array, entryName: string): Uint8Array {
104
- const raw = gunzipSync(archive);
105
-
106
- let offset = 0;
107
- while (offset + 512 <= raw.length) {
108
- const block = raw.subarray(offset, offset + 512);
109
- if (block.every((b) => b === 0)) {
110
- break;
111
- }
112
-
113
- // Entry name is at offset 0..100 of the header, null-terminated.
114
- let nameEnd = 0;
115
- while (nameEnd < 100 && block[nameEnd] !== 0) {
116
- nameEnd += 1;
117
- }
118
- const name = new TextDecoder().decode(block.subarray(0, nameEnd));
119
-
120
- const sizeStr = new TextDecoder()
121
- .decode(block.subarray(124, 136))
122
- .replace(/\0.*$/, "")
123
- .trim();
124
- const size = parseInt(sizeStr, 8) || 0;
125
- const dataBlocks = Math.ceil(size / 512);
126
- const entryLen = 512 + dataBlocks * 512;
127
-
128
- if (name === entryName) {
129
- const out = new Uint8Array(raw.length - entryLen);
130
- out.set(raw.subarray(0, offset), 0);
131
- out.set(raw.subarray(offset + entryLen), offset);
132
- return gzipSync(out);
133
- }
134
-
135
- offset += entryLen;
136
- }
137
-
138
- throw new Error(
139
- `removeEntry: test helper could not find entry "${entryName}" in archive`,
140
- );
141
- }
142
-
143
- /**
144
- * Update manifest.json in place to drop the entry with the given archive
145
- * path AND recompute the v1 `checksum` so the manifest itself stays valid.
146
- * Used to craft the "extra entry" (manifest_mismatch) fixture — the tar
147
- * has the file, but the manifest does not.
148
- */
149
- function dropFromManifestAndRepack(
150
- archive: Uint8Array,
151
- pathToDrop: string,
152
- ): Uint8Array {
153
- const raw = gunzipSync(archive);
154
- const sizeStr = new TextDecoder()
155
- .decode(raw.subarray(124, 136))
156
- .replace(/\0.*$/, "")
157
- .trim();
158
- const origSize = parseInt(sizeStr, 8);
159
- const manifestJson = new TextDecoder().decode(
160
- raw.subarray(512, 512 + origSize),
161
- );
162
- const manifest = JSON.parse(manifestJson) as {
163
- contents: Array<{ path: string; sha256: string; size_bytes: number }>;
164
- checksum: string;
165
- [k: string]: unknown;
166
- };
167
- manifest.contents = manifest.contents.filter((f) => f.path !== pathToDrop);
168
- // Recompute checksum: place an empty-string placeholder in the canonical
169
- // form, exactly mirroring the v1 emit-time canonicalization.
170
- const withEmptyChecksum: Record<string, unknown> = {
171
- ...manifest,
172
- checksum: "",
173
- };
174
- manifest.checksum = sha256Hex(canonicalizeJson(withEmptyChecksum));
175
-
176
- const newJson = JSON.stringify(manifest);
177
- const newBytes = new TextEncoder().encode(newJson);
178
-
179
- // The manifest has almost certainly changed length — rebuild the tar.
180
- // Rewrite the first entry's size field and pad the body to the next
181
- // 512-byte boundary, then concatenate everything after the old manifest.
182
- const header = new Uint8Array(512);
183
- header.set(raw.subarray(0, 512), 0);
184
- const newSizeOctal = newBytes.length.toString(8).padStart(11, "0");
185
- for (let i = 0; i < 11; i++) {
186
- header[124 + i] = newSizeOctal.charCodeAt(i);
187
- }
188
- header[135] = 0;
189
- // Zero out the old checksum field before recomputing.
190
- for (let i = 148; i < 156; i++) {
191
- header[i] = 0x20;
192
- }
193
- let sum = 0;
194
- for (let i = 0; i < 512; i++) {
195
- sum += header[i];
196
- }
197
- const cksum = sum.toString(8).padStart(6, "0");
198
- for (let i = 0; i < 6; i++) {
199
- header[148 + i] = cksum.charCodeAt(i);
200
- }
201
- header[154] = 0;
202
- header[155] = 0x20;
203
-
204
- const oldPaddedLen = 512 + Math.ceil(origSize / 512) * 512;
205
- const newPadded = Math.ceil(newBytes.length / 512) * 512;
206
- const out = new Uint8Array(
207
- header.length + newPadded + (raw.length - oldPaddedLen),
208
- );
209
- out.set(header, 0);
210
- out.set(newBytes, 512);
211
- out.set(raw.subarray(oldPaddedLen), 512 + newPadded);
212
- return gzipSync(out);
213
- }
214
-
215
- // ---------------------------------------------------------------------------
216
- // Happy path
217
- // ---------------------------------------------------------------------------
218
-
219
- describe("streamCommitImport — happy path", () => {
220
- let workspaceDir: string;
221
- beforeEach(() => {
222
- workspaceDir = freshWorkspace();
223
- });
224
- afterEach(() => {
225
- // Clean up any sibling temp/backup dirs left under the workspace parent.
226
- const parent = join(workspaceDir, "..");
227
- try {
228
- rmSync(parent, { recursive: true, force: true });
229
- } catch {
230
- // best-effort
231
- }
232
- });
233
-
234
- test("writes every file into the workspace and returns a report with the expected shape", async () => {
235
- const fileA = new TextEncoder().encode("alpha alpha alpha\n");
236
- const fileB = new TextEncoder().encode("beta beta\n");
237
- const fileC = new TextEncoder().encode("gamma payload\n");
238
-
239
- const { archive } = buildVBundle({
240
- files: [
241
- {
242
- path: "data/db/assistant.db",
243
- data: new Uint8Array(),
244
- },
245
- { path: "workspace/a.txt", data: fileA },
246
- { path: "workspace/sub/b.txt", data: fileB },
247
- { path: "workspace/sub/c.txt", data: fileC },
248
- ],
249
- ...defaultV1Options(),
250
- });
251
-
252
- const result = await streamCommitImport({
253
- source: readableFrom(archive),
254
- pathResolver: new DefaultPathResolver(workspaceDir),
255
- workspaceDir,
256
- });
257
-
258
- expect(result.ok).toBe(true);
259
- if (!result.ok) {
260
- throw new Error("unreachable");
261
- }
262
-
263
- expect(existsSync(join(workspaceDir, "a.txt"))).toBe(true);
264
- expect(readFileSync(join(workspaceDir, "a.txt"))).toEqual(
265
- Buffer.from(fileA),
266
- );
267
- expect(readFileSync(join(workspaceDir, "sub/b.txt"))).toEqual(
268
- Buffer.from(fileB),
269
- );
270
- expect(readFileSync(join(workspaceDir, "sub/c.txt"))).toEqual(
271
- Buffer.from(fileC),
272
- );
273
-
274
- expect(result.report.success).toBe(true);
275
- // Includes the synthetic data/db/assistant.db entry plus a/b/c.txt.
276
- expect(result.report.summary.total_files).toBe(4);
277
- expect(result.report.summary.files_created).toBe(4);
278
- expect(result.report.manifest.contents).toHaveLength(4);
279
- for (const f of result.report.files) {
280
- expect(f.action).toBe("created");
281
- expect(f.backup_path).toBeNull();
282
- expect(typeof f.sha256).toBe("string");
283
- expect(f.disk_path.startsWith(workspaceDir)).toBe(true);
284
- }
285
- });
286
-
287
- test("invokes onProgress after each file entry finishes", async () => {
288
- const { archive } = buildVBundle({
289
- files: [
290
- {
291
- path: "data/db/assistant.db",
292
- data: new Uint8Array(),
293
- },
294
- {
295
- path: "workspace/a.txt",
296
- data: new TextEncoder().encode("one"),
297
- },
298
- {
299
- path: "workspace/b.txt",
300
- data: new TextEncoder().encode("two!"),
301
- },
302
- ],
303
- ...defaultV1Options(),
304
- });
305
-
306
- const events: Array<{
307
- archivePath: string;
308
- bytesWritten: number;
309
- entryIndex: number;
310
- }> = [];
311
- const result = await streamCommitImport({
312
- source: readableFrom(archive),
313
- pathResolver: new DefaultPathResolver(workspaceDir),
314
- workspaceDir,
315
- onProgress: (e) => events.push(e),
316
- });
317
-
318
- expect(result.ok).toBe(true);
319
- expect(events.map((e) => e.archivePath)).toEqual([
320
- "data/db/assistant.db",
321
- "workspace/a.txt",
322
- "workspace/b.txt",
323
- ]);
324
- expect(events[1]?.bytesWritten).toBe(3);
325
- expect(events[2]?.bytesWritten).toBe(4);
326
- expect(events[0]?.entryIndex).toBeLessThan(events[1]?.entryIndex ?? -1);
327
- });
328
-
329
- test("forwards credentials to importCredentials callback but never writes them to disk", async () => {
330
- const { archive } = buildVBundle({
331
- files: [
332
- {
333
- path: "data/db/assistant.db",
334
- data: new Uint8Array(),
335
- },
336
- {
337
- path: "workspace/config.json",
338
- data: new TextEncoder().encode("{}"),
339
- },
340
- {
341
- path: "credentials/openai-key",
342
- data: new TextEncoder().encode("sk-test-1"),
343
- },
344
- {
345
- path: "credentials/anthropic-key",
346
- data: new TextEncoder().encode("sk-ant-2"),
347
- },
348
- ],
349
- ...defaultV1Options(),
350
- });
351
-
352
- const received: Array<{ account: string; value: string }> = [];
353
- const result = await streamCommitImport({
354
- source: readableFrom(archive),
355
- pathResolver: new DefaultPathResolver(workspaceDir),
356
- workspaceDir,
357
- importCredentials: async (creds) => {
358
- received.push(...creds);
359
- },
360
- });
361
-
362
- expect(result.ok).toBe(true);
363
- expect(received).toHaveLength(2);
364
- expect(received).toContainEqual({
365
- account: "openai-key",
366
- value: "sk-test-1",
367
- });
368
- expect(received).toContainEqual({
369
- account: "anthropic-key",
370
- value: "sk-ant-2",
371
- });
372
- // Credentials must NOT appear on disk.
373
- expect(existsSync(join(workspaceDir, "credentials"))).toBe(false);
374
- });
375
- });
376
-
377
- // ---------------------------------------------------------------------------
378
- // Failure modes — every one must leave the real workspace untouched and
379
- // clean up the sibling temp dir.
380
- // ---------------------------------------------------------------------------
381
-
382
- describe("streamCommitImport — failure modes", () => {
383
- let workspaceDir: string;
384
- beforeEach(() => {
385
- workspaceDir = freshWorkspace();
386
- });
387
- afterEach(() => {
388
- const parent = join(workspaceDir, "..");
389
- try {
390
- rmSync(parent, { recursive: true, force: true });
391
- } catch {
392
- // best-effort
393
- }
394
- });
395
-
396
- /** Ensure no sibling temp/backup dirs for this workspace remain. */
397
- function assertNoLeftoverTempDirs(): void {
398
- const parent = join(workspaceDir, "..");
399
- const base = workspaceDir.split("/").pop()!;
400
- const siblings = readdirSync(parent);
401
- const leftover = siblings.filter(
402
- (name) =>
403
- name.startsWith(`${base}.import-`) ||
404
- name.startsWith(`${base}.pre-import-`),
405
- );
406
- expect(leftover).toEqual([]);
407
- }
408
-
409
- test("manifest-first failure: non-manifest first entry → validation_failed, real workspace untouched", async () => {
410
- // Seed the real workspace with a marker file so we can verify it's
411
- // untouched after the failed import.
412
- mkdirSync(workspaceDir, { recursive: true });
413
- writeFileSync(join(workspaceDir, "existing.txt"), "keep me\n");
414
-
415
- // Hand-roll a gzipped tar whose first entry is NOT manifest.json.
416
- // Reuse buildVBundle for a valid archive, then strip the manifest
417
- // entry using removeEntry — the remaining archive opens with a
418
- // workspace/ file as entry #1.
419
- const { archive } = buildVBundle({
420
- files: [
421
- {
422
- path: "data/db/assistant.db",
423
- data: new Uint8Array(),
424
- },
425
- {
426
- path: "workspace/a.txt",
427
- data: new TextEncoder().encode("hello"),
428
- },
429
- ],
430
- ...defaultV1Options(),
431
- });
432
- const noManifest = removeEntry(archive, "manifest.json");
433
-
434
- const result = await streamCommitImport({
435
- source: readableFrom(noManifest),
436
- pathResolver: new DefaultPathResolver(workspaceDir),
437
- workspaceDir,
438
- });
439
-
440
- expect(result.ok).toBe(false);
441
- if (result.ok) {
442
- throw new Error("unreachable");
443
- }
444
- expect(result.reason).toBe("validation_failed");
445
-
446
- // Real workspace's pre-existing content is still there, unmodified.
447
- expect(readFileSync(join(workspaceDir, "existing.txt"), "utf8")).toBe(
448
- "keep me\n",
449
- );
450
- assertNoLeftoverTempDirs();
451
- });
452
-
453
- test("mid-stream hash failure: tampered manifest sha → validation_failed, cleanup intact", async () => {
454
- mkdirSync(workspaceDir, { recursive: true });
455
- writeFileSync(join(workspaceDir, "existing.txt"), "keep me\n");
456
-
457
- // Build a valid bundle with one file whose data is 32 bytes long.
458
- const body = new TextEncoder().encode("x".repeat(32));
459
- const { archive } = buildVBundle({
460
- files: [
461
- { path: "data/db/assistant.db", data: new Uint8Array() },
462
- { path: "workspace/victim.txt", data: body },
463
- ],
464
- ...defaultV1Options(),
465
- });
466
-
467
- // Tamper the per-file sha256 for workspace/victim.txt by substituting
468
- // one hex character. Keeps the manifest schema-valid (substitution is
469
- // same-length) — but because the v1 `checksum` is recomputed over the
470
- // declared data, we ALSO need to recompute `checksum` (with the empty
471
- // placeholder) to keep the manifest itself valid. Otherwise the
472
- // manifest fails its self-checksum and the test exercises the wrong
473
- // path.
474
- const raw = gunzipSync(archive);
475
- const sizeStr = new TextDecoder()
476
- .decode(raw.subarray(124, 136))
477
- .replace(/\0.*$/, "")
478
- .trim();
479
- const origSize = parseInt(sizeStr, 8);
480
- const manifestJson = new TextDecoder().decode(
481
- raw.subarray(512, 512 + origSize),
482
- );
483
- const manifest = JSON.parse(manifestJson) as {
484
- contents: Array<{ path: string; sha256: string; size_bytes: number }>;
485
- checksum: string;
486
- [k: string]: unknown;
487
- };
488
- manifest.contents = manifest.contents.map((f) =>
489
- f.path === "workspace/victim.txt"
490
- ? {
491
- ...f,
492
- // Deterministic-but-wrong sha: flip the high bit of char 0.
493
- sha256: "0" + f.sha256.slice(1),
494
- }
495
- : f,
496
- );
497
- const withEmptyChecksum: Record<string, unknown> = {
498
- ...manifest,
499
- checksum: "",
500
- };
501
- manifest.checksum = sha256Hex(canonicalizeJson(withEmptyChecksum));
502
-
503
- const newJson = JSON.stringify(manifest);
504
- const newBytes = new TextEncoder().encode(newJson);
505
- if (newBytes.length !== origSize) {
506
- throw new Error(
507
- `hash-failure test fixture: manifest length drifted (${newBytes.length} vs ${origSize})`,
508
- );
509
- }
510
- const tampered = new Uint8Array(raw.length);
511
- tampered.set(raw);
512
- tampered.set(newBytes, 512);
513
- const tamperedArchive = gzipSync(tampered);
514
-
515
- const result = await streamCommitImport({
516
- source: readableFrom(tamperedArchive),
517
- pathResolver: new DefaultPathResolver(workspaceDir),
518
- workspaceDir,
519
- });
520
-
521
- expect(result.ok).toBe(false);
522
- if (result.ok) {
523
- throw new Error("unreachable");
524
- }
525
- expect(result.reason).toBe("validation_failed");
526
-
527
- // Existing workspace content preserved, no temp dir hanging around.
528
- expect(readFileSync(join(workspaceDir, "existing.txt"), "utf8")).toBe(
529
- "keep me\n",
530
- );
531
- assertNoLeftoverTempDirs();
532
- });
533
-
534
- test("missing entry: manifest declares a path absent from the tar → validation_failed", async () => {
535
- mkdirSync(workspaceDir, { recursive: true });
536
- writeFileSync(join(workspaceDir, "existing.txt"), "keep me\n");
537
-
538
- const { archive } = buildVBundle({
539
- files: [
540
- {
541
- path: "data/db/assistant.db",
542
- data: new Uint8Array(),
543
- },
544
- {
545
- path: "workspace/present.txt",
546
- data: new TextEncoder().encode("here"),
547
- },
548
- {
549
- path: "workspace/missing.txt",
550
- data: new TextEncoder().encode("gone"),
551
- },
552
- ],
553
- ...defaultV1Options(),
554
- });
555
- const stripped = removeEntry(archive, "workspace/missing.txt");
556
-
557
- const result = await streamCommitImport({
558
- source: readableFrom(stripped),
559
- pathResolver: new DefaultPathResolver(workspaceDir),
560
- workspaceDir,
561
- });
562
-
563
- expect(result.ok).toBe(false);
564
- if (result.ok) {
565
- throw new Error("unreachable");
566
- }
567
- expect(result.reason).toBe("validation_failed");
568
- // The error payload should surface the missing path.
569
- const combined = JSON.stringify(result);
570
- expect(combined).toContain("workspace/missing.txt");
571
-
572
- expect(readFileSync(join(workspaceDir, "existing.txt"), "utf8")).toBe(
573
- "keep me\n",
574
- );
575
- assertNoLeftoverTempDirs();
576
- });
577
-
578
- test("extra entry: tar contains a file the manifest does not declare → validation_failed", async () => {
579
- mkdirSync(workspaceDir, { recursive: true });
580
- writeFileSync(join(workspaceDir, "existing.txt"), "keep me\n");
581
-
582
- const { archive } = buildVBundle({
583
- files: [
584
- {
585
- path: "data/db/assistant.db",
586
- data: new Uint8Array(),
587
- },
588
- {
589
- path: "workspace/declared.txt",
590
- data: new TextEncoder().encode("fine"),
591
- },
592
- {
593
- path: "workspace/extra.txt",
594
- data: new TextEncoder().encode("surprise"),
595
- },
596
- ],
597
- ...defaultV1Options(),
598
- });
599
- const extraPresent = dropFromManifestAndRepack(
600
- archive,
601
- "workspace/extra.txt",
602
- );
603
-
604
- const result = await streamCommitImport({
605
- source: readableFrom(extraPresent),
606
- pathResolver: new DefaultPathResolver(workspaceDir),
607
- workspaceDir,
608
- });
609
-
610
- expect(result.ok).toBe(false);
611
- if (result.ok) {
612
- throw new Error("unreachable");
613
- }
614
- expect(result.reason).toBe("validation_failed");
615
-
616
- expect(readFileSync(join(workspaceDir, "existing.txt"), "utf8")).toBe(
617
- "keep me\n",
618
- );
619
- assertNoLeftoverTempDirs();
620
- });
621
- });
622
-
623
- // ---------------------------------------------------------------------------
624
- // Runtime-version compat gate — the streaming importer must refuse to
625
- // populate the temp tree when the bundle's compat range excludes APP_VERSION.
626
- // ---------------------------------------------------------------------------
627
-
628
- describe("streamCommitImport — runtime-version compat gate", () => {
629
- let workspaceDir: string;
630
- beforeEach(() => {
631
- workspaceDir = freshWorkspace();
632
- });
633
- afterEach(() => {
634
- const parent = join(workspaceDir, "..");
635
- try {
636
- rmSync(parent, { recursive: true, force: true });
637
- } catch {
638
- // best-effort
639
- }
640
- });
641
-
642
- /**
643
- * The streaming importer creates scratch dirs `${workspaceDir}/.import-<uuid>`
644
- * and `${workspaceDir}/.pre-import-<ts>-<uuid>` INSIDE workspaceDir (so every
645
- * rename during the content-level swap stays on the same filesystem). Scan
646
- * for orphan entries with those prefixes inside workspaceDir using the
647
- * importer's own constants so this assertion stays in sync with the
648
- * importer's actual layout.
649
- */
650
- function assertNoLeftoverTempDirs(): void {
651
- if (!existsSync(workspaceDir)) {
652
- return;
653
- }
654
- const entries = readdirSync(workspaceDir);
655
- const leftover = entries.filter(
656
- (name) =>
657
- name.startsWith(IMPORT_TEMP_PREFIX) ||
658
- name.startsWith(IMPORT_BACKUP_PREFIX),
659
- );
660
- expect(leftover).toEqual([]);
661
- }
662
-
663
- test("incompatible bundle returns version_incompatible without writing the temp tree", async () => {
664
- const { archive } = buildVBundle({
665
- files: [
666
- { path: "data/db/assistant.db", data: new Uint8Array() },
667
- {
668
- path: "workspace/a.txt",
669
- data: new TextEncoder().encode("never-written"),
670
- },
671
- ],
672
- ...defaultV1Options(),
673
- compatibility: {
674
- min_runtime_version: "99.0.0",
675
- max_runtime_version: null,
676
- },
677
- });
678
-
679
- // Pre-condition: workspace dir does NOT exist on a fresh filesystem.
680
- // The plan invariant requires that the streaming importer gate on
681
- // runtime-version compat BEFORE any state mutation, so an
682
- // incompatible bundle must leave the filesystem untouched.
683
- expect(existsSync(workspaceDir)).toBe(false);
684
-
685
- const result = await streamCommitImport({
686
- source: readableFrom(archive),
687
- pathResolver: new DefaultPathResolver(workspaceDir),
688
- workspaceDir,
689
- });
690
-
691
- if (result.ok || result.reason !== "version_incompatible") {
692
- throw new Error(
693
- `expected version_incompatible, got ${JSON.stringify(result)}`,
694
- );
695
- }
696
- expect(result.bundle_compat.min_runtime_version).toBe("99.0.0");
697
- expect(result.bundle_compat.max_runtime_version).toBeNull();
698
- expect(result.runtime_version).toBe(APP_VERSION);
699
-
700
- // Post-condition: workspace dir STILL does not exist. The version
701
- // gate now runs before the temp-staging mkdir, so rejecting an
702
- // incompatible bundle leaves zero filesystem trace.
703
- expect(existsSync(workspaceDir)).toBe(false);
704
- assertNoLeftoverTempDirs();
705
- });
706
-
707
- test("legacy sentinel passes through the streaming path", async () => {
708
- const fileA = new TextEncoder().encode("legacy-import\n");
709
- const { archive } = buildVBundle({
710
- files: [
711
- { path: "data/db/assistant.db", data: new Uint8Array() },
712
- { path: "workspace/a.txt", data: fileA },
713
- ],
714
- ...defaultV1Options(),
715
- compatibility: {
716
- min_runtime_version: LEGACY_RUNTIME_VERSION_SENTINEL,
717
- max_runtime_version: null,
718
- },
719
- });
720
-
721
- const result = await streamCommitImport({
722
- source: readableFrom(archive),
723
- pathResolver: new DefaultPathResolver(workspaceDir),
724
- workspaceDir,
725
- });
726
-
727
- expect(result.ok).toBe(true);
728
- expect(readFileSync(join(workspaceDir, "a.txt"))).toEqual(
729
- Buffer.from(fileA),
730
- );
731
- assertNoLeftoverTempDirs();
732
- });
733
-
734
- test("pre-existing workspace files stay untouched on incompatibility", async () => {
735
- mkdirSync(workspaceDir, { recursive: true });
736
- const seedBytes = new TextEncoder().encode("preserve-me\n");
737
- writeFileSync(join(workspaceDir, "seed.txt"), seedBytes);
738
-
739
- const { archive } = buildVBundle({
740
- files: [
741
- { path: "data/db/assistant.db", data: new Uint8Array() },
742
- {
743
- path: "workspace/a.txt",
744
- data: new TextEncoder().encode("never-written"),
745
- },
746
- ],
747
- ...defaultV1Options(),
748
- compatibility: {
749
- min_runtime_version: "99.0.0",
750
- max_runtime_version: null,
751
- },
752
- });
753
-
754
- const result = await streamCommitImport({
755
- source: readableFrom(archive),
756
- pathResolver: new DefaultPathResolver(workspaceDir),
757
- workspaceDir,
758
- });
759
-
760
- expect(result.ok).toBe(false);
761
- if (result.ok) {
762
- throw new Error("unreachable");
763
- }
764
- expect(result.reason).toBe("version_incompatible");
765
-
766
- expect(readFileSync(join(workspaceDir, "seed.txt"))).toEqual(
767
- Buffer.from(seedBytes),
768
- );
769
- assertNoLeftoverTempDirs();
770
- });
771
- });
772
-
773
- // ---------------------------------------------------------------------------
774
- // Memory ceiling — the point of the streaming path.
775
- // ---------------------------------------------------------------------------
776
-
777
- /**
778
- * Materialize a ~100 MB .vbundle fixture on disk and return its path.
779
- * Wrapping the build in its own function lets the intermediate Uint8Arrays
780
- * go out of scope before we start measuring heap — the fixture itself
781
- * must not count against the importer's working-set budget.
782
- */
783
- function writeLargeFixtureToDisk(archivePath: string): void {
784
- const CHUNK = 25 * 1024 * 1024;
785
- const files = [
786
- { path: "data/db/assistant.db", data: new Uint8Array() },
787
- ...[0, 1, 2, 3].map((i) => ({
788
- path: `workspace/big-${i}.bin`,
789
- data: new Uint8Array(CHUNK).fill(0x41 + i),
790
- })),
791
- ];
792
- const { archive } = buildVBundle({ files, ...defaultV1Options() });
793
- writeFileSync(archivePath, archive);
794
- }
795
-
796
- describe("streamCommitImport — memory ceiling", () => {
797
- test("100 MB fixture streams in without pushing RSS past ~64 MB over baseline", async () => {
798
- const workspaceDir = freshWorkspace();
799
- const parent = join(workspaceDir, "..");
800
- const archivePath = join(parent, "fixture.vbundle");
801
-
802
- try {
803
- // Build the fixture in an isolated scope so intermediate buffers go
804
- // out of scope before we start measuring.
805
- writeLargeFixtureToDisk(archivePath);
806
-
807
- // Bun's `process.memoryUsage().heapUsed` can include accounting for
808
- // off-heap Buffer backing stores, so a strict heap ceiling is noisy
809
- // across engines. Use RSS instead — that's the actual "did the
810
- // process grow" signal. If the importer were buffering the full 100
811
- // MB archive, RSS would spike by at least 100 MB; a streaming
812
- // importer's per-entry working set is bounded by ~one tar entry's
813
- // internal buffers (a few MB).
814
- //
815
- // Bun 1.3.11 can throw `SystemError: Failed to get memory usage` from the
816
- // underlying syscall; guard every sample so a spurious failure doesn't
817
- // flake the run — a skipped sample just keeps the prior peak.
818
- const sampleRss = (): number | null => {
819
- try {
820
- return process.memoryUsage().rss;
821
- } catch {
822
- return null;
823
- }
824
- };
825
- const baselineRss = sampleRss();
826
- let peakRss = baselineRss ?? 0;
827
- let progressCount = 0;
828
-
829
- const result = await streamCommitImport({
830
- source: createReadStream(archivePath),
831
- pathResolver: new DefaultPathResolver(workspaceDir),
832
- workspaceDir,
833
- onProgress: () => {
834
- progressCount += 1;
835
- const cur = sampleRss();
836
- if (cur !== null && cur > peakRss) {
837
- peakRss = cur;
838
- }
839
- },
840
- });
841
-
842
- expect(result.ok).toBe(true);
843
- // We expect onProgress to fire at least 4 times (one per big file) —
844
- // spot-check that we actually sampled during import.
845
- expect(progressCount).toBeGreaterThanOrEqual(4);
846
-
847
- // The 64 MB delta bound is a rough guard proving "it doesn't buffer
848
- // the whole bundle" — if the importer were accumulating the 100 MB
849
- // archive in memory, RSS would jump well past this threshold.
850
- const delta = peakRss - (baselineRss ?? 0);
851
- expect(delta).toBeLessThan(64 * 1024 * 1024);
852
- } finally {
853
- try {
854
- rmSync(parent, { recursive: true, force: true });
855
- } catch {
856
- // best-effort
857
- }
858
- }
859
- }, 60_000);
860
- });
861
-
862
- // ---------------------------------------------------------------------------
863
- // Sanity parity with buffer-based commitImport.
864
- // ---------------------------------------------------------------------------
865
-
866
- describe("streamCommitImport — report parity with commitImport", () => {
867
- test("buffer-based and streaming importer produce report objects with the same field shape", async () => {
868
- const bufferWorkspace = freshWorkspace();
869
- const streamWorkspace = freshWorkspace();
870
-
871
- const files = [
872
- {
873
- path: "data/db/assistant.db",
874
- data: new Uint8Array(),
875
- },
876
- {
877
- path: "workspace/a.txt",
878
- data: new TextEncoder().encode("alpha"),
879
- },
880
- {
881
- path: "workspace/sub/b.txt",
882
- data: new TextEncoder().encode("beta beta"),
883
- },
884
- ];
885
- const { archive } = buildVBundle({ files, ...defaultV1Options() });
886
-
887
- // Buffer-based path.
888
- mkdirSync(bufferWorkspace, { recursive: true });
889
- const bufferResult = commitImport({
890
- archiveData: archive,
891
- pathResolver: new DefaultPathResolver(bufferWorkspace),
892
- workspaceDir: bufferWorkspace,
893
- });
894
-
895
- // Streaming path.
896
- const streamResult = await streamCommitImport({
897
- source: readableFrom(archive),
898
- pathResolver: new DefaultPathResolver(streamWorkspace),
899
- workspaceDir: streamWorkspace,
900
- });
901
-
902
- try {
903
- expect(bufferResult.ok).toBe(true);
904
- expect(streamResult.ok).toBe(true);
905
- if (!bufferResult.ok || !streamResult.ok) {
906
- throw new Error("unreachable");
907
- }
908
-
909
- // The shapes must match key-for-key.
910
- expect(Object.keys(streamResult.report).sort()).toEqual(
911
- Object.keys(bufferResult.report).sort(),
912
- );
913
- expect(Object.keys(streamResult.report.summary).sort()).toEqual(
914
- Object.keys(bufferResult.report.summary).sort(),
915
- );
916
- expect(streamResult.report.files.length).toBe(
917
- bufferResult.report.files.length,
918
- );
919
- for (let i = 0; i < streamResult.report.files.length; i++) {
920
- expect(Object.keys(streamResult.report.files[i]).sort()).toEqual(
921
- Object.keys(bufferResult.report.files[i]).sort(),
922
- );
923
- }
924
-
925
- // Manifest payload itself should match — the streaming path parses it
926
- // directly from the same bytes.
927
- expect(streamResult.report.manifest.checksum).toBe(
928
- bufferResult.report.manifest.checksum,
929
- );
930
- } finally {
931
- for (const ws of [bufferWorkspace, streamWorkspace]) {
932
- const parent = join(ws, "..");
933
- try {
934
- rmSync(parent, { recursive: true, force: true });
935
- } catch {
936
- // best-effort
937
- }
938
- }
939
- }
940
- });
941
- });
942
-
943
- // ---------------------------------------------------------------------------
944
- // Parity with commitImport: workspace-swap gating, config sanitization,
945
- // legacy USER.md skip when persona is customized. Each of these regressed
946
- // when the streaming path was first introduced.
947
- // ---------------------------------------------------------------------------
948
-
949
- describe("streamCommitImport — no workspace entries means no swap", () => {
950
- let workspaceDir: string;
951
- beforeEach(() => {
952
- workspaceDir = freshWorkspace();
953
- });
954
- afterEach(() => {
955
- const parent = join(workspaceDir, "..");
956
- try {
957
- rmSync(parent, { recursive: true, force: true });
958
- } catch {
959
- // best-effort
960
- }
961
- });
962
-
963
- test("bundle with only credentials leaves the real workspace untouched", async () => {
964
- // Seed the real workspace with a marker file. A successful import that
965
- // had workspace entries would wipe-and-swap this file out of existence,
966
- // so its survival post-import proves we skipped the rename pair.
967
- mkdirSync(workspaceDir, { recursive: true });
968
- writeFileSync(
969
- join(workspaceDir, "marker-please-preserve.txt"),
970
- "do not touch\n",
971
- );
972
- mkdirSync(join(workspaceDir, "deep", "nested"), { recursive: true });
973
- writeFileSync(
974
- join(workspaceDir, "deep", "nested", "file.txt"),
975
- "nested content\n",
976
- );
977
-
978
- // Credential entries resolve to null via DefaultPathResolver — they are
979
- // buffered in memory for CES, never land on disk. A bundle consisting
980
- // entirely of credentials therefore has zero workspace-targeted writes.
981
- const { archive } = buildVBundle({
982
- files: [
983
- {
984
- path: "data/db/assistant.db",
985
- data: new Uint8Array(),
986
- },
987
- {
988
- path: "credentials/openai-key",
989
- data: new TextEncoder().encode("sk-test-creds-only"),
990
- },
991
- ],
992
- ...defaultV1Options(),
993
- });
994
-
995
- const received: Array<{ account: string; value: string }> = [];
996
- const result = await streamCommitImport({
997
- source: readableFrom(archive),
998
- pathResolver: new DefaultPathResolver(workspaceDir),
999
- workspaceDir,
1000
- importCredentials: async (creds) => {
1001
- received.push(...creds);
1002
- },
1003
- });
1004
-
1005
- expect(result.ok).toBe(true);
1006
- if (!result.ok) {
1007
- throw new Error("unreachable");
1008
- }
1009
-
1010
- // Real workspace's pre-existing files are STILL THERE — the temp tree
1011
- // was not swapped in.
1012
- expect(
1013
- readFileSync(join(workspaceDir, "marker-please-preserve.txt"), "utf8"),
1014
- ).toBe("do not touch\n");
1015
- expect(
1016
- readFileSync(join(workspaceDir, "deep", "nested", "file.txt"), "utf8"),
1017
- ).toBe("nested content\n");
1018
-
1019
- // Credentials still flowed through post-commit as they should.
1020
- expect(received).toEqual([
1021
- { account: "openai-key", value: "sk-test-creds-only" },
1022
- ]);
1023
-
1024
- // Report should reflect "only the synthetic data/db/assistant.db
1025
- // landed; no `workspace/*` entries were written".
1026
- expect(result.report.summary.files_created).toBe(1);
1027
- expect(result.report.summary.files_overwritten).toBe(0);
1028
- expect(
1029
- result.report.files.find((f) => f.path === "data/db/assistant.db")
1030
- ?.action,
1031
- ).toBe("created");
1032
-
1033
- // Cleanup removed the temp dir — no sibling left behind.
1034
- const parent = join(workspaceDir, "..");
1035
- const base = workspaceDir.split("/").pop()!;
1036
- const siblings = readdirSync(parent);
1037
- const leftover = siblings.filter(
1038
- (name) =>
1039
- name.startsWith(`${base}.import-`) ||
1040
- name.startsWith(`${base}.pre-import-`),
1041
- );
1042
- expect(leftover).toEqual([]);
1043
- });
1044
-
1045
- test("bundle with only out-of-workspace resolved targets leaves real workspace untouched", async () => {
1046
- mkdirSync(workspaceDir, { recursive: true });
1047
- writeFileSync(
1048
- join(workspaceDir, "marker-please-preserve.txt"),
1049
- "survive\n",
1050
- );
1051
-
1052
- // A resolver that resolves paths OUTSIDE the workspace dir — the
1053
- // streaming importer drains these through the verifier and records
1054
- // them as skipped, so no writes land in the temp workspace.
1055
- const outOfWorkspaceDir = realpathSync(
1056
- mkdtempSync(join(tmpdir(), "oow-target-")),
1057
- );
1058
- const externalResolver = {
1059
- resolve(archivePath: string): string | null {
1060
- if (archivePath.startsWith("credentials/")) {
1061
- return null;
1062
- }
1063
- return join(outOfWorkspaceDir, archivePath.replace(/\//g, "_"));
1064
- },
1065
- };
1066
-
1067
- const { archive } = buildVBundle({
1068
- files: [
1069
- {
1070
- path: "data/db/assistant.db",
1071
- data: new Uint8Array(),
1072
- },
1073
- {
1074
- path: "workspace/something.txt",
1075
- data: new TextEncoder().encode("ignored"),
1076
- },
1077
- ],
1078
- ...defaultV1Options(),
1079
- });
1080
-
1081
- try {
1082
- const result = await streamCommitImport({
1083
- source: readableFrom(archive),
1084
- pathResolver: externalResolver,
1085
- workspaceDir,
1086
- });
1087
-
1088
- expect(result.ok).toBe(true);
1089
- if (!result.ok) {
1090
- throw new Error("unreachable");
1091
- }
1092
-
1093
- // Everything was skipped as "outside workspace".
1094
- expect(result.report.summary.files_skipped).toBeGreaterThanOrEqual(1);
1095
- expect(result.report.summary.files_created).toBe(0);
1096
-
1097
- // Real workspace is still intact.
1098
- expect(
1099
- readFileSync(join(workspaceDir, "marker-please-preserve.txt"), "utf8"),
1100
- ).toBe("survive\n");
1101
- } finally {
1102
- try {
1103
- rmSync(outOfWorkspaceDir, { recursive: true, force: true });
1104
- } catch {
1105
- // best-effort
1106
- }
1107
- }
1108
- });
1109
- });
1110
-
1111
- describe("streamCommitImport — config sanitization parity", () => {
1112
- let workspaceDir: string;
1113
- beforeEach(() => {
1114
- workspaceDir = freshWorkspace();
1115
- });
1116
- afterEach(() => {
1117
- const parent = join(workspaceDir, "..");
1118
- try {
1119
- rmSync(parent, { recursive: true, force: true });
1120
- } catch {
1121
- // best-effort
1122
- }
1123
- });
1124
-
1125
- test("workspace/config.json is sanitized before being written", async () => {
1126
- // sanitizeConfigForTransfer strips `daemon` entirely, clears
1127
- // `ingress.publicBaseUrl`, deletes `ingress.enabled`, and zeros
1128
- // `skills.load.extraDirs`. We plant all of these in the archived
1129
- // config and assert they're gone on disk.
1130
- const tainted = JSON.stringify({
1131
- daemon: { pid: 1234, host: "private.example.com" },
1132
- ingress: { publicBaseUrl: "https://leaky.example", enabled: true },
1133
- skills: { load: { extraDirs: ["/tmp/leak-a", "/tmp/leak-b"] } },
1134
- unrelated: "keep-me",
1135
- });
1136
-
1137
- const { archive } = buildVBundle({
1138
- files: [
1139
- {
1140
- path: "data/db/assistant.db",
1141
- data: new Uint8Array(),
1142
- },
1143
- {
1144
- path: "workspace/config.json",
1145
- data: new TextEncoder().encode(tainted),
1146
- },
1147
- ],
1148
- ...defaultV1Options(),
1149
- });
1150
-
1151
- const result = await streamCommitImport({
1152
- source: readableFrom(archive),
1153
- pathResolver: new DefaultPathResolver(workspaceDir),
1154
- workspaceDir,
1155
- });
1156
-
1157
- expect(result.ok).toBe(true);
1158
- if (!result.ok) {
1159
- throw new Error("unreachable");
1160
- }
1161
-
1162
- const writtenPath = join(workspaceDir, "config.json");
1163
- expect(existsSync(writtenPath)).toBe(true);
1164
- const writtenJson = JSON.parse(readFileSync(writtenPath, "utf8")) as Record<
1165
- string,
1166
- unknown
1167
- >;
1168
-
1169
- // Environment-specific fields have been stripped or reset.
1170
- expect(writtenJson.daemon).toBeUndefined();
1171
- expect((writtenJson.ingress as Record<string, unknown>).publicBaseUrl).toBe(
1172
- "",
1173
- );
1174
- expect(
1175
- (writtenJson.ingress as Record<string, unknown>).enabled,
1176
- ).toBeUndefined();
1177
- expect(
1178
- (
1179
- (writtenJson.skills as Record<string, unknown>).load as Record<
1180
- string,
1181
- unknown
1182
- >
1183
- ).extraDirs,
1184
- ).toEqual([]);
1185
-
1186
- // Unrelated content is preserved verbatim.
1187
- expect(writtenJson.unrelated).toBe("keep-me");
1188
- });
1189
- });
1190
-
1191
- describe("streamCommitImport — legacy USER.md skip on customized persona", () => {
1192
- let workspaceDir: string;
1193
- beforeEach(() => {
1194
- workspaceDir = freshWorkspace();
1195
- });
1196
- afterEach(() => {
1197
- const parent = join(workspaceDir, "..");
1198
- try {
1199
- rmSync(parent, { recursive: true, force: true });
1200
- } catch {
1201
- // best-effort
1202
- }
1203
- });
1204
-
1205
- test("skips writing prompts/USER.md when guardian persona is customized", async () => {
1206
- // Seed the live workspace with a customized guardian persona file.
1207
- // `isGuardianPersonaCustomized` inspects its content vs the bare
1208
- // scaffold template; customized content must prevent the write.
1209
- //
1210
- // NOTE: the streaming importer uses an atomic temp-dir swap. The swap
1211
- // REPLACES the entire live workspace with the temp workspace — so the
1212
- // customized file at users/captain.md is expected to be gone after
1213
- // import regardless (its contents are not re-materialized into the
1214
- // temp tree). The behavior under test here is narrower: we verify
1215
- // the legacy bundle's USER.md content was NEVER written into the
1216
- // temp workspace's guardian path, and that the entry is reported as
1217
- // `"skipped"` with a warning. This matches commitImport's semantics
1218
- // for the legacy entry itself.
1219
- const guardianPath = join(workspaceDir, "users", "captain.md");
1220
- mkdirSync(join(workspaceDir, "users"), { recursive: true });
1221
- writeFileSync(guardianPath, CUSTOMIZED_PERSONA_FIXTURE, "utf8");
1222
-
1223
- const legacyContent = new TextEncoder().encode(
1224
- "# Legacy bundle persona — should NOT be written over a customized file\n",
1225
- );
1226
-
1227
- const resolver = new DefaultPathResolver(
1228
- workspaceDir,
1229
- undefined,
1230
- () => guardianPath,
1231
- );
1232
-
1233
- const { archive } = buildVBundle({
1234
- files: [
1235
- {
1236
- path: "data/db/assistant.db",
1237
- data: new Uint8Array(),
1238
- },
1239
- {
1240
- path: "prompts/USER.md",
1241
- data: legacyContent,
1242
- },
1243
- // Second entry ensures there's at least one workspace-targeted
1244
- // write, so the atomic swap runs and we're exercising the skip
1245
- // branch on the full flow rather than the no-swap short circuit.
1246
- {
1247
- path: "workspace/other.txt",
1248
- data: new TextEncoder().encode("other content"),
1249
- },
1250
- ],
1251
- ...defaultV1Options(),
1252
- });
1253
-
1254
- const result = await streamCommitImport({
1255
- source: readableFrom(archive),
1256
- pathResolver: resolver,
1257
- workspaceDir,
1258
- });
1259
-
1260
- expect(result.ok).toBe(true);
1261
- if (!result.ok) {
1262
- throw new Error("unreachable");
1263
- }
1264
-
1265
- // The other file in the bundle was written normally (proves the swap
1266
- // happened — we're not accidentally hitting the no-swap short circuit).
1267
- expect(readFileSync(join(workspaceDir, "other.txt"), "utf8")).toBe(
1268
- "other content",
1269
- );
1270
-
1271
- // Legacy content was NEVER written to the guardian target path. Because
1272
- // the USER.md entry was skipped (not written into the temp tree) and
1273
- // the swap replaces the entire workspace with the temp tree, the
1274
- // guardian path should simply not exist after import.
1275
- if (existsSync(guardianPath)) {
1276
- // If something did write there, it must not be the bundle's legacy
1277
- // content — that's the crucial regression to prevent.
1278
- expect(readFileSync(guardianPath, "utf8")).not.toBe(
1279
- new TextDecoder().decode(legacyContent),
1280
- );
1281
- } else {
1282
- expect(existsSync(guardianPath)).toBe(false);
1283
- }
1284
-
1285
- // The legacy entry is present in the report as "skipped".
1286
- const legacyEntry = result.report.files.find(
1287
- (f) => f.path === "prompts/USER.md",
1288
- );
1289
- expect(legacyEntry).toBeDefined();
1290
- expect(legacyEntry!.action).toBe("skipped");
1291
-
1292
- // A warning surfaces the skip reason.
1293
- expect(
1294
- result.report.warnings.some((w) => w.includes("prompts/USER.md")),
1295
- ).toBe(true);
1296
- });
1297
- });
1298
-
1299
- // ---------------------------------------------------------------------------
1300
- // Carry-over parity: live workspace paths that the buffer-based importer
1301
- // preserves (data/db, data/qdrant, embedding-models, deprecated) must also
1302
- // survive the streaming importer's temp-dir atomic swap when the bundle
1303
- // does not carry them. Without this behavior, a partial bundle (e.g. one
1304
- // that only ships prompts + config) would wipe the user's SQLite DB and
1305
- // Qdrant vector store when imported through the streaming path.
1306
- // ---------------------------------------------------------------------------
1307
-
1308
- describe("streamCommitImport — preserves live workspace paths when bundle omits them", () => {
1309
- let workspaceDir: string;
1310
- beforeEach(() => {
1311
- workspaceDir = freshWorkspace();
1312
- });
1313
- afterEach(() => {
1314
- const parent = join(workspaceDir, "..");
1315
- try {
1316
- rmSync(parent, { recursive: true, force: true });
1317
- } catch {
1318
- // best-effort
1319
- }
1320
- });
1321
-
1322
- // Note: the prior "bundle omits data/db/*" preserve test was retired
1323
- // when the v1 manifest schema started requiring `data/db/assistant.db`
1324
- // (or its `workspace/`-prefixed counterpart) to be present. Under v1
1325
- // every bundle declares the DB, so the bundle-omits-DB scenario is no
1326
- // longer reachable through the public emit path.
1327
-
1328
- test("keeps the live data/qdrant/ directory when the bundle omits qdrant entries", async () => {
1329
- // Populate a fake qdrant store with a nested file.
1330
- mkdirSync(join(workspaceDir, "data", "qdrant", "segments"), {
1331
- recursive: true,
1332
- });
1333
- const segmentBytes = Buffer.from("qdrant-segment-bytes");
1334
- writeFileSync(
1335
- join(workspaceDir, "data", "qdrant", "segments", "0.seg"),
1336
- segmentBytes,
1337
- );
1338
-
1339
- const { archive } = buildVBundle({
1340
- files: [
1341
- {
1342
- path: "data/db/assistant.db",
1343
- data: new Uint8Array(),
1344
- },
1345
- {
1346
- path: "workspace/config.json",
1347
- data: new TextEncoder().encode("{}"),
1348
- },
1349
- ],
1350
- ...defaultV1Options(),
1351
- });
1352
-
1353
- const result = await streamCommitImport({
1354
- source: readableFrom(archive),
1355
- pathResolver: new DefaultPathResolver(workspaceDir),
1356
- workspaceDir,
1357
- });
1358
-
1359
- expect(result.ok).toBe(true);
1360
- if (!result.ok) {
1361
- throw new Error("unreachable");
1362
- }
1363
-
1364
- const postSegPath = join(
1365
- workspaceDir,
1366
- "data",
1367
- "qdrant",
1368
- "segments",
1369
- "0.seg",
1370
- );
1371
- expect(existsSync(postSegPath)).toBe(true);
1372
- expect(readFileSync(postSegPath)).toEqual(segmentBytes);
1373
- });
1374
-
1375
- test("lets the bundle overwrite data/db when it does carry an assistant.db entry", async () => {
1376
- // Seed the live workspace with OLD content so we can tell whether the
1377
- // carry-over logic accidentally kept it instead of honoring the
1378
- // bundle's new DB file.
1379
- mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
1380
- writeFileSync(
1381
- join(workspaceDir, "data", "db", "assistant.db"),
1382
- "OLD-LIVE-DB",
1383
- );
1384
-
1385
- const newDbBytes = new TextEncoder().encode("NEW-BUNDLE-DB");
1386
- const { archive } = buildVBundle({
1387
- files: [
1388
- {
1389
- path: "workspace/data/db/assistant.db",
1390
- data: newDbBytes,
1391
- },
1392
- ],
1393
- ...defaultV1Options(),
1394
- });
1395
-
1396
- const result = await streamCommitImport({
1397
- source: readableFrom(archive),
1398
- pathResolver: new DefaultPathResolver(workspaceDir),
1399
- workspaceDir,
1400
- });
1401
-
1402
- expect(result.ok).toBe(true);
1403
- if (!result.ok) {
1404
- throw new Error("unreachable");
1405
- }
1406
-
1407
- const postDbPath = join(workspaceDir, "data", "db", "assistant.db");
1408
- expect(readFileSync(postDbPath)).toEqual(Buffer.from(newDbBytes));
1409
- });
1410
- });
1411
-
1412
- // ---------------------------------------------------------------------------
1413
- // Gap A — legacy-only bundle (no `workspace/*` entries) writes each file in
1414
- // place WITHOUT triggering the atomic workspace swap. This matches
1415
- // commitImport's legacy branch: a `data/db/assistant.db`- or
1416
- // `config/settings.json`- style bundle preserves arbitrary live files
1417
- // outside WORKSPACE_PRESERVE_PATHS (where the swap path would have wiped
1418
- // them). Regression test for the self-review Gap A finding.
1419
- // ---------------------------------------------------------------------------
1420
-
1421
- describe("streamCommitImport — legacy-only bundle writes in place", () => {
1422
- let workspaceDir: string;
1423
- beforeEach(() => {
1424
- workspaceDir = freshWorkspace();
1425
- });
1426
- afterEach(() => {
1427
- const parent = join(workspaceDir, "..");
1428
- try {
1429
- rmSync(parent, { recursive: true, force: true });
1430
- } catch {
1431
- // best-effort
1432
- }
1433
- });
1434
-
1435
- test("bundle carrying only `data/db/assistant.db` writes the DB in place and leaves unrelated live files untouched", async () => {
1436
- // Seed the live workspace with BOTH a pre-existing `data/db/assistant.db`
1437
- // (which the bundle will overwrite) AND an unrelated marker file at
1438
- // top-level that is not in WORKSPACE_PRESERVE_PATHS — proving the old
1439
- // atomic-swap path would have wiped it. Under the legacy-only branch
1440
- // that file must survive.
1441
- mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
1442
- writeFileSync(
1443
- join(workspaceDir, "data", "db", "assistant.db"),
1444
- "OLD-LIVE-DB",
1445
- );
1446
- writeFileSync(
1447
- join(workspaceDir, "unrelated-top-level.txt"),
1448
- "must-survive-legacy-import\n",
1449
- );
1450
- // A sibling directory — also outside preserve paths — that the atomic
1451
- // swap would have wiped.
1452
- mkdirSync(join(workspaceDir, "custom-user-dir"), { recursive: true });
1453
- writeFileSync(
1454
- join(workspaceDir, "custom-user-dir", "note.md"),
1455
- "custom note\n",
1456
- );
1457
-
1458
- const newDbBytes = new TextEncoder().encode("NEW-LEGACY-BUNDLE-DB");
1459
- const { archive } = buildVBundle({
1460
- files: [
1461
- {
1462
- // Legacy archive path — NO `workspace/` prefix. This is what
1463
- // older bundle exports produce.
1464
- path: "data/db/assistant.db",
1465
- data: newDbBytes,
1466
- },
1467
- ],
1468
- ...defaultV1Options(),
1469
- });
1470
-
1471
- const result = await streamCommitImport({
1472
- source: readableFrom(archive),
1473
- pathResolver: new DefaultPathResolver(workspaceDir),
1474
- workspaceDir,
1475
- });
1476
-
1477
- expect(result.ok).toBe(true);
1478
- if (!result.ok) {
1479
- throw new Error("unreachable");
1480
- }
1481
-
1482
- // Bundle's DB landed at the right live location with the new bytes.
1483
- const postDbPath = join(workspaceDir, "data", "db", "assistant.db");
1484
- expect(readFileSync(postDbPath)).toEqual(Buffer.from(newDbBytes));
1485
-
1486
- // Unrelated live files still present — the atomic-swap path would
1487
- // have wiped them. Their survival here proves we took the in-place
1488
- // legacy branch.
1489
- expect(
1490
- readFileSync(join(workspaceDir, "unrelated-top-level.txt"), "utf8"),
1491
- ).toBe("must-survive-legacy-import\n");
1492
- expect(
1493
- readFileSync(join(workspaceDir, "custom-user-dir", "note.md"), "utf8"),
1494
- ).toBe("custom note\n");
1495
-
1496
- // The legacy entry is reported as "overwritten" (pre-existing DB),
1497
- // with a backup path captured alongside.
1498
- const dbEntry = result.report.files.find(
1499
- (f) => f.path === "data/db/assistant.db",
1500
- );
1501
- expect(dbEntry).toBeDefined();
1502
- expect(dbEntry!.action).toBe("overwritten");
1503
- expect(dbEntry!.backup_path).not.toBeNull();
1504
- // And the report summary reflects the same.
1505
- expect(result.report.summary.files_overwritten).toBe(1);
1506
- expect(result.report.summary.backups_created).toBe(1);
1507
- });
1508
- });
1509
-
1510
- // ---------------------------------------------------------------------------
1511
- // Gap B — carry-over does per-file merge inside preserved directories.
1512
- // A bundle that writes ONE file under `workspace/data/qdrant/` must not
1513
- // cause the rest of the live `data/qdrant/` tree to be wiped by the
1514
- // atomic swap. Regression test for the self-review Gap B finding.
1515
- // ---------------------------------------------------------------------------
1516
-
1517
- describe("streamCommitImport — preserved-path carry-over is per-file", () => {
1518
- let workspaceDir: string;
1519
- beforeEach(() => {
1520
- workspaceDir = freshWorkspace();
1521
- });
1522
- afterEach(() => {
1523
- const parent = join(workspaceDir, "..");
1524
- try {
1525
- rmSync(parent, { recursive: true, force: true });
1526
- } catch {
1527
- // best-effort
1528
- }
1529
- });
1530
-
1531
- test("bundle touching one file in data/qdrant/ does not wipe other live files in the same preserved dir", async () => {
1532
- // Populate the live qdrant store with several nested files / dirs.
1533
- // Only one of these paths overlaps with what the bundle will write;
1534
- // the rest must survive the atomic swap via per-file carry-over.
1535
- mkdirSync(join(workspaceDir, "data", "qdrant", "segments"), {
1536
- recursive: true,
1537
- });
1538
- writeFileSync(
1539
- join(workspaceDir, "data", "qdrant", "segments", "0.seg"),
1540
- "seg-0-live",
1541
- );
1542
- writeFileSync(
1543
- join(workspaceDir, "data", "qdrant", "segments", "1.seg"),
1544
- "seg-1-live",
1545
- );
1546
- // A top-level file inside the preserved dir that the bundle will
1547
- // overwrite.
1548
- writeFileSync(
1549
- join(workspaceDir, "data", "qdrant", "meta.json"),
1550
- '{"stale":true}',
1551
- );
1552
- // A sibling directory the bundle never touches — must survive.
1553
- mkdirSync(join(workspaceDir, "data", "qdrant", "wal"), {
1554
- recursive: true,
1555
- });
1556
- writeFileSync(
1557
- join(workspaceDir, "data", "qdrant", "wal", "wal-000"),
1558
- "wal-entry",
1559
- );
1560
-
1561
- // Bundle carries exactly ONE file under `data/qdrant/` AND at least
1562
- // one other `workspace/*` entry so the atomic-swap path fires (not
1563
- // the legacy in-place path).
1564
- const newMeta = new TextEncoder().encode('{"fresh":true}');
1565
- const { archive } = buildVBundle({
1566
- files: [
1567
- {
1568
- path: "data/db/assistant.db",
1569
- data: new Uint8Array(),
1570
- },
1571
- {
1572
- path: "workspace/data/qdrant/meta.json",
1573
- data: newMeta,
1574
- },
1575
- {
1576
- path: "workspace/marker.txt",
1577
- data: new TextEncoder().encode("marker\n"),
1578
- },
1579
- ],
1580
- ...defaultV1Options(),
1581
- });
1582
-
1583
- const result = await streamCommitImport({
1584
- source: readableFrom(archive),
1585
- pathResolver: new DefaultPathResolver(workspaceDir),
1586
- workspaceDir,
1587
- });
1588
-
1589
- expect(result.ok).toBe(true);
1590
- if (!result.ok) {
1591
- throw new Error("unreachable");
1592
- }
1593
-
1594
- // Bundle's overwrite landed.
1595
- expect(
1596
- readFileSync(join(workspaceDir, "data", "qdrant", "meta.json")),
1597
- ).toEqual(Buffer.from(newMeta));
1598
-
1599
- // All the other live files in the preserved dir survived.
1600
- expect(
1601
- readFileSync(
1602
- join(workspaceDir, "data", "qdrant", "segments", "0.seg"),
1603
- "utf8",
1604
- ),
1605
- ).toBe("seg-0-live");
1606
- expect(
1607
- readFileSync(
1608
- join(workspaceDir, "data", "qdrant", "segments", "1.seg"),
1609
- "utf8",
1610
- ),
1611
- ).toBe("seg-1-live");
1612
- expect(
1613
- readFileSync(
1614
- join(workspaceDir, "data", "qdrant", "wal", "wal-000"),
1615
- "utf8",
1616
- ),
1617
- ).toBe("wal-entry");
1618
-
1619
- // And the workspace-targeted file outside the preserved dir landed.
1620
- expect(readFileSync(join(workspaceDir, "marker.txt"), "utf8")).toBe(
1621
- "marker\n",
1622
- );
1623
- });
1624
- });
1625
-
1626
- // ---------------------------------------------------------------------------
1627
- // Gap C — resource ceilings. Bundles declaring too many entries or too
1628
- // many bytes of data should abort with validation_failed before we commit
1629
- // anything to disk. Uses the test-only `maxBundleEntries` /
1630
- // `maxBundleBytes` knobs so we can exercise the abort with tiny
1631
- // fixtures. Regression test for the self-review Gap C finding.
1632
- // ---------------------------------------------------------------------------
1633
-
1634
- describe("streamCommitImport — bundle resource ceilings", () => {
1635
- let workspaceDir: string;
1636
- beforeEach(() => {
1637
- workspaceDir = freshWorkspace();
1638
- });
1639
- afterEach(() => {
1640
- const parent = join(workspaceDir, "..");
1641
- try {
1642
- rmSync(parent, { recursive: true, force: true });
1643
- } catch {
1644
- // best-effort
1645
- }
1646
- });
1647
-
1648
- /** Assert no sibling temp/backup dirs remain. */
1649
- function assertNoLeftoverTempDirs(): void {
1650
- const parent = join(workspaceDir, "..");
1651
- const base = workspaceDir.split("/").pop()!;
1652
- const siblings = readdirSync(parent);
1653
- const leftover = siblings.filter(
1654
- (name) =>
1655
- name.startsWith(`${base}.import-`) ||
1656
- name.startsWith(`${base}.pre-import-`),
1657
- );
1658
- expect(leftover).toEqual([]);
1659
- }
1660
-
1661
- test("bundle declaring more entries than maxBundleEntries → validation_failed with bundle_too_many_entries", async () => {
1662
- mkdirSync(workspaceDir, { recursive: true });
1663
- writeFileSync(join(workspaceDir, "existing.txt"), "keep me\n");
1664
-
1665
- // Three small files — we'll set the cap to 2 so the manifest's
1666
- // declared-count check trips before any tar entry is processed.
1667
- const { archive } = buildVBundle({
1668
- files: [
1669
- {
1670
- path: "data/db/assistant.db",
1671
- data: new Uint8Array(),
1672
- },
1673
- { path: "workspace/a.txt", data: new TextEncoder().encode("a") },
1674
- { path: "workspace/b.txt", data: new TextEncoder().encode("b") },
1675
- { path: "workspace/c.txt", data: new TextEncoder().encode("c") },
1676
- ],
1677
- ...defaultV1Options(),
1678
- });
1679
-
1680
- const result = await streamCommitImport({
1681
- source: readableFrom(archive),
1682
- pathResolver: new DefaultPathResolver(workspaceDir),
1683
- workspaceDir,
1684
- maxBundleEntries: 2,
1685
- });
1686
-
1687
- expect(result.ok).toBe(false);
1688
- if (result.ok) {
1689
- throw new Error("unreachable");
1690
- }
1691
- expect(result.reason).toBe("validation_failed");
1692
- if (result.reason !== "validation_failed") {
1693
- throw new Error("unreachable");
1694
- }
1695
- expect(result.errors[0]!.code).toBe("bundle_too_many_entries");
1696
-
1697
- // Real workspace untouched, temp dir cleaned up.
1698
- expect(readFileSync(join(workspaceDir, "existing.txt"), "utf8")).toBe(
1699
- "keep me\n",
1700
- );
1701
- assertNoLeftoverTempDirs();
1702
- });
1703
-
1704
- test("bundle exceeding maxBundleBytes → validation_failed with bundle_too_large", async () => {
1705
- mkdirSync(workspaceDir, { recursive: true });
1706
- writeFileSync(join(workspaceDir, "existing.txt"), "keep me\n");
1707
-
1708
- // Two files totaling 200 bytes of user data. Cap at 128 bytes so the
1709
- // second file's streamed size pushes totalBytesStreamed over.
1710
- const big = new Uint8Array(100).fill(0x41);
1711
- const { archive } = buildVBundle({
1712
- files: [
1713
- {
1714
- path: "data/db/assistant.db",
1715
- data: new Uint8Array(),
1716
- },
1717
- { path: "workspace/big1.bin", data: big },
1718
- { path: "workspace/big2.bin", data: big },
1719
- ],
1720
- ...defaultV1Options(),
1721
- });
1722
-
1723
- const result = await streamCommitImport({
1724
- source: readableFrom(archive),
1725
- pathResolver: new DefaultPathResolver(workspaceDir),
1726
- workspaceDir,
1727
- maxBundleBytes: 128,
1728
- });
1729
-
1730
- expect(result.ok).toBe(false);
1731
- if (result.ok) {
1732
- throw new Error("unreachable");
1733
- }
1734
- expect(result.reason).toBe("validation_failed");
1735
- if (result.reason !== "validation_failed") {
1736
- throw new Error("unreachable");
1737
- }
1738
- expect(result.errors[0]!.code).toBe("bundle_too_large");
1739
-
1740
- expect(readFileSync(join(workspaceDir, "existing.txt"), "utf8")).toBe(
1741
- "keep me\n",
1742
- );
1743
- assertNoLeftoverTempDirs();
1744
- });
1745
- });
1746
-
1747
- // ---------------------------------------------------------------------------
1748
- // Gap D — sanitized config reports sha256 of the BYTES ACTUALLY WRITTEN,
1749
- // not the manifest-declared sha of the raw archive content. commitImport
1750
- // already does this; the streaming importer regressed to reporting the
1751
- // manifest sha, so downstream integrity re-checks failed. Regression test
1752
- // for the self-review Gap D finding.
1753
- // ---------------------------------------------------------------------------
1754
-
1755
- describe("streamCommitImport — report.sha256 reflects post-sanitization bytes", () => {
1756
- let workspaceDir: string;
1757
- beforeEach(() => {
1758
- workspaceDir = freshWorkspace();
1759
- });
1760
- afterEach(() => {
1761
- const parent = join(workspaceDir, "..");
1762
- try {
1763
- rmSync(parent, { recursive: true, force: true });
1764
- } catch {
1765
- // best-effort
1766
- }
1767
- });
1768
-
1769
- test("workspace/config.json report.sha256 equals sha256 of the on-disk sanitized bytes, not the raw archive sha", async () => {
1770
- // Plant content that sanitizeConfigForTransfer will demonstrably
1771
- // change (`daemon` is deleted, `ingress.publicBaseUrl` cleared, etc).
1772
- // The raw archive bytes and the post-sanitization bytes therefore
1773
- // differ — a correctly implemented importer reports the latter's sha.
1774
- const tainted = JSON.stringify({
1775
- daemon: { pid: 9999, host: "private.example.com" },
1776
- ingress: { publicBaseUrl: "https://leak.example", enabled: true },
1777
- unrelated: "keep-me",
1778
- });
1779
- const rawArchiveBytes = new TextEncoder().encode(tainted);
1780
- const rawArchiveSha = sha256Hex(rawArchiveBytes);
1781
-
1782
- const { archive } = buildVBundle({
1783
- files: [
1784
- {
1785
- path: "data/db/assistant.db",
1786
- data: new Uint8Array(),
1787
- },
1788
- {
1789
- path: "workspace/config.json",
1790
- data: rawArchiveBytes,
1791
- },
1792
- ],
1793
- ...defaultV1Options(),
1794
- });
1795
-
1796
- const result = await streamCommitImport({
1797
- source: readableFrom(archive),
1798
- pathResolver: new DefaultPathResolver(workspaceDir),
1799
- workspaceDir,
1800
- });
1801
-
1802
- expect(result.ok).toBe(true);
1803
- if (!result.ok) {
1804
- throw new Error("unreachable");
1805
- }
1806
-
1807
- const writtenPath = join(workspaceDir, "config.json");
1808
- const onDiskBytes = readFileSync(writtenPath);
1809
- const onDiskSha = sha256Hex(onDiskBytes);
1810
-
1811
- // Sanity: sanitization actually changed the bytes (otherwise the test
1812
- // would be tautologically true).
1813
- expect(onDiskSha).not.toBe(rawArchiveSha);
1814
-
1815
- const configEntry = result.report.files.find(
1816
- (f) => f.path === "workspace/config.json",
1817
- );
1818
- expect(configEntry).toBeDefined();
1819
- expect(configEntry!.sha256).toBe(onDiskSha);
1820
- // Reported size is also the post-sanitization size, not raw.
1821
- expect(configEntry!.size).toBe(onDiskBytes.length);
1822
- });
1823
- });