mulmoclaude 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/assets/{feat-3497-qPLQlhNs.js → feat-3497-Cwr8t4l4.js} +469 -469
- package/client/assets/{feat-3497-CVVJjM2K.css → feat-3497-DEfIEPf6.css} +1 -1
- package/client/assets/{marp-C3oyTBz_.js → marp-CmMNIT1E.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +3 -3
- package/server/api/routes/files.ts +230 -89
- package/server/api/routes/pdf.ts +68 -62
- package/server/api/routes/runtime-plugin.ts +33 -32
- package/server/build/mcp-server.mjs +590 -590
- package/server/index.ts +27 -94
- package/server/plugins/confined-file-ops.ts +5 -3
- package/server/system/config.ts +1 -1
- package/server/utils/exif.ts +4 -7
- package/server/utils/files/artifactMount.ts +89 -0
- package/server/utils/files/attachment-store.ts +17 -29
- package/server/utils/files/image-store.ts +7 -13
- package/server/utils/files/plugins-io.ts +13 -5
- package/server/utils/files/safe.ts +0 -8
- package/server/utils/files/sendFileHandle.ts +115 -0
- package/server/utils/files/store-files.ts +86 -0
- package/server/utils/files/storePaths.ts +23 -0
- package/server/utils/files/thumbnail-store.ts +25 -23
- package/server/utils/files/upload-io.ts +9 -8
- package/server/utils/html/htmlArtifactSplicer.ts +19 -14
- package/server/utils/share/packHtml.ts +23 -34
- package/server/workspace/photo-locations/index.ts +26 -27
- package/server/workspace/photo-locations/list.ts +17 -29
- package/server/workspace/photo-locations/store.ts +4 -0
- package/server/workspace/wiki-pages/io.ts +9 -8
- package/server/utils/files/store-resolvers.ts +0 -58
package/server/api/routes/pdf.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { realpathSync } from "fs";
|
|
2
1
|
import path from "path";
|
|
3
2
|
import { Router, Request, Response } from "express";
|
|
4
3
|
import { randomUUID } from "node:crypto";
|
|
@@ -10,7 +9,8 @@ import puppeteer from "puppeteer";
|
|
|
10
9
|
import { errorMessage } from "../../utils/errors.js";
|
|
11
10
|
import { badRequest, serverError } from "../../utils/httpError.js";
|
|
12
11
|
import { WORKSPACE_DIRS } from "../../workspace/paths.js";
|
|
13
|
-
import {
|
|
12
|
+
import type { VerifiedScope } from "@mulmoclaude/core/files";
|
|
13
|
+
import { artifactScope, readArtifactFile } from "../../utils/files/artifactMount.js";
|
|
14
14
|
import { resolveWorkspacePath } from "../../utils/files/workspace-io.js";
|
|
15
15
|
import { parseFrontmatter } from "@mulmoclaude/markdown-utils/markdown/frontmatter";
|
|
16
16
|
import { log } from "../../system/logger/index.js";
|
|
@@ -66,21 +66,15 @@ const MIME_BY_EXT: Record<string, string> = {
|
|
|
66
66
|
".webp": "image/webp",
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
// Realpath of the workspace, resolved once at module load. Used to
|
|
70
|
-
// validate that image paths resolved relative to markdowns/ stay
|
|
71
|
-
// inside the workspace after symlink resolution.
|
|
72
|
-
const defaultWorkspaceRoot = realpathSync(resolveWorkspacePath(""));
|
|
73
|
-
|
|
74
69
|
export interface InlineImagesOptions {
|
|
75
|
-
/** Workspace root absolute path. Defaults to the
|
|
76
|
-
* realpath of the configured workspace. */
|
|
70
|
+
/** Workspace root absolute path. Defaults to the configured workspace. */
|
|
77
71
|
workspaceRoot?: string;
|
|
78
72
|
/** Workspace-relative directory the markdown source lives in,
|
|
79
73
|
* used to resolve `../foo.png`-style references. e.g.
|
|
80
74
|
* `"data/wiki/pages"` for Wiki page PDFs. Defaults to
|
|
81
75
|
* `WORKSPACE_DIRS.markdowns` for legacy callers. Inputs are
|
|
82
76
|
* rejected if they're absolute or contain `..` segments — the
|
|
83
|
-
* workspace boundary is enforced anyway by
|
|
77
|
+
* workspace boundary is enforced anyway by the verified read,
|
|
84
78
|
* but rejecting up-front gives a clearer log line than a
|
|
85
79
|
* silently-broken image. */
|
|
86
80
|
sourceDir?: string | undefined;
|
|
@@ -98,46 +92,38 @@ function isSafeSourceDir(dir: string): boolean {
|
|
|
98
92
|
return !dir.split(/[/\\]/).some((segment) => segment === "..");
|
|
99
93
|
}
|
|
100
94
|
|
|
101
|
-
// Resolve a workspace-rooted-or-relative `src` value to
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
function resolveImageAbsPath(src: string, workspaceRoot: string, baseDir: string): string | null {
|
|
95
|
+
// Resolve a workspace-rooted-or-relative `src` value to a path relative
|
|
96
|
+
// to the workspace root, lexically inside it. Returns null (and logs) on
|
|
97
|
+
// an escape attempt; whether the file exists and stays inside after links
|
|
98
|
+
// is decided by the verified read in `loadImageAsDataUri`.
|
|
99
|
+
function imageWorkspaceRelPath(src: string, workspaceRoot: string, baseDir: string): string | null {
|
|
107
100
|
// Strip query / fragment before any filesystem-level resolution —
|
|
108
101
|
// a `<img src="foo.png?v=123">` cache-bust must still find the
|
|
109
|
-
// file at `foo.png` on disk
|
|
110
|
-
// bakes the `?v=123` into the candidate path and the safe-resolve
|
|
111
|
-
// / readBinarySafeSync reject (codex review iter-2 #1028).
|
|
102
|
+
// file at `foo.png` on disk (codex review iter-2 #1028).
|
|
112
103
|
const pathPart = urlPathname(src);
|
|
113
104
|
// LLM-generated HTML often emits leading-slash workspace-rooted
|
|
114
105
|
// paths like "/artifacts/images/2026/04/foo.png" (web convention).
|
|
115
106
|
// Treat those as workspace-relative; otherwise path.resolve below
|
|
116
|
-
// sees the slash as host-absolute and the
|
|
107
|
+
// sees the slash as host-absolute and the check rejects it.
|
|
117
108
|
const workspaceRooted = pathPart.startsWith("/");
|
|
118
109
|
const resolveBase = workspaceRooted ? workspaceRoot : baseDir;
|
|
119
110
|
const relSrc = workspaceRooted ? pathPart.slice(1) : pathPart;
|
|
120
|
-
const
|
|
121
|
-
const relToWorkspace = path.relative(workspaceRoot, unsafeAbs);
|
|
111
|
+
const relToWorkspace = path.relative(workspaceRoot, path.resolve(resolveBase, relSrc));
|
|
122
112
|
if (relToWorkspace.startsWith("..") || path.isAbsolute(relToWorkspace)) {
|
|
123
113
|
log.warn("pdf", "image path escapes workspace", { src });
|
|
124
114
|
return null;
|
|
125
115
|
}
|
|
126
|
-
|
|
127
|
-
if (!abs) {
|
|
128
|
-
log.warn("pdf", "image path rejected by safe-resolve", { src });
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
return abs;
|
|
116
|
+
return relToWorkspace;
|
|
132
117
|
}
|
|
133
118
|
|
|
134
|
-
|
|
135
|
-
|
|
119
|
+
// Read through the verified handle (#3573), so a path swapped mid-call cannot reach outside the workspace.
|
|
120
|
+
async function loadImageAsDataUri(scope: VerifiedScope, relPath: string): Promise<string | null> {
|
|
121
|
+
const buf = await readArtifactFile(scope, relPath);
|
|
136
122
|
if (!buf) {
|
|
137
|
-
log.warn("pdf", "could not read image", {
|
|
123
|
+
log.warn("pdf", "could not read image", { relPath });
|
|
138
124
|
return null;
|
|
139
125
|
}
|
|
140
|
-
const ext = path.extname(
|
|
126
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
141
127
|
const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
142
128
|
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
143
129
|
}
|
|
@@ -167,7 +153,7 @@ function urlPathname(url: string): string {
|
|
|
167
153
|
|
|
168
154
|
/** Whether a URL points at a video / audio file that should NOT be
|
|
169
155
|
* base64-inlined into a PDF. Exported for direct unit testing —
|
|
170
|
-
* filesystem-level resolver checks (`
|
|
156
|
+
* filesystem-level resolver checks (`imageWorkspaceRelPath`) can mask
|
|
171
157
|
* the regex's behaviour because a missing file also returns null,
|
|
172
158
|
* so the in-line callback's output looks identical for "skip" vs
|
|
173
159
|
* "resolve-failed". */
|
|
@@ -186,46 +172,66 @@ export function shouldSkipMediaForPdf(url: string): boolean {
|
|
|
186
172
|
*
|
|
187
173
|
* Handles double-quoted, single-quoted, and unquoted values. Skips
|
|
188
174
|
* `data:` URIs and `http(s)` URLs. Refuses values that escape the
|
|
189
|
-
* workspace root
|
|
190
|
-
*
|
|
175
|
+
* workspace root, regardless of `sourceDir`, and reads every image
|
|
176
|
+
* through the verified handle.
|
|
191
177
|
*
|
|
192
178
|
* Tag + attribute coverage is shared with the browser markdown
|
|
193
179
|
* surface (`rewriteImgSrcAttrsInHtml`) via `RESOLVABLE_TAG_ATTRS` in
|
|
194
180
|
* `src/utils/image/htmlSrcAttrs.ts`.
|
|
195
181
|
*/
|
|
196
|
-
export function inlineImages(html: string, options: InlineImagesOptions = {}): string {
|
|
197
|
-
const workspaceRoot = options.workspaceRoot ??
|
|
182
|
+
export async function inlineImages(html: string, options: InlineImagesOptions = {}): Promise<string> {
|
|
183
|
+
const workspaceRoot = options.workspaceRoot ?? resolveWorkspacePath("");
|
|
184
|
+
const baseDir = path.join(workspaceRoot, inlineSourceDir(options.sourceDir));
|
|
185
|
+
const urls = collectInlinableUrls(html);
|
|
186
|
+
const scope = urls.size === 0 ? null : await artifactScope(workspaceRoot, "").catch(() => null);
|
|
187
|
+
const dataUris = new Map<string, string>();
|
|
188
|
+
if (scope !== null) {
|
|
189
|
+
await Promise.all(
|
|
190
|
+
[...urls].map(async (url) => {
|
|
191
|
+
const relPath = imageWorkspaceRelPath(url, workspaceRoot, baseDir);
|
|
192
|
+
const dataUri = relPath === null ? null : await loadImageAsDataUri(scope, relPath);
|
|
193
|
+
if (dataUri !== null) dataUris.set(url, dataUri);
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return transformResolvableUrlsInHtml(html, (url) => dataUris.get(url) ?? null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The URL transform is synchronous and a verified read is not, so the URLs are gathered first.
|
|
201
|
+
function collectInlinableUrls(html: string): Set<string> {
|
|
202
|
+
const urls = new Set<string>();
|
|
203
|
+
transformResolvableUrlsInHtml(html, (url) => {
|
|
204
|
+
if (isInlinableUrl(url)) urls.add(url);
|
|
205
|
+
return null;
|
|
206
|
+
});
|
|
207
|
+
return urls;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isInlinableUrl(url: string): boolean {
|
|
211
|
+
// Narrow to exact `http://` / `https://` prefixes so a relative
|
|
212
|
+
// path like `http-assets/logo.png` isn't misclassified as
|
|
213
|
+
// external (CR follow-up on #1023).
|
|
214
|
+
if (url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) return false;
|
|
215
|
+
// Skip media (mp4 / mp3 / webm / ...) — see PDF_SKIP_MEDIA_EXT_RE.
|
|
216
|
+
// `<video poster="x.png">` still inlines because the poster value's
|
|
217
|
+
// pathname ends in an image extension (codex iter-1).
|
|
218
|
+
return !shouldSkipMediaForPdf(url);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function inlineSourceDir(sourceDirOption: unknown): string {
|
|
198
222
|
// Defensive type guard: a malformed request body could send
|
|
199
223
|
// `baseDir: null` / `baseDir: 42` / etc. Coerce anything non-
|
|
200
224
|
// string to undefined so the legacy default kicks in instead of
|
|
201
225
|
// `path.join` throwing on a non-string.
|
|
202
|
-
const requestedDir = typeof
|
|
226
|
+
const requestedDir = typeof sourceDirOption === "string" ? sourceDirOption : undefined;
|
|
203
227
|
// Distinguish "explicitly empty" (= workspace root, e.g. a top-
|
|
204
228
|
// level `README.md`) from "absent" (= legacy `markdowns/` default
|
|
205
229
|
// for chat callers). Without this, the `||` collapse would route
|
|
206
230
|
// every workspace-root file through the legacy default.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
const sourceDir = dirIsSafe && hasRequestedDir ? requestedDir : WORKSPACE_DIRS.markdowns;
|
|
213
|
-
const baseDir = path.join(workspaceRoot, sourceDir);
|
|
214
|
-
return transformResolvableUrlsInHtml(html, (url) => {
|
|
215
|
-
// Narrow to exact `http://` / `https://` prefixes so a relative
|
|
216
|
-
// path like `http-assets/logo.png` isn't misclassified as
|
|
217
|
-
// external (CR follow-up on #1023).
|
|
218
|
-
if (url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) return null;
|
|
219
|
-
// Skip media (mp4 / mp3 / webm / ...) — see PDF_SKIP_MEDIA_EXT_RE
|
|
220
|
-
// comment. `<video poster="x.png">` still inlines because the
|
|
221
|
-
// poster value's pathname ends in an image extension. The
|
|
222
|
-
// pathname slice means `foo.png?cacheBust=clip.mp4` correctly
|
|
223
|
-
// routes through the PNG inline path (codex iter-1).
|
|
224
|
-
if (shouldSkipMediaForPdf(url)) return null;
|
|
225
|
-
const abs = resolveImageAbsPath(url, workspaceRoot, baseDir);
|
|
226
|
-
if (!abs) return null;
|
|
227
|
-
return loadImageAsDataUri(abs);
|
|
228
|
-
});
|
|
231
|
+
if (requestedDir === undefined) return WORKSPACE_DIRS.markdowns;
|
|
232
|
+
if (isSafeSourceDir(requestedDir)) return requestedDir;
|
|
233
|
+
log.warn("pdf", "rejecting unsafe sourceDir, falling back to default", { sourceDir: requestedDir });
|
|
234
|
+
return WORKSPACE_DIRS.markdowns;
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
// This document is `marked.parse` output with NO sanitiser — unlike every
|
|
@@ -305,7 +311,7 @@ async function renderMarpHtml(markdown: string, baseDir?: string): Promise<{ ful
|
|
|
305
311
|
themes: listMarpThemes(),
|
|
306
312
|
inlineSVG: true,
|
|
307
313
|
});
|
|
308
|
-
const inlinedHtml = inlineImages(html, { sourceDir: baseDir });
|
|
314
|
+
const inlinedHtml = await inlineImages(html, { sourceDir: baseDir });
|
|
309
315
|
const fullHtml = `<!doctype html>
|
|
310
316
|
<html><head><meta charset="utf-8"><style>
|
|
311
317
|
html,body { margin:0; padding:0; background:white; }
|
|
@@ -388,7 +394,7 @@ export async function renderMarkdownHtml(options: RenderMarkdownHtmlOptions): Pr
|
|
|
388
394
|
const { markdown, marp = false, baseDir, stripFrontmatter = false } = options;
|
|
389
395
|
if (marp) return (await renderMarpHtml(markdown, baseDir)).fullHtml;
|
|
390
396
|
const source = stripFrontmatter ? parseFrontmatter(markdown).body : markdown;
|
|
391
|
-
return wrapHtml(inlineImages(await marked.parse(source), { sourceDir: baseDir }), MARKDOWN_CSS);
|
|
397
|
+
return wrapHtml(await inlineImages(await marked.parse(source), { sourceDir: baseDir }), MARKDOWN_CSS);
|
|
392
398
|
}
|
|
393
399
|
|
|
394
400
|
/** Render markdown (or a Marp deck) to a PDF buffer. The single code
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
// routes means the plugin isn't installed (or failed to load — see
|
|
31
31
|
// boot logs).
|
|
32
32
|
|
|
33
|
-
import { realpathSync } from "node:fs";
|
|
34
33
|
import { Router, type Request, type Response } from "express";
|
|
35
34
|
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
36
35
|
import { getRuntimePluginByOauthAlias, getRuntimePlugins } from "../../plugins/runtime-registry.js";
|
|
@@ -39,8 +38,10 @@ import { notFound, serverError } from "../../utils/httpError.js";
|
|
|
39
38
|
import { errorMessage } from "../../utils/errors.js";
|
|
40
39
|
import { escapeHtml } from "@mulmoclaude/core/wiki";
|
|
41
40
|
import { isRecord } from "../../utils/types.js";
|
|
42
|
-
import {
|
|
41
|
+
import { realpathIfPresent, scopeRealpath, type VerifiedScope } from "@mulmoclaude/core/files";
|
|
42
|
+
import { lexicallyInside } from "../../utils/files/artifactMount.js";
|
|
43
43
|
import { readPluginAsset } from "../../utils/files/plugins-io.js";
|
|
44
|
+
import { workspacePath } from "../../workspace/workspace.js";
|
|
44
45
|
import { log } from "../../system/logger/index.js";
|
|
45
46
|
import { singleLineForLog } from "../../utils/logPreview.js";
|
|
46
47
|
|
|
@@ -185,28 +186,28 @@ function renderFallbackCallbackHtml(title: string, body: string): string {
|
|
|
185
186
|
// this is the trust boundary that prevents arbitrary-file reads
|
|
186
187
|
// via percent-encoded `../` in `pkg` / `version` (the bearer-
|
|
187
188
|
// auth exemption makes this an unauthenticated path).
|
|
188
|
-
// 2.
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
* Returns null when the (pkg, version) pair is not registered, when
|
|
200
|
-
* the cachePath does not exist on disk, or when realpath fails.
|
|
201
|
-
* Exported for tests. */
|
|
202
|
-
export function resolvePluginRoot(pkg: string, version: string): string | null {
|
|
189
|
+
// 2. Serve from a verified scope. A plugin installed into the
|
|
190
|
+
// workspace's plugin cache is anchored at the workspace, so a
|
|
191
|
+
// cache folder replaced by a link out of the workspace is refused
|
|
192
|
+
// instead of becoming the root (#3573). A preset, under the app's
|
|
193
|
+
// node_modules, is anchored at its own realpath. Symlinks inside
|
|
194
|
+
// the extracted tree (e.g. dist/foo.js → /etc/passwd) cannot
|
|
195
|
+
// escape, and the bytes come from a handle proven to be the
|
|
196
|
+
// in-scope file, so a path swapped mid-request is not followed.
|
|
197
|
+
/** The verified scope of a registered plugin's assets; null when the (pkg, version) pair is not registered or its
|
|
198
|
+
* anchor cannot be resolved. Exported for tests. */
|
|
199
|
+
export async function pluginAssetScope(pkg: string, version: string): Promise<VerifiedScope | null> {
|
|
203
200
|
const plugin = getRuntimePlugins().find((entry) => entry.name === pkg && entry.version === version);
|
|
204
201
|
if (!plugin) return null;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
202
|
+
const withinWorkspace = lexicallyInside(workspacePath, plugin.cachePath);
|
|
203
|
+
const trustedReal = await realpathIfPresent(withinWorkspace === null ? plugin.cachePath : workspacePath).catch(() => null);
|
|
204
|
+
return trustedReal === null ? null : { trustedReal, scopeWithinTrusted: withinWorkspace ?? "" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The in-scope relative path of a request, or null when the plugin's folder is gone or led out, or `subPath` escapes.
|
|
208
|
+
async function assetRelPath(scope: VerifiedScope, subPath: string): Promise<string | null> {
|
|
209
|
+
const scopeReal = await scopeRealpath(scope).catch(() => null);
|
|
210
|
+
return scopeReal === null ? null : lexicallyInside(scopeReal, subPath);
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
router.get(API_ROUTES.plugins.runtimeAsset, async (req: Request<{ pkg: string; version: string; splat?: string | string[] }>, res: Response) => {
|
|
@@ -218,20 +219,20 @@ router.get(API_ROUTES.plugins.runtimeAsset, async (req: Request<{ pkg: string; v
|
|
|
218
219
|
// path so downstream `path.join` works on every shape.
|
|
219
220
|
const rawSplat = req.params.splat;
|
|
220
221
|
const subPath = Array.isArray(rawSplat) ? rawSplat.join("/") : (rawSplat ?? "");
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
const resolved = resolveWithinRoot(rootReal, subPath);
|
|
227
|
-
if (!resolved) {
|
|
222
|
+
const scope = await pluginAssetScope(pkg, version);
|
|
223
|
+
const relPath = scope === null ? null : await assetRelPath(scope, subPath);
|
|
224
|
+
if (scope === null || relPath === null) {
|
|
228
225
|
notFound(res, "asset not found");
|
|
229
226
|
return;
|
|
230
227
|
}
|
|
231
228
|
try {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
229
|
+
const asset = await readPluginAsset(scope, relPath);
|
|
230
|
+
if (asset === null) {
|
|
231
|
+
notFound(res, "asset not found");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
res.setHeader("Content-Type", asset.contentType);
|
|
235
|
+
res.send(asset.data);
|
|
235
236
|
} catch (err) {
|
|
236
237
|
log.error(LOG_PREFIX, "asset read failed", {
|
|
237
238
|
pkg: singleLineForLog(pkg),
|