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.
- package/bin/mulmoclaude.js +1 -1
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +3 -0
- package/client/assets/{html2canvas-CDGcmOD3-BbPeutDg.js → html2canvas-CDGcmOD3-Bkf2uOth.js} +1 -1
- package/client/assets/{index-BbgSjFQ8.js → index-BwrlMMHr.js} +178 -141
- package/client/assets/index-CvvNuegU.css +2 -0
- package/client/assets/{index.es-DqtpmBm8-DJdTPdnc.js → index.es-DqtpmBm8-D9mAh_KQ.js} +1 -1
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +1 -0
- package/client/index.html +7 -6
- package/package.json +9 -7
- package/server/accounting/eventPublisher.ts +2 -1
- package/server/accounting/snapshotCache.ts +2 -1
- package/server/agent/activeTools.ts +16 -6
- package/server/agent/backend/claude-code.ts +1 -0
- package/server/agent/backend/types.ts +3 -0
- package/server/agent/config.ts +25 -2
- package/server/agent/index.ts +6 -0
- package/server/agent/mcp-server.ts +9 -6
- package/server/agent/mcp-tools/index.ts +15 -2
- package/server/agent/mcp-tools/notify.ts +20 -2
- package/server/agent/prompt.ts +37 -24
- package/server/api/routes/accounting.ts +31 -24
- package/server/api/routes/agent.ts +2 -2
- package/server/api/routes/config-refresh.ts +49 -0
- package/server/api/routes/config.ts +86 -68
- package/server/api/routes/files.ts +41 -17
- package/server/api/routes/hookLog.ts +95 -0
- package/server/api/routes/news.ts +39 -52
- package/server/api/routes/notifier.ts +14 -19
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/photo-locations.ts +79 -0
- package/server/api/routes/plugins.ts +11 -0
- package/server/api/routes/presentSvg.ts +107 -0
- package/server/api/routes/scheduler.ts +100 -98
- package/server/api/routes/schedulerTasks.ts +98 -95
- package/server/api/routes/sessions.ts +22 -27
- package/server/api/routes/sources.ts +45 -43
- package/server/api/routes/wiki/history.ts +6 -15
- package/server/api/routes/wiki.ts +73 -276
- package/server/events/file-change.ts +3 -2
- package/server/events/session-store/index.ts +2 -1
- package/server/index.ts +130 -8
- package/server/notifier/store.ts +3 -3
- package/server/plugins/preset-list.ts +16 -5
- package/server/plugins/runtime.ts +2 -2
- package/server/system/config.ts +138 -16
- package/server/utils/asyncHandler.ts +75 -0
- package/server/utils/exif.ts +321 -0
- package/server/utils/files/accounting-io.ts +19 -20
- package/server/utils/files/attachment-store.ts +69 -12
- package/server/utils/files/journal-io.ts +2 -1
- package/server/utils/files/json.ts +8 -1
- package/server/utils/files/reference-dirs-io.ts +2 -3
- package/server/utils/files/scheduler-overrides-io.ts +2 -3
- package/server/utils/files/svg-store.ts +27 -0
- package/server/utils/files/user-tasks-io.ts +2 -3
- package/server/utils/regex.ts +3 -12
- package/server/utils/text.ts +29 -0
- package/server/workspace/chat-index/summarizer.ts +5 -3
- package/server/workspace/cooking-recipes/migrate.ts +125 -0
- package/server/workspace/custom-dirs.ts +2 -2
- package/server/workspace/hooks/dispatcher.mjs +300 -0
- package/server/workspace/hooks/dispatcher.ts +55 -0
- package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
- package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
- package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
- package/server/workspace/hooks/provision.ts +222 -0
- package/server/workspace/hooks/shared/sidecar.ts +124 -0
- package/server/workspace/hooks/shared/stdin.ts +60 -0
- package/server/workspace/hooks/shared/workspace.ts +13 -0
- package/server/workspace/journal/dailyPass.ts +1 -6
- package/server/workspace/memory/io.ts +1 -34
- package/server/workspace/memory/migrate.ts +2 -1
- package/server/workspace/memory/snapshot.ts +26 -0
- package/server/workspace/memory/topic-io.ts +1 -18
- package/server/workspace/paths.ts +16 -0
- package/server/workspace/photo-locations/index.ts +149 -0
- package/server/workspace/photo-locations/list.ts +124 -0
- package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
- package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
- package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
- package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
- package/server/workspace/skills-preset.ts +2 -1
- package/server/workspace/wiki-pages/io.ts +2 -1
- package/src/App.vue +78 -3
- package/src/components/ChatInput.vue +7 -8
- package/src/components/FileContentHeader.vue +1 -6
- package/src/components/FileDropOverlay.vue +18 -0
- package/src/components/NewsView.vue +2 -1
- package/src/components/RolesView.vue +14 -5
- package/src/components/SettingsMapTab.vue +140 -0
- package/src/components/SettingsMcpTab.vue +15 -10
- package/src/components/SettingsModal.vue +138 -112
- package/src/components/SettingsModelTab.vue +121 -0
- package/src/components/SettingsPhotosTab.vue +118 -0
- package/src/components/SourcesManager.vue +4 -3
- package/src/components/StackView.vue +43 -12
- package/src/composables/useContentDisplay.ts +16 -0
- package/src/composables/useFileDropZone.ts +148 -0
- package/src/composables/useImageErrorRepair.ts +29 -19
- package/src/composables/useSkillsList.ts +2 -1
- package/src/config/apiRoutes.ts +24 -0
- package/src/config/roles.ts +121 -70
- package/src/config/systemFileDescriptors.ts +2 -2
- package/src/config/toolNames.ts +26 -0
- package/src/index.css +26 -0
- package/src/lang/de.ts +70 -1
- package/src/lang/en.ts +69 -1
- package/src/lang/es.ts +69 -1
- package/src/lang/fr.ts +69 -1
- package/src/lang/ja.ts +69 -1
- package/src/lang/ko.ts +68 -1
- package/src/lang/pt-BR.ts +69 -1
- package/src/lang/zh.ts +67 -1
- package/src/lib/wiki-page/index-parse.ts +221 -0
- package/src/lib/wiki-page/link.ts +62 -0
- package/src/lib/wiki-page/lint.ts +105 -0
- package/src/lib/wiki-page/paths.ts +35 -0
- package/src/lib/wiki-page/slug.ts +28 -40
- package/src/main.ts +8 -0
- package/src/plugins/_extras.ts +6 -2
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +4 -0
- package/src/plugins/_generated/server-bindings.ts +6 -0
- package/src/plugins/accounting/Preview.vue +3 -6
- package/src/plugins/accounting/View.vue +2 -1
- package/src/plugins/accounting/components/AccountsModal.vue +3 -2
- package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
- package/src/plugins/accounting/components/JournalList.vue +2 -1
- package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
- package/src/plugins/accounting/currencies.ts +13 -0
- package/src/plugins/manageRoles/View.vue +16 -5
- package/src/plugins/manageSkills/View.vue +12 -4
- package/src/plugins/markdown/View.vue +6 -0
- package/src/plugins/photoLocations/View.vue +231 -0
- package/src/plugins/photoLocations/definition.ts +47 -0
- package/src/plugins/photoLocations/index.ts +38 -0
- package/src/plugins/photoLocations/meta.ts +35 -0
- package/src/plugins/presentMulmoScript/View.vue +76 -7
- package/src/plugins/presentMulmoScript/helpers.ts +15 -0
- package/src/plugins/presentSVG/Preview.vue +56 -0
- package/src/plugins/presentSVG/View.vue +465 -0
- package/src/plugins/presentSVG/definition.ts +29 -0
- package/src/plugins/presentSVG/index.ts +49 -0
- package/src/plugins/presentSVG/meta.ts +14 -0
- package/src/plugins/scheduler/View.vue +3 -7
- package/src/plugins/skill/View.vue +15 -16
- package/src/plugins/spreadsheet/View.vue +4 -0
- package/src/plugins/wiki/View.vue +1 -1
- package/src/plugins/wiki/helpers.ts +23 -5
- package/src/plugins/wiki/route.ts +12 -11
- package/src/tools/runtimeLoader.ts +75 -9
- package/src/utils/dom/iframeHeightClamp.ts +42 -0
- package/src/utils/format/bytes.ts +41 -0
- package/src/utils/format/date.ts +14 -2
- package/src/utils/image/imageRepairInlineScript.ts +192 -41
- package/src/utils/markdown/sanitize.ts +68 -0
- package/src/utils/markdown/setup.ts +36 -0
- package/src/utils/markdown/wikiEmbedHandlers.ts +170 -0
- package/src/utils/markdown/wikiEmbeds.ts +141 -0
- package/src/utils/markdown/workspaceLinkify.ts +73 -0
- package/src/utils/path/workspaceLinkRouter.ts +17 -1
- package/client/assets/index-ECD0lgIv.css +0 -2
- package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
- package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
- package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
- package/server/workspace/wiki-history/provision.ts +0 -181
- /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
- /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
- /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
- /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,
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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,
|
|
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,
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
}
|
|
198
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
}
|
|
231
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
}
|
|
279
|
-
|
|
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
|
|
309
|
-
//
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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`
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
|
|
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
|
|
21
|
-
import { errorMessage } from "../../utils/errors.js";
|
|
20
|
+
import { badRequest } from "../../utils/httpError.js";
|
|
22
21
|
import { isRecord } from "../../utils/types.js";
|
|
23
|
-
import {
|
|
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(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
}
|
|
54
|
-
|
|
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(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
}
|
|
91
|
-
|
|
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(
|
|
101
|
-
|
|
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
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
}
|
|
124
|
-
|
|
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 {
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
}
|
|
84
|
-
|
|
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;
|
package/server/api/routes/pdf.ts
CHANGED
|
@@ -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 `
|
|
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: "
|
|
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" },
|