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.
Files changed (30) hide show
  1. package/client/assets/{feat-3497-qPLQlhNs.js → feat-3497-Cwr8t4l4.js} +469 -469
  2. package/client/assets/{feat-3497-CVVJjM2K.css → feat-3497-DEfIEPf6.css} +1 -1
  3. package/client/assets/{marp-C3oyTBz_.js → marp-CmMNIT1E.js} +1 -1
  4. package/client/index.html +2 -2
  5. package/package.json +3 -3
  6. package/server/api/routes/files.ts +230 -89
  7. package/server/api/routes/pdf.ts +68 -62
  8. package/server/api/routes/runtime-plugin.ts +33 -32
  9. package/server/build/mcp-server.mjs +590 -590
  10. package/server/index.ts +27 -94
  11. package/server/plugins/confined-file-ops.ts +5 -3
  12. package/server/system/config.ts +1 -1
  13. package/server/utils/exif.ts +4 -7
  14. package/server/utils/files/artifactMount.ts +89 -0
  15. package/server/utils/files/attachment-store.ts +17 -29
  16. package/server/utils/files/image-store.ts +7 -13
  17. package/server/utils/files/plugins-io.ts +13 -5
  18. package/server/utils/files/safe.ts +0 -8
  19. package/server/utils/files/sendFileHandle.ts +115 -0
  20. package/server/utils/files/store-files.ts +86 -0
  21. package/server/utils/files/storePaths.ts +23 -0
  22. package/server/utils/files/thumbnail-store.ts +25 -23
  23. package/server/utils/files/upload-io.ts +9 -8
  24. package/server/utils/html/htmlArtifactSplicer.ts +19 -14
  25. package/server/utils/share/packHtml.ts +23 -34
  26. package/server/workspace/photo-locations/index.ts +26 -27
  27. package/server/workspace/photo-locations/list.ts +17 -29
  28. package/server/workspace/photo-locations/store.ts +4 -0
  29. package/server/workspace/wiki-pages/io.ts +9 -8
  30. package/server/utils/files/store-resolvers.ts +0 -58
@@ -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 { resolveWithinRoot, readBinarySafeSync } from "../../utils/files/safe.js";
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 lazily-resolved
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 `resolveWithinRoot`,
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 an absolute
102
- // path on disk, validated to stay inside the workspace root. Returns
103
- // null on any failure (escape attempt, missing file, malformed path).
104
- // Logs the reason so the developer can grep when a PDF image is
105
- // missing.
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. Without this strip, `path.resolve`
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 safe-resolve rejects.
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 unsafeAbs = path.resolve(resolveBase, relSrc);
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
- const abs = resolveWithinRoot(workspaceRoot, relToWorkspace);
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
- function loadImageAsDataUri(abs: string): string | null {
135
- const buf = readBinarySafeSync(abs);
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", { abs });
123
+ log.warn("pdf", "could not read image", { relPath });
138
124
  return null;
139
125
  }
140
- const ext = path.extname(abs).toLowerCase();
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 (`resolveImageAbsPath`) can mask
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 after resolution — the workspace boundary is
190
- * enforced by `resolveWithinRoot`, regardless of `sourceDir`.
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 ?? defaultWorkspaceRoot;
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 options.sourceDir === "string" ? options.sourceDir : undefined;
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
- const hasRequestedDir = requestedDir !== undefined;
208
- const dirIsSafe = !hasRequestedDir || isSafeSourceDir(requestedDir);
209
- if (hasRequestedDir && !dirIsSafe) {
210
- log.warn("pdf", "rejecting unsafe sourceDir, falling back to default", { sourceDir: requestedDir });
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 { resolveWithinRoot } from "../../utils/files/safe.js";
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. realpath the registered cachePath. Symlinks inside the
189
- // extracted tree (e.g. dist/foo.js → /etc/passwd) cannot escape
190
- // because `resolveWithinRoot(rootReal, subPath)` rejects any
191
- // target that resolves outside the plugin's own root.
192
- //
193
- // The earlier "must be inside WORKSPACE_PATHS.pluginCache" anchor is
194
- // gone — presets live under `node_modules/<pkg>/`, not in the
195
- // workspace cache. The registry-membership check replaces that
196
- // anchor: the registry is server-set, so its cachePath values are
197
- // already trusted regardless of where on disk they point.
198
- /** Look up a registered plugin and return the realpath of its root.
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
- try {
206
- return realpathSync(plugin.cachePath);
207
- } catch {
208
- return null;
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 rootReal = resolvePluginRoot(pkg, version);
222
- if (!rootReal) {
223
- notFound(res, "asset not found");
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 { data, contentType } = await readPluginAsset(resolved);
233
- res.setHeader("Content-Type", contentType);
234
- res.send(data);
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),