@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.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/openapi.yaml +2 -708
- package/package.json +2 -3
- package/scripts/sync-web-search-catalog.ts +1 -2
- package/src/__tests__/cloud-profile-guard.test.ts +41 -5
- package/src/__tests__/config-schema.test.ts +6 -38
- package/src/__tests__/conversation-queue.test.ts +45 -6
- package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
- package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
- package/src/__tests__/persona-resolver.test.ts +0 -51
- package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
- package/src/__tests__/run-due-schedules.test.ts +0 -4
- package/src/__tests__/schedule-store.test.ts +0 -12
- package/src/__tests__/stt-stream-session.test.ts +4 -4
- package/src/__tests__/telephony-tts-guard.test.ts +2 -50
- package/src/__tests__/voice-config-update.test.ts +12 -167
- package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
- package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
- package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
- package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
- package/src/calls/resolve-call-tts-provider.ts +6 -25
- package/src/calls/telephony-credential-preflight.ts +0 -10
- package/src/calls/telephony-tts-capability.ts +1 -17
- package/src/cli/commands/tts.ts +1 -2
- package/src/cli/index.help.ts +0 -2
- package/src/cli/program.ts +0 -2
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
- package/src/config/schemas/__tests__/stt.test.ts +10 -14
- package/src/config/schemas/llm.ts +3 -5
- package/src/config/schemas/services.ts +5 -6
- package/src/config/schemas/stt.ts +1 -3
- package/src/config/schemas/tts.ts +1 -30
- package/src/daemon/daemon-readiness.ts +4 -29
- package/src/daemon/lifecycle.ts +0 -31
- package/src/daemon/providers-setup.ts +0 -8
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
- package/src/heartbeat/heartbeat-service.ts +1 -66
- package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
- package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
- package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
- package/src/live-voice/live-voice-credential-preflight.ts +9 -33
- package/src/live-voice/live-voice-session.ts +10 -6
- package/src/live-voice/live-voice-tts.ts +1 -8
- package/src/plugins/defaults/memory/worker.ts +3 -3
- package/src/prompts/persona-resolver.ts +0 -35
- package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
- package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
- package/src/providers/search-provider-catalog.ts +7 -39
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
- package/src/providers/speech-to-text/provider-catalog.ts +2 -73
- package/src/providers/speech-to-text/resolve.ts +3 -120
- package/src/runtime/http-router-types.ts +1 -3
- package/src/runtime/http-server.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +13 -11
- package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
- package/src/runtime/routes/conversation-query-routes.ts +0 -96
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
- package/src/runtime/routes/index.ts +0 -4
- package/src/runtime/routes/live-voice-routes.test.ts +4 -11
- package/src/runtime/routes/live-voice-routes.ts +8 -11
- package/src/runtime/routes/secret-routes.ts +0 -6
- package/src/runtime/routes/tts-routes.ts +0 -38
- package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
- package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
- package/src/schedule/schedule-store.ts +0 -7
- package/src/schedule/scheduler.ts +0 -5
- package/src/schedule/worker.ts +1 -1
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
- package/src/stt/daemon-batch-transcriber.ts +4 -38
- package/src/stt/types.ts +1 -9
- package/src/tools/network/__tests__/web-search.test.ts +3 -250
- package/src/tools/network/web-search.ts +4 -165
- package/src/tts/__tests__/provider-resolution.test.ts +0 -5
- package/src/tts/provider-catalog.ts +0 -4
- package/src/tts/types.ts +0 -1
- package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
- package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
- package/src/__tests__/migration-export-http.test.ts +0 -557
- package/src/__tests__/migration-export-streaming.test.ts +0 -350
- package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
- package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
- package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
- package/src/__tests__/migration-import-from-path.test.ts +0 -324
- package/src/__tests__/migration-import-from-url.test.ts +0 -588
- package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
- package/src/__tests__/migration-jobs-status.test.ts +0 -139
- package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
- package/src/__tests__/migration-transport.test.ts +0 -1089
- package/src/__tests__/migration-validate-http.test.ts +0 -634
- package/src/__tests__/migration-wizard.test.ts +0 -1395
- package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
- package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
- package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
- package/src/__tests__/validation-results-screen.test.ts +0 -1130
- package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
- package/src/background-wake/background-wake-routes.test.ts +0 -906
- package/src/background-wake/cloud-client.test.ts +0 -328
- package/src/background-wake/cloud-client.ts +0 -196
- package/src/background-wake/next-wake.test.ts +0 -335
- package/src/background-wake/next-wake.ts +0 -188
- package/src/background-wake/publisher.ts +0 -93
- package/src/background-wake/wake-intent-hooks.test.ts +0 -234
- package/src/cli/commands/__tests__/backup.test.ts +0 -363
- package/src/cli/commands/backup.help.ts +0 -180
- package/src/cli/commands/backup.ts +0 -398
- package/src/cloud/cloud-speech.test.ts +0 -335
- package/src/cloud/cloud-speech.ts +0 -300
- package/src/config/cloud-speech-defaults.ts +0 -300
- package/src/config/sanitize-for-transfer.ts +0 -111
- package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
- package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
- package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
- package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
- package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
- package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
- package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
- package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
- package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
- package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
- package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
- package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
- package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
- package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
- package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
- package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
- package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
- package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
- package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
- package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
- package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
- package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
- package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
- package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
- package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
- package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
- package/src/runtime/migrations/gcs-signed-url.ts +0 -190
- package/src/runtime/migrations/job-registry.ts +0 -297
- package/src/runtime/migrations/migration-transport.ts +0 -612
- package/src/runtime/migrations/migration-wizard.ts +0 -747
- package/src/runtime/migrations/origin-mode.ts +0 -42
- package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
- package/src/runtime/migrations/staged-import-path.ts +0 -116
- package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
- package/src/runtime/migrations/validation-results-screen.ts +0 -469
- package/src/runtime/migrations/vbundle-builder.ts +0 -1382
- package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
- package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
- package/src/runtime/migrations/vbundle-importer.ts +0 -826
- package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
- package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
- package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
- package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
- package/src/runtime/migrations/vbundle-validator.ts +0 -724
- package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
- package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
- package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
- package/src/runtime/routes/background-wake-routes.ts +0 -387
- package/src/runtime/routes/migration-routes.ts +0 -2584
- package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
- package/src/tools/network/cloud-search-proxy.ts +0 -211
- package/src/tts/__tests__/cadet-provider.test.ts +0 -253
- package/src/tts/providers/cadet-provider.ts +0 -244
- package/src/types/tar-stream.d.ts +0 -66
|
@@ -1,1382 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Builds .vbundle archive files for migration export.
|
|
3
|
-
*
|
|
4
|
-
* A .vbundle is a gzip-compressed tar archive containing:
|
|
5
|
-
* - manifest.json: metadata with schema_version, checksums, and bundle info
|
|
6
|
-
* - workspace/: the entire $CADET_WORKSPACE_DIR/ directory tree (DB, config,
|
|
7
|
-
* skills, prompts, attachments, etc.) — excluding large/regenerable
|
|
8
|
-
* dirs (embedding-models/, data/qdrant/)
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
-
import {
|
|
13
|
-
closeSync,
|
|
14
|
-
createWriteStream,
|
|
15
|
-
existsSync,
|
|
16
|
-
lstatSync,
|
|
17
|
-
openSync,
|
|
18
|
-
readdirSync,
|
|
19
|
-
readFileSync,
|
|
20
|
-
readSync,
|
|
21
|
-
realpathSync,
|
|
22
|
-
} from "node:fs";
|
|
23
|
-
import { type FileHandle, open, stat, unlink } from "node:fs/promises";
|
|
24
|
-
import { tmpdir } from "node:os";
|
|
25
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
26
|
-
import { Readable } from "node:stream";
|
|
27
|
-
import { pipeline } from "node:stream/promises";
|
|
28
|
-
import { createGzip, gzipSync } from "node:zlib";
|
|
29
|
-
|
|
30
|
-
import { sanitizeConfigForTransfer } from "../../config/sanitize-for-transfer.js";
|
|
31
|
-
import { trackDaemonActivity } from "../../daemon/activity-trail.js";
|
|
32
|
-
import { getLogger } from "../../util/logger.js";
|
|
33
|
-
import type { VBundleOriginMode } from "./origin-mode.js";
|
|
34
|
-
import {
|
|
35
|
-
MANIFEST_MAX_BYTES,
|
|
36
|
-
MANIFEST_MAX_ENTRIES,
|
|
37
|
-
type ManifestFileEntryType,
|
|
38
|
-
type ManifestType,
|
|
39
|
-
} from "./vbundle-validator.js";
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Public types
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
export interface VBundleFileEntry {
|
|
46
|
-
path: string;
|
|
47
|
-
data: Uint8Array;
|
|
48
|
-
/** When set, `data` is ignored: the entry is emitted as a tar typeflag-2 (symlink) record with empty body, and `linkTarget` is the symlink target encoded relative to the symlink's own directory inside the archive. */
|
|
49
|
-
linkTarget?: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** v1 manifest `assistant` block. */
|
|
53
|
-
export interface VBundleAssistantInfo {
|
|
54
|
-
id: string;
|
|
55
|
-
name: string;
|
|
56
|
-
runtime_version: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** v1 manifest `origin` block. */
|
|
60
|
-
export interface VBundleOriginInfo {
|
|
61
|
-
mode: VBundleOriginMode;
|
|
62
|
-
platform_version?: string;
|
|
63
|
-
hostname?: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** v1 manifest `compatibility` block. */
|
|
67
|
-
export interface VBundleCompatibility {
|
|
68
|
-
min_runtime_version: string;
|
|
69
|
-
max_runtime_version: string | null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** v1 manifest `export_options` block. */
|
|
73
|
-
export interface VBundleExportOptions {
|
|
74
|
-
include_logs: boolean;
|
|
75
|
-
include_browser_state: boolean;
|
|
76
|
-
include_memory_vectors: boolean;
|
|
77
|
-
/**
|
|
78
|
-
* Debug profile: the bundle carries `gateway/export.tar.gz`, the gateway's
|
|
79
|
-
* database and recent logs, for Cadet staff to inspect. Absent on
|
|
80
|
-
* ordinary migration bundles.
|
|
81
|
-
*/
|
|
82
|
-
include_gateway?: boolean;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface BuildVBundleOptions {
|
|
86
|
-
/** Files to include in the archive. Must include data/db/assistant.db. */
|
|
87
|
-
files: VBundleFileEntry[];
|
|
88
|
-
/** Identity of the assistant that produced this bundle. */
|
|
89
|
-
assistant: VBundleAssistantInfo;
|
|
90
|
-
/** Where this bundle was produced. */
|
|
91
|
-
origin: VBundleOriginInfo;
|
|
92
|
-
/** Runtime-version compatibility window for importers. */
|
|
93
|
-
compatibility: VBundleCompatibility;
|
|
94
|
-
/** Which optional bundle contents this export carries. */
|
|
95
|
-
exportOptions: VBundleExportOptions;
|
|
96
|
-
/**
|
|
97
|
-
* Whether secrets were stripped from the bundle before archiving.
|
|
98
|
-
* Required at the type level — defaulting silently is exactly how the
|
|
99
|
-
* prior schema mismatch went unnoticed.
|
|
100
|
-
*/
|
|
101
|
-
secretsRedacted: boolean;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export interface BuildVBundleResult {
|
|
105
|
-
/** The complete .vbundle archive as gzipped tar bytes. */
|
|
106
|
-
archive: Uint8Array;
|
|
107
|
-
/** The manifest that was embedded in the archive. */
|
|
108
|
-
manifest: ManifestType;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
interface FileMetadata {
|
|
112
|
-
archivePath: string;
|
|
113
|
-
diskPath: string;
|
|
114
|
-
size: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** In-memory entry for data not backed by a file on disk (e.g. credentials). */
|
|
118
|
-
interface InMemoryEntry {
|
|
119
|
-
archivePath: string;
|
|
120
|
-
data: Uint8Array;
|
|
121
|
-
size: number;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Symlink entry — emitted as a tar typeflag-2 record with empty body. */
|
|
125
|
-
interface SymlinkMetadata {
|
|
126
|
-
archivePath: string;
|
|
127
|
-
linkTarget: string;
|
|
128
|
-
size: 0;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Union of disk-backed, in-memory, and symlink tar stream entries. */
|
|
132
|
-
type TarStreamEntry = FileMetadata | InMemoryEntry | SymlinkMetadata;
|
|
133
|
-
|
|
134
|
-
function isInMemoryEntry(entry: TarStreamEntry): entry is InMemoryEntry {
|
|
135
|
-
return "data" in entry;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function isSymlinkEntry(entry: TarStreamEntry): entry is SymlinkMetadata {
|
|
139
|
-
return "linkTarget" in entry;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
// Hash helpers
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
function sha256Hex(data: Uint8Array | string): string {
|
|
147
|
-
return createHash("sha256").update(data).digest("hex");
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Canonicalize a JSON object by sorting keys recursively, then stringify.
|
|
152
|
-
* Matches the canonicalization used by vbundle-validator.
|
|
153
|
-
*/
|
|
154
|
-
function canonicalizeJson(obj: unknown): string {
|
|
155
|
-
return JSON.stringify(obj, (_key, value) => {
|
|
156
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
157
|
-
const sorted: Record<string, unknown> = {};
|
|
158
|
-
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
|
159
|
-
sorted[k] = (value as Record<string, unknown>)[k];
|
|
160
|
-
}
|
|
161
|
-
return sorted;
|
|
162
|
-
}
|
|
163
|
-
return value;
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
// Tar archive builder (minimal, ustar-compatible)
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
const BLOCK_SIZE = 512;
|
|
172
|
-
|
|
173
|
-
function padToBlock(data: Uint8Array): Uint8Array {
|
|
174
|
-
const remainder = data.length % BLOCK_SIZE;
|
|
175
|
-
if (remainder === 0) {
|
|
176
|
-
return data;
|
|
177
|
-
}
|
|
178
|
-
const padded = new Uint8Array(data.length + (BLOCK_SIZE - remainder));
|
|
179
|
-
padded.set(data);
|
|
180
|
-
return padded;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function writeOctal(
|
|
184
|
-
buf: Uint8Array,
|
|
185
|
-
offset: number,
|
|
186
|
-
length: number,
|
|
187
|
-
value: number,
|
|
188
|
-
): void {
|
|
189
|
-
const str = value.toString(8).padStart(length - 1, "0");
|
|
190
|
-
for (let i = 0; i < str.length; i++) {
|
|
191
|
-
buf[offset + i] = str.charCodeAt(i);
|
|
192
|
-
}
|
|
193
|
-
buf[offset + length - 1] = 0;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function computeHeaderChecksum(header: Uint8Array): number {
|
|
197
|
-
let sum = 0;
|
|
198
|
-
for (let i = 0; i < 512; i++) {
|
|
199
|
-
if (i >= 148 && i < 156) {
|
|
200
|
-
sum += 0x20; // space placeholder per tar spec
|
|
201
|
-
} else {
|
|
202
|
-
sum += header[i];
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return sum;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Build a PAX extended header entry for paths that exceed the 100-byte ustar
|
|
210
|
-
* limit. The PAX entry is a special tar record (typeflag 'x') whose body
|
|
211
|
-
* contains "key=value" records. The following data entry uses a truncated name
|
|
212
|
-
* in its ustar header, but tar extractors use the PAX path attribute instead.
|
|
213
|
-
*/
|
|
214
|
-
function createPaxPathEntry(name: string): Uint8Array {
|
|
215
|
-
const encoder = new TextEncoder();
|
|
216
|
-
|
|
217
|
-
// Build PAX payload: "<length> path=<name>\n"
|
|
218
|
-
// The length field includes itself, the space, and the trailing newline.
|
|
219
|
-
const record = `path=${name}\n`;
|
|
220
|
-
// Start with a guess for the decimal length prefix
|
|
221
|
-
let prefix = `${record.length + 2} `; // +2 for prefix digit + space (min)
|
|
222
|
-
let full = `${prefix}${record}`;
|
|
223
|
-
// Iterate until the length prefix is self-consistent
|
|
224
|
-
while (new TextEncoder().encode(full).length !== Number.parseInt(prefix)) {
|
|
225
|
-
prefix = `${new TextEncoder().encode(full).length} `;
|
|
226
|
-
full = `${prefix}${record}`;
|
|
227
|
-
}
|
|
228
|
-
const paxData = encoder.encode(full);
|
|
229
|
-
|
|
230
|
-
// Build a ustar header for the PAX entry itself
|
|
231
|
-
const header = new Uint8Array(BLOCK_SIZE);
|
|
232
|
-
|
|
233
|
-
// Use a synthetic name for the PAX header entry
|
|
234
|
-
const paxName = encoder.encode("PaxHeader/entry");
|
|
235
|
-
header.set(paxName.subarray(0, 100), 0);
|
|
236
|
-
|
|
237
|
-
writeOctal(header, 100, 8, 0o644);
|
|
238
|
-
writeOctal(header, 108, 8, 0);
|
|
239
|
-
writeOctal(header, 116, 8, 0);
|
|
240
|
-
writeOctal(header, 124, 12, paxData.length);
|
|
241
|
-
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
|
|
242
|
-
|
|
243
|
-
// Type flag 'x' = PAX extended header for the next entry
|
|
244
|
-
header[156] = "x".charCodeAt(0);
|
|
245
|
-
|
|
246
|
-
const magic = encoder.encode("ustar\0");
|
|
247
|
-
header.set(magic, 257);
|
|
248
|
-
header[263] = "0".charCodeAt(0);
|
|
249
|
-
header[264] = "0".charCodeAt(0);
|
|
250
|
-
|
|
251
|
-
const checksum = computeHeaderChecksum(header);
|
|
252
|
-
writeOctal(header, 148, 7, checksum);
|
|
253
|
-
header[155] = 0x20;
|
|
254
|
-
|
|
255
|
-
const paddedData = padToBlock(paxData);
|
|
256
|
-
const result = new Uint8Array(header.length + paddedData.length);
|
|
257
|
-
result.set(header, 0);
|
|
258
|
-
result.set(paddedData, header.length);
|
|
259
|
-
return result;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function createTarEntry(
|
|
263
|
-
name: string,
|
|
264
|
-
data: Uint8Array,
|
|
265
|
-
linkTarget?: string,
|
|
266
|
-
): Uint8Array {
|
|
267
|
-
const encoder = new TextEncoder();
|
|
268
|
-
const nameBytes = encoder.encode(name);
|
|
269
|
-
|
|
270
|
-
// If the name exceeds 100 bytes, emit a PAX extended header first
|
|
271
|
-
// so that the full path is preserved in the archive.
|
|
272
|
-
const needsPax = nameBytes.length > 100;
|
|
273
|
-
const paxEntry = needsPax ? createPaxPathEntry(name) : null;
|
|
274
|
-
|
|
275
|
-
const isSymlink = linkTarget !== undefined;
|
|
276
|
-
const linkTargetBytes = isSymlink ? encoder.encode(linkTarget) : null;
|
|
277
|
-
if (linkTargetBytes && linkTargetBytes.length > 100) {
|
|
278
|
-
throw new Error(
|
|
279
|
-
`Symlink target "${linkTarget}" is ${linkTargetBytes.length} bytes, exceeding the ustar linkname-field 100-byte limit. The walker should guard against this case before calling createTarEntry.`,
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const header = new Uint8Array(BLOCK_SIZE);
|
|
284
|
-
|
|
285
|
-
// File name (0-99) — truncated if >100 bytes; PAX header carries the full name
|
|
286
|
-
header.set(nameBytes.subarray(0, 100), 0);
|
|
287
|
-
|
|
288
|
-
// File mode (100-107): 0644
|
|
289
|
-
writeOctal(header, 100, 8, 0o644);
|
|
290
|
-
|
|
291
|
-
// Owner ID (108-115)
|
|
292
|
-
writeOctal(header, 108, 8, 0);
|
|
293
|
-
|
|
294
|
-
// Group ID (116-123)
|
|
295
|
-
writeOctal(header, 116, 8, 0);
|
|
296
|
-
|
|
297
|
-
// File size (124-135) — symlink entries always carry size 0
|
|
298
|
-
writeOctal(header, 124, 12, isSymlink ? 0 : data.length);
|
|
299
|
-
|
|
300
|
-
// Modification time (136-147)
|
|
301
|
-
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
|
|
302
|
-
|
|
303
|
-
// Type flag (156): regular file ("0") or symlink ("2")
|
|
304
|
-
header[156] = (isSymlink ? "2" : "0").charCodeAt(0);
|
|
305
|
-
|
|
306
|
-
// Linkname (157-256) — only set for symlinks; null-padded by default
|
|
307
|
-
if (linkTargetBytes) {
|
|
308
|
-
header.set(linkTargetBytes, 157);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// USTAR magic (257-262)
|
|
312
|
-
const magic = encoder.encode("ustar\0");
|
|
313
|
-
header.set(magic, 257);
|
|
314
|
-
|
|
315
|
-
// USTAR version (263-264)
|
|
316
|
-
header[263] = "0".charCodeAt(0);
|
|
317
|
-
header[264] = "0".charCodeAt(0);
|
|
318
|
-
|
|
319
|
-
// Compute and write checksum (148-155) — must be last so the linkname
|
|
320
|
-
// (and every other field) contributes to the sum.
|
|
321
|
-
const checksum = computeHeaderChecksum(header);
|
|
322
|
-
writeOctal(header, 148, 7, checksum);
|
|
323
|
-
header[155] = 0x20; // trailing space
|
|
324
|
-
|
|
325
|
-
// Symlink entries are header-only — no body, no padding.
|
|
326
|
-
const fileEntry = isSymlink
|
|
327
|
-
? header
|
|
328
|
-
: (() => {
|
|
329
|
-
const paddedData = padToBlock(data);
|
|
330
|
-
const combined = new Uint8Array(header.length + paddedData.length);
|
|
331
|
-
combined.set(header, 0);
|
|
332
|
-
combined.set(paddedData, header.length);
|
|
333
|
-
return combined;
|
|
334
|
-
})();
|
|
335
|
-
|
|
336
|
-
if (paxEntry) {
|
|
337
|
-
const result = new Uint8Array(paxEntry.length + fileEntry.length);
|
|
338
|
-
result.set(paxEntry, 0);
|
|
339
|
-
result.set(fileEntry, paxEntry.length);
|
|
340
|
-
return result;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
return fileEntry;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function createTarArchive(
|
|
347
|
-
entries: Array<{ name: string; data: Uint8Array; linkTarget?: string }>,
|
|
348
|
-
): Uint8Array {
|
|
349
|
-
const parts: Uint8Array[] = [];
|
|
350
|
-
for (const entry of entries) {
|
|
351
|
-
parts.push(createTarEntry(entry.name, entry.data, entry.linkTarget));
|
|
352
|
-
}
|
|
353
|
-
// End-of-archive: two zero blocks
|
|
354
|
-
parts.push(new Uint8Array(BLOCK_SIZE * 2));
|
|
355
|
-
|
|
356
|
-
const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
|
|
357
|
-
const result = new Uint8Array(totalLength);
|
|
358
|
-
let offset = 0;
|
|
359
|
-
for (const part of parts) {
|
|
360
|
-
result.set(part, offset);
|
|
361
|
-
offset += part.length;
|
|
362
|
-
}
|
|
363
|
-
return result;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ---------------------------------------------------------------------------
|
|
367
|
-
// Core builder
|
|
368
|
-
// ---------------------------------------------------------------------------
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Build the v1 manifest object and its serialized JSON bytes for a vbundle.
|
|
372
|
-
*
|
|
373
|
-
* Shared by the buffered (`buildVBundle`) and streaming
|
|
374
|
-
* (`streamExportVBundle`) emit sites so the manifest shape and self-checksum
|
|
375
|
-
* computation live in exactly one place.
|
|
376
|
-
*
|
|
377
|
-
* The checksum is computed over the canonicalized manifest with the
|
|
378
|
-
* `checksum` field set to the empty string (per the schema spec) — both
|
|
379
|
-
* producers and the validator agree on this exact wire shape.
|
|
380
|
-
*/
|
|
381
|
-
function buildManifestObject(input: {
|
|
382
|
-
contents: ManifestFileEntryType[];
|
|
383
|
-
assistant: VBundleAssistantInfo;
|
|
384
|
-
origin: VBundleOriginInfo;
|
|
385
|
-
compatibility: VBundleCompatibility;
|
|
386
|
-
exportOptions: VBundleExportOptions;
|
|
387
|
-
secretsRedacted: boolean;
|
|
388
|
-
now: Date;
|
|
389
|
-
}): { manifest: ManifestType; manifestData: Uint8Array } {
|
|
390
|
-
const manifestWithEmptyChecksum = {
|
|
391
|
-
schema_version: 1 as const,
|
|
392
|
-
bundle_id: randomUUID(),
|
|
393
|
-
created_at: input.now.toISOString(),
|
|
394
|
-
assistant: input.assistant,
|
|
395
|
-
origin: input.origin,
|
|
396
|
-
compatibility: input.compatibility,
|
|
397
|
-
contents: input.contents,
|
|
398
|
-
checksum: "",
|
|
399
|
-
secrets_redacted: input.secretsRedacted,
|
|
400
|
-
export_options: input.exportOptions,
|
|
401
|
-
};
|
|
402
|
-
const checksum = sha256Hex(canonicalizeJson(manifestWithEmptyChecksum));
|
|
403
|
-
const manifest: ManifestType = { ...manifestWithEmptyChecksum, checksum };
|
|
404
|
-
const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
|
|
405
|
-
if (
|
|
406
|
-
manifest.contents.length > MANIFEST_MAX_ENTRIES ||
|
|
407
|
-
manifestData.length > MANIFEST_MAX_BYTES
|
|
408
|
-
) {
|
|
409
|
-
getLogger("vbundle-builder").warn(
|
|
410
|
-
{
|
|
411
|
-
entries: manifest.contents.length,
|
|
412
|
-
manifestBytes: manifestData.length,
|
|
413
|
-
maxEntries: MANIFEST_MAX_ENTRIES,
|
|
414
|
-
maxBytes: MANIFEST_MAX_BYTES,
|
|
415
|
-
},
|
|
416
|
-
"Bundle manifest exceeds the streaming import limits; teleport and staged-path imports will reject this bundle",
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
return { manifest, manifestData };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Build a .vbundle archive from the given files and metadata.
|
|
424
|
-
*
|
|
425
|
-
* Generates a valid manifest with SHA-256 checksums for all files and
|
|
426
|
-
* a self-referencing `checksum`. The archive is returned
|
|
427
|
-
* as gzip-compressed tar bytes.
|
|
428
|
-
*/
|
|
429
|
-
export function buildVBundle(options: BuildVBundleOptions): BuildVBundleResult {
|
|
430
|
-
const {
|
|
431
|
-
files,
|
|
432
|
-
assistant,
|
|
433
|
-
origin,
|
|
434
|
-
compatibility,
|
|
435
|
-
exportOptions,
|
|
436
|
-
secretsRedacted,
|
|
437
|
-
} = options;
|
|
438
|
-
|
|
439
|
-
// Build file entries for the manifest. Symlink entries hash the link target
|
|
440
|
-
// string (not the empty data buffer) and declare size_bytes: 0.
|
|
441
|
-
const fileEntries: ManifestFileEntryType[] = files.map((f) =>
|
|
442
|
-
f.linkTarget !== undefined
|
|
443
|
-
? {
|
|
444
|
-
path: f.path,
|
|
445
|
-
sha256: sha256Hex(f.linkTarget),
|
|
446
|
-
size_bytes: 0,
|
|
447
|
-
link_target: f.linkTarget,
|
|
448
|
-
}
|
|
449
|
-
: {
|
|
450
|
-
path: f.path,
|
|
451
|
-
sha256: sha256Hex(f.data),
|
|
452
|
-
size_bytes: f.data.length,
|
|
453
|
-
},
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
const { manifest, manifestData } = buildManifestObject({
|
|
457
|
-
contents: fileEntries,
|
|
458
|
-
assistant,
|
|
459
|
-
origin,
|
|
460
|
-
compatibility,
|
|
461
|
-
exportOptions,
|
|
462
|
-
secretsRedacted,
|
|
463
|
-
now: new Date(),
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
// Build tar entries: manifest first, then all files. Symlink entries forward
|
|
467
|
-
// `linkTarget` so createTarEntry emits a typeflag-2 header; `data` is unused
|
|
468
|
-
// in that branch but must still be a valid Uint8Array.
|
|
469
|
-
const tarEntries = [
|
|
470
|
-
{ name: "manifest.json", data: manifestData },
|
|
471
|
-
...files.map((f) =>
|
|
472
|
-
f.linkTarget !== undefined
|
|
473
|
-
? { name: f.path, data: new Uint8Array(0), linkTarget: f.linkTarget }
|
|
474
|
-
: { name: f.path, data: f.data },
|
|
475
|
-
),
|
|
476
|
-
];
|
|
477
|
-
|
|
478
|
-
const tar = createTarArchive(tarEntries);
|
|
479
|
-
const archive = gzipSync(tar);
|
|
480
|
-
|
|
481
|
-
return { archive, manifest };
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// ---------------------------------------------------------------------------
|
|
485
|
-
// Directory walker — recursively collects files for archive inclusion
|
|
486
|
-
// ---------------------------------------------------------------------------
|
|
487
|
-
|
|
488
|
-
interface WalkDirectoryOptions {
|
|
489
|
-
/** Include binary files (files containing null bytes). Default: false. */
|
|
490
|
-
includeBinary?: boolean;
|
|
491
|
-
/** Directory names to skip (matched against relative path from walk root). */
|
|
492
|
-
skipDirs?: string[];
|
|
493
|
-
/** File names to skip (matched against the entry basename). */
|
|
494
|
-
skipFiles?: string[];
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Resolve and classify a symlink encountered during a walk.
|
|
499
|
-
*
|
|
500
|
-
* Returns one of:
|
|
501
|
-
* { kind: "class1", linkTarget } — emit as a tar typeflag-2 entry whose
|
|
502
|
-
* `linkname` field holds `linkTarget` (the symlink target encoded as a
|
|
503
|
-
* POSIX path relative to the symlink's own directory).
|
|
504
|
-
* { kind: "drop", reason } — drop the symlink. Reasons cover broken
|
|
505
|
-
* links, targets outside the workspace (class 2), targets inside a
|
|
506
|
-
* skipped directory (class 3), directory targets (out of scope), and
|
|
507
|
-
* link targets whose UTF-8 encoding exceeds the 100-byte ustar
|
|
508
|
-
* `linkname` field limit.
|
|
509
|
-
*/
|
|
510
|
-
type SymlinkClassification =
|
|
511
|
-
| { kind: "class1"; linkTarget: string }
|
|
512
|
-
| { kind: "drop"; reason: string };
|
|
513
|
-
|
|
514
|
-
function classifySymlink(args: {
|
|
515
|
-
fullPath: string;
|
|
516
|
-
walkRoot: string;
|
|
517
|
-
skipDirs: readonly string[];
|
|
518
|
-
}): SymlinkClassification {
|
|
519
|
-
const { fullPath, walkRoot, skipDirs } = args;
|
|
520
|
-
|
|
521
|
-
let absoluteTarget: string;
|
|
522
|
-
try {
|
|
523
|
-
absoluteTarget = realpathSync(fullPath);
|
|
524
|
-
} catch {
|
|
525
|
-
return { kind: "drop", reason: "broken symlink (realpath failed)" };
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
let targetStat;
|
|
529
|
-
try {
|
|
530
|
-
targetStat = lstatSync(absoluteTarget);
|
|
531
|
-
} catch {
|
|
532
|
-
return { kind: "drop", reason: "broken symlink (target stat failed)" };
|
|
533
|
-
}
|
|
534
|
-
if (!targetStat.isFile()) {
|
|
535
|
-
return { kind: "drop", reason: "target is not a regular file" };
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
let dirAbs: string;
|
|
539
|
-
try {
|
|
540
|
-
dirAbs = realpathSync(walkRoot);
|
|
541
|
-
} catch {
|
|
542
|
-
dirAbs = resolve(walkRoot);
|
|
543
|
-
}
|
|
544
|
-
const targetAbs = resolve(absoluteTarget);
|
|
545
|
-
const insideWorkspace =
|
|
546
|
-
targetAbs === dirAbs || targetAbs.startsWith(dirAbs + sep);
|
|
547
|
-
if (!insideWorkspace) {
|
|
548
|
-
return { kind: "drop", reason: "target outside workspace" };
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const targetRelToWorkspace = relative(dirAbs, targetAbs);
|
|
552
|
-
if (
|
|
553
|
-
skipDirs.some(
|
|
554
|
-
(s) =>
|
|
555
|
-
targetRelToWorkspace === s || targetRelToWorkspace.startsWith(s + "/"),
|
|
556
|
-
)
|
|
557
|
-
) {
|
|
558
|
-
return { kind: "drop", reason: "target inside skipDir" };
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Canonicalize the symlink's parent directory so the relative linkTarget
|
|
562
|
-
// computation lines up with `absoluteTarget` (which is canonical from
|
|
563
|
-
// realpathSync). On macOS, walking through /var/folders/... and resolving
|
|
564
|
-
// the target through /private/var/folders/... would otherwise produce a
|
|
565
|
-
// long ../../../private/... path that exceeds the 100-byte ustar limit.
|
|
566
|
-
let parentAbs: string;
|
|
567
|
-
try {
|
|
568
|
-
parentAbs = realpathSync(dirname(fullPath));
|
|
569
|
-
} catch {
|
|
570
|
-
parentAbs = resolve(dirname(fullPath));
|
|
571
|
-
}
|
|
572
|
-
const linkTarget = relative(parentAbs, absoluteTarget);
|
|
573
|
-
if (new TextEncoder().encode(linkTarget).length > 100) {
|
|
574
|
-
return {
|
|
575
|
-
kind: "drop",
|
|
576
|
-
reason: "encoded link target exceeds 100-byte ustar limit",
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
return { kind: "class1", linkTarget };
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Recursively walk a directory and return all regular files (and bundleable
|
|
585
|
-
* symlinks) as VBundleFileEntry objects with paths prefixed by
|
|
586
|
-
* `archivePrefix`. Symlinks that resolve to a regular file inside the walk
|
|
587
|
-
* root and outside any skipDir are emitted as typeflag-2 entries (data
|
|
588
|
-
* empty, `linkTarget` populated). All other symlinks (broken, directory
|
|
589
|
-
* target, target outside workspace, target inside skipDir, encoded
|
|
590
|
-
* linkTarget over 100 bytes) are reported via the returned `droppedSymlinks`
|
|
591
|
-
* array as workspace-relative paths of the symlink itself.
|
|
592
|
-
*
|
|
593
|
-
* By default, binary files (detected via null-byte heuristic in the first
|
|
594
|
-
* 8 KB) are skipped. Pass `includeBinary: true` to include them.
|
|
595
|
-
*/
|
|
596
|
-
export function walkDirectory(
|
|
597
|
-
dir: string,
|
|
598
|
-
archivePrefix: string,
|
|
599
|
-
options: WalkDirectoryOptions = {},
|
|
600
|
-
): { files: VBundleFileEntry[]; droppedSymlinks: string[] } {
|
|
601
|
-
const { includeBinary = false, skipDirs = [], skipFiles = [] } = options;
|
|
602
|
-
const entries: VBundleFileEntry[] = [];
|
|
603
|
-
const droppedSymlinks: string[] = [];
|
|
604
|
-
|
|
605
|
-
function walk(currentDir: string): void {
|
|
606
|
-
const dirEntries = readdirSync(currentDir, { withFileTypes: true });
|
|
607
|
-
for (const entry of dirEntries) {
|
|
608
|
-
const fullPath = join(currentDir, entry.name);
|
|
609
|
-
|
|
610
|
-
const stat = lstatSync(fullPath);
|
|
611
|
-
if (stat.isSymbolicLink()) {
|
|
612
|
-
const classification = classifySymlink({
|
|
613
|
-
fullPath,
|
|
614
|
-
walkRoot: dir,
|
|
615
|
-
skipDirs,
|
|
616
|
-
});
|
|
617
|
-
if (classification.kind === "class1") {
|
|
618
|
-
entries.push({
|
|
619
|
-
path: `${archivePrefix}/${relative(dir, fullPath)}`,
|
|
620
|
-
data: new Uint8Array(0),
|
|
621
|
-
linkTarget: classification.linkTarget,
|
|
622
|
-
});
|
|
623
|
-
} else {
|
|
624
|
-
droppedSymlinks.push(relative(dir, fullPath));
|
|
625
|
-
}
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
if (stat.isDirectory()) {
|
|
630
|
-
// Check skip list against the relative path from the walk root
|
|
631
|
-
const relDir = relative(dir, fullPath);
|
|
632
|
-
if (skipDirs.some((s) => relDir === s || relDir.startsWith(s + "/"))) {
|
|
633
|
-
continue;
|
|
634
|
-
}
|
|
635
|
-
walk(fullPath);
|
|
636
|
-
} else if (stat.isFile()) {
|
|
637
|
-
// Skip files by basename (e.g. backup key)
|
|
638
|
-
if (skipFiles.includes(entry.name)) {
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Skip SQLite auxiliary files — these are ephemeral and race-prone
|
|
643
|
-
// with the live DB connection. The WAL is checkpointed before the
|
|
644
|
-
// walk, so the main .db file has all committed rows.
|
|
645
|
-
if (
|
|
646
|
-
entry.name.endsWith(".db-wal") ||
|
|
647
|
-
entry.name.endsWith(".db-shm") ||
|
|
648
|
-
entry.name.endsWith(".db-journal")
|
|
649
|
-
) {
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const data = new Uint8Array(readFileSync(fullPath));
|
|
654
|
-
|
|
655
|
-
// Skip binary files unless explicitly included
|
|
656
|
-
if (!includeBinary) {
|
|
657
|
-
const checkLength = Math.min(data.length, 8192);
|
|
658
|
-
let isBinary = false;
|
|
659
|
-
for (let i = 0; i < checkLength; i++) {
|
|
660
|
-
if (data[i] === 0) {
|
|
661
|
-
isBinary = true;
|
|
662
|
-
break;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
if (isBinary) {
|
|
666
|
-
continue;
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const relativePath = relative(dir, fullPath);
|
|
671
|
-
entries.push({
|
|
672
|
-
path: `${archivePrefix}/${relativePath}`,
|
|
673
|
-
data,
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
walk(dir);
|
|
680
|
-
return { files: entries, droppedSymlinks };
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// ---------------------------------------------------------------------------
|
|
684
|
-
// Export builder — reads real data from disk
|
|
685
|
-
// ---------------------------------------------------------------------------
|
|
686
|
-
|
|
687
|
-
export interface BuildExportVBundleOptions {
|
|
688
|
-
/** Identity of the assistant that produced this bundle. */
|
|
689
|
-
assistant: VBundleAssistantInfo;
|
|
690
|
-
/** Where this bundle was produced. */
|
|
691
|
-
origin: VBundleOriginInfo;
|
|
692
|
-
/** Runtime-version compatibility window for importers. */
|
|
693
|
-
compatibility: VBundleCompatibility;
|
|
694
|
-
/** Which optional bundle contents this export carries. */
|
|
695
|
-
exportOptions: VBundleExportOptions;
|
|
696
|
-
/** Whether secrets were stripped from the bundle before archiving. */
|
|
697
|
-
secretsRedacted: boolean;
|
|
698
|
-
/**
|
|
699
|
-
* Absolute path to the workspace directory ($CADET_WORKSPACE_DIR/).
|
|
700
|
-
* When provided and exists, the entire directory tree is walked and
|
|
701
|
-
* included in the archive under the "workspace/" prefix, skipping
|
|
702
|
-
* large/regenerable dirs (embedding-models/, data/qdrant/).
|
|
703
|
-
* Binary files (SQLite DB, attachments) are included.
|
|
704
|
-
*/
|
|
705
|
-
workspaceDir?: string;
|
|
706
|
-
/**
|
|
707
|
-
* Optional callback to checkpoint the WAL before reading the database file.
|
|
708
|
-
* In WAL mode, committed rows may live in the -wal file and not yet be
|
|
709
|
-
* flushed to the main .db file. Callers should pass a function that runs
|
|
710
|
-
* PRAGMA wal_checkpoint(FULL) on the live database connection — FULL
|
|
711
|
-
* flushes committed frames to the .db file without the extra WAL restart
|
|
712
|
-
* and truncation that TRUNCATE performs.
|
|
713
|
-
* Called before the workspace walk so the DB file is up to date.
|
|
714
|
-
*
|
|
715
|
-
* May return a Promise — `streamExportVBundle` awaits the result so an
|
|
716
|
-
* async-dispatched checkpoint (e.g. via `runAsyncSqlite`) does not race
|
|
717
|
-
* with the file walk. The synchronous `buildExportVBundle` path does
|
|
718
|
-
* not await; pass a sync callback there.
|
|
719
|
-
*/
|
|
720
|
-
checkpoint?: () => void | Promise<void>;
|
|
721
|
-
/** Optional credential entries to include in the archive under credentials/ prefix. */
|
|
722
|
-
credentials?: Array<{ account: string; value: string }>;
|
|
723
|
-
/**
|
|
724
|
-
* Extra in-memory entries at exact archive paths, outside `workspace/`.
|
|
725
|
-
* The debug profile uses this for `gateway/export.tar.gz`.
|
|
726
|
-
*/
|
|
727
|
-
extraFiles?: Array<{ archivePath: string; data: Uint8Array }>;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Build a .vbundle archive populated with real assistant data.
|
|
732
|
-
*
|
|
733
|
-
* Walks the entire workspace directory ($CADET_WORKSPACE_DIR/) and includes
|
|
734
|
-
* all files in the archive, skipping only large/regenerable directories
|
|
735
|
-
* (embedding-models/, data/qdrant/). Binary files (SQLite DB, attachments)
|
|
736
|
-
* are included.
|
|
737
|
-
*
|
|
738
|
-
* The WAL is checkpointed before the walk so the exported DB file contains
|
|
739
|
-
* all committed rows.
|
|
740
|
-
*/
|
|
741
|
-
export function buildExportVBundle(
|
|
742
|
-
options: BuildExportVBundleOptions,
|
|
743
|
-
): BuildVBundleResult {
|
|
744
|
-
const {
|
|
745
|
-
assistant,
|
|
746
|
-
origin,
|
|
747
|
-
compatibility,
|
|
748
|
-
exportOptions,
|
|
749
|
-
secretsRedacted,
|
|
750
|
-
checkpoint,
|
|
751
|
-
workspaceDir,
|
|
752
|
-
credentials,
|
|
753
|
-
extraFiles,
|
|
754
|
-
} = options;
|
|
755
|
-
|
|
756
|
-
// Flush WAL to the main database file before reading so the export
|
|
757
|
-
// captures all committed rows (SQLite WAL mode keeps recent writes
|
|
758
|
-
// in a separate -wal file until checkpoint).
|
|
759
|
-
if (checkpoint) {
|
|
760
|
-
checkpoint();
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
const files: VBundleFileEntry[] = [];
|
|
764
|
-
for (const extra of extraFiles ?? []) {
|
|
765
|
-
files.push({ path: extra.archivePath, data: extra.data });
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// Walk the entire workspace directory, including binary files (DB,
|
|
769
|
-
// attachments) but skipping large/regenerable subdirectories.
|
|
770
|
-
if (
|
|
771
|
-
workspaceDir &&
|
|
772
|
-
existsSync(workspaceDir) &&
|
|
773
|
-
lstatSync(workspaceDir).isDirectory()
|
|
774
|
-
) {
|
|
775
|
-
const { files: walkedFiles, droppedSymlinks } = walkDirectory(
|
|
776
|
-
workspaceDir,
|
|
777
|
-
"workspace",
|
|
778
|
-
{
|
|
779
|
-
includeBinary: true,
|
|
780
|
-
skipDirs: ["embedding-models", "data/qdrant", "signals", "deprecated"],
|
|
781
|
-
skipFiles: [".backup.key"],
|
|
782
|
-
},
|
|
783
|
-
);
|
|
784
|
-
files.push(...walkedFiles);
|
|
785
|
-
if (droppedSymlinks.length > 0) {
|
|
786
|
-
getLogger("vbundle-builder").warn(
|
|
787
|
-
{ count: droppedSymlinks.length, paths: droppedSymlinks },
|
|
788
|
-
`Dropped ${droppedSymlinks.length} symlinks pointing outside workspace or into skipped directories`,
|
|
789
|
-
);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Sanitize workspace/config.json to strip environment-specific fields
|
|
794
|
-
const configEntry = files.find((f) => f.path === "workspace/config.json");
|
|
795
|
-
if (configEntry) {
|
|
796
|
-
const configJson = new TextDecoder().decode(configEntry.data);
|
|
797
|
-
const sanitized = sanitizeConfigForTransfer(configJson);
|
|
798
|
-
configEntry.data = new TextEncoder().encode(sanitized);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Include credential entries if provided
|
|
802
|
-
if (credentials?.length) {
|
|
803
|
-
for (const { account, value } of credentials) {
|
|
804
|
-
const data = new TextEncoder().encode(value);
|
|
805
|
-
files.push({ path: `credentials/${account}`, data });
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
return buildVBundle({
|
|
810
|
-
files,
|
|
811
|
-
assistant,
|
|
812
|
-
origin,
|
|
813
|
-
compatibility,
|
|
814
|
-
exportOptions,
|
|
815
|
-
secretsRedacted,
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// ---------------------------------------------------------------------------
|
|
820
|
-
// Streaming export builder — two-pass approach for bounded memory usage
|
|
821
|
-
// ---------------------------------------------------------------------------
|
|
822
|
-
|
|
823
|
-
/**
|
|
824
|
-
* Walk a directory tree and collect file metadata (paths + sizes) without
|
|
825
|
-
* reading file contents into memory. Mirrors `walkDirectory`'s filtering
|
|
826
|
-
* logic (SQLite auxiliary skip, binary detection, skipDirs) and symlink
|
|
827
|
-
* classification — bundleable symlinks are emitted as `SymlinkMetadata`
|
|
828
|
-
* entries; non-bundleable symlinks are reported via `droppedSymlinks`.
|
|
829
|
-
*/
|
|
830
|
-
export function walkDirectoryForMetadata(
|
|
831
|
-
dir: string,
|
|
832
|
-
archivePrefix: string,
|
|
833
|
-
options: WalkDirectoryOptions = {},
|
|
834
|
-
): {
|
|
835
|
-
files: FileMetadata[];
|
|
836
|
-
symlinks: SymlinkMetadata[];
|
|
837
|
-
droppedSymlinks: string[];
|
|
838
|
-
} {
|
|
839
|
-
const { includeBinary = false, skipDirs = [], skipFiles = [] } = options;
|
|
840
|
-
const entries: FileMetadata[] = [];
|
|
841
|
-
const symlinks: SymlinkMetadata[] = [];
|
|
842
|
-
const droppedSymlinks: string[] = [];
|
|
843
|
-
|
|
844
|
-
function walk(currentDir: string): void {
|
|
845
|
-
const dirEntries = readdirSync(currentDir, { withFileTypes: true });
|
|
846
|
-
for (const entry of dirEntries) {
|
|
847
|
-
const fullPath = join(currentDir, entry.name);
|
|
848
|
-
|
|
849
|
-
const fileStat = lstatSync(fullPath);
|
|
850
|
-
if (fileStat.isSymbolicLink()) {
|
|
851
|
-
const classification = classifySymlink({
|
|
852
|
-
fullPath,
|
|
853
|
-
walkRoot: dir,
|
|
854
|
-
skipDirs,
|
|
855
|
-
});
|
|
856
|
-
if (classification.kind === "class1") {
|
|
857
|
-
symlinks.push({
|
|
858
|
-
archivePath: `${archivePrefix}/${relative(dir, fullPath)}`,
|
|
859
|
-
linkTarget: classification.linkTarget,
|
|
860
|
-
size: 0,
|
|
861
|
-
});
|
|
862
|
-
} else {
|
|
863
|
-
droppedSymlinks.push(relative(dir, fullPath));
|
|
864
|
-
}
|
|
865
|
-
continue;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
if (fileStat.isDirectory()) {
|
|
869
|
-
// Check skip list against the relative path from the walk root
|
|
870
|
-
const relDir = relative(dir, fullPath);
|
|
871
|
-
if (skipDirs.some((s) => relDir === s || relDir.startsWith(s + "/"))) {
|
|
872
|
-
continue;
|
|
873
|
-
}
|
|
874
|
-
walk(fullPath);
|
|
875
|
-
} else if (fileStat.isFile()) {
|
|
876
|
-
// Skip files by basename (e.g. backup key)
|
|
877
|
-
if (skipFiles.includes(entry.name)) {
|
|
878
|
-
continue;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// Skip SQLite auxiliary files — these are ephemeral and race-prone
|
|
882
|
-
if (
|
|
883
|
-
entry.name.endsWith(".db-wal") ||
|
|
884
|
-
entry.name.endsWith(".db-shm") ||
|
|
885
|
-
entry.name.endsWith(".db-journal")
|
|
886
|
-
) {
|
|
887
|
-
continue;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Skip binary files unless explicitly included
|
|
891
|
-
if (!includeBinary) {
|
|
892
|
-
// Read only the first 8 KB to check for null bytes
|
|
893
|
-
const checkLength = Math.min(fileStat.size, 8192);
|
|
894
|
-
if (checkLength > 0) {
|
|
895
|
-
const buf = Buffer.alloc(checkLength);
|
|
896
|
-
const fd = openSync(fullPath, "r");
|
|
897
|
-
try {
|
|
898
|
-
readSync(fd, buf, 0, checkLength, 0);
|
|
899
|
-
} finally {
|
|
900
|
-
closeSync(fd);
|
|
901
|
-
}
|
|
902
|
-
let isBinary = false;
|
|
903
|
-
for (let i = 0; i < checkLength; i++) {
|
|
904
|
-
if (buf[i] === 0) {
|
|
905
|
-
isBinary = true;
|
|
906
|
-
break;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
if (isBinary) {
|
|
910
|
-
continue;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
const relativePath = relative(dir, fullPath);
|
|
916
|
-
entries.push({
|
|
917
|
-
archivePath: `${archivePrefix}/${relativePath}`,
|
|
918
|
-
diskPath: fullPath,
|
|
919
|
-
size: fileStat.size,
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
walk(dir);
|
|
926
|
-
return { files: entries, symlinks, droppedSymlinks };
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Compute SHA-256 hex digest of a file by reading it in bounded chunks —
|
|
931
|
-
* never buffers the entire file in memory. When `size` is provided, only
|
|
932
|
-
* hashes the first `size` bytes to match what will be archived in the tar
|
|
933
|
-
* entry.
|
|
934
|
-
*/
|
|
935
|
-
async function computeFileSha256(
|
|
936
|
-
filePath: string,
|
|
937
|
-
size?: number,
|
|
938
|
-
): Promise<string> {
|
|
939
|
-
const hash = createHash("sha256");
|
|
940
|
-
if (size === 0) {
|
|
941
|
-
return hash.digest("hex");
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// Read through an explicitly-managed FileHandle so the descriptor is
|
|
945
|
-
// always released in `finally`. This pass opens every file in the
|
|
946
|
-
// workspace, so any per-file descriptor leak here would exhaust the
|
|
947
|
-
// daemon's file-descriptor limit (EMFILE). A bounded read loop keeps peak
|
|
948
|
-
// memory flat regardless of file size.
|
|
949
|
-
const readChunkBytes = 64 * 1024;
|
|
950
|
-
const handle = await open(filePath, "r");
|
|
951
|
-
try {
|
|
952
|
-
const chunk = Buffer.allocUnsafe(readChunkBytes);
|
|
953
|
-
let position = 0;
|
|
954
|
-
for (;;) {
|
|
955
|
-
const remaining = size !== undefined ? size - position : Infinity;
|
|
956
|
-
if (remaining <= 0) {
|
|
957
|
-
break;
|
|
958
|
-
}
|
|
959
|
-
const toRead = Math.min(chunk.length, remaining);
|
|
960
|
-
const { bytesRead } = await handle.read(chunk, 0, toRead, position);
|
|
961
|
-
if (bytesRead === 0) {
|
|
962
|
-
break;
|
|
963
|
-
}
|
|
964
|
-
hash.update(chunk.subarray(0, bytesRead));
|
|
965
|
-
position += bytesRead;
|
|
966
|
-
}
|
|
967
|
-
} finally {
|
|
968
|
-
await handle.close();
|
|
969
|
-
}
|
|
970
|
-
return hash.digest("hex");
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/**
|
|
974
|
-
* Create just the 512-byte tar header block for a regular file entry.
|
|
975
|
-
* Extracted from `createTarEntry` logic — does NOT include data or padding.
|
|
976
|
-
*
|
|
977
|
-
* When `linkTarget` is provided, the header is emitted as a tar typeflag-2
|
|
978
|
-
* (symlink) record: typeflag is "2", the link target is written into the
|
|
979
|
-
* `linkname` field (header[157..256], 100-byte limit), and `size` is forced
|
|
980
|
-
* to 0 in the header field. Caller is responsible for not yielding any body
|
|
981
|
-
* or padding bytes for symlink entries.
|
|
982
|
-
*/
|
|
983
|
-
function createTarHeaderBlock(
|
|
984
|
-
name: string,
|
|
985
|
-
size: number,
|
|
986
|
-
linkTarget?: string,
|
|
987
|
-
): Uint8Array {
|
|
988
|
-
const encoder = new TextEncoder();
|
|
989
|
-
const nameBytes = encoder.encode(name);
|
|
990
|
-
|
|
991
|
-
const header = new Uint8Array(BLOCK_SIZE);
|
|
992
|
-
|
|
993
|
-
// File name (0-99) — truncated if >100 bytes
|
|
994
|
-
header.set(nameBytes.subarray(0, 100), 0);
|
|
995
|
-
|
|
996
|
-
// File mode (100-107): 0644
|
|
997
|
-
writeOctal(header, 100, 8, 0o644);
|
|
998
|
-
|
|
999
|
-
// Owner ID (108-115)
|
|
1000
|
-
writeOctal(header, 108, 8, 0);
|
|
1001
|
-
|
|
1002
|
-
// Group ID (116-123)
|
|
1003
|
-
writeOctal(header, 116, 8, 0);
|
|
1004
|
-
|
|
1005
|
-
// File size (124-135) — symlink entries always declare size=0
|
|
1006
|
-
writeOctal(header, 124, 12, linkTarget !== undefined ? 0 : size);
|
|
1007
|
-
|
|
1008
|
-
// Modification time (136-147)
|
|
1009
|
-
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
|
|
1010
|
-
|
|
1011
|
-
// Type flag (156): regular file ("0") or symlink ("2")
|
|
1012
|
-
header[156] =
|
|
1013
|
-
linkTarget !== undefined ? "2".charCodeAt(0) : "0".charCodeAt(0);
|
|
1014
|
-
|
|
1015
|
-
// Linkname (157-256, 100 bytes) — only set for symlink entries
|
|
1016
|
-
if (linkTarget !== undefined) {
|
|
1017
|
-
const linkBytes = encoder.encode(linkTarget);
|
|
1018
|
-
if (linkBytes.length > 100) {
|
|
1019
|
-
throw new Error(
|
|
1020
|
-
`symlink target exceeds 100-byte ustar linkname limit (${linkBytes.length} bytes): ${linkTarget}`,
|
|
1021
|
-
);
|
|
1022
|
-
}
|
|
1023
|
-
header.set(linkBytes, 157);
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// USTAR magic (257-262)
|
|
1027
|
-
const magic = encoder.encode("ustar\0");
|
|
1028
|
-
header.set(magic, 257);
|
|
1029
|
-
|
|
1030
|
-
// USTAR version (263-264)
|
|
1031
|
-
header[263] = "0".charCodeAt(0);
|
|
1032
|
-
header[264] = "0".charCodeAt(0);
|
|
1033
|
-
|
|
1034
|
-
// Compute and write checksum (148-155). Must run AFTER linkname is set
|
|
1035
|
-
// so the checksum covers the symlink target bytes.
|
|
1036
|
-
const checksum = computeHeaderChecksum(header);
|
|
1037
|
-
writeOctal(header, 148, 7, checksum);
|
|
1038
|
-
header[155] = 0x20; // trailing space
|
|
1039
|
-
|
|
1040
|
-
return header;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/**
|
|
1044
|
-
* If name exceeds 100 bytes, returns the PAX extended header entry
|
|
1045
|
-
* concatenated with the regular header block. Otherwise returns just
|
|
1046
|
-
* the header block.
|
|
1047
|
-
*
|
|
1048
|
-
* `linkTarget` is forwarded to `createTarHeaderBlock` so symlink entries
|
|
1049
|
-
* still get a PAX path header for long names while emitting a typeflag-2
|
|
1050
|
-
* ustar block.
|
|
1051
|
-
*/
|
|
1052
|
-
function createPaxAndHeaderBlocks(
|
|
1053
|
-
name: string,
|
|
1054
|
-
size: number,
|
|
1055
|
-
linkTarget?: string,
|
|
1056
|
-
): Uint8Array {
|
|
1057
|
-
const encoder = new TextEncoder();
|
|
1058
|
-
const nameBytes = encoder.encode(name);
|
|
1059
|
-
const needsPax = nameBytes.length > 100;
|
|
1060
|
-
|
|
1061
|
-
const header = createTarHeaderBlock(name, size, linkTarget);
|
|
1062
|
-
|
|
1063
|
-
if (needsPax) {
|
|
1064
|
-
const paxEntry = createPaxPathEntry(name);
|
|
1065
|
-
const result = new Uint8Array(paxEntry.length + header.length);
|
|
1066
|
-
result.set(paxEntry, 0);
|
|
1067
|
-
result.set(header, paxEntry.length);
|
|
1068
|
-
return result;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
return header;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Returns zero-filled padding bytes to align data to the tar block boundary.
|
|
1076
|
-
*/
|
|
1077
|
-
function tarPaddingBytes(dataSize: number): Uint8Array {
|
|
1078
|
-
const remainder = dataSize % BLOCK_SIZE;
|
|
1079
|
-
if (remainder === 0) {
|
|
1080
|
-
return new Uint8Array(0);
|
|
1081
|
-
}
|
|
1082
|
-
return new Uint8Array(BLOCK_SIZE - remainder);
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Async generator that yields raw tar bytes in order:
|
|
1087
|
-
* manifest entry, then each file entry, then end-of-archive marker.
|
|
1088
|
-
* Each file is streamed from disk — never fully buffered in memory.
|
|
1089
|
-
*/
|
|
1090
|
-
async function* generateTarStream(
|
|
1091
|
-
manifestJson: Uint8Array,
|
|
1092
|
-
files: TarStreamEntry[],
|
|
1093
|
-
): AsyncGenerator<Uint8Array> {
|
|
1094
|
-
// Manifest entry
|
|
1095
|
-
yield createPaxAndHeaderBlocks("manifest.json", manifestJson.length);
|
|
1096
|
-
yield manifestJson;
|
|
1097
|
-
yield tarPaddingBytes(manifestJson.length);
|
|
1098
|
-
|
|
1099
|
-
// File entries
|
|
1100
|
-
for (const file of files) {
|
|
1101
|
-
if (isSymlinkEntry(file)) {
|
|
1102
|
-
// Symlink entry: typeflag-2 header carries the linkname; no body, no
|
|
1103
|
-
// padding. Skip the entrySize/body/padding logic entirely so the
|
|
1104
|
-
// surrounding stream stays block-aligned.
|
|
1105
|
-
yield createPaxAndHeaderBlocks(file.archivePath, 0, file.linkTarget);
|
|
1106
|
-
continue;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
const entrySize = file.size;
|
|
1110
|
-
yield createPaxAndHeaderBlocks(file.archivePath, entrySize);
|
|
1111
|
-
|
|
1112
|
-
if (isInMemoryEntry(file)) {
|
|
1113
|
-
// In-memory entry — yield data directly
|
|
1114
|
-
if (file.size > 0) {
|
|
1115
|
-
yield file.data;
|
|
1116
|
-
}
|
|
1117
|
-
} else {
|
|
1118
|
-
// Disk-backed entry — stream from disk
|
|
1119
|
-
// Stream exactly file.size bytes from disk. Capping the read at the
|
|
1120
|
-
// declared size keeps the tar structure valid even if the file grows
|
|
1121
|
-
// between passes (common for log files on active assistants). If the
|
|
1122
|
-
// file shrinks below the declared size, zero-pad to maintain block
|
|
1123
|
-
// alignment. The WAL checkpoint before export is the primary
|
|
1124
|
-
// consistency mechanism for the database.
|
|
1125
|
-
let bytesWritten = 0;
|
|
1126
|
-
if (entrySize > 0) {
|
|
1127
|
-
// Read through an explicitly-managed FileHandle so the descriptor is
|
|
1128
|
-
// released in `finally` even when the consumer abandons the generator
|
|
1129
|
-
// mid-file. Each read uses a fresh buffer so a yielded chunk is never
|
|
1130
|
-
// overwritten by the next read before the consumer drains it.
|
|
1131
|
-
let handle: FileHandle | undefined;
|
|
1132
|
-
try {
|
|
1133
|
-
handle = await open(file.diskPath, "r");
|
|
1134
|
-
const readChunkBytes = 64 * 1024;
|
|
1135
|
-
while (bytesWritten < entrySize) {
|
|
1136
|
-
const toRead = Math.min(readChunkBytes, entrySize - bytesWritten);
|
|
1137
|
-
const buf = Buffer.allocUnsafe(toRead);
|
|
1138
|
-
const { bytesRead } = await handle.read(
|
|
1139
|
-
buf,
|
|
1140
|
-
0,
|
|
1141
|
-
toRead,
|
|
1142
|
-
bytesWritten,
|
|
1143
|
-
);
|
|
1144
|
-
if (bytesRead === 0) {
|
|
1145
|
-
break;
|
|
1146
|
-
}
|
|
1147
|
-
bytesWritten += bytesRead;
|
|
1148
|
-
yield bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
|
|
1149
|
-
}
|
|
1150
|
-
} catch {
|
|
1151
|
-
// File was deleted or rotated between passes — emit zeros for
|
|
1152
|
-
// the full declared size so the tar structure stays valid
|
|
1153
|
-
} finally {
|
|
1154
|
-
if (handle) {
|
|
1155
|
-
await handle.close();
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
// If the file shrank, pad with zeros in bounded chunks to reach
|
|
1161
|
-
// the declared size without a large single allocation
|
|
1162
|
-
let remaining = file.size - bytesWritten;
|
|
1163
|
-
while (remaining > 0) {
|
|
1164
|
-
const chunkSize = Math.min(remaining, 65536);
|
|
1165
|
-
yield new Uint8Array(chunkSize);
|
|
1166
|
-
remaining -= chunkSize;
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
yield tarPaddingBytes(entrySize);
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
// End-of-archive: two zero blocks
|
|
1174
|
-
yield new Uint8Array(BLOCK_SIZE * 2);
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// ---------------------------------------------------------------------------
|
|
1178
|
-
// Streaming export result type
|
|
1179
|
-
// ---------------------------------------------------------------------------
|
|
1180
|
-
|
|
1181
|
-
export interface StreamExportVBundleResult {
|
|
1182
|
-
tempPath: string;
|
|
1183
|
-
size: number;
|
|
1184
|
-
manifest: ManifestType;
|
|
1185
|
-
cleanup: () => Promise<void>;
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/**
|
|
1189
|
-
* Build a .vbundle archive using a streaming two-pass approach that keeps
|
|
1190
|
-
* peak memory usage bounded to ~1 MB regardless of workspace size.
|
|
1191
|
-
*
|
|
1192
|
-
* Pass 1: Walk directory metadata and compute SHA-256 checksums without
|
|
1193
|
-
* loading file contents into memory (builds manifest).
|
|
1194
|
-
* Pass 2: Stream tar entries through gzip into a temp file on disk.
|
|
1195
|
-
*
|
|
1196
|
-
* Returns a result with the temp file path, size, manifest, and a cleanup
|
|
1197
|
-
* function to remove the temp file when done.
|
|
1198
|
-
*/
|
|
1199
|
-
export async function streamExportVBundle(
|
|
1200
|
-
options: BuildExportVBundleOptions,
|
|
1201
|
-
): Promise<StreamExportVBundleResult> {
|
|
1202
|
-
return trackDaemonActivity({ kind: "vbundle_export" }, () =>
|
|
1203
|
-
streamExport(options),
|
|
1204
|
-
);
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
async function streamExport(
|
|
1208
|
-
options: BuildExportVBundleOptions,
|
|
1209
|
-
): Promise<StreamExportVBundleResult> {
|
|
1210
|
-
const {
|
|
1211
|
-
assistant,
|
|
1212
|
-
origin,
|
|
1213
|
-
compatibility,
|
|
1214
|
-
exportOptions,
|
|
1215
|
-
secretsRedacted,
|
|
1216
|
-
checkpoint,
|
|
1217
|
-
workspaceDir,
|
|
1218
|
-
credentials,
|
|
1219
|
-
extraFiles,
|
|
1220
|
-
} = options;
|
|
1221
|
-
|
|
1222
|
-
// Flush WAL to the main database file before reading. Awaiting allows
|
|
1223
|
-
// the callback to dispatch the checkpoint via `runAsyncSqlite` so the
|
|
1224
|
-
// daemon event loop stays responsive while SQLite flushes the WAL.
|
|
1225
|
-
if (checkpoint) {
|
|
1226
|
-
await checkpoint();
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
const allFileMetadata: FileMetadata[] = [];
|
|
1230
|
-
const symlinkEntries: SymlinkMetadata[] = [];
|
|
1231
|
-
|
|
1232
|
-
// Walk the entire workspace directory, including binary files
|
|
1233
|
-
if (
|
|
1234
|
-
workspaceDir &&
|
|
1235
|
-
existsSync(workspaceDir) &&
|
|
1236
|
-
lstatSync(workspaceDir).isDirectory()
|
|
1237
|
-
) {
|
|
1238
|
-
const {
|
|
1239
|
-
files: walkedFiles,
|
|
1240
|
-
symlinks: walkedSymlinks,
|
|
1241
|
-
droppedSymlinks,
|
|
1242
|
-
} = walkDirectoryForMetadata(workspaceDir, "workspace", {
|
|
1243
|
-
includeBinary: true,
|
|
1244
|
-
skipDirs: ["embedding-models", "data/qdrant", "signals", "deprecated"],
|
|
1245
|
-
skipFiles: [".backup.key"],
|
|
1246
|
-
});
|
|
1247
|
-
allFileMetadata.push(...walkedFiles);
|
|
1248
|
-
symlinkEntries.push(...walkedSymlinks);
|
|
1249
|
-
if (droppedSymlinks.length > 0) {
|
|
1250
|
-
getLogger("vbundle-builder").warn(
|
|
1251
|
-
{ count: droppedSymlinks.length, paths: droppedSymlinks },
|
|
1252
|
-
`Dropped ${droppedSymlinks.length} symlinks pointing outside workspace or into skipped directories`,
|
|
1253
|
-
);
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
// Sanitize workspace/config.json: read from disk, sanitize, and replace the
|
|
1258
|
-
// disk-backed metadata entry with an in-memory entry so the streaming tar
|
|
1259
|
-
// writes sanitized content instead of the raw file.
|
|
1260
|
-
const configMetadataIdx = allFileMetadata.findIndex(
|
|
1261
|
-
(f) => f.archivePath === "workspace/config.json",
|
|
1262
|
-
);
|
|
1263
|
-
|
|
1264
|
-
const sanitizedConfigEntries: InMemoryEntry[] = [];
|
|
1265
|
-
if (configMetadataIdx !== -1) {
|
|
1266
|
-
const configMeta = allFileMetadata[configMetadataIdx];
|
|
1267
|
-
const rawConfigData = readFileSync(configMeta.diskPath, "utf8");
|
|
1268
|
-
const sanitized = sanitizeConfigForTransfer(rawConfigData);
|
|
1269
|
-
const sanitizedData = new TextEncoder().encode(sanitized);
|
|
1270
|
-
|
|
1271
|
-
// Remove the disk-backed entry and replace with an in-memory entry
|
|
1272
|
-
allFileMetadata.splice(configMetadataIdx, 1);
|
|
1273
|
-
sanitizedConfigEntries.push({
|
|
1274
|
-
archivePath: "workspace/config.json",
|
|
1275
|
-
data: sanitizedData,
|
|
1276
|
-
size: sanitizedData.length,
|
|
1277
|
-
});
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
// Build in-memory entries for credentials and any extra files (not disk-backed)
|
|
1281
|
-
const inMemoryEntries: InMemoryEntry[] = [];
|
|
1282
|
-
for (const extra of extraFiles ?? []) {
|
|
1283
|
-
inMemoryEntries.push({
|
|
1284
|
-
archivePath: extra.archivePath,
|
|
1285
|
-
data: extra.data,
|
|
1286
|
-
size: extra.data.length,
|
|
1287
|
-
});
|
|
1288
|
-
}
|
|
1289
|
-
if (credentials?.length) {
|
|
1290
|
-
for (const { account, value } of credentials) {
|
|
1291
|
-
const data = new TextEncoder().encode(value);
|
|
1292
|
-
inMemoryEntries.push({
|
|
1293
|
-
archivePath: `credentials/${account}`,
|
|
1294
|
-
data,
|
|
1295
|
-
size: data.length,
|
|
1296
|
-
});
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
// ------------------------------------------------------------------
|
|
1301
|
-
// Pass 1: Compute SHA-256 checksums to build the manifest
|
|
1302
|
-
// ------------------------------------------------------------------
|
|
1303
|
-
|
|
1304
|
-
const fileEntries: ManifestFileEntryType[] = [];
|
|
1305
|
-
for (const file of allFileMetadata) {
|
|
1306
|
-
const sha256 = await computeFileSha256(file.diskPath, file.size);
|
|
1307
|
-
fileEntries.push({
|
|
1308
|
-
path: file.archivePath,
|
|
1309
|
-
sha256,
|
|
1310
|
-
size_bytes: file.size,
|
|
1311
|
-
});
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// Add in-memory entries (sanitized config, credentials) to the manifest
|
|
1315
|
-
for (const entry of [...sanitizedConfigEntries, ...inMemoryEntries]) {
|
|
1316
|
-
const sha256 = sha256Hex(entry.data);
|
|
1317
|
-
fileEntries.push({
|
|
1318
|
-
path: entry.archivePath,
|
|
1319
|
-
sha256,
|
|
1320
|
-
size_bytes: entry.size,
|
|
1321
|
-
});
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// Add symlink entries to the manifest. The sha256 is computed over the
|
|
1325
|
-
// link target string (UTF-8 encoded) so the streaming validator can
|
|
1326
|
-
// verify the manifest declared the same target the tar header carries.
|
|
1327
|
-
// size_bytes is always 0 for symlink entries.
|
|
1328
|
-
for (const entry of symlinkEntries) {
|
|
1329
|
-
fileEntries.push({
|
|
1330
|
-
path: entry.archivePath,
|
|
1331
|
-
sha256: sha256Hex(entry.linkTarget),
|
|
1332
|
-
size_bytes: 0,
|
|
1333
|
-
link_target: entry.linkTarget,
|
|
1334
|
-
});
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
const { manifest, manifestData } = buildManifestObject({
|
|
1338
|
-
contents: fileEntries,
|
|
1339
|
-
assistant,
|
|
1340
|
-
origin,
|
|
1341
|
-
compatibility,
|
|
1342
|
-
exportOptions,
|
|
1343
|
-
secretsRedacted,
|
|
1344
|
-
now: new Date(),
|
|
1345
|
-
});
|
|
1346
|
-
|
|
1347
|
-
// ------------------------------------------------------------------
|
|
1348
|
-
// Pass 2: Stream tar through gzip into a temp file
|
|
1349
|
-
// ------------------------------------------------------------------
|
|
1350
|
-
|
|
1351
|
-
const tempPath = join(tmpdir(), `vbundle-export-${randomUUID()}.tmp`);
|
|
1352
|
-
|
|
1353
|
-
const allEntries: TarStreamEntry[] = [
|
|
1354
|
-
...allFileMetadata,
|
|
1355
|
-
...sanitizedConfigEntries,
|
|
1356
|
-
...inMemoryEntries,
|
|
1357
|
-
...symlinkEntries,
|
|
1358
|
-
];
|
|
1359
|
-
const tarGenerator = generateTarStream(manifestData, allEntries);
|
|
1360
|
-
const tarReadable = Readable.from(tarGenerator);
|
|
1361
|
-
const gzipStream = createGzip();
|
|
1362
|
-
const writeStream = createWriteStream(tempPath, { mode: 0o600 });
|
|
1363
|
-
|
|
1364
|
-
try {
|
|
1365
|
-
await pipeline(tarReadable, gzipStream, writeStream);
|
|
1366
|
-
} catch (error) {
|
|
1367
|
-
await unlink(tempPath).catch(() => {});
|
|
1368
|
-
throw error;
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
const tempStat = await stat(tempPath);
|
|
1372
|
-
|
|
1373
|
-
const cleanup = async () => {
|
|
1374
|
-
try {
|
|
1375
|
-
await unlink(tempPath);
|
|
1376
|
-
} catch {
|
|
1377
|
-
// Ignore errors during cleanup
|
|
1378
|
-
}
|
|
1379
|
-
};
|
|
1380
|
-
|
|
1381
|
-
return { tempPath, size: tempStat.size, manifest, cleanup };
|
|
1382
|
-
}
|