mixdog 1.3.3 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSES/oh-my-pi-MIT.txt +23 -0
  2. package/NOTICE.md +15 -0
  3. package/README.md +11 -0
  4. package/package.json +3 -2
  5. package/scripts/embed-google-oauth-client.mjs +49 -0
  6. package/scripts/lib/test-spawn-ledger.mjs +46 -1
  7. package/scripts/test.mjs +5 -0
  8. package/src/defaults/shipped-history.json +8 -0
  9. package/src/defaults/skills/browser-use/SKILL.md +21 -9
  10. package/src/defaults/skills/setup/SKILL.md +7 -2
  11. package/src/defaults/skills/setup/references/actions.md +82 -27
  12. package/src/defaults/skills/setup/references/surfaces.md +67 -34
  13. package/src/defaults/skills/setup-guide/SKILL.md +3 -3
  14. package/src/lib/keychain-cjs.cjs +359 -29
  15. package/src/rules/agent-mode/AGENT_MODE.md +10 -2
  16. package/src/rules/agent-mode/MIXY.md +12 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +193 -0
  18. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  19. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +2 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  21. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +31 -2
  22. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +16 -3
  23. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +42 -7
  24. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +21 -2
  25. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +23 -0
  26. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +11 -1
  27. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +68 -23
  28. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +22 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +18 -5
  30. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +52 -8
  33. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +22 -6
  35. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +15 -4
  36. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +17 -2
  37. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +10 -4
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +10 -217
  41. package/src/runtime/agent/orchestrator/providers/cursor.mjs +7 -0
  42. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +409 -0
  43. package/src/runtime/agent/orchestrator/providers/devin-wire-families.mjs +187 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-identity.mjs +342 -0
  45. package/src/runtime/agent/orchestrator/providers/devin-wire-models.mjs +541 -0
  46. package/src/runtime/agent/orchestrator/providers/devin-wire-protobuf.mjs +305 -0
  47. package/src/runtime/agent/orchestrator/providers/devin-wire-request.mjs +558 -0
  48. package/src/runtime/agent/orchestrator/providers/devin-wire-schema.mjs +880 -0
  49. package/src/runtime/agent/orchestrator/providers/devin-wire-session.mjs +190 -0
  50. package/src/runtime/agent/orchestrator/providers/devin-wire-tool-args.mjs +351 -0
  51. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +560 -0
  52. package/src/runtime/agent/orchestrator/providers/devin.mjs +491 -0
  53. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/gemini-output-limit.mjs +46 -0
  55. package/src/runtime/agent/orchestrator/providers/gemini-reasoning-relay.mjs +30 -0
  56. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +7 -11
  57. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +61 -22
  58. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -0
  59. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -4
  60. package/src/runtime/agent/orchestrator/providers/gemini.mjs +25 -2
  61. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +96 -5
  63. package/src/runtime/agent/orchestrator/providers/lib/tail-reminder-split.mjs +23 -5
  64. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +17 -0
  65. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +39 -3
  66. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +2 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +6 -1
  69. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/providers/provider-availability.mjs +19 -0
  71. package/src/runtime/agent/orchestrator/providers/registry.mjs +17 -3
  72. package/src/runtime/agent/orchestrator/providers/wire-protobuf-codec.mjs +368 -0
  73. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  74. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +1 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs +133 -0
  76. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +23 -5
  78. package/src/runtime/agent/orchestrator/runtime-core/tool-profile.mjs +35 -10
  79. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +57 -10
  80. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +9 -6
  81. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  83. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +0 -3
  84. package/src/runtime/agent/orchestrator/session/agent-room.mjs +10 -4
  85. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +15 -3
  86. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -0
  87. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +36 -4
  88. package/src/runtime/agent/orchestrator/session/human-gate/outbound-policy.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/human-gate/store.mjs +15 -3
  90. package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +8 -0
  91. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  92. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +50 -2
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +3 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +11 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +11 -2
  97. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +60 -0
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +2 -0
  99. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +14 -0
  100. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +13 -0
  102. package/src/runtime/agent/orchestrator/session/message-ids.mjs +5 -3
  103. package/src/runtime/agent/orchestrator/session/message-reactions.mjs +89 -0
  104. package/src/runtime/agent/orchestrator/session/message-reactions.test-support.mjs +75 -0
  105. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +0 -2
  106. package/src/runtime/agent/orchestrator/session/routine-result.mjs +11 -2
  107. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +20 -2
  108. package/src/runtime/agent/orchestrator/session/store-agent-worker-header-cache.mjs +85 -0
  109. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +125 -12
  110. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +11 -5
  111. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +4 -0
  112. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -0
  113. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +7 -1
  114. package/src/runtime/channels/lib/schedule-errors.mjs +51 -0
  115. package/src/runtime/channels/lib/scheduler.mjs +109 -14
  116. package/src/runtime/channels/lib/schedules-db.mjs +49 -0
  117. package/src/runtime/channels/lib/tool-dispatch.mjs +13 -0
  118. package/src/runtime/channels/lib/worker-main.mjs +9 -0
  119. package/src/runtime/office/com/com-adapter.mjs +17 -1
  120. package/src/runtime/office/com/office-com-host.ps1 +51 -1
  121. package/src/runtime/office/pdf/cfb-fixture.mjs +328 -0
  122. package/src/runtime/office/pdf/cfb-protection.mjs +311 -0
  123. package/src/runtime/office/pdf/document-preview.mjs +326 -8
  124. package/src/runtime/office/pdf/ooxml-crypto.mjs +469 -0
  125. package/src/runtime/office/pdf/ooxml-key-worker.mjs +14 -0
  126. package/src/runtime/office/portable/portable-soffice.mjs +45 -5
  127. package/src/runtime/shared/agent-room-host.mjs +1 -1
  128. package/src/runtime/shared/bot-hops.mjs +62 -2
  129. package/src/runtime/shared/config.mjs +49 -22
  130. package/src/runtime/shared/connector-catalog.d.mts +4 -2
  131. package/src/runtime/shared/connector-catalog.mjs +16 -8
  132. package/src/runtime/shared/developer-options.mjs +32 -1
  133. package/src/runtime/shared/google-oauth-client.generated.json +4 -0
  134. package/src/runtime/shared/google-oauth-client.mjs +34 -9
  135. package/src/runtime/shared/llm/anthropic-betas.mjs +7 -0
  136. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +12 -0
  137. package/src/runtime/shared/llm/provider-catalog-registry.mjs +1 -0
  138. package/src/runtime/shared/llm/usage-rollup.mjs +4 -383
  139. package/src/runtime/shared/llm/well-formed.mjs +66 -0
  140. package/src/runtime/shared/provider-accounts.mjs +224 -58
  141. package/src/runtime/shared/provider-api-key.mjs +19 -8
  142. package/src/runtime/shared/routine-trigger.mjs +42 -0
  143. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +38 -0
  145. package/src/runtime/shared/user-data-guard.mjs +30 -2
  146. package/src/runtime/shared/worktrees/activity-gate.mjs +12 -5
  147. package/src/session-runtime/agent-home-delivery.mjs +57 -13
  148. package/src/session-runtime/agent-room-delivery.mjs +28 -1
  149. package/src/session-runtime/agent-task-sessions.mjs +292 -79
  150. package/src/session-runtime/agent-tool-routing.mjs +49 -4
  151. package/src/session-runtime/boot/begin.mjs +3 -2
  152. package/src/session-runtime/boot/tools.mjs +4 -0
  153. package/src/session-runtime/bot-chain.mjs +32 -0
  154. package/src/session-runtime/bot-status.mjs +7 -4
  155. package/src/session-runtime/channel-config-api.mjs +63 -17
  156. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +45 -0
  157. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +72 -5
  158. package/src/session-runtime/internal-tool-executor.mjs +4 -0
  159. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -1
  160. package/src/session-runtime/mixy-heartbeat.mjs +807 -0
  161. package/src/session-runtime/model-route/route-tuning.mjs +10 -6
  162. package/src/session-runtime/owned-automation-run.mjs +33 -12
  163. package/src/session-runtime/provider-auth/login.mjs +7 -0
  164. package/src/session-runtime/provider-models/row-hydration.mjs +13 -2
  165. package/src/session-runtime/routine-home-turn.mjs +33 -6
  166. package/src/session-runtime/schedule-session-run.mjs +7 -3
  167. package/src/session-runtime/services/agent-tool/close-flow.mjs +14 -2
  168. package/src/session-runtime/services/agent-tool/execute.mjs +6 -1
  169. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +11 -2
  170. package/src/session-runtime/services/agent-tool/render.mjs +3 -1
  171. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +8 -12
  172. package/src/session-runtime/services/agent-tool/tool-def.mjs +1 -1
  173. package/src/session-runtime/services/channel-admin.mjs +66 -18
  174. package/src/session-runtime/services/channel-worker.mjs +1 -1
  175. package/src/session-runtime/services/provider-admin.mjs +128 -25
  176. package/src/session-runtime/services/schedule-access.mjs +14 -0
  177. package/src/session-runtime/services/usage-dashboard-model.mjs +1 -0
  178. package/src/session-runtime/session-create/session-options.mjs +7 -4
  179. package/src/session-runtime/settings-system-api.mjs +16 -1
  180. package/src/session-runtime/setup-tool/executor.mjs +49 -14
  181. package/src/session-runtime/setup-tool/extended-actions.mjs +29 -3
  182. package/src/session-runtime/setup-tool/settings-contract.mjs +65 -4
  183. package/src/session-runtime/setup-tool/tool-defs.mjs +30 -3
  184. package/src/session-runtime/task-session-route.mjs +99 -0
  185. package/src/session-runtime/tool-policy/policy-stage.mjs +5 -2
  186. package/src/session-runtime/tool-policy/surface-helpers.mjs +2 -3
  187. package/src/session-runtime/tool-surface.mjs +4 -0
  188. package/src/session-runtime/usage-stats-api.mjs +1 -1
  189. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +98 -21
  190. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +6 -1
  191. package/src/session-runtime/workspace/transitions.mjs +10 -2
  192. package/src/standalone/daemon-session-control.mjs +88 -20
  193. package/src/standalone/daemon.mjs +8 -0
  194. package/src/standalone/session-protocol.mjs +5 -0
  195. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +6 -2
  196. package/src/standalone/session-service/session-calls/session-turns.mjs +6 -0
  197. package/src/standalone/session-service.mjs +13 -0
  198. package/src/standalone/session-tool-output.mjs +8 -5
  199. package/src/tui/app/model-options.mjs +2 -0
  200. package/src/tui/dist/index.mjs +335 -172
  201. package/src/tui/session/notice-surface.mjs +17 -1
  202. package/src/tui/session/session-api/integrations.mjs +1 -0
  203. package/src/tui/session/session-api/lifecycle.mjs +8 -0
  204. package/src/tui/session/session-api/settings.mjs +14 -2
  205. package/src/tui/session/turn-stream.mjs +17 -1
  206. package/src/tui/session/turn.mjs +22 -2
  207. package/src/vendor/statusline/src/gateway/claude-current.mjs +0 -58
  208. package/src/vendor/statusline/src/gateway/route-meta.mjs +1 -361
  209. package/src/vendor/statusline/src/gateway/session-routes.mjs +0 -55
  210. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +0 -285
@@ -12,11 +12,22 @@
12
12
  // removal instead of a filename convention that has to stay in sync with
13
13
  // whatever the renderer happens to write.
14
14
  import { createHash } from 'node:crypto';
15
- import { mkdir, readdir, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
16
- import { join, resolve } from 'node:path';
15
+ import { rmSync } from 'node:fs';
16
+ import { copyFile, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
17
19
 
20
+ import {
21
+ isEncryptedBinaryPresentation,
22
+ isEncryptedOoxml,
23
+ isEncryptedWordDocument,
24
+ readEncryptedOoxml,
25
+ readOoxmlEncryptionInfo,
26
+ } from './cfb-protection.mjs';
27
+ import { decryptOoxmlPackage, ooxmlEncryption, ooxmlPackageIntact, ooxmlPasswordKey } from './ooxml-crypto.mjs';
18
28
  import { renderPdfPages } from './pdf-render.mjs';
19
- import { renderPortableOoxml } from '../portable/portable-soffice.mjs';
29
+ import { callMicrosoftOffice, detectMicrosoftOffice } from '../com/com-adapter.mjs';
30
+ import { libreOfficeAvailable, renderPortableOoxml } from '../portable/portable-soffice.mjs';
20
31
 
21
32
  // What LibreOffice converts faithfully enough to READ. PDF is absent on
22
33
  // purpose: it is already viewable and never needs the conversion pass.
@@ -50,6 +61,251 @@ const MAX_CACHED_PAGE_REVISIONS = 48;
50
61
  const MAX_MEMORY_PAGES = 32;
51
62
  const MAX_MEMORY_PAGE_BYTES = 48 * 1024 * 1024;
52
63
 
64
+ // The Office application family that opens each previewable extension.
65
+ const OFFICE_FAMILY = {
66
+ docx: 'docx',
67
+ doc: 'docx',
68
+ docm: 'docx',
69
+ dotx: 'docx',
70
+ rtf: 'docx',
71
+ odt: 'docx',
72
+ xlsx: 'xlsx',
73
+ xls: 'xlsx',
74
+ xlsm: 'xlsx',
75
+ ods: 'xlsx',
76
+ pptx: 'pptx',
77
+ ppt: 'pptx',
78
+ pptm: 'pptx',
79
+ odp: 'pptx',
80
+ };
81
+
82
+ /** Marks the "no converter installed" failure; the desktop shows it in the UI language. */
83
+ export const OFFICE_PREVIEW_UNAVAILABLE = 'OFFICE_PREVIEW_UNAVAILABLE';
84
+
85
+ /** Marks a password-protected document that a password can open: the desktop asks for it. */
86
+ export const OFFICE_PREVIEW_PASSWORD_PROTECTED = 'OFFICE_PREVIEW_PASSWORD_PROTECTED';
87
+
88
+ /** The password given for a protected document does not open it. */
89
+ export const OFFICE_PREVIEW_WRONG_PASSWORD = 'OFFICE_PREVIEW_WRONG_PASSWORD';
90
+
91
+ /** A protected document no password route can preview (an encrypted .ppt, an unknown encryption). */
92
+ export const OFFICE_PREVIEW_PASSWORD_UNSUPPORTED = 'OFFICE_PREVIEW_PASSWORD_UNSUPPORTED';
93
+
94
+ /** A protected binary Word document only Microsoft Word can open with its password, and Word is not installed. */
95
+ export const OFFICE_PREVIEW_PASSWORD_NEEDS_OFFICE = 'OFFICE_PREVIEW_PASSWORD_NEEDS_OFFICE';
96
+
97
+ /**
98
+ * A protected document whose encrypted package fails its integrity check ([MS-OFFCRYPTO] 2.3.4.14), or whose
99
+ * integrity data cannot be checked: it is never rendered, whatever the password.
100
+ */
101
+ export const OFFICE_PREVIEW_PASSWORD_CORRUPT = 'OFFICE_PREVIEW_PASSWORD_CORRUPT';
102
+
103
+ const OFFICE_PREVIEW_RENDER_TIMEOUT_MS = 300_000;
104
+
105
+ const defaultEngines = {
106
+ libreOfficeAvailable,
107
+ renderLibreOffice: renderPortableOoxml,
108
+ detectOffice: detectMicrosoftOffice,
109
+ callOffice: callMicrosoftOffice,
110
+ };
111
+
112
+ function codedError(message, code) {
113
+ const error = new Error(`${message} (${code})`);
114
+ error.code = code;
115
+ return error;
116
+ }
117
+
118
+ const passwordProtectedError = () =>
119
+ codedError("This file is password-protected and can't be previewed.", OFFICE_PREVIEW_PASSWORD_PROTECTED);
120
+ const wrongPasswordError = () => codedError("That password didn't work.", OFFICE_PREVIEW_WRONG_PASSWORD);
121
+ const passwordUnsupportedError = () =>
122
+ codedError("This file is password-protected and can't be previewed.", OFFICE_PREVIEW_PASSWORD_UNSUPPORTED);
123
+ const passwordNeedsOfficeError = () =>
124
+ codedError('Previewing this password-protected file needs Microsoft Office.', OFFICE_PREVIEW_PASSWORD_NEEDS_OFFICE);
125
+ const passwordCorruptError = () =>
126
+ codedError("This password-protected file is damaged and can't be previewed.", OFFICE_PREVIEW_PASSWORD_CORRUPT);
127
+
128
+ // An engine's error text may echo what it was given; the password never leaves in it.
129
+ const withoutPassword = (text, password) => (password ? String(text).split(password).join('***') : String(text));
130
+
131
+ // Microsoft Office renders a private COPY of the document, read-only: the host
132
+ // opens it read-only (no recalculation, no link updates, macros off), exports the
133
+ // PDF, then closes the document without saving and quits the application
134
+ // instance it started. The copy keeps the source unlocked and unreachable. A
135
+ // password travels in the request on the host's stdin, never on its command line.
136
+ async function renderWithMicrosoftOffice(path, output, family, { signal, engines, password }) {
137
+ const { callOffice, removeDirectory: remove, removeBackoffMs: backoffMs } = engines;
138
+ const scratch = await createScratch('mixdog-preview-');
139
+ let result;
140
+ try {
141
+ const copy = join(scratch, basename(path));
142
+ await copyFile(path, copy);
143
+ const request = { action: 'render', format: family, mode: 'background', readOnly: true, path: copy, output };
144
+ if (password) request.password = password;
145
+ result = await callOffice(request, { signal, timeoutMs: OFFICE_PREVIEW_RENDER_TIMEOUT_MS });
146
+ signal?.throwIfAborted();
147
+ if (!result.ok) {
148
+ const error = String(result.error);
149
+ if (error.includes(OFFICE_PREVIEW_WRONG_PASSWORD)) throw wrongPasswordError();
150
+ if (error.includes(OFFICE_PREVIEW_PASSWORD_PROTECTED)) throw passwordProtectedError();
151
+ throw new Error(withoutPassword(result.error || 'Microsoft Office could not render this document.', password));
152
+ }
153
+ } finally {
154
+ await removeScratch(scratch, result?.hostExit, { remove, backoffMs });
155
+ }
156
+ }
157
+
158
+ // Every scratch directory this module creates holds document content: a private copy for Microsoft Office, a
159
+ // decrypted package, an unlocked PDF. Each is tracked from its creation, while its render still runs, until its
160
+ // removal is confirmed; whatever is still tracked when the process exits (a removal that kept failing, a render
161
+ // still running) is removed then.
162
+ const scratchDirectories = new Set();
163
+ let scratchExitHook = false;
164
+
165
+ async function createScratch(prefix) {
166
+ const directory = await mkdtemp(join(tmpdir(), prefix));
167
+ scratchDirectories.add(directory);
168
+ if (!scratchExitHook) {
169
+ scratchExitHook = true;
170
+ process.once('exit', removeTrackedPreviewScratchSync);
171
+ }
172
+ return directory;
173
+ }
174
+
175
+ /** Scratch directories whose removal is not confirmed yet (for diagnostics and tests). */
176
+ export const trackedPreviewScratch = () => [...scratchDirectories];
177
+
178
+ /** Removes every tracked scratch directory at once; the process does this as it exits. */
179
+ export function removeTrackedPreviewScratchSync() {
180
+ for (const directory of scratchDirectories) {
181
+ try {
182
+ rmSync(directory, { recursive: true, force: true });
183
+ scratchDirectories.delete(directory);
184
+ } catch {}
185
+ }
186
+ }
187
+
188
+ // Whether a path lies inside a tracked scratch directory (an unlocked PDF does).
189
+ function inScratch(path) {
190
+ const target = resolve(path);
191
+ for (const directory of scratchDirectories) {
192
+ const inside = relative(directory, target);
193
+ if (inside && inside !== '..' && !inside.startsWith(`..${sep}`) && !isAbsolute(inside)) return true;
194
+ }
195
+ return false;
196
+ }
197
+
198
+ // A cancelled render can return while the host (and so Office) still holds the copy open. The first
199
+ // removal is immediate; if it fails, ownership moves to a background task that waits for the host to
200
+ // exit and retries a bounded number of times with growing delays. The caller never waits on it. The
201
+ // directory stays tracked until a removal succeeds, so one that never does is removed at exit.
202
+ const SCRATCH_REMOVE_ATTEMPTS = 6;
203
+ const SCRATCH_REMOVE_BACKOFF_MS = 250;
204
+
205
+ const removeTree = (target) => rm(target, { recursive: true, force: true });
206
+
207
+ async function removeScratch(scratch, hostExit, { remove = removeTree, backoffMs = SCRATCH_REMOVE_BACKOFF_MS } = {}) {
208
+ const removed = async () => {
209
+ try {
210
+ await remove(scratch);
211
+ } catch {
212
+ return false;
213
+ }
214
+ scratchDirectories.delete(scratch);
215
+ return true;
216
+ };
217
+ if (await removed()) return;
218
+ void (async () => {
219
+ await Promise.resolve(hostExit).catch(() => {});
220
+ for (let attempt = 0; attempt < SCRATCH_REMOVE_ATTEMPTS; attempt++) {
221
+ await new Promise((done) => setTimeout(done, backoffMs * 2 ** attempt).unref?.());
222
+ if (await removed()) return;
223
+ }
224
+ })();
225
+ }
226
+
227
+ // What protects a document, read from its container before any renderer starts (a hidden PowerPoint or Word would
228
+ // wait on a password prompt nobody sees, LibreOffice would fail late): an encrypted OOXML package this module
229
+ // unlocks with the password ('package'), one whose integrity data is malformed ('corrupt'), an encrypted binary
230
+ // Word document Word opens with it ('word'), a protection no password route opens ('unsupported'), or null. An
231
+ // unreadable container is left to the renderer.
232
+ async function protection(path) {
233
+ if (/\.ppt$/i.test(path)) return (await isEncryptedBinaryPresentation(path)) ? 'unsupported' : null;
234
+ if (/\.doc$/i.test(path)) return (await isEncryptedWordDocument(path)) ? 'word' : null;
235
+ if (/\.(docx|docm|dotx|dotm|xlsx|xlsm|xltx|xltm|pptx|pptm|potx|potm|ppsx|ppsm)$/i.test(path)) {
236
+ if (!(await isEncryptedOoxml(path))) return null;
237
+ const descriptor = ooxmlEncryption(await readOoxmlEncryptionInfo(path));
238
+ if (!descriptor) return 'unsupported';
239
+ return descriptor.integrity?.malformed ? 'corrupt' : 'package';
240
+ }
241
+ return null;
242
+ }
243
+
244
+ async function officeInstalled(engines, family) {
245
+ const detected = await engines.detectOffice({ format: family });
246
+ return Boolean(detected?.applications?.some((application) => application.format === family && application.installed));
247
+ }
248
+
249
+ // LibreOffice when it is available, else Microsoft Office. A password only reaches Microsoft Office: a document
250
+ // its host finds protected (a legacy one the container check does not cover) is opened with it there.
251
+ async function renderWithEngine(path, output, format, { signal, engines, password }) {
252
+ if (await engines.libreOfficeAvailable()) {
253
+ await engines.renderLibreOffice(path, output, { signal });
254
+ return;
255
+ }
256
+ const family = OFFICE_FAMILY[format];
257
+ if (!(await officeInstalled(engines, family))) {
258
+ throw codedError(
259
+ 'Previewing Office files needs LibreOffice or Microsoft Office, and neither was found. To install LibreOffice, open Extensions → Built-in → Office and choose Install, then try again.',
260
+ OFFICE_PREVIEW_UNAVAILABLE
261
+ );
262
+ }
263
+ await renderWithMicrosoftOffice(path, output, family, { signal, engines, password });
264
+ }
265
+
266
+ // An encrypted OOXML package is opened the way [MS-OFFCRYPTO] specifies (see ooxml-crypto.mjs): the password
267
+ // verifier decides a wrong password without starting anything (its hashing off this thread, ended by `signal`),
268
+ // the package's HMAC must then prove it intact, and only then does the key decrypt it into a private scratch copy
269
+ // that the usual engine renders and that is removed with it. No engine ever sees the password.
270
+ async function renderUnlockedPackage(path, output, format, { signal, engines, password }) {
271
+ const encrypted = await readEncryptedOoxml(path);
272
+ const descriptor = encrypted && ooxmlEncryption(encrypted.encryptionInfo);
273
+ if (!descriptor) throw passwordUnsupportedError();
274
+ if (descriptor.integrity?.malformed) throw passwordCorruptError();
275
+ const key = await ooxmlPasswordKey(descriptor, password, { signal });
276
+ if (!key) throw wrongPasswordError();
277
+ if (!ooxmlPackageIntact(descriptor, key, encrypted.encryptedPackage)) throw passwordCorruptError();
278
+ const plain = decryptOoxmlPackage(descriptor, key, encrypted.encryptedPackage);
279
+ signal?.throwIfAborted();
280
+ const scratch = await createScratch('mixdog-unlocked-');
281
+ try {
282
+ const copy = join(scratch, basename(path));
283
+ await writeFile(copy, plain, { mode: 0o600 });
284
+ await renderWithEngine(copy, output, format, { signal, engines });
285
+ } finally {
286
+ await removeScratch(scratch, undefined, { remove: engines.removeDirectory, backoffMs: engines.removeBackoffMs });
287
+ }
288
+ }
289
+
290
+ async function renderPreviewPdf(path, output, format, { signal, engines, password }) {
291
+ const protectedBy = await protection(path);
292
+ if (protectedBy === 'unsupported') throw passwordUnsupportedError();
293
+ if (protectedBy === 'corrupt') throw passwordCorruptError();
294
+ if (protectedBy === 'package') {
295
+ if (!password) throw passwordProtectedError();
296
+ await renderUnlockedPackage(path, output, format, { signal, engines, password });
297
+ return;
298
+ }
299
+ if (protectedBy === 'word') {
300
+ // LibreOffice's conversion takes no password, so only Word can open it.
301
+ if (!(await officeInstalled(engines, 'docx'))) throw passwordNeedsOfficeError();
302
+ if (!password) throw passwordProtectedError();
303
+ await renderWithMicrosoftOffice(path, output, 'docx', { signal, engines, password });
304
+ return;
305
+ }
306
+ await renderWithEngine(path, output, format, { signal, engines, password });
307
+ }
308
+
53
309
  /** The convertible format for this filename, or '' when there is none. */
54
310
  export function documentPreviewFormat(path) {
55
311
  const name =
@@ -92,19 +348,79 @@ async function pruneCache(root) {
92
348
  // second one starts a second LibreOffice run writing the same output file.
93
349
  const conversions = new Map();
94
350
 
351
+ // A document opened with its password is never written to the cache: its PDF lives in a private scratch directory
352
+ // for as long as this process runs (or until newer unlocks push it out), so the viewer's later page requests are
353
+ // served without the password, and nothing of it outlives the process. Keyed by source identity like the cache, so
354
+ // a changed document asks for its password again. An entry pushed out is no longer served at once; its directory
355
+ // stays tracked (and its pages off disk) until its removal is confirmed.
356
+ const MAX_UNLOCKED_DOCUMENTS = 8;
357
+ const unlockedPreviews = new Map();
358
+
359
+ function forgetUnlocked(key) {
360
+ const entry = unlockedPreviews.get(key);
361
+ if (!entry) return;
362
+ unlockedPreviews.delete(key);
363
+ void removeScratch(entry.directory, undefined, entry.removal);
364
+ }
365
+
366
+ function rememberUnlocked(key, entry) {
367
+ forgetUnlocked(key);
368
+ unlockedPreviews.set(key, entry);
369
+ for (const oldest of unlockedPreviews.keys()) {
370
+ if (unlockedPreviews.size <= MAX_UNLOCKED_DOCUMENTS) break;
371
+ forgetUnlocked(oldest);
372
+ }
373
+ }
374
+
375
+ async function unlockedPreview(key) {
376
+ const entry = unlockedPreviews.get(key);
377
+ if (!entry) return null;
378
+ const ready = await stat(entry.output).catch(() => null);
379
+ if (!ready?.isFile()) {
380
+ forgetUnlocked(key);
381
+ return null;
382
+ }
383
+ // Most recently used last, so eviction drops the one nobody looked at.
384
+ unlockedPreviews.delete(key);
385
+ unlockedPreviews.set(key, entry);
386
+ return entry.output;
387
+ }
388
+
389
+ async function renderUnlocked(path, key, identity, { signal, engines, password }) {
390
+ const removal = { remove: engines.removeDirectory, backoffMs: engines.removeBackoffMs };
391
+ const directory = await createScratch('mixdog-unlocked-preview-');
392
+ const output = join(directory, PREVIEW_PDF_NAME);
393
+ try {
394
+ await renderPreviewPdf(path, output, identity.format, { signal, engines, password });
395
+ } catch (error) {
396
+ await removeScratch(directory, undefined, removal);
397
+ throw error;
398
+ }
399
+ rememberUnlocked(key, { directory, output, removal });
400
+ return { path: output, ...identity, cached: false };
401
+ }
402
+
95
403
  /**
96
- * The cached PDF rendition of one document, converting it when needed.
404
+ * The cached PDF rendition of one document, converting it when needed. `password` opens a protected document for
405
+ * this one request; it is handed to the opening step and nowhere else.
97
406
  * @returns {Promise<{ path: string; format: string; mtimeMs: number; size: number; cached: boolean }>}
98
407
  */
99
- export async function documentPreviewPdf(path, { cacheRoot, signal = null } = {}) {
408
+ export async function documentPreviewPdf(
409
+ path,
410
+ { cacheRoot, signal = null, engines = defaultEngines, password = '' } = {}
411
+ ) {
100
412
  const format = documentPreviewFormat(path);
101
413
  if (!format) throw new Error('This file type has no built-in document preview.');
102
414
  if (!cacheRoot) throw new Error('Document preview storage is unavailable.');
103
415
  const info = await stat(path);
104
416
  if (!info.isFile()) throw new Error('Not a file.');
105
- const directory = join(cacheRoot, CACHE_DIR_NAME, cacheKey(path, info));
417
+ const key = cacheKey(path, info);
418
+ const directory = join(cacheRoot, CACHE_DIR_NAME, key);
106
419
  const output = join(directory, PREVIEW_PDF_NAME);
107
420
  const identity = { format, mtimeMs: info.mtimeMs, size: info.size };
421
+ const unlocked = await unlockedPreview(key);
422
+ if (unlocked) return { path: unlocked, ...identity, cached: true };
423
+ if (password) return await renderUnlocked(path, key, identity, { signal, engines, password });
108
424
  const ready = await stat(output).catch(() => null);
109
425
  if (ready?.isFile() && ready.size > 0) {
110
426
  // Touch on USE, so eviction drops what nobody opens rather than what was
@@ -118,7 +434,7 @@ export async function documentPreviewPdf(path, { cacheRoot, signal = null } = {}
118
434
  const conversion = (async () => {
119
435
  await mkdir(directory, { recursive: true });
120
436
  try {
121
- await renderPortableOoxml(path, output, { signal });
437
+ await renderPreviewPdf(path, output, format, { signal, engines });
122
438
  } catch (error) {
123
439
  await rm(directory, { recursive: true, force: true }).catch(() => {});
124
440
  throw error;
@@ -138,8 +454,10 @@ export async function documentPreviewPdf(path, { cacheRoot, signal = null } = {}
138
454
  */
139
455
  export async function documentPreviewPages(
140
456
  pdfPath,
141
- { pages = [1], maxWidth = 1200, signal = null, cacheRoot = '', renderPages = renderPdfPages } = {}
457
+ { pages = [1], maxWidth = 1200, signal = null, cacheRoot: storageRoot = '', renderPages = renderPdfPages } = {}
142
458
  ) {
459
+ // Pages of a document opened with its password stay in memory only, like its PDF.
460
+ const cacheRoot = inScratch(pdfPath) ? '' : storageRoot;
143
461
  const wanted = Array.isArray(pages) && pages.length ? pages : [1];
144
462
  const info = await stat(pdfPath).catch(() => null);
145
463
  const revision = info?.isFile() ? pageRevision(pdfPath, info) : null;