mulmoclaude 0.6.0 → 0.6.2

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 (172) hide show
  1. package/bin/mulmoclaude.js +1 -1
  2. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +3 -0
  3. package/client/assets/{html2canvas-CDGcmOD3-BbPeutDg.js → html2canvas-CDGcmOD3-Bkf2uOth.js} +1 -1
  4. package/client/assets/{index-BbgSjFQ8.js → index-BwrlMMHr.js} +178 -141
  5. package/client/assets/index-CvvNuegU.css +2 -0
  6. package/client/assets/{index.es-DqtpmBm8-DJdTPdnc.js → index.es-DqtpmBm8-D9mAh_KQ.js} +1 -1
  7. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  8. package/client/assets/runtime-protocol-vue-C1To4M3t.js +1 -0
  9. package/client/index.html +7 -6
  10. package/package.json +9 -7
  11. package/server/accounting/eventPublisher.ts +2 -1
  12. package/server/accounting/snapshotCache.ts +2 -1
  13. package/server/agent/activeTools.ts +16 -6
  14. package/server/agent/backend/claude-code.ts +1 -0
  15. package/server/agent/backend/types.ts +3 -0
  16. package/server/agent/config.ts +25 -2
  17. package/server/agent/index.ts +6 -0
  18. package/server/agent/mcp-server.ts +9 -6
  19. package/server/agent/mcp-tools/index.ts +15 -2
  20. package/server/agent/mcp-tools/notify.ts +20 -2
  21. package/server/agent/prompt.ts +37 -24
  22. package/server/api/routes/accounting.ts +31 -24
  23. package/server/api/routes/agent.ts +2 -2
  24. package/server/api/routes/config-refresh.ts +49 -0
  25. package/server/api/routes/config.ts +86 -68
  26. package/server/api/routes/files.ts +41 -17
  27. package/server/api/routes/hookLog.ts +95 -0
  28. package/server/api/routes/news.ts +39 -52
  29. package/server/api/routes/notifier.ts +14 -19
  30. package/server/api/routes/pdf.ts +2 -2
  31. package/server/api/routes/photo-locations.ts +79 -0
  32. package/server/api/routes/plugins.ts +11 -0
  33. package/server/api/routes/presentSvg.ts +107 -0
  34. package/server/api/routes/scheduler.ts +100 -98
  35. package/server/api/routes/schedulerTasks.ts +98 -95
  36. package/server/api/routes/sessions.ts +22 -27
  37. package/server/api/routes/sources.ts +45 -43
  38. package/server/api/routes/wiki/history.ts +6 -15
  39. package/server/api/routes/wiki.ts +73 -276
  40. package/server/events/file-change.ts +3 -2
  41. package/server/events/session-store/index.ts +2 -1
  42. package/server/index.ts +130 -8
  43. package/server/notifier/store.ts +3 -3
  44. package/server/plugins/preset-list.ts +16 -5
  45. package/server/plugins/runtime.ts +2 -2
  46. package/server/system/config.ts +138 -16
  47. package/server/utils/asyncHandler.ts +75 -0
  48. package/server/utils/exif.ts +321 -0
  49. package/server/utils/files/accounting-io.ts +19 -20
  50. package/server/utils/files/attachment-store.ts +69 -12
  51. package/server/utils/files/journal-io.ts +2 -1
  52. package/server/utils/files/json.ts +8 -1
  53. package/server/utils/files/reference-dirs-io.ts +2 -3
  54. package/server/utils/files/scheduler-overrides-io.ts +2 -3
  55. package/server/utils/files/svg-store.ts +27 -0
  56. package/server/utils/files/user-tasks-io.ts +2 -3
  57. package/server/utils/regex.ts +3 -12
  58. package/server/utils/text.ts +29 -0
  59. package/server/workspace/chat-index/summarizer.ts +5 -3
  60. package/server/workspace/cooking-recipes/migrate.ts +125 -0
  61. package/server/workspace/custom-dirs.ts +2 -2
  62. package/server/workspace/hooks/dispatcher.mjs +300 -0
  63. package/server/workspace/hooks/dispatcher.ts +55 -0
  64. package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
  65. package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
  66. package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
  67. package/server/workspace/hooks/provision.ts +222 -0
  68. package/server/workspace/hooks/shared/sidecar.ts +124 -0
  69. package/server/workspace/hooks/shared/stdin.ts +60 -0
  70. package/server/workspace/hooks/shared/workspace.ts +13 -0
  71. package/server/workspace/journal/dailyPass.ts +1 -6
  72. package/server/workspace/memory/io.ts +1 -34
  73. package/server/workspace/memory/migrate.ts +2 -1
  74. package/server/workspace/memory/snapshot.ts +26 -0
  75. package/server/workspace/memory/topic-io.ts +1 -18
  76. package/server/workspace/paths.ts +16 -0
  77. package/server/workspace/photo-locations/index.ts +149 -0
  78. package/server/workspace/photo-locations/list.ts +124 -0
  79. package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
  80. package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
  81. package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
  82. package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
  83. package/server/workspace/skills-preset.ts +2 -1
  84. package/server/workspace/wiki-pages/io.ts +2 -1
  85. package/src/App.vue +78 -3
  86. package/src/components/ChatInput.vue +7 -8
  87. package/src/components/FileContentHeader.vue +1 -6
  88. package/src/components/FileDropOverlay.vue +18 -0
  89. package/src/components/NewsView.vue +2 -1
  90. package/src/components/RolesView.vue +14 -5
  91. package/src/components/SettingsMapTab.vue +140 -0
  92. package/src/components/SettingsMcpTab.vue +15 -10
  93. package/src/components/SettingsModal.vue +138 -112
  94. package/src/components/SettingsModelTab.vue +121 -0
  95. package/src/components/SettingsPhotosTab.vue +118 -0
  96. package/src/components/SourcesManager.vue +4 -3
  97. package/src/components/StackView.vue +43 -12
  98. package/src/composables/useContentDisplay.ts +16 -0
  99. package/src/composables/useFileDropZone.ts +148 -0
  100. package/src/composables/useImageErrorRepair.ts +29 -19
  101. package/src/composables/useSkillsList.ts +2 -1
  102. package/src/config/apiRoutes.ts +24 -0
  103. package/src/config/roles.ts +121 -70
  104. package/src/config/systemFileDescriptors.ts +2 -2
  105. package/src/config/toolNames.ts +26 -0
  106. package/src/index.css +26 -0
  107. package/src/lang/de.ts +70 -1
  108. package/src/lang/en.ts +69 -1
  109. package/src/lang/es.ts +69 -1
  110. package/src/lang/fr.ts +69 -1
  111. package/src/lang/ja.ts +69 -1
  112. package/src/lang/ko.ts +68 -1
  113. package/src/lang/pt-BR.ts +69 -1
  114. package/src/lang/zh.ts +67 -1
  115. package/src/lib/wiki-page/index-parse.ts +221 -0
  116. package/src/lib/wiki-page/link.ts +62 -0
  117. package/src/lib/wiki-page/lint.ts +105 -0
  118. package/src/lib/wiki-page/paths.ts +35 -0
  119. package/src/lib/wiki-page/slug.ts +28 -40
  120. package/src/main.ts +8 -0
  121. package/src/plugins/_extras.ts +6 -2
  122. package/src/plugins/_generated/metas.ts +4 -0
  123. package/src/plugins/_generated/registrations.ts +4 -0
  124. package/src/plugins/_generated/server-bindings.ts +6 -0
  125. package/src/plugins/accounting/Preview.vue +3 -6
  126. package/src/plugins/accounting/View.vue +2 -1
  127. package/src/plugins/accounting/components/AccountsModal.vue +3 -2
  128. package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
  129. package/src/plugins/accounting/components/JournalList.vue +2 -1
  130. package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
  131. package/src/plugins/accounting/currencies.ts +13 -0
  132. package/src/plugins/manageRoles/View.vue +16 -5
  133. package/src/plugins/manageSkills/View.vue +12 -4
  134. package/src/plugins/markdown/View.vue +6 -0
  135. package/src/plugins/photoLocations/View.vue +231 -0
  136. package/src/plugins/photoLocations/definition.ts +47 -0
  137. package/src/plugins/photoLocations/index.ts +38 -0
  138. package/src/plugins/photoLocations/meta.ts +35 -0
  139. package/src/plugins/presentMulmoScript/View.vue +76 -7
  140. package/src/plugins/presentMulmoScript/helpers.ts +15 -0
  141. package/src/plugins/presentSVG/Preview.vue +56 -0
  142. package/src/plugins/presentSVG/View.vue +465 -0
  143. package/src/plugins/presentSVG/definition.ts +29 -0
  144. package/src/plugins/presentSVG/index.ts +49 -0
  145. package/src/plugins/presentSVG/meta.ts +14 -0
  146. package/src/plugins/scheduler/View.vue +3 -7
  147. package/src/plugins/skill/View.vue +15 -16
  148. package/src/plugins/spreadsheet/View.vue +4 -0
  149. package/src/plugins/wiki/View.vue +1 -1
  150. package/src/plugins/wiki/helpers.ts +23 -5
  151. package/src/plugins/wiki/route.ts +12 -11
  152. package/src/tools/runtimeLoader.ts +75 -9
  153. package/src/utils/dom/iframeHeightClamp.ts +42 -0
  154. package/src/utils/format/bytes.ts +41 -0
  155. package/src/utils/format/date.ts +14 -2
  156. package/src/utils/image/imageRepairInlineScript.ts +192 -41
  157. package/src/utils/markdown/sanitize.ts +68 -0
  158. package/src/utils/markdown/setup.ts +36 -0
  159. package/src/utils/markdown/wikiEmbedHandlers.ts +170 -0
  160. package/src/utils/markdown/wikiEmbeds.ts +141 -0
  161. package/src/utils/markdown/workspaceLinkify.ts +73 -0
  162. package/src/utils/path/workspaceLinkRouter.ts +17 -1
  163. package/client/assets/index-ECD0lgIv.css +0 -2
  164. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  165. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
  166. package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
  167. package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
  168. package/server/workspace/wiki-history/provision.ts +0 -181
  169. /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
  170. /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
  171. /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
  172. /package/client/assets/{vue-1e_vz2LW.js → vue-C8UuIO9J.js} +0 -0
@@ -2,12 +2,15 @@ import { Router, type Request, type Response } from "express";
2
2
  import {
3
3
  fromMcpEntries,
4
4
  isAppSettings,
5
+ isAppSettingsPatch,
5
6
  loadMcpConfig,
6
7
  loadSettings,
8
+ normaliseAppSettingsPatch,
7
9
  saveMcpConfig,
8
10
  saveSettings,
9
11
  toMcpEntries,
10
12
  type AppSettings,
13
+ type AppSettingsPatch,
11
14
  type McpConfigFile,
12
15
  type McpServerEntry,
13
16
  } from "../../system/config.js";
@@ -16,6 +19,7 @@ import { errorMessage } from "../../utils/errors.js";
16
19
  import { isRecord } from "../../utils/types.js";
17
20
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
18
21
  import { log } from "../../system/logger/index.js";
22
+ import { asyncHandler } from "../../utils/asyncHandler.js";
19
23
  import { loadCustomDirs, saveCustomDirs, ensureCustomDirs, validateCustomDirs, type CustomDirEntry } from "../../workspace/custom-dirs.js";
20
24
  import { loadReferenceDirs, saveReferenceDirs, validateReferenceDirs, type ReferenceDirEntry } from "../../workspace/reference-dirs.js";
21
25
 
@@ -66,16 +70,18 @@ function parseMcpPayloadOrFail(res: ConfigRes, servers: McpServerEntry[]): McpCo
66
70
  }
67
71
  }
68
72
 
69
- // Run a filesystem save. On failure, respond 500 with the error's
70
- // message and return false so the caller can early-return. Returns
71
- // true on success.
73
+ // Run a filesystem save. On failure, log the raw error server-side
74
+ // (full triage detail kept in logs), respond 500 with the safe
75
+ // `fallback` message, and return false so the caller can early-return.
76
+ // Returns true on success. The raw `err.message` deliberately doesn't
77
+ // reach the client — same threat model as `asyncHandler`.
72
78
  function runSaveOrFail(res: ConfigRes, save: () => void, fallback: string): boolean {
73
79
  try {
74
80
  save();
75
81
  return true;
76
82
  } catch (err) {
77
83
  log.error("config", `save failed: ${fallback}`, { error: errorMessage(err) });
78
- serverError(res, errorMessage(err, fallback));
84
+ serverError(res, fallback);
79
85
  return false;
80
86
  }
81
87
  }
@@ -133,15 +139,30 @@ router.put(API_ROUTES.config.base, (req: Request<unknown, unknown, PutConfigBody
133
139
  res.json(buildFullResponse());
134
140
  });
135
141
 
136
- router.put(API_ROUTES.config.settings, (req: Request<unknown, unknown, AppSettings>, res: ConfigRes) => {
142
+ router.put(API_ROUTES.config.settings, (req: Request<unknown, unknown, AppSettingsPatch>, res: ConfigRes) => {
137
143
  const { body } = req;
138
144
  log.info("config", "PUT settings: start");
139
- if (!isAppSettings(body)) {
145
+ if (!isAppSettingsPatch(body)) {
140
146
  log.warn("config", "PUT settings: invalid payload");
141
147
  badRequest(res, "Invalid AppSettings payload");
142
148
  return;
143
149
  }
144
- if (!runSaveOrFail(res, () => saveSettings(body), "saveSettings failed")) {
150
+ // Merge the PUT body (a partial patch — fields the caller doesn't
151
+ // own can be omitted) onto the existing on-disk settings so a tab
152
+ // that knows about only some fields (e.g. Tools tab sends only
153
+ // `extraAllowedTools`, Map tab sends only `googleMapsApiKey`)
154
+ // doesn't wipe fields owned by other tabs.
155
+ //
156
+ // `null` in the patch is a sentinel for "clear this field":
157
+ // normaliseAppSettingsPatch drops the entry from the patch, AND we
158
+ // must also delete it from the merged result so the existing
159
+ // value doesn't leak through the spread.
160
+ const existing = loadSettings();
161
+ const merged: AppSettings = { ...existing, ...normaliseAppSettingsPatch(body) };
162
+ if (body.effortLevel === null) {
163
+ delete merged.effortLevel;
164
+ }
165
+ if (!runSaveOrFail(res, () => saveSettings(merged), "saveSettings failed")) {
145
166
  return;
146
167
  }
147
168
  log.info("config", "PUT settings: ok");
@@ -175,30 +196,29 @@ router.get(API_ROUTES.config.workspaceDirs, (_req: Request, res: Response<{ dirs
175
196
 
176
197
  router.put(
177
198
  API_ROUTES.config.workspaceDirs,
178
- (req: Request<unknown, unknown, { dirs: unknown }>, res: Response<{ dirs: CustomDirEntry[] } | ConfigErrorResponse>) => {
179
- const { body } = req;
180
- log.info("config", "PUT workspace-dirs: start");
181
- if (!isRecord(body) || !("dirs" in body)) {
182
- log.warn("config", "PUT workspace-dirs: invalid envelope");
183
- badRequest(res, "expected { dirs: [...] }");
184
- return;
185
- }
186
- const result = validateCustomDirs(body.dirs);
187
- if ("error" in result) {
188
- log.warn("config", "PUT workspace-dirs: validation failed", { error: result.error });
189
- badRequest(res, result.error);
190
- return;
191
- }
192
- try {
199
+ asyncHandler<Request<unknown, unknown, { dirs: unknown }>, Response<{ dirs: CustomDirEntry[] } | ConfigErrorResponse>>(
200
+ "config",
201
+ "save failed",
202
+ async (req, res) => {
203
+ const { body } = req;
204
+ log.info("config", "PUT workspace-dirs: start");
205
+ if (!isRecord(body) || !("dirs" in body)) {
206
+ log.warn("config", "PUT workspace-dirs: invalid envelope");
207
+ badRequest(res, "expected { dirs: [...] }");
208
+ return;
209
+ }
210
+ const result = validateCustomDirs(body.dirs);
211
+ if ("error" in result) {
212
+ log.warn("config", "PUT workspace-dirs: validation failed", { error: result.error });
213
+ badRequest(res, result.error);
214
+ return;
215
+ }
193
216
  saveCustomDirs(result.entries);
194
217
  ensureCustomDirs(result.entries);
195
218
  log.info("config", "PUT workspace-dirs: ok", { dirs: result.entries.length });
196
219
  res.json({ dirs: result.entries });
197
- } catch (err) {
198
- log.error("config", "PUT workspace-dirs: threw", { error: errorMessage(err) });
199
- serverError(res, errorMessage(err, "save failed"));
200
- }
201
- },
220
+ },
221
+ ),
202
222
  );
203
223
 
204
224
  // ── Reference directories (#455) ────────────────────────────────
@@ -209,29 +229,28 @@ router.get(API_ROUTES.config.referenceDirs, (_req: Request, res: Response<{ dirs
209
229
 
210
230
  router.put(
211
231
  API_ROUTES.config.referenceDirs,
212
- (req: Request<unknown, unknown, { dirs: unknown }>, res: Response<{ dirs: ReferenceDirEntry[] } | ConfigErrorResponse>) => {
213
- const { body } = req;
214
- log.info("config", "PUT reference-dirs: start");
215
- if (!isRecord(body) || !("dirs" in body)) {
216
- log.warn("config", "PUT reference-dirs: invalid envelope");
217
- badRequest(res, "expected { dirs: [...] }");
218
- return;
219
- }
220
- const result = validateReferenceDirs(body.dirs);
221
- if ("error" in result) {
222
- log.warn("config", "PUT reference-dirs: validation failed", { error: result.error });
223
- badRequest(res, result.error);
224
- return;
225
- }
226
- try {
232
+ asyncHandler<Request<unknown, unknown, { dirs: unknown }>, Response<{ dirs: ReferenceDirEntry[] } | ConfigErrorResponse>>(
233
+ "config",
234
+ "save failed",
235
+ async (req, res) => {
236
+ const { body } = req;
237
+ log.info("config", "PUT reference-dirs: start");
238
+ if (!isRecord(body) || !("dirs" in body)) {
239
+ log.warn("config", "PUT reference-dirs: invalid envelope");
240
+ badRequest(res, "expected { dirs: [...] }");
241
+ return;
242
+ }
243
+ const result = validateReferenceDirs(body.dirs);
244
+ if ("error" in result) {
245
+ log.warn("config", "PUT reference-dirs: validation failed", { error: result.error });
246
+ badRequest(res, result.error);
247
+ return;
248
+ }
227
249
  saveReferenceDirs(result.entries);
228
250
  log.info("config", "PUT reference-dirs: ok", { dirs: result.entries.length });
229
251
  res.json({ dirs: result.entries });
230
- } catch (err) {
231
- log.error("config", "PUT reference-dirs: threw", { error: errorMessage(err) });
232
- serverError(res, errorMessage(err, "save failed"));
233
- }
234
- },
252
+ },
253
+ ),
235
254
  );
236
255
 
237
256
  router.get(API_ROUTES.config.schedulerOverrides, (_req: Request, res: Response<{ overrides: ScheduleOverrides }>) => {
@@ -240,22 +259,24 @@ router.get(API_ROUTES.config.schedulerOverrides, (_req: Request, res: Response<{
240
259
 
241
260
  router.put(
242
261
  API_ROUTES.config.schedulerOverrides,
243
- async (req: Request<unknown, unknown, { overrides: unknown }>, res: Response<{ overrides: ScheduleOverrides } | ConfigErrorResponse>) => {
244
- const { body } = req;
245
- log.info("config", "PUT scheduler-overrides: start");
246
- if (!isRecord(body) || !("overrides" in body)) {
247
- log.warn("config", "PUT scheduler-overrides: invalid envelope");
248
- badRequest(res, "expected { overrides: { ... } }");
249
- return;
250
- }
251
- const raw = body.overrides;
252
- if (!isRecord(raw)) {
253
- log.warn("config", "PUT scheduler-overrides: overrides not an object");
254
- badRequest(res, "overrides must be an object");
255
- return;
256
- }
257
- const overrides = raw as ScheduleOverrides;
258
- try {
262
+ asyncHandler<Request<unknown, unknown, { overrides: unknown }>, Response<{ overrides: ScheduleOverrides } | ConfigErrorResponse>>(
263
+ "config",
264
+ "save failed",
265
+ async (req, res) => {
266
+ const { body } = req;
267
+ log.info("config", "PUT scheduler-overrides: start");
268
+ if (!isRecord(body) || !("overrides" in body)) {
269
+ log.warn("config", "PUT scheduler-overrides: invalid envelope");
270
+ badRequest(res, "expected { overrides: { ... } }");
271
+ return;
272
+ }
273
+ const raw = body.overrides;
274
+ if (!isRecord(raw)) {
275
+ log.warn("config", "PUT scheduler-overrides: overrides not an object");
276
+ badRequest(res, "overrides must be an object");
277
+ return;
278
+ }
279
+ const overrides = raw as ScheduleOverrides;
259
280
  saveSchedulerOverrides(overrides);
260
281
 
261
282
  // Apply to running task-manager immediately
@@ -275,11 +296,8 @@ router.put(
275
296
 
276
297
  log.info("config", "PUT scheduler-overrides: ok", { tasks: Object.keys(overrides).length });
277
298
  res.json({ overrides: loadSchedulerOverrides() });
278
- } catch (err) {
279
- log.error("config", "PUT scheduler-overrides: threw", { error: errorMessage(err) });
280
- serverError(res, errorMessage(err, "save failed"));
281
- }
282
- },
299
+ },
300
+ ),
283
301
  );
284
302
 
285
303
  export default router;
@@ -305,9 +305,9 @@ export function parseRange(header: string, size: number): ByteRange | null {
305
305
  return { start, end };
306
306
  }
307
307
 
308
- // Security headers applied to every `/files/raw` response. Exported
309
- // so a regression test can pin the exact strings down — a silent
310
- // regression here reopens a real XSS surface (see plans/
308
+ // Security headers applied to `/files/raw` responses. Exported so a
309
+ // regression test can pin the exact strings down — a silent
310
+ // regression here reopens a real XSS surface (see plans/done/
311
311
  // fix-files-raw-csp-sandbox.md for the full threat model).
312
312
  //
313
313
  // `sandbox` (no allow-flags) creates an opaque origin for the
@@ -315,8 +315,7 @@ export function parseRange(header: string, size: number): ByteRange | null {
315
315
  // gets loaded as a top-level document or inside an iframe, its
316
316
  // scripts can't access the localhost:3001 origin's cookies,
317
317
  // session storage, or hit the `/api/*` endpoints. Frames rendering
318
- // the response become sandboxed too — PDFs still work because
319
- // they don't rely on same-origin access to the parent.
318
+ // the response become sandboxed too.
320
319
  //
321
320
  // `nosniff` stops Chrome / Firefox from re-guessing Content-Type
322
321
  // on files the server declared but the browser might want to
@@ -326,8 +325,27 @@ export const RAW_SECURITY_HEADERS: Readonly<Record<string, string>> = {
326
325
  "X-Content-Type-Options": "nosniff",
327
326
  };
328
327
 
329
- function applyRawSecurityHeaders(res: Response): void {
330
- for (const [name, value] of Object.entries(RAW_SECURITY_HEADERS)) {
328
+ // PDF responses skip `Content-Security-Policy: sandbox`. Issue
329
+ // #1299: WebKit refuses to render `sandbox`-opaque PDFs and forces
330
+ // a download, breaking the Files preview iframe on Safari. The
331
+ // PDF viewer (PDFium on Chromium, the WebKit PDF renderer, pdf.js
332
+ // on Firefox) runs embedded AcroJS inside its own sandbox; the
333
+ // response-level CSP was never the layer enforcing PDF script
334
+ // isolation. `nosniff` is kept so the response can't be
335
+ // re-interpreted as HTML.
336
+ export const RAW_SECURITY_HEADERS_PDF: Readonly<Record<string, string>> = {
337
+ "X-Content-Type-Options": "nosniff",
338
+ };
339
+
340
+ /** Pick the header set for a given MIME. PDF is the only special
341
+ * case today — every other MIME (`image/*`, `text/*`,
342
+ * `application/octet-stream`, …) keeps the sandbox CSP. */
343
+ export function rawSecurityHeadersForMime(mime: string): Readonly<Record<string, string>> {
344
+ return mime === "application/pdf" ? RAW_SECURITY_HEADERS_PDF : RAW_SECURITY_HEADERS;
345
+ }
346
+
347
+ function applyRawSecurityHeaders(res: Response, mime: string): void {
348
+ for (const [name, value] of Object.entries(rawSecurityHeadersForMime(mime))) {
331
349
  res.setHeader(name, value);
332
350
  }
333
351
  }
@@ -341,7 +359,11 @@ function pipeWithErrorHandling(stream: ReadStream, res: Response<ErrorResponse>)
341
359
  res.destroy(err);
342
360
  return;
343
361
  }
344
- serverError(res, `Failed to read file: ${err.message}`);
362
+ // The raw `err.message` carries filesystem paths / system error
363
+ // detail — keep it in the server log but ship a stable opaque
364
+ // string to the client. Same threat model as `asyncHandler`.
365
+ log.error("files", "raw stream error", { error: errorMessage(err) });
366
+ serverError(res, "Failed to read file");
345
367
  });
346
368
  stream.pipe(res);
347
369
  }
@@ -497,7 +519,7 @@ router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown,
497
519
  res.json(tree);
498
520
  } catch (err) {
499
521
  log.error("files", "GET tree: threw", { error: errorMessage(err) });
500
- serverError(res, `Failed to read workspace: ${errorMessage(err)}`);
522
+ serverError(res, "Failed to read workspace");
501
523
  }
502
524
  });
503
525
 
@@ -560,7 +582,7 @@ router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, P
560
582
  res.json(listing);
561
583
  } catch (err) {
562
584
  log.error("files", "GET dir: threw", { pathPreview: previewSnippet(relPath), error: errorMessage(err) });
563
- serverError(res, `Failed to read directory: ${errorMessage(err)}`);
585
+ serverError(res, "Failed to read directory");
564
586
  }
565
587
  });
566
588
 
@@ -700,7 +722,7 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
700
722
  content = readFileSync(absPath, "utf-8");
701
723
  } catch (err) {
702
724
  log.error("files", "GET content: read threw", { pathPreview: previewSnippet(relPath), error: errorMessage(err) });
703
- serverError(res, `Failed to read file: ${errorMessage(err)}`);
725
+ serverError(res, "Failed to read file");
704
726
  return;
705
727
  }
706
728
  log.info("files", "GET content: ok", { pathPreview: previewSnippet(relPath), bytes: stat.size });
@@ -818,7 +840,7 @@ router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteC
818
840
  await writeFileContent(resolved.absPath, content);
819
841
  } catch (err) {
820
842
  log.error("files", "PUT content: write threw", { pathPreview: previewSnippet(relPath), error: errorMessage(err) });
821
- serverError(res, `Failed to write file: ${errorMessage(err)}`);
843
+ serverError(res, "Failed to write file");
822
844
  return;
823
845
  }
824
846
  const fresh = await statSafeAsync(resolved.absPath);
@@ -859,11 +881,13 @@ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQue
859
881
  const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
860
882
  res.setHeader("Accept-Ranges", "bytes");
861
883
  res.setHeader("Content-Type", mime);
862
- // Sandbox the response so an `.svg` / `.html` / `.pdf` with
863
- // embedded JavaScript can't escape into the localhost:3001
864
- // origin via direct navigation or <iframe>. See
865
- // plans/done/fix-files-raw-csp-sandbox.md for the threat model.
866
- applyRawSecurityHeaders(res);
884
+ // Sandbox the response so an `.svg` / `.html` with embedded
885
+ // JavaScript can't escape into the localhost:3001 origin via
886
+ // direct navigation or <iframe>. PDFs get a narrower header set
887
+ // (no sandbox CSP) because Safari/WebKit refuses to render
888
+ // sandbox-opaque PDFs (#1299). See plans/done/
889
+ // fix-files-raw-csp-sandbox.md for the full threat model.
890
+ applyRawSecurityHeaders(res, mime);
867
891
 
868
892
  // Range support is required for `<video>` playback (Safari refuses
869
893
  // to play media without 206 responses) and for seek-past-buffered
@@ -0,0 +1,95 @@
1
+ // POST /api/hooks/log — receives one structured log line from the
2
+ // PostToolUse dispatcher and forwards it into the server's logger.
3
+ //
4
+ // Why: hook handlers run inside Claude CLI's process space and have
5
+ // no path to the server's structured logger. Without this endpoint,
6
+ // every hook side-effect (skill-bridge mirror copy, future ones)
7
+ // silently succeeds — a user trying to verify a copy actually
8
+ // happened has no signal to look at, and a partial failure is
9
+ // indistinguishable from "the hook didn't fire". This endpoint is
10
+ // the bridge from "the hook side did something" to the same log
11
+ // stream the rest of the server writes to.
12
+ //
13
+ // Body shape:
14
+ //
15
+ // { namespace: string; message: string;
16
+ // level?: "info" | "warn" | "error"; data?: object }
17
+ //
18
+ // Authentication: bearer auth (same as every other internal hook
19
+ // endpoint). The dispatcher reads the workspace's `.session-token`
20
+ // sidecar before POSTing.
21
+
22
+ import { Router, type Request, type Response } from "express";
23
+ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
24
+ import { badRequest } from "../../utils/httpError.js";
25
+ import { log } from "../../system/logger/index.js";
26
+ import { isRecord } from "../../utils/types.js";
27
+
28
+ type Level = "info" | "warn" | "error";
29
+
30
+ interface HookLogBody {
31
+ namespace?: unknown;
32
+ message?: unknown;
33
+ level?: unknown;
34
+ data?: unknown;
35
+ }
36
+
37
+ const router = Router();
38
+
39
+ // Cap the inbound payload's text length. The dispatcher is trusted
40
+ // (bearer-authed, same machine) but a runaway handler emitting an
41
+ // unbounded `data` blob would crowd the server logs. 2 KB per field
42
+ // is comfortable for "mirror src → dst" style messages and tight
43
+ // enough to keep one event readable in the tail.
44
+ const MAX_FIELD_CHARS = 2048;
45
+
46
+ interface ValidatedHookLog {
47
+ namespace: string;
48
+ message: string;
49
+ level: Level;
50
+ // `Record<string, unknown>` matches the server logger's `data?`
51
+ // parameter exactly — `object` would force a cast at the log
52
+ // call site. The validator already narrows via `isRecord`, so the
53
+ // tighter type also documents the gate visually.
54
+ data?: Record<string, unknown>;
55
+ }
56
+
57
+ // Pull validation into a helper so the route body stays under the
58
+ // 20-line cognitive-complexity guideline. The helper either returns
59
+ // the typed shape ready for `log[level](...)` or writes a 400 and
60
+ // returns null — caller short-circuits on null.
61
+ function validateHookLogBody(body: HookLogBody | undefined, res: Response): ValidatedHookLog | null {
62
+ const { namespace, message, level, data } = body ?? {};
63
+ if (typeof namespace !== "string" || namespace.length === 0) {
64
+ badRequest(res, "namespace required");
65
+ return null;
66
+ }
67
+ if (typeof message !== "string" || message.length === 0) {
68
+ badRequest(res, "message required");
69
+ return null;
70
+ }
71
+ return {
72
+ // Tag the log entry's namespace so it's easy to grep against
73
+ // server-side noise — every hook-side log line starts with
74
+ // `hook:<handler>` rather than the bare handler name a server
75
+ // module would use.
76
+ namespace: `hook:${namespace.slice(0, MAX_FIELD_CHARS)}`,
77
+ message: message.slice(0, MAX_FIELD_CHARS),
78
+ level: resolveLevel(level),
79
+ data: isRecord(data) ? data : undefined,
80
+ };
81
+ }
82
+
83
+ router.post(API_ROUTES.hooks.log, (req: Request<object, unknown, HookLogBody>, res: Response) => {
84
+ const validated = validateHookLogBody(req.body, res);
85
+ if (validated === null) return;
86
+ log[validated.level](validated.namespace, validated.message, validated.data);
87
+ res.status(204).end();
88
+ });
89
+
90
+ function resolveLevel(raw: unknown): Level {
91
+ if (raw === "warn" || raw === "error") return raw;
92
+ return "info";
93
+ }
94
+
95
+ export default router;
@@ -17,10 +17,9 @@ import { aggregateRecentItems, loadItemBody, type NewsItem } from "../../workspa
17
17
  import { loadJsonFile, writeJsonAtomic } from "../../utils/files/json.js";
18
18
  import { WORKSPACE_FILES } from "../../../src/config/workspacePaths.js";
19
19
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
20
- import { badRequest, serverError } from "../../utils/httpError.js";
21
- import { errorMessage } from "../../utils/errors.js";
20
+ import { badRequest } from "../../utils/httpError.js";
22
21
  import { isRecord } from "../../utils/types.js";
23
- import { log } from "../../system/logger/index.js";
22
+ import { asyncHandler } from "../../utils/asyncHandler.js";
24
23
 
25
24
  // Window upper bound — keeps memory bounded if a caller passes
26
25
  // a comically large `days`. Daily JSON aggregation is O(days * items)
@@ -41,20 +40,18 @@ const router = Router();
41
40
 
42
41
  // ── /api/news/items ─────────────────────────────────────────────
43
42
 
44
- router.get(API_ROUTES.news.items, async (req: Request, res: Response<{ items: NewsItem[] } | { error: string }>) => {
45
- const days = parseDays(req.query.days);
46
- if (days === null) {
47
- badRequest(res, "invalid `days` query parameter");
48
- return;
49
- }
50
- try {
43
+ router.get(
44
+ API_ROUTES.news.items,
45
+ asyncHandler<Request, Response<{ items: NewsItem[] } | { error: string }>>("news", "failed to load news items", async (req, res) => {
46
+ const days = parseDays(req.query.days);
47
+ if (days === null) {
48
+ badRequest(res, "invalid `days` query parameter");
49
+ return;
50
+ }
51
51
  const items = await aggregateRecentItems(workspacePath, days);
52
52
  res.json({ items });
53
- } catch (err) {
54
- log.error("news", "aggregate failed", { error: errorMessage(err) });
55
- serverError(res, "failed to load news items");
56
- }
57
- });
53
+ }),
54
+ );
58
55
 
59
56
  function parseDays(raw: unknown): number | null {
60
57
  if (raw === undefined) return DEFAULT_DAYS;
@@ -72,13 +69,14 @@ function parseDays(raw: unknown): number | null {
72
69
  // params would let an attacker fabricate paths — so we resolve from
73
70
  // the trusted in-memory aggregate keyed by `id` here.
74
71
 
75
- router.get(API_ROUTES.news.itemBody, async (req: Request<{ id: string }>, res: Response<{ body: string | null } | { error: string }>) => {
76
- const { id } = req.params;
77
- if (!id) {
78
- badRequest(res, "missing item id");
79
- return;
80
- }
81
- try {
72
+ router.get(
73
+ API_ROUTES.news.itemBody,
74
+ asyncHandler<Request<{ id: string }>, Response<{ body: string | null } | { error: string }>>("news", "failed to load item body", async (req, res) => {
75
+ const { id } = req.params;
76
+ if (!id) {
77
+ badRequest(res, "missing item id");
78
+ return;
79
+ }
82
80
  const items = await aggregateRecentItems(workspacePath, MAX_DAYS);
83
81
  const match = items.find((entry) => entry.id === id);
84
82
  if (!match) {
@@ -87,46 +85,35 @@ router.get(API_ROUTES.news.itemBody, async (req: Request<{ id: string }>, res: R
87
85
  }
88
86
  const body = await loadItemBody(workspacePath, match.sourceSlug, match.url, match.publishedAt);
89
87
  res.json({ body });
90
- } catch (err) {
91
- log.error("news", "body lookup failed", { error: errorMessage(err) });
92
- serverError(res, "failed to load item body");
93
- }
94
- });
88
+ }),
89
+ );
95
90
 
96
91
  // ── /api/news/read-state ─────────────────────────────────────────
97
92
 
98
93
  const readStateAbsPath = (): string => path.join(workspacePath, WORKSPACE_FILES.newsReadState);
99
94
 
100
- router.get(API_ROUTES.news.readState, (_req: Request, res: Response<ReadState | { error: string }>) => {
101
- try {
95
+ router.get(
96
+ API_ROUTES.news.readState,
97
+ asyncHandler<Request, Response<ReadState | { error: string }>>("news", "failed to load news read-state", async (_req, res) => {
102
98
  const data = loadJsonFile<ReadState>(readStateAbsPath(), { readIds: [] });
103
99
  const sanitized = sanitizeReadState(data);
104
100
  res.json(sanitized);
105
- } catch (err) {
106
- log.error("news", "read-state load failed", {
107
- error: errorMessage(err),
108
- });
109
- serverError(res, "failed to load news read-state");
110
- }
111
- });
112
-
113
- router.put(API_ROUTES.news.readState, async (req: Request, res: Response<ReadState | { error: string }>) => {
114
- const { body } = req;
115
- if (!isRecord(body) || !Array.isArray(body.readIds)) {
116
- badRequest(res, "expected { readIds: string[] }");
117
- return;
118
- }
119
- const sanitized = sanitizeReadState({ readIds: body.readIds });
120
- try {
101
+ }),
102
+ );
103
+
104
+ router.put(
105
+ API_ROUTES.news.readState,
106
+ asyncHandler<Request, Response<ReadState | { error: string }>>("news", "failed to save news read-state", async (req, res) => {
107
+ const { body } = req;
108
+ if (!isRecord(body) || !Array.isArray(body.readIds)) {
109
+ badRequest(res, "expected { readIds: string[] }");
110
+ return;
111
+ }
112
+ const sanitized = sanitizeReadState({ readIds: body.readIds });
121
113
  await writeJsonAtomic(readStateAbsPath(), sanitized);
122
114
  res.json(sanitized);
123
- } catch (err) {
124
- log.error("news", "read-state save failed", {
125
- error: errorMessage(err),
126
- });
127
- serverError(res, "failed to save news read-state");
128
- }
129
- });
115
+ }),
116
+ );
130
117
 
131
118
  // Drop non-string entries, dedupe, cap at MAX_READ_IDS (keeping the
132
119
  // most recent — i.e. tail end — of the list). Pure for testability.
@@ -24,7 +24,7 @@ import { Router, type Request, type Response } from "express";
24
24
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
25
25
  import { cancel, clear, listAll, listHistory } from "../../notifier/engine.js";
26
26
  import type { NotifierEntry, NotifierHistoryEntry } from "../../notifier/types.js";
27
- import { log } from "../../system/logger/index.js";
27
+ import { asyncHandler } from "../../utils/asyncHandler.js";
28
28
 
29
29
  interface DispatchBody {
30
30
  action?: unknown;
@@ -40,10 +40,17 @@ function isNonEmptyString(value: unknown): value is string {
40
40
 
41
41
  const notifierRouter: Router = Router();
42
42
 
43
- notifierRouter.post(API_ROUTES.notifier.dispatch, async (req: Request<object, DispatchResponse, DispatchBody>, res: Response<DispatchResponse>) => {
44
- const body = req.body ?? {};
45
- const { action } = body;
46
- try {
43
+ // Detailed error stays in the server log for triage (via asyncHandler's
44
+ // `log.error`); the HTTP response gets the opaque "internal error"
45
+ // message so a parser-thrown filesystem path / internal stack frame
46
+ // can't leak to the client. Echoing `String(err)` would have given
47
+ // e.g. `Error: ENOENT, open '/Users/<...>/active.json'` to anyone
48
+ // holding the bearer token (CodeRabbit review on PR #1196).
49
+ notifierRouter.post(
50
+ API_ROUTES.notifier.dispatch,
51
+ asyncHandler<Request<object, DispatchResponse, DispatchBody>, Response<DispatchResponse>>("notifier-route", "internal error", async (req, res) => {
52
+ const body = req.body ?? {};
53
+ const { action } = body;
47
54
  switch (action) {
48
55
  case "clear": {
49
56
  if (!isNonEmptyString(body.id)) {
@@ -80,19 +87,7 @@ notifierRouter.post(API_ROUTES.notifier.dispatch, async (req: Request<object, Di
80
87
  default:
81
88
  res.status(400).json({ error: `unknown action: ${typeof action === "string" ? action : "<missing>"}` });
82
89
  }
83
- } catch (err) {
84
- // Detailed error stays in the server log for triage; the HTTP
85
- // response gets an opaque message so a parser-thrown filesystem
86
- // path / internal stack frame can't leak to the client. Echoing
87
- // `String(err)` would have given e.g. `Error: ENOENT, open
88
- // '/Users/<...>/active.json'` to anyone holding the bearer token
89
- // (CodeRabbit review on PR #1196).
90
- log.error("notifier-route", "dispatch failed", {
91
- action: typeof action === "string" ? action : "<unknown>",
92
- error: String(err),
93
- });
94
- res.status(500).json({ error: "internal error" });
95
- }
96
- });
90
+ }),
91
+ );
97
92
 
98
93
  export default notifierRouter;
@@ -139,7 +139,7 @@ function loadImageAsDataUri(abs: string): string | null {
139
139
  // only thing the user actually sees in a PDF anyway. The `<video
140
140
  // src>` / `<source src>` (in a `<video>`) / `<audio src>` URL is
141
141
  // left as the original relative path; puppeteer's fetch will fail
142
- // quickly and `networkidle0` still resolves.
142
+ // quickly and the page's `load` event still fires.
143
143
  //
144
144
  // Anchored at end-of-pathname (callers strip query / fragment first
145
145
  // via `urlPathname` below) so a query-string-only mention of the
@@ -232,7 +232,7 @@ async function renderPdf(fullHtml: string, format: "Letter" | "A4" = "Letter"):
232
232
  const browser = await puppeteer.launch({ headless: true });
233
233
  try {
234
234
  const page = await browser.newPage();
235
- await page.setContent(fullHtml, { waitUntil: "networkidle0" });
235
+ await page.setContent(fullHtml, { waitUntil: "load" });
236
236
  const pdfBuffer = await page.pdf({
237
237
  format,
238
238
  margin: { top: "16mm", bottom: "16mm", left: "16mm", right: "16mm" },