@vellumai/assistant 0.6.3 → 0.6.4
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/ARCHITECTURE.md +273 -10
- package/Dockerfile +2 -3
- package/bun.lock +5 -13
- package/docs/backup-troubleshooting.md +52 -0
- package/docs/browser-use-architecture-phase2.md +174 -0
- package/docs/stt-provider-onboarding.md +120 -0
- package/knip.json +12 -2
- package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
- package/node_modules/@vellumai/ces-contracts/package.json +3 -3
- package/openapi.yaml +982 -72
- package/package.json +4 -6
- package/scripts/generate-openapi.ts +0 -1
- package/scripts/test.sh +73 -18
- package/src/__tests__/agent-image-optimize.test.ts +28 -0
- package/src/__tests__/agent-loop.test.ts +123 -0
- package/src/__tests__/anthropic-provider.test.ts +263 -10
- package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
- package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
- package/src/__tests__/browser-fill-credential.test.ts +11 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
- package/src/__tests__/browser-skill-endstate.test.ts +31 -7
- package/src/__tests__/btw-routes.test.ts +7 -0
- package/src/__tests__/call-controller.test.ts +581 -20
- package/src/__tests__/catalog-files.test.ts +138 -0
- package/src/__tests__/channel-invite-transport.test.ts +2 -2
- package/src/__tests__/channel-readiness-routes.test.ts +16 -20
- package/src/__tests__/channel-readiness-service.test.ts +12 -7
- package/src/__tests__/checker.test.ts +157 -10
- package/src/__tests__/clawhub-files.test.ts +347 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
- package/src/__tests__/config-analysis.test.ts +100 -0
- package/src/__tests__/config-schema.test.ts +1013 -66
- package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
- package/src/__tests__/config-watcher.test.ts +43 -8
- package/src/__tests__/contact-store-user-file.test.ts +512 -0
- package/src/__tests__/contacts-write.test.ts +197 -0
- package/src/__tests__/context-window-manager.test.ts +88 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +98 -2
- package/src/__tests__/conversation-confirmation-signals.test.ts +135 -0
- package/src/__tests__/conversation-error.test.ts +70 -0
- package/src/__tests__/conversation-history-web-search.test.ts +11 -4
- package/src/__tests__/conversation-init.benchmark.test.ts +6 -1
- package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
- package/src/__tests__/conversation-list-source.test.ts +145 -0
- package/src/__tests__/conversation-pre-run-repair.test.ts +2 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
- package/src/__tests__/conversation-queue.test.ts +901 -60
- package/src/__tests__/conversation-routes-disk-view.test.ts +270 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +55 -0
- package/src/__tests__/conversation-skill-tools.test.ts +7 -4
- package/src/__tests__/conversation-slash-commands.test.ts +33 -0
- package/src/__tests__/conversation-slash-queue.test.ts +89 -18
- package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
- package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
- package/src/__tests__/credential-health-service.test.ts +352 -0
- package/src/__tests__/credential-security-invariants.test.ts +5 -3
- package/src/__tests__/credential-vault-unit.test.ts +379 -3
- package/src/__tests__/credentials-cli.test.ts +40 -16
- package/src/__tests__/cross-provider-web-search.test.ts +146 -35
- package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
- package/src/__tests__/device-id.test.ts +112 -0
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
- package/src/__tests__/email-html-renderer.test.ts +71 -0
- package/src/__tests__/email-invite-adapter.test.ts +36 -32
- package/src/__tests__/emit-event-signal.test.ts +71 -0
- package/src/__tests__/extension-id-sync-guard.test.ts +75 -8
- package/src/__tests__/fixtures/mock-chrome-extension.ts +11 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
- package/src/__tests__/gateway-only-guard.test.ts +0 -1
- package/src/__tests__/gemini-provider.test.ts +64 -0
- package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
- package/src/__tests__/gmail-archive-fallback.test.ts +193 -0
- package/src/__tests__/gmail-archive-gate.test.ts +246 -0
- package/src/__tests__/gmail-preferences.test.ts +117 -0
- package/src/__tests__/headless-browser-interactions.test.ts +43 -0
- package/src/__tests__/headless-browser-mode.test.ts +614 -0
- package/src/__tests__/headless-browser-navigate.test.ts +142 -5
- package/src/__tests__/headless-browser-read-tools.test.ts +11 -0
- package/src/__tests__/headless-browser-snapshot.test.ts +10 -0
- package/src/__tests__/heartbeat-service.test.ts +70 -17
- package/src/__tests__/home-state-routes.test.ts +162 -0
- package/src/__tests__/host-bash-proxy.test.ts +0 -5
- package/src/__tests__/host-browser-e2e-cloud.test.ts +138 -4
- package/src/__tests__/host-browser-e2e-self-hosted.test.ts +4 -4
- package/src/__tests__/host-browser-ws-events-e2e.test.ts +103 -0
- package/src/__tests__/host-cu-proxy.test.ts +0 -5
- package/src/__tests__/identity-intro-cache.test.ts +40 -10
- package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
- package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
- package/src/__tests__/llm-context-normalization.test.ts +488 -0
- package/src/__tests__/llm-context-route-provider.test.ts +86 -5
- package/src/__tests__/llm-usage-store.test.ts +363 -0
- package/src/__tests__/media-stream-output.test.ts +555 -0
- package/src/__tests__/media-stream-parser.test.ts +374 -0
- package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
- package/src/__tests__/media-stream-stt-session.test.ts +588 -0
- package/src/__tests__/media-turn-detector.test.ts +440 -0
- package/src/__tests__/message-queue.test.ts +125 -0
- package/src/__tests__/migration-export-http.test.ts +6 -6
- package/src/__tests__/migration-import-commit-http.test.ts +8 -6
- package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
- package/src/__tests__/migration-validate-http.test.ts +3 -3
- package/src/__tests__/mock-gateway-ipc.ts +151 -0
- package/src/__tests__/model-intents.test.ts +2 -2
- package/src/__tests__/oauth-apps-routes.test.ts +1 -0
- package/src/__tests__/oauth-cli.test.ts +2 -0
- package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
- package/src/__tests__/oauth-providers-routes.test.ts +2 -0
- package/src/__tests__/oauth-store.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +249 -6
- package/src/__tests__/onboarding-template-contract.test.ts +6 -13
- package/src/__tests__/openai-provider.test.ts +176 -0
- package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
- package/src/__tests__/openai-responses-provider.test.ts +1105 -0
- package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
- package/src/__tests__/outlook-unsubscribe.test.ts +31 -2
- package/src/__tests__/persona-resolver.test.ts +251 -0
- package/src/__tests__/platform-bash-auto-approve.test.ts +4 -0
- package/src/__tests__/platform.test.ts +92 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +47 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
- package/src/__tests__/pricing.test.ts +174 -0
- package/src/__tests__/qdrant-manager.test.ts +29 -8
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
- package/src/__tests__/relationship-state-contract.test.ts +175 -0
- package/src/__tests__/relay-server.test.ts +423 -5
- package/src/__tests__/search-skills-unified.test.ts +118 -0
- package/src/__tests__/secret-scanner-executor.test.ts +4 -0
- package/src/__tests__/secure-keys.test.ts +107 -0
- package/src/__tests__/send-endpoint-busy.test.ts +5 -1
- package/src/__tests__/sequence-store.test.ts +1 -1
- package/src/__tests__/server-history-render.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +201 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
- package/src/__tests__/skills-file-content-endpoint.test.ts +276 -145
- package/src/__tests__/skills-files-catalog-fallback.test.ts +381 -93
- package/src/__tests__/skills.test.ts +5 -2
- package/src/__tests__/skillssh-files.test.ts +446 -0
- package/src/__tests__/slack-block-formatting.test.ts +110 -0
- package/src/__tests__/slack-channel-config.test.ts +564 -1
- package/src/__tests__/stt-catalog-parity.test.ts +282 -0
- package/src/__tests__/stt-stream-session.test.ts +535 -0
- package/src/__tests__/system-prompt.test.ts +112 -26
- package/src/__tests__/telephony-stt-routing.test.ts +329 -0
- package/src/__tests__/terminal-tools.test.ts +18 -7
- package/src/__tests__/test-preload.ts +18 -0
- package/src/__tests__/test-support/browser-skill-harness.ts +4 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +9 -5
- package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +33 -24
- package/src/__tests__/tool-result-truncation.test.ts +36 -0
- package/src/__tests__/trust-store.test.ts +7 -1
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
- package/src/__tests__/tts-catalog-parity.test.ts +345 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
- package/src/__tests__/twilio-routes.test.ts +376 -0
- package/src/__tests__/unicode.test.ts +293 -0
- package/src/__tests__/update-bulletin-format.test.ts +59 -0
- package/src/__tests__/update-bulletin.test.ts +206 -5
- package/src/__tests__/usage-routes.test.ts +25 -4
- package/src/__tests__/user-reference.test.ts +46 -61
- package/src/__tests__/verification-control-plane-policy.test.ts +4 -0
- package/src/__tests__/voice-config-update.test.ts +403 -0
- package/src/__tests__/voice-quality.test.ts +434 -19
- package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
- package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
- package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
- package/src/__tests__/workspace-migration-meets.test.ts +244 -0
- package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
- package/src/__tests__/workspace-policy.test.ts +2 -0
- package/src/agent/image-optimize.ts +24 -12
- package/src/agent/loop.ts +43 -3
- package/src/backup/__tests__/backup-key.test.ts +152 -0
- package/src/backup/__tests__/backup-worker.test.ts +767 -0
- package/src/backup/__tests__/list-snapshots.test.ts +87 -0
- package/src/backup/__tests__/local-writer.test.ts +218 -0
- package/src/backup/__tests__/offsite-writer.test.ts +641 -0
- package/src/backup/__tests__/paths.test.ts +300 -0
- package/src/backup/__tests__/restore.test.ts +498 -0
- package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
- package/src/backup/__tests__/stream-crypt.test.ts +228 -0
- package/src/backup/backup-key.ts +137 -0
- package/src/backup/backup-worker.ts +459 -0
- package/src/backup/list-snapshots.ts +147 -0
- package/src/backup/local-writer.ts +133 -0
- package/src/backup/offsite-writer.ts +222 -0
- package/src/backup/paths.ts +226 -0
- package/src/backup/restore.ts +322 -0
- package/src/backup/snapshot-lock.ts +431 -0
- package/src/backup/stream-crypt.ts +263 -0
- package/src/bundler/package-resolver.ts +4 -0
- package/src/calls/audio-store.ts +11 -5
- package/src/calls/call-controller.ts +226 -71
- package/src/calls/call-domain.ts +9 -0
- package/src/calls/call-speech-output.ts +190 -0
- package/src/calls/call-transport.ts +77 -0
- package/src/calls/media-stream-audio-transcode.ts +173 -0
- package/src/calls/media-stream-output.ts +660 -0
- package/src/calls/media-stream-parser.ts +300 -0
- package/src/calls/media-stream-protocol.ts +166 -0
- package/src/calls/media-stream-server.ts +592 -0
- package/src/calls/media-stream-stt-session.ts +460 -0
- package/src/calls/media-turn-detector.ts +230 -0
- package/src/calls/relay-server.ts +90 -75
- package/src/calls/resolve-call-tts-provider.ts +136 -0
- package/src/calls/telephony-stt-routing.ts +145 -0
- package/src/calls/tts-call-strategy.ts +161 -0
- package/src/calls/tts-text-sanitizer.ts +32 -16
- package/src/calls/twilio-routes.ts +281 -17
- package/src/calls/voice-quality.ts +78 -35
- package/src/calls/voice-session-bridge.ts +8 -1
- package/src/channels/types.ts +16 -0
- package/src/cli/__tests__/run-assistant-command.ts +11 -1
- package/src/cli/commands/__tests__/backup.test.ts +1165 -0
- package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
- package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
- package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
- package/src/cli/commands/__tests__/email-download.test.ts +16 -1
- package/src/cli/commands/__tests__/email-list.test.ts +22 -4
- package/src/cli/commands/__tests__/email-register.test.ts +4 -4
- package/src/cli/commands/__tests__/email-send.test.ts +37 -4
- package/src/cli/commands/__tests__/email-status.test.ts +5 -1
- package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
- package/src/cli/commands/backup.ts +993 -0
- package/src/cli/commands/conversations.ts +77 -0
- package/src/cli/commands/credentials.ts +0 -1
- package/src/cli/commands/domain.ts +210 -0
- package/src/cli/commands/email.ts +255 -3
- package/src/cli/commands/oauth/__tests__/connect.test.ts +12 -0
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -0
- package/src/cli/commands/oauth/mode.ts +12 -3
- package/src/cli/commands/oauth/providers.ts +15 -0
- package/src/cli/commands/oauth/shared.ts +2 -1
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +4 -9
- package/src/cli/commands/platform/__tests__/connect.test.ts +6 -0
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +6 -0
- package/src/cli/program.ts +30 -4
- package/src/config/__tests__/backup-schema.test.ts +134 -0
- package/src/config/assistant-feature-flags.ts +61 -62
- package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +37 -1
- package/src/config/bundled-skills/browser/SKILL.md +30 -5
- package/src/config/bundled-skills/browser/TOOLS.json +123 -0
- package/src/config/bundled-skills/browser/tools/browser-attach.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-detach.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-status.ts +12 -0
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +17 -0
- package/src/config/bundled-skills/contacts/SKILL.md +2 -2
- package/src/config/bundled-skills/gmail/SKILL.md +53 -7
- package/src/config/bundled-skills/gmail/TOOLS.json +33 -3
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +116 -9
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +138 -11
- package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +59 -0
- package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +82 -0
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +113 -17
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -2
- package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
- package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
- package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
- package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
- package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
- package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
- package/src/config/bundled-skills/messaging/SKILL.md +3 -3
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
- package/src/config/bundled-skills/outlook/SKILL.md +2 -2
- package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +2 -2
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +27 -18
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
- package/src/config/bundled-skills/slack/SKILL.md +1 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
- package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
- package/src/config/bundled-tool-registry.ts +8 -0
- package/src/config/env-registry.ts +24 -0
- package/src/config/env.ts +34 -10
- package/src/config/feature-flag-registry.json +46 -14
- package/src/config/loader.ts +26 -12
- package/src/config/schema.ts +35 -10
- package/src/config/schemas/__tests__/stt.test.ts +43 -0
- package/src/config/schemas/analysis.ts +51 -0
- package/src/config/schemas/backup.ts +72 -0
- package/src/config/schemas/calls.ts +1 -26
- package/src/config/schemas/elevenlabs.ts +0 -59
- package/src/config/schemas/filing.ts +47 -7
- package/src/config/schemas/heartbeat.ts +27 -5
- package/src/config/schemas/host-browser.ts +47 -1
- package/src/config/schemas/inference.ts +1 -1
- package/src/config/schemas/memory-lifecycle.ts +14 -2
- package/src/config/schemas/services.ts +44 -0
- package/src/config/schemas/stt.ts +59 -0
- package/src/config/schemas/tts.ts +230 -0
- package/src/config/schemas/updates.ts +14 -0
- package/src/config/skills.ts +4 -0
- package/src/config/types.ts +4 -0
- package/src/contacts/contact-store.ts +56 -11
- package/src/contacts/contacts-write.ts +38 -1
- package/src/context/post-turn-tool-result-truncation.ts +3 -2
- package/src/context/tool-result-truncation.ts +2 -1
- package/src/context/window-manager.ts +45 -12
- package/src/credential-execution/executable-discovery.ts +12 -2
- package/src/credential-execution/process-manager.ts +33 -2
- package/src/credential-health/credential-health-service.ts +366 -0
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
- package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +17 -8
- package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
- package/src/daemon/config-watcher.ts +99 -5
- package/src/daemon/conversation-agent-loop-handlers.ts +6 -0
- package/src/daemon/conversation-agent-loop.ts +101 -24
- package/src/daemon/conversation-error.ts +11 -0
- package/src/daemon/conversation-history.ts +40 -6
- package/src/daemon/conversation-launch.ts +220 -0
- package/src/daemon/conversation-lifecycle.ts +59 -9
- package/src/daemon/conversation-messaging.ts +37 -3
- package/src/daemon/conversation-notifiers.ts +5 -0
- package/src/daemon/conversation-process.ts +581 -19
- package/src/daemon/conversation-queue-manager.ts +24 -0
- package/src/daemon/conversation-runtime-assembly.ts +11 -1
- package/src/daemon/conversation-slash.ts +36 -0
- package/src/daemon/conversation-surfaces.ts +94 -4
- package/src/daemon/conversation-tool-setup.ts +25 -0
- package/src/daemon/conversation-usage.ts +7 -4
- package/src/daemon/conversation.ts +86 -28
- package/src/daemon/handlers/config-slack-channel.ts +269 -94
- package/src/daemon/handlers/conversations.ts +4 -1
- package/src/daemon/handlers/shared.ts +22 -0
- package/src/daemon/handlers/skills.ts +321 -77
- package/src/daemon/host-browser-proxy.ts +2 -1
- package/src/daemon/lifecycle.ts +122 -25
- package/src/daemon/message-protocol.ts +6 -0
- package/src/daemon/message-types/conversations.ts +34 -1
- package/src/daemon/message-types/home.ts +40 -0
- package/src/daemon/message-types/meet.ts +143 -0
- package/src/daemon/message-types/messages.ts +14 -0
- package/src/daemon/message-types/schedules.ts +34 -2
- package/src/daemon/message-types/skills.ts +16 -0
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/daemon/server.ts +347 -2
- package/src/daemon/shutdown-handlers.ts +32 -4
- package/src/daemon/shutdown-registry.ts +40 -0
- package/src/daemon/tool-side-effects.ts +9 -0
- package/src/email/html-renderer.ts +76 -0
- package/src/heartbeat/heartbeat-service.ts +93 -7
- package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
- package/src/home/__tests__/emit-feed-event.test.ts +169 -0
- package/src/home/__tests__/feed-scheduler.test.ts +194 -0
- package/src/home/__tests__/feed-types.test.ts +275 -0
- package/src/home/__tests__/feed-writer.test.ts +688 -0
- package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
- package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
- package/src/home/__tests__/progress-formula.test.ts +213 -0
- package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
- package/src/home/__tests__/rollup-producer.test.ts +398 -0
- package/src/home/assistant-feed-authoring.ts +124 -0
- package/src/home/emit-feed-event.ts +158 -0
- package/src/home/feed-scheduler.ts +247 -0
- package/src/home/feed-types.ts +181 -0
- package/src/home/feed-writer.ts +469 -0
- package/src/home/platform-gmail-digest.ts +163 -0
- package/src/home/progress-formula.ts +86 -0
- package/src/home/relationship-state-writer.ts +824 -0
- package/src/home/relationship-state.ts +143 -0
- package/src/home/rollup-producer.ts +384 -0
- package/src/hooks/runner.ts +7 -0
- package/src/inbound/platform-callback-registration.ts +12 -3
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/instrument.ts +1 -1
- package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
- package/src/ipc/cli-client.ts +151 -0
- package/src/ipc/cli-server.ts +234 -0
- package/src/ipc/gateway-client.ts +180 -0
- package/src/ipc/routes/index.ts +5 -0
- package/src/ipc/routes/wake-conversation.ts +19 -0
- package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
- package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
- package/src/memory/__tests__/conversation-analyze-job.test.ts +232 -0
- package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
- package/src/memory/app-store.ts +1 -1
- package/src/memory/attachments-store.ts +70 -0
- package/src/memory/auto-analysis-enqueue.ts +127 -0
- package/src/memory/auto-analysis-guard.ts +27 -0
- package/src/memory/cleanup-schedule-state.ts +37 -0
- package/src/memory/conversation-analyze-job.ts +73 -0
- package/src/memory/conversation-crud.ts +99 -0
- package/src/memory/conversation-disk-view.ts +7 -0
- package/src/memory/conversation-group-migration.ts +34 -2
- package/src/memory/conversation-queries.ts +6 -5
- package/src/memory/db-init.ts +6 -0
- package/src/memory/db-maintenance.ts +108 -0
- package/src/memory/db.ts +1 -0
- package/src/memory/graph/conversation-graph-memory.ts +15 -0
- package/src/memory/graph/extraction.test.ts +23 -0
- package/src/memory/graph/extraction.ts +8 -0
- package/src/memory/graph/retriever.ts +27 -18
- package/src/memory/graph/scoring.test.ts +186 -0
- package/src/memory/graph/scoring.ts +31 -1
- package/src/memory/graph/tools.ts +1 -1
- package/src/memory/group-crud.ts +6 -1
- package/src/memory/indexer.ts +95 -16
- package/src/memory/job-handlers/cleanup.ts +11 -8
- package/src/memory/job-handlers/conversation-starters.ts +16 -10
- package/src/memory/jobs-store.ts +64 -4
- package/src/memory/jobs-worker.ts +22 -9
- package/src/memory/llm-usage-store.ts +92 -56
- package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
- package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
- package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
- package/src/memory/migrations/index.ts +6 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/qdrant-manager.ts +43 -16
- package/src/memory/schema/conversations.ts +2 -0
- package/src/memory/schema/oauth.ts +3 -0
- package/src/memory/usage-buckets.ts +396 -0
- package/src/messaging/providers/gmail/client.ts +57 -6
- package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
- package/src/messaging/providers/slack/adapter.ts +143 -38
- package/src/messaging/providers/slack/client.ts +16 -0
- package/src/messaging/providers/slack/types.ts +4 -0
- package/src/notifications/decision-engine.ts +3 -3
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/__tests__/identity-verifier.test.ts +1 -0
- package/src/oauth/byo-connection.test.ts +18 -1
- package/src/oauth/byo-connection.ts +3 -1
- package/src/oauth/connect-orchestrator.ts +2 -0
- package/src/oauth/connection-resolver.ts +6 -2
- package/src/oauth/connection.ts +2 -0
- package/src/oauth/oauth-store.ts +9 -0
- package/src/oauth/platform-connection.test.ts +98 -0
- package/src/oauth/platform-connection.ts +52 -31
- package/src/oauth/seed-providers.ts +7 -0
- package/src/permissions/checker.ts +16 -6
- package/src/permissions/defaults.ts +49 -1
- package/src/permissions/trust-store.ts +3 -3
- package/src/permissions/workspace-policy.ts +3 -0
- package/src/platform/client.test.ts +10 -0
- package/src/platform/sync-identity.ts +129 -0
- package/src/prompts/persona-resolver.ts +126 -2
- package/src/prompts/system-prompt.ts +59 -18
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/SOUL.md +3 -1
- package/src/prompts/templates/UPDATES.md +12 -0
- package/src/prompts/templates/channels/slack.md +20 -0
- package/src/prompts/update-bulletin-format.ts +26 -9
- package/src/prompts/update-bulletin.ts +34 -23
- package/src/prompts/user-reference.ts +20 -17
- package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
- package/src/providers/anthropic/client.ts +157 -61
- package/src/providers/fireworks/client.ts +2 -2
- package/src/providers/gemini/client.ts +9 -1
- package/src/providers/model-catalog.ts +6 -0
- package/src/providers/model-intents.ts +4 -4
- package/src/providers/ollama/client.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +474 -0
- package/src/providers/openai/client.ts +25 -440
- package/src/providers/openai/responses-provider.ts +502 -0
- package/src/providers/openrouter/client.ts +101 -4
- package/src/providers/provider-secret-catalog.ts +139 -0
- package/src/providers/registry.ts +2 -2
- package/src/providers/retry.ts +14 -3
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +828 -0
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
- package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
- package/src/providers/speech-to-text/deepgram.test.ts +332 -0
- package/src/providers/speech-to-text/deepgram.ts +115 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
- package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
- package/src/providers/speech-to-text/google-gemini.ts +101 -0
- package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
- package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
- package/src/providers/speech-to-text/openai-whisper.ts +63 -33
- package/src/providers/speech-to-text/provider-catalog.ts +306 -0
- package/src/providers/speech-to-text/resolve.ts +386 -6
- package/src/providers/types.ts +9 -0
- package/src/runtime/AGENTS.md +43 -1
- package/src/runtime/__tests__/agent-wake.test.ts +831 -0
- package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
- package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
- package/src/runtime/agent-wake.ts +512 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +40 -0
- package/src/runtime/auth/route-policy.ts +30 -5
- package/src/runtime/auth/token-service.ts +56 -1
- package/src/runtime/btw-sidechain.ts +2 -0
- package/src/runtime/capability-tokens.ts +10 -10
- package/src/runtime/channel-invite-transport.ts +1 -1
- package/src/runtime/channel-invite-transports/email.ts +14 -6
- package/src/runtime/channel-readiness-service.ts +12 -22
- package/src/runtime/chrome-extension-registry.ts +38 -2
- package/src/runtime/http-server.ts +395 -10
- package/src/runtime/http-types.ts +6 -2
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +36 -0
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
- package/src/runtime/migrations/migration-transport.ts +1 -0
- package/src/runtime/migrations/migration-wizard.ts +1 -0
- package/src/runtime/migrations/vbundle-import-analyzer.ts +77 -1
- package/src/runtime/migrations/vbundle-importer.ts +34 -0
- package/src/runtime/pending-interactions.ts +0 -11
- package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +507 -0
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +208 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
- package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
- package/src/runtime/routes/app-management-routes.ts +12 -18
- package/src/runtime/routes/attachment-routes.test.ts +9 -3
- package/src/runtime/routes/attachment-routes.ts +216 -17
- package/src/runtime/routes/backup-routes.ts +519 -0
- package/src/runtime/routes/browser-extension-pair-routes.ts +82 -23
- package/src/runtime/routes/btw-routes.ts +8 -6
- package/src/runtime/routes/contact-routes.test.ts +298 -0
- package/src/runtime/routes/contact-routes.ts +132 -5
- package/src/runtime/routes/conversation-analysis-routes.ts +22 -142
- package/src/runtime/routes/conversation-management-routes.ts +115 -0
- package/src/runtime/routes/conversation-routes.ts +367 -146
- package/src/runtime/routes/filing-routes.ts +93 -0
- package/src/runtime/routes/home-feed-routes.ts +334 -0
- package/src/runtime/routes/home-state-routes.ts +138 -0
- package/src/runtime/routes/host-browser-routes.ts +3 -14
- package/src/runtime/routes/identity-intro-cache.ts +7 -3
- package/src/runtime/routes/identity-routes.ts +3 -17
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
- package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
- package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
- package/src/runtime/routes/integrations/slack/channel.ts +11 -3
- package/src/runtime/routes/integrations/slack/share.ts +45 -7
- package/src/runtime/routes/llm-context-normalization.ts +303 -0
- package/src/runtime/routes/memory-item-routes.test.ts +3 -2
- package/src/runtime/routes/migration-routes.ts +40 -5
- package/src/runtime/routes/settings-routes.ts +22 -5
- package/src/runtime/routes/skills-routes.ts +76 -7
- package/src/runtime/routes/stt-routes.ts +233 -0
- package/src/runtime/routes/surface-action-routes.ts +41 -2
- package/src/runtime/routes/tts-routes.ts +108 -24
- package/src/runtime/routes/usage-routes.ts +30 -2
- package/src/runtime/routes/user-route-dispatcher.ts +50 -5
- package/src/runtime/routes/user-routes.ts +13 -1
- package/src/runtime/routes/work-items-routes.ts +8 -1
- package/src/runtime/runtime-mode.ts +33 -0
- package/src/runtime/services/__tests__/analyze-conversation.test.ts +444 -0
- package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
- package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
- package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
- package/src/runtime/services/analyze-conversation.ts +344 -0
- package/src/runtime/services/analyze-deps-singleton.ts +32 -0
- package/src/runtime/services/auto-analysis-prompt.ts +55 -0
- package/src/runtime/skill-route-registry.ts +49 -0
- package/src/runtime/slack-block-formatting.ts +437 -10
- package/src/schedule/scheduler.ts +50 -0
- package/src/security/oauth2.ts +26 -4
- package/src/security/secure-keys.ts +25 -2
- package/src/security/token-manager.ts +8 -0
- package/src/sequence/engine.ts +23 -0
- package/src/sequence/types.ts +1 -1
- package/src/skills/catalog-files.ts +64 -2
- package/src/skills/category-inference.ts +122 -0
- package/src/skills/clawhub-files.ts +213 -0
- package/src/skills/clawhub.ts +84 -23
- package/src/skills/skill-file-provider.ts +40 -0
- package/src/skills/skillssh-files.ts +395 -0
- package/src/skills/skillssh-registry.ts +4 -4
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +392 -0
- package/src/stt/__tests__/types.test.ts +89 -0
- package/src/stt/daemon-batch-transcriber.ts +195 -0
- package/src/stt/stt-stream-session.ts +499 -0
- package/src/stt/types.ts +330 -0
- package/src/stt/wav-encoder.test.ts +373 -0
- package/src/stt/wav-encoder.ts +175 -0
- package/src/subagent/manager.ts +38 -14
- package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
- package/src/tools/browser/__tests__/browser-status.test.ts +123 -0
- package/src/tools/browser/browser-execution.ts +1163 -23
- package/src/tools/browser/browser-manager.ts +45 -0
- package/src/tools/browser/browser-mode-constants.ts +12 -0
- package/src/tools/browser/browser-mode.ts +92 -0
- package/src/tools/browser/browser-status-constants.ts +33 -0
- package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +393 -0
- package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +29 -0
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1648 -32
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +264 -0
- package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +183 -17
- package/src/tools/browser/cdp-client/cdp-inspect-client.ts +254 -21
- package/src/tools/browser/cdp-client/errors.ts +15 -0
- package/src/tools/browser/cdp-client/extension-cdp-client.ts +39 -16
- package/src/tools/browser/cdp-client/factory.ts +797 -87
- package/src/tools/browser/cdp-client/index.ts +16 -2
- package/src/tools/browser/cdp-client/types.ts +68 -0
- package/src/tools/credentials/vault.ts +35 -6
- package/src/tools/network/web-fetch.ts +5 -2
- package/src/tools/network/web-search.ts +5 -2
- package/src/tools/shared/shell-output.ts +3 -1
- package/src/tools/side-effects.ts +2 -0
- package/src/tools/skills/sandbox-runner.ts +3 -2
- package/src/tools/terminal/safe-env.ts +10 -2
- package/src/tools/terminal/shell.ts +15 -4
- package/src/tools/tool-manifest.ts +21 -0
- package/src/tools/types.ts +17 -0
- package/src/tools/ui-surface/definitions.ts +6 -1
- package/src/tts/__tests__/provider-adapters.test.ts +834 -0
- package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
- package/src/tts/__tests__/provider-catalog.test.ts +183 -0
- package/src/tts/__tests__/provider-registry.test.ts +90 -0
- package/src/tts/provider-catalog.ts +201 -0
- package/src/tts/provider-registry.ts +73 -0
- package/src/tts/providers/deepgram-provider.ts +219 -0
- package/src/tts/providers/elevenlabs-provider.ts +211 -0
- package/src/tts/providers/fish-audio-provider.ts +183 -0
- package/src/tts/providers/index.ts +42 -0
- package/src/tts/providers/register-builtins.ts +130 -0
- package/src/tts/synthesize-text.ts +110 -0
- package/src/tts/tts-config-resolver.ts +78 -0
- package/src/tts/types.ts +153 -0
- package/src/types/onboarding-context.ts +7 -0
- package/src/util/abort-reasons.ts +58 -0
- package/src/util/device-id.ts +32 -16
- package/src/util/errors.ts +9 -1
- package/src/util/platform.ts +54 -10
- package/src/util/pricing.ts +66 -3
- package/src/util/spawn.ts +1 -1
- package/src/util/truncate.ts +4 -2
- package/src/util/unicode.ts +201 -0
- package/src/version.ts +19 -24
- package/src/watcher/engine.ts +23 -0
- package/src/watcher/watcher-store.ts +31 -0
- package/src/workspace/migrations/003-seed-device-id.ts +9 -3
- package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
- package/src/workspace/migrations/029-seed-pkb.ts +1 -1
- package/src/workspace/migrations/031-drop-user-md.ts +317 -0
- package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
- package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
- package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
- package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
- package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
- package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
- package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
- package/src/workspace/migrations/registry.ts +16 -0
- package/src/workspace/top-level-renderer.ts +13 -1
- package/src/workspace/turn-commit.ts +31 -0
- package/src/__tests__/email-cli.test.ts +0 -297
- package/src/__tests__/email-service-config-fallback.test.ts +0 -102
- package/src/cli/commands/browser-relay.ts +0 -466
- package/src/email/guardrails.ts +0 -221
- package/src/email/provider.ts +0 -117
- package/src/email/providers/agentmail.ts +0 -361
- package/src/email/providers/index.ts +0 -65
- package/src/email/service.ts +0 -384
- package/src/email/types.ts +0 -126
- package/src/prompts/templates/USER.md +0 -13
- package/src/providers/speech-to-text/types.ts +0 -17
- package/src/runtime/routes/browser-cdp-routes.ts +0 -229
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the cross-process snapshot lock helper. Each test gets a fresh
|
|
3
|
+
* temp directory so runs never collide with the real `~/.vellum/backups`
|
|
4
|
+
* directory and so parallel test workers never see each other's lock files.
|
|
5
|
+
*
|
|
6
|
+
* The interesting corners covered here are:
|
|
7
|
+
* - Acquire → release round-trip leaves the filesystem clean
|
|
8
|
+
* - A second acquire against a held lock throws with the expected prefix
|
|
9
|
+
* - A dead-PID lock file (simulated by writing a garbage PID that is not
|
|
10
|
+
* alive on this host) is taken over transparently
|
|
11
|
+
* - The release function is idempotent — calling it twice is a no-op
|
|
12
|
+
* - The lock file is created with mode `0o600` so an unprivileged
|
|
13
|
+
* peer on the same machine cannot read the holder PID
|
|
14
|
+
* - **TOCTOU mutual exclusion**: two concurrent acquires against a stale
|
|
15
|
+
* lock end up with exactly one winner (no double-acquire, no lost lock)
|
|
16
|
+
* - **Rename-aside**: takeover does not unlink-then-reacquire, so an
|
|
17
|
+
* interleaved second acquirer cannot destroy the fresh lock
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdtempSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
statSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
31
|
+
|
|
32
|
+
import { acquireSnapshotLock } from "../snapshot-lock.js";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Fixtures
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
let ROOT: string;
|
|
39
|
+
let LOCK: string;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
ROOT = mkdtempSync(join(tmpdir(), "vellum-snapshot-lock-"));
|
|
43
|
+
LOCK = join(ROOT, ".snapshot.lock");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(ROOT, { recursive: true, force: true });
|
|
49
|
+
} catch {
|
|
50
|
+
// best-effort
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Happy path
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe("acquireSnapshotLock — happy path", () => {
|
|
59
|
+
test("acquire creates the lock file; release removes it", async () => {
|
|
60
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
61
|
+
|
|
62
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
63
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
64
|
+
|
|
65
|
+
await release();
|
|
66
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("lock file is created with mode 0o600", async () => {
|
|
70
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
71
|
+
try {
|
|
72
|
+
const stats = statSync(LOCK);
|
|
73
|
+
// mask to permission bits only
|
|
74
|
+
expect(stats.mode & 0o777).toBe(0o600);
|
|
75
|
+
} finally {
|
|
76
|
+
await release();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("acquire creates the parent directory if missing", async () => {
|
|
81
|
+
// Point the lock at a nested path whose parent does not exist yet so
|
|
82
|
+
// we exercise the mkdir-on-demand code path.
|
|
83
|
+
const nested = join(ROOT, "missing-parent", ".snapshot.lock");
|
|
84
|
+
expect(existsSync(join(ROOT, "missing-parent"))).toBe(false);
|
|
85
|
+
|
|
86
|
+
const release = await acquireSnapshotLock(nested);
|
|
87
|
+
try {
|
|
88
|
+
expect(existsSync(nested)).toBe(true);
|
|
89
|
+
} finally {
|
|
90
|
+
await release();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Conflict detection
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe("acquireSnapshotLock — conflicts", () => {
|
|
100
|
+
test("two acquires against a live holder: second throws 'snapshot in progress'", async () => {
|
|
101
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
102
|
+
try {
|
|
103
|
+
await expect(acquireSnapshotLock(LOCK)).rejects.toThrow(
|
|
104
|
+
/snapshot in progress/,
|
|
105
|
+
);
|
|
106
|
+
} finally {
|
|
107
|
+
await release();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("conflict error includes the holder PID", async () => {
|
|
112
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
113
|
+
try {
|
|
114
|
+
await expect(acquireSnapshotLock(LOCK)).rejects.toThrow(
|
|
115
|
+
new RegExp(`snapshot in progress \\(locked by pid ${process.pid}\\)`),
|
|
116
|
+
);
|
|
117
|
+
} finally {
|
|
118
|
+
await release();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Stale-lock takeover
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
describe("acquireSnapshotLock — stale locks", () => {
|
|
128
|
+
test("dead PID: acquire takes over and creates a fresh lock", async () => {
|
|
129
|
+
// PID 2^31 - 1 is virtually guaranteed to be dead on any sane host —
|
|
130
|
+
// the platform PID_MAX is typically much smaller. Writing it as the
|
|
131
|
+
// lock holder simulates a crashed prior writer whose process has since
|
|
132
|
+
// exited without releasing.
|
|
133
|
+
const deadPid = 2_147_483_647;
|
|
134
|
+
writeFileSync(LOCK, `${deadPid} ${Date.now()}\n`, { mode: 0o600 });
|
|
135
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
136
|
+
|
|
137
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
138
|
+
try {
|
|
139
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
140
|
+
} finally {
|
|
141
|
+
await release();
|
|
142
|
+
}
|
|
143
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("unparseable lock file: acquire refuses takeover and surfaces conflict", async () => {
|
|
147
|
+
// A lock file with no parseable PID is indistinguishable from an
|
|
148
|
+
// in-progress partial write by a live holder (both read back as
|
|
149
|
+
// `null`). We conservatively refuse takeover in both cases — we
|
|
150
|
+
// only ever take over a lock whose holder PID is readable AND
|
|
151
|
+
// confirmed not running. Garbage files require manual cleanup; the
|
|
152
|
+
// alternative (silently unlinking) is what introduced the TOCTOU
|
|
153
|
+
// race flagged on PR #24896.
|
|
154
|
+
writeFileSync(LOCK, "not a pid at all\n", { mode: 0o600 });
|
|
155
|
+
|
|
156
|
+
await expect(acquireSnapshotLock(LOCK)).rejects.toThrow(
|
|
157
|
+
/^snapshot in progress/,
|
|
158
|
+
);
|
|
159
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("empty lock file: acquire refuses takeover and preserves the file", async () => {
|
|
163
|
+
// An empty lock file is ambiguous: it could be debris from a dead
|
|
164
|
+
// writer, or it could belong to a *live* holder that just won
|
|
165
|
+
// `O_EXCL` but has not yet flushed its PID. Taking it over would
|
|
166
|
+
// allow a second process to acquire and run concurrently — the
|
|
167
|
+
// exact TOCTOU race this module exists to prevent. The correct
|
|
168
|
+
// behavior is to surface a conflict and leave the file alone.
|
|
169
|
+
writeFileSync(LOCK, "", { mode: 0o600 });
|
|
170
|
+
|
|
171
|
+
await expect(acquireSnapshotLock(LOCK)).rejects.toThrow(
|
|
172
|
+
/^snapshot in progress/,
|
|
173
|
+
);
|
|
174
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Release semantics
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe("acquireSnapshotLock — release", () => {
|
|
183
|
+
test("release is idempotent: calling twice is safe", async () => {
|
|
184
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
185
|
+
await release();
|
|
186
|
+
// Second call must not throw, even though the file is already gone.
|
|
187
|
+
await release();
|
|
188
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("release tolerates an externally-unlinked lock file", async () => {
|
|
192
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
193
|
+
// Simulate another process (or a rogue admin) removing our lock file
|
|
194
|
+
// out from under us. Release must still return without throwing.
|
|
195
|
+
rmSync(LOCK, { force: true });
|
|
196
|
+
await release();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("after release, the lock can be acquired again", async () => {
|
|
200
|
+
const release1 = await acquireSnapshotLock(LOCK);
|
|
201
|
+
await release1();
|
|
202
|
+
|
|
203
|
+
const release2 = await acquireSnapshotLock(LOCK);
|
|
204
|
+
try {
|
|
205
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
206
|
+
} finally {
|
|
207
|
+
await release2();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// TOCTOU / race safety — regression tests for the rename-aside takeover fix
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe("acquireSnapshotLock — TOCTOU mutual exclusion", () => {
|
|
217
|
+
test("sequential stale-takeover calls: first wins, second sees the fresh lock", async () => {
|
|
218
|
+
// Two sequential takeover attempts against the same stale lock. The
|
|
219
|
+
// first caller wins via rename-aside and holds the fresh lock. The
|
|
220
|
+
// second caller observes the fresh lock (owned by process.pid) and
|
|
221
|
+
// must throw "snapshot in progress (locked by pid <process.pid>)"
|
|
222
|
+
// — not quietly unlink-and-reacquire.
|
|
223
|
+
const deadPid = 2_147_483_647;
|
|
224
|
+
writeFileSync(LOCK, `${deadPid} ${Date.now()}\n`, { mode: 0o600 });
|
|
225
|
+
|
|
226
|
+
const release1 = await acquireSnapshotLock(LOCK);
|
|
227
|
+
try {
|
|
228
|
+
// The fresh lock exists and belongs to this process.
|
|
229
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
230
|
+
// A second acquire must see the fresh lock — not the dead one — and
|
|
231
|
+
// refuse takeover because the holder is alive (process.pid === our pid).
|
|
232
|
+
await expect(acquireSnapshotLock(LOCK)).rejects.toThrow(
|
|
233
|
+
new RegExp(`snapshot in progress \\(locked by pid ${process.pid}\\)`),
|
|
234
|
+
);
|
|
235
|
+
} finally {
|
|
236
|
+
await release1();
|
|
237
|
+
}
|
|
238
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("Promise.all of two stale-takeover attempts: exactly one wins", async () => {
|
|
242
|
+
// Proof-of-fix for the original TOCTOU race:
|
|
243
|
+
//
|
|
244
|
+
// Before the fix, two processes observing the same stale lock would
|
|
245
|
+
// both call unlinkSync and both succeed at O_EXCL — ending up with
|
|
246
|
+
// independent beliefs that they hold the lock. The rename-aside pattern
|
|
247
|
+
// makes the takeover atomic: only one rename wins, and the loser
|
|
248
|
+
// retries the acquire loop and sees the winner's fresh lock.
|
|
249
|
+
//
|
|
250
|
+
// We can't literally race two processes in a unit test, but within a
|
|
251
|
+
// single process we can stress the same event-loop interleaving via
|
|
252
|
+
// Promise.all. The assertion is: one succeeds, one throws with the
|
|
253
|
+
// expected prefix, and the winner's PID is the live process.
|
|
254
|
+
const deadPid = 2_147_483_647;
|
|
255
|
+
writeFileSync(LOCK, `${deadPid} ${Date.now()}\n`, { mode: 0o600 });
|
|
256
|
+
|
|
257
|
+
const results = await Promise.allSettled([
|
|
258
|
+
acquireSnapshotLock(LOCK),
|
|
259
|
+
acquireSnapshotLock(LOCK),
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
const fulfilled = results.filter((r) => r.status === "fulfilled");
|
|
263
|
+
const rejected = results.filter((r) => r.status === "rejected");
|
|
264
|
+
// Exactly one winner, exactly one loser. Mutual exclusion holds.
|
|
265
|
+
expect(fulfilled).toHaveLength(1);
|
|
266
|
+
expect(rejected).toHaveLength(1);
|
|
267
|
+
|
|
268
|
+
// The loser's error must start with the documented "snapshot in progress"
|
|
269
|
+
// prefix so the HTTP 409 mapping in backup-routes.ts picks it up.
|
|
270
|
+
const err = (rejected[0] as PromiseRejectedResult).reason as Error;
|
|
271
|
+
expect(err.message).toMatch(/^snapshot in progress/);
|
|
272
|
+
|
|
273
|
+
// The winning acquire returned a release function — make sure we clean up.
|
|
274
|
+
const release = (fulfilled[0] as PromiseFulfilledResult<() => Promise<void>>)
|
|
275
|
+
.value;
|
|
276
|
+
await release();
|
|
277
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("live PID in a stale lock refuses takeover", async () => {
|
|
281
|
+
// Regression test for "take over too aggressively": if the lock file's
|
|
282
|
+
// holder PID is ALIVE (e.g. the current process for this test), the
|
|
283
|
+
// takeover path must not fire. This also catches any reversed alive /
|
|
284
|
+
// dead-check logic in the stale-takeover branch.
|
|
285
|
+
writeFileSync(LOCK, `${process.pid} ${Date.now()}\n`, { mode: 0o600 });
|
|
286
|
+
try {
|
|
287
|
+
await expect(acquireSnapshotLock(LOCK)).rejects.toThrow(
|
|
288
|
+
new RegExp(`snapshot in progress \\(locked by pid ${process.pid}\\)`),
|
|
289
|
+
);
|
|
290
|
+
// The pre-existing lock must still be there.
|
|
291
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
292
|
+
} finally {
|
|
293
|
+
// Clean up the hand-rolled lock file so afterEach's rmSync doesn't
|
|
294
|
+
// collide with a leftover fd.
|
|
295
|
+
try {
|
|
296
|
+
rmSync(LOCK, { force: true });
|
|
297
|
+
} catch {
|
|
298
|
+
// best-effort
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("zero-length lock file from partial write is NOT taken over", async () => {
|
|
304
|
+
// Regression test for the TOCTOU race flagged on PR #24896.
|
|
305
|
+
//
|
|
306
|
+
// Window: process A has just succeeded at `openSync(O_EXCL)` but has
|
|
307
|
+
// not yet flushed `<pid> <timestamp>` to the file. Process B opens
|
|
308
|
+
// the file and sees zero bytes. If B takes over, it unlinks / renames
|
|
309
|
+
// A's lock and acquires its own — now both A and B believe they own
|
|
310
|
+
// the lock and both snapshots run concurrently (WAL corruption, path
|
|
311
|
+
// clobber, racing retention pruner).
|
|
312
|
+
//
|
|
313
|
+
// The fix bounds how many times we re-read an empty file and refuses
|
|
314
|
+
// takeover if it is still empty after the retry budget is exhausted.
|
|
315
|
+
// We simulate the live-holder case here by leaving the file empty
|
|
316
|
+
// throughout the acquire attempt — acquire must throw "snapshot in
|
|
317
|
+
// progress" and must NOT touch the existing file.
|
|
318
|
+
writeFileSync(LOCK, "", { mode: 0o600 });
|
|
319
|
+
expect(statSync(LOCK).size).toBe(0);
|
|
320
|
+
const inodeBefore = statSync(LOCK).ino;
|
|
321
|
+
|
|
322
|
+
await expect(acquireSnapshotLock(LOCK)).rejects.toThrow(
|
|
323
|
+
/^snapshot in progress/,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// The original empty file must still exist and must not have been
|
|
327
|
+
// replaced — if the inode changed, something unlinked/recreated it.
|
|
328
|
+
expect(existsSync(LOCK)).toBe(true);
|
|
329
|
+
expect(statSync(LOCK).ino).toBe(inodeBefore);
|
|
330
|
+
expect(statSync(LOCK).size).toBe(0);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("rename-aside sideband file is cleaned up after takeover", async () => {
|
|
334
|
+
// After a successful stale takeover, no `.snapshot.lock.stale.*` debris
|
|
335
|
+
// should remain in the parent directory — the takeover unlinks the
|
|
336
|
+
// sideband file after the fresh lock is in place. This asserts the
|
|
337
|
+
// cleanup path is wired up so we don't accumulate orphaned files on
|
|
338
|
+
// every crash.
|
|
339
|
+
const deadPid = 2_147_483_647;
|
|
340
|
+
writeFileSync(LOCK, `${deadPid} ${Date.now()}\n`, { mode: 0o600 });
|
|
341
|
+
|
|
342
|
+
const release = await acquireSnapshotLock(LOCK);
|
|
343
|
+
try {
|
|
344
|
+
const entries = readdirSync(ROOT);
|
|
345
|
+
// Only the fresh lock file should remain; no `.stale.*` sidebands.
|
|
346
|
+
const sidebandLeftover = entries.filter((e) => e.includes(".stale."));
|
|
347
|
+
expect(sidebandLeftover).toEqual([]);
|
|
348
|
+
} finally {
|
|
349
|
+
await release();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
decryptFile,
|
|
10
|
+
ENCRYPTED_HEADER_SIZE,
|
|
11
|
+
encryptFile,
|
|
12
|
+
GCM_TAG_SIZE,
|
|
13
|
+
verifyEncryptedFile,
|
|
14
|
+
} from "../stream-crypt.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Fixtures
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
let TEST_DIR: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
TEST_DIR = join(
|
|
24
|
+
tmpdir(),
|
|
25
|
+
`vellum-stream-crypt-test-${randomBytes(6).toString("hex")}`,
|
|
26
|
+
);
|
|
27
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
try {
|
|
32
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
33
|
+
} catch {
|
|
34
|
+
// best-effort
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function makeKey(seed: number): Buffer {
|
|
39
|
+
const buf = Buffer.alloc(32);
|
|
40
|
+
for (let i = 0; i < 32; i++) {
|
|
41
|
+
buf[i] = (seed + i) & 0xff;
|
|
42
|
+
}
|
|
43
|
+
return buf;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Tests
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
describe("stream-crypt", () => {
|
|
51
|
+
test("round-trips a 1 KB random file", async () => {
|
|
52
|
+
const key = randomBytes(32);
|
|
53
|
+
const plaintext = randomBytes(1024);
|
|
54
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
55
|
+
const encPath = join(TEST_DIR, "enc.bin");
|
|
56
|
+
const roundTripPath = join(TEST_DIR, "roundtrip.bin");
|
|
57
|
+
|
|
58
|
+
writeFileSync(plainPath, plaintext);
|
|
59
|
+
await encryptFile(plainPath, encPath, key);
|
|
60
|
+
await decryptFile(encPath, roundTripPath, key);
|
|
61
|
+
|
|
62
|
+
const result = readFileSync(roundTripPath);
|
|
63
|
+
expect(result.equals(plaintext)).toBe(true);
|
|
64
|
+
|
|
65
|
+
// Encrypted file has the IV + tag overhead
|
|
66
|
+
const encBytes = readFileSync(encPath);
|
|
67
|
+
expect(encBytes.length).toBe(
|
|
68
|
+
plaintext.length + ENCRYPTED_HEADER_SIZE + GCM_TAG_SIZE,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("round-trips a 10 MB file (streaming across chunk boundaries)", async () => {
|
|
73
|
+
const key = randomBytes(32);
|
|
74
|
+
const size = 10 * 1024 * 1024;
|
|
75
|
+
const plaintext = randomBytes(size);
|
|
76
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
77
|
+
const encPath = join(TEST_DIR, "enc.bin");
|
|
78
|
+
const roundTripPath = join(TEST_DIR, "roundtrip.bin");
|
|
79
|
+
|
|
80
|
+
writeFileSync(plainPath, plaintext);
|
|
81
|
+
await encryptFile(plainPath, encPath, key);
|
|
82
|
+
await decryptFile(encPath, roundTripPath, key);
|
|
83
|
+
|
|
84
|
+
const result = readFileSync(roundTripPath);
|
|
85
|
+
expect(result.length).toBe(size);
|
|
86
|
+
expect(result.equals(plaintext)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("auth tag verification: flipping a byte in the ciphertext causes decrypt to throw", async () => {
|
|
90
|
+
const key = randomBytes(32);
|
|
91
|
+
const plaintext = randomBytes(2048);
|
|
92
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
93
|
+
const encPath = join(TEST_DIR, "enc.bin");
|
|
94
|
+
const outPath = join(TEST_DIR, "out.bin");
|
|
95
|
+
|
|
96
|
+
writeFileSync(plainPath, plaintext);
|
|
97
|
+
await encryptFile(plainPath, encPath, key);
|
|
98
|
+
|
|
99
|
+
// Flip one byte somewhere in the middle of the ciphertext body
|
|
100
|
+
// (not in the IV header or the trailing auth tag).
|
|
101
|
+
const ciphertextByteOffset =
|
|
102
|
+
ENCRYPTED_HEADER_SIZE + Math.floor(plaintext.length / 2);
|
|
103
|
+
const fh = await open(encPath, "r+");
|
|
104
|
+
try {
|
|
105
|
+
const one = Buffer.alloc(1);
|
|
106
|
+
await fh.read(one, 0, 1, ciphertextByteOffset);
|
|
107
|
+
one[0] = one[0] ^ 0xff;
|
|
108
|
+
await fh.write(one, 0, 1, ciphertextByteOffset);
|
|
109
|
+
} finally {
|
|
110
|
+
await fh.close();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await expect(decryptFile(encPath, outPath, key)).rejects.toThrow();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("decrypting with the wrong key throws", async () => {
|
|
117
|
+
const keyA = makeKey(1);
|
|
118
|
+
const keyB = makeKey(99);
|
|
119
|
+
const plaintext = randomBytes(4096);
|
|
120
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
121
|
+
const encPath = join(TEST_DIR, "enc.bin");
|
|
122
|
+
const outPath = join(TEST_DIR, "out.bin");
|
|
123
|
+
|
|
124
|
+
writeFileSync(plainPath, plaintext);
|
|
125
|
+
await encryptFile(plainPath, encPath, keyA);
|
|
126
|
+
|
|
127
|
+
await expect(decryptFile(encPath, outPath, keyB)).rejects.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("passing a 16-byte key throws the typed error", async () => {
|
|
131
|
+
const badKey = randomBytes(16);
|
|
132
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
133
|
+
const encPath = join(TEST_DIR, "enc.bin");
|
|
134
|
+
|
|
135
|
+
writeFileSync(plainPath, Buffer.from("hello world", "utf-8"));
|
|
136
|
+
|
|
137
|
+
await expect(encryptFile(plainPath, encPath, badKey)).rejects.toThrow(
|
|
138
|
+
"Backup encryption key must be 32 bytes",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("IV uniqueness: encrypting the same file twice yields different outputs", async () => {
|
|
143
|
+
const key = randomBytes(32);
|
|
144
|
+
const plaintext = randomBytes(4096);
|
|
145
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
146
|
+
const encPathA = join(TEST_DIR, "enc-a.bin");
|
|
147
|
+
const encPathB = join(TEST_DIR, "enc-b.bin");
|
|
148
|
+
|
|
149
|
+
writeFileSync(plainPath, plaintext);
|
|
150
|
+
await encryptFile(plainPath, encPathA, key);
|
|
151
|
+
await encryptFile(plainPath, encPathB, key);
|
|
152
|
+
|
|
153
|
+
const a = readFileSync(encPathA);
|
|
154
|
+
const b = readFileSync(encPathB);
|
|
155
|
+
|
|
156
|
+
expect(a.equals(b)).toBe(false);
|
|
157
|
+
// The first 12 bytes are the IV — they must differ with overwhelming
|
|
158
|
+
// probability (collision chance is 1/2^96 for random 12-byte IVs).
|
|
159
|
+
expect(
|
|
160
|
+
a.subarray(0, ENCRYPTED_HEADER_SIZE).equals(
|
|
161
|
+
b.subarray(0, ENCRYPTED_HEADER_SIZE),
|
|
162
|
+
),
|
|
163
|
+
).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("verifyEncryptedFile returns true for a valid bundle and false for a tampered one", async () => {
|
|
167
|
+
const key = randomBytes(32);
|
|
168
|
+
const plaintext = randomBytes(1024);
|
|
169
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
170
|
+
const encPath = join(TEST_DIR, "enc.bin");
|
|
171
|
+
|
|
172
|
+
writeFileSync(plainPath, plaintext);
|
|
173
|
+
await encryptFile(plainPath, encPath, key);
|
|
174
|
+
|
|
175
|
+
expect(await verifyEncryptedFile(encPath, key)).toBe(true);
|
|
176
|
+
|
|
177
|
+
// Tamper the ciphertext and re-verify
|
|
178
|
+
const fh = await open(encPath, "r+");
|
|
179
|
+
try {
|
|
180
|
+
const flipOffset = ENCRYPTED_HEADER_SIZE + 10;
|
|
181
|
+
const one = Buffer.alloc(1);
|
|
182
|
+
await fh.read(one, 0, 1, flipOffset);
|
|
183
|
+
one[0] = one[0] ^ 0x01;
|
|
184
|
+
await fh.write(one, 0, 1, flipOffset);
|
|
185
|
+
} finally {
|
|
186
|
+
await fh.close();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
expect(await verifyEncryptedFile(encPath, key)).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("verifyEncryptedFile returns true for a valid bundle even when tmpdir is unwritable", async () => {
|
|
193
|
+
const key = randomBytes(32);
|
|
194
|
+
const plaintext = randomBytes(2048);
|
|
195
|
+
const plainPath = join(TEST_DIR, "plain.bin");
|
|
196
|
+
const encPath = join(TEST_DIR, "enc.bin");
|
|
197
|
+
|
|
198
|
+
writeFileSync(plainPath, plaintext);
|
|
199
|
+
await encryptFile(plainPath, encPath, key);
|
|
200
|
+
|
|
201
|
+
// Point tmpdir at a path that cannot be written to. The implementation
|
|
202
|
+
// must authenticate the bundle without writing any scratch file — a full
|
|
203
|
+
// or read-only /tmp must not block restore for healthy backups.
|
|
204
|
+
const originalTmpdir = process.env.TMPDIR;
|
|
205
|
+
const originalTmp = process.env.TMP;
|
|
206
|
+
const originalTemp = process.env.TEMP;
|
|
207
|
+
process.env.TMPDIR = "/dev/null/does-not-exist";
|
|
208
|
+
process.env.TMP = "/dev/null/does-not-exist";
|
|
209
|
+
process.env.TEMP = "/dev/null/does-not-exist";
|
|
210
|
+
try {
|
|
211
|
+
expect(await verifyEncryptedFile(encPath, key)).toBe(true);
|
|
212
|
+
} finally {
|
|
213
|
+
if (originalTmpdir === undefined) delete process.env.TMPDIR;
|
|
214
|
+
else process.env.TMPDIR = originalTmpdir;
|
|
215
|
+
if (originalTmp === undefined) delete process.env.TMP;
|
|
216
|
+
else process.env.TMP = originalTmp;
|
|
217
|
+
if (originalTemp === undefined) delete process.env.TEMP;
|
|
218
|
+
else process.env.TEMP = originalTemp;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("verifyEncryptedFile rethrows filesystem errors (e.g. ENOENT) instead of masking them as tamper", async () => {
|
|
223
|
+
const key = randomBytes(32);
|
|
224
|
+
const missingPath = join(TEST_DIR, "does-not-exist.bin");
|
|
225
|
+
|
|
226
|
+
await expect(verifyEncryptedFile(missingPath, key)).rejects.toThrow();
|
|
227
|
+
});
|
|
228
|
+
});
|