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
package/server/index.ts CHANGED
@@ -104,7 +104,7 @@ import { initCollectionChangePublisher } from "./events/collection-change.js";
104
104
  import { initPhotoLocationsChangePublisher } from "./events/photo-locations-change.js";
105
105
  import { getRole, loadAllRoles } from "./workspace/roles.js";
106
106
  import { discoverSkills } from "./workspace/skills/index.js";
107
- import { WORKSPACE_PATHS } from "./workspace/paths.js";
107
+ import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "./workspace/paths.js";
108
108
  import { resolveClientDir } from "./utils/clientDir.js";
109
109
  import { serverError } from "./utils/httpError.js";
110
110
  import { browserVisibleOrigin } from "./utils/forwardedOrigin.js";
@@ -121,7 +121,8 @@ import { buildSandboxStatus } from "./api/sandboxStatus.js";
121
121
  import { buildDiagnosticsMarkdown } from "./utils/diagnostics/collect.js";
122
122
  import { existsSync, readFileSync } from "fs";
123
123
  import { realpath as fsRealpath, stat as fsStat } from "fs/promises";
124
- import { makeCachedRealpath, resolveArtifactRequestPath } from "./utils/files/safe.js";
124
+ import { makeCachedRealpath } from "./utils/files/safe.js";
125
+ import { artifactMount, sendArtifactFile } from "./utils/files/artifactMount.js";
125
126
  import { cpus, loadavg } from "os";
126
127
  import { isDockerAvailable, ensureSandboxImage } from "./system/docker.js";
127
128
  import { maybeRunJournal } from "./workspace/journal/index.js";
@@ -387,7 +388,7 @@ app.use("/api", (req, res, next) => {
387
388
  // requireSameOrigin guard above still applies; the listener also
388
389
  // stays loopback-only.
389
390
  //
390
- // Three-layer guard:
391
+ // Guard:
391
392
  // 1. Extension allowlist — reject anything that isn't an image,
392
393
  // video, or audio extension. `saveImage` currently writes `.png`
393
394
  // only, but Stage B (#1011) extends the markdown / wiki rewriter
@@ -396,36 +397,14 @@ app.use("/api", (req, res, next) => {
396
397
  // `artifacts/images/<id>.mp4` (or any user-dropped media file
397
398
  // under that dir) needs to round-trip through this mount the
398
399
  // same way image refs do — otherwise the rewritten URL hits this
399
- // mount and 404s before `express.static` gets a chance.
400
- // 2. realpath-based traversal check via `resolveWithinRoot` — same
401
- // guard `/api/files/raw` uses. Catches symlinks pointing outside
402
- // the images dir, which `express.static` would otherwise follow.
403
- // 3. `dotfiles: deny` + `fallthrough: false` on `express.static`
404
- // itself, plus its built-in `..` normalize for path traversal.
400
+ // mount and 404s.
401
+ // 2. `artifactMount` (server/utils/files/artifactMount.ts): the mount
402
+ // folder must resolve inside the workspace on every request,
403
+ // dotfile / traversal segments are refused, and the file is served
404
+ // from a handle verified to be the in-folder file — never re-opened
405
+ // by path, so a symlink swapped in mid-request is not followed.
405
406
  const IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|svg|mp4|webm|mov|m4v|ogv|mp3|ogg|oga|wav|m4a|aac)$/i;
406
- const getImagesDirReal = makeCachedRealpath(WORKSPACE_PATHS.images);
407
- app.use(
408
- "/artifacts/images",
409
- async (req, res, next) => {
410
- if (!IMAGE_EXT_RE.test(req.path)) {
411
- res.status(404).end();
412
- return;
413
- }
414
- const root = await getImagesDirReal();
415
- if (!root) {
416
- res.status(404).end();
417
- return;
418
- }
419
- // No explicit dotfile check here: this mount never short-circuits,
420
- // so `express.static`'s `dotfiles: "deny"` below is the authority.
421
- if (resolveArtifactRequestPath(root, req.path, false) === null) {
422
- res.status(404).end();
423
- return;
424
- }
425
- next();
426
- },
427
- express.static(WORKSPACE_PATHS.images, { dotfiles: "deny", fallthrough: false }),
428
- );
407
+ app.use("/artifacts/images", artifactMount(workspacePath, WORKSPACE_DIRS.images, IMAGE_EXT_RE));
429
408
 
430
409
  // Static mount for HTML artifacts. The Files-view preview iframe
431
410
  // switched from `srcdoc` to `src=/artifacts/html/<name>.html` so the
@@ -444,10 +423,7 @@ app.use(
444
423
  // (`previewCsp.ts`) so allowing those extensions would only delivery-
445
424
  // vector for blocked resources.
446
425
  //
447
- // Same three-layer guard as `/artifacts/images`:
448
- // 1. extension allowlist (`.html` / `.htm` plus image types).
449
- // 2. `resolveWithinRoot` symlink-aware traversal check.
450
- // 3. `dotfiles: deny` + `fallthrough: false` on `express.static`.
426
+ // Same guard as `/artifacts/images` (extension allowlist + `artifactMount`).
451
427
  //
452
428
  // Bearer auth skipped for the same reason as /artifacts/images and
453
429
  // /api/files/*: an iframe `src` request can't carry an Authorization
@@ -474,47 +450,23 @@ app.use(
474
450
  // eslint-disable-next-line sonarjs/regex-complexity -- flat extension allowlist with no nested quantifiers, ReDoS-safe; complexity is just the disjunction count
475
451
  const HTML_PREVIEW_EXT_RE = /\.(html?|png|jpe?g|webp|gif|svg|ico|mp4|webm|mov|m4v|ogv|mp3|ogg|oga|wav|m4a|aac)$/i;
476
452
  const HTML_DOCUMENT_EXT_RE = /\.html?$/i;
477
- const getHtmlsDirReal = makeCachedRealpath(WORKSPACE_PATHS.htmls);
478
-
479
453
  app.use(
480
454
  "/artifacts/html",
481
- async (req, res, next) => {
482
- if (!HTML_PREVIEW_EXT_RE.test(req.path)) {
483
- res.status(404).end();
484
- return;
485
- }
486
- const root = await getHtmlsDirReal();
487
- if (!root) {
488
- res.status(404).end();
455
+ artifactMount(workspacePath, WORKSPACE_DIRS.htmls, HTML_PREVIEW_EXT_RE, async (req, res, scope, relPath) => {
456
+ res.setHeader("X-Content-Type-Options", "nosniff");
457
+ if (!HTML_DOCUMENT_EXT_RE.test(req.path)) {
458
+ await sendArtifactFile(req, res, scope, relPath);
489
459
  return;
490
460
  }
491
- // denyDotfiles: the HTML short-circuit below serves the file itself
492
- // (bypassing `express.static`'s `dotfiles: "deny"`), so this mount
493
- // must reject dotfile segments in the guard — without it,
494
- // `/artifacts/html/.hidden.html` would be served (Codex review on
495
- // #1056).
496
- const relPath = resolveArtifactRequestPath(root, req.path, true);
497
- if (relPath === null) {
461
+ res.setHeader("Content-Security-Policy", buildHtmlPreviewCsp(browserVisibleOrigin(req), undefined, readCspExtraSync()));
462
+ const spliced = await readAndInjectHtmlArtifact(scope, relPath);
463
+ if (spliced === null) {
498
464
  res.status(404).end();
499
465
  return;
500
466
  }
501
- if (HTML_DOCUMENT_EXT_RE.test(req.path)) {
502
- const origin = browserVisibleOrigin(req);
503
- res.setHeader("Content-Security-Policy", buildHtmlPreviewCsp(origin, undefined, readCspExtraSync()));
504
- res.setHeader("X-Content-Type-Options", "nosniff");
505
- const spliced = await readAndInjectHtmlArtifact(root, relPath);
506
- if (spliced === null) {
507
- res.status(404).end();
508
- return;
509
- }
510
- res.setHeader("Content-Type", "text/html; charset=utf-8");
511
- res.send(spliced);
512
- return;
513
- }
514
- res.setHeader("X-Content-Type-Options", "nosniff");
515
- next();
516
- },
517
- express.static(WORKSPACE_PATHS.htmls, { dotfiles: "deny", fallthrough: false }),
467
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
468
+ res.send(spliced);
469
+ }),
518
470
  );
519
471
 
520
472
  // Mount for HTML pages OUTSIDE `artifacts/html/` — presentHtml's `path` form
@@ -601,39 +553,20 @@ app.use(HTML_FILE_MOUNT, htmlFileRateLimit, async (req, res, next) => {
601
553
  // header doesn't interfere with the normal View / Preview path.
602
554
  const SVG_RESPONSE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox";
603
555
 
604
- // Strict three-layer guard mirroring the `/artifacts/images` mount:
605
- // extension allowlist, realpath traversal check, dotfiles deny +
606
- // fallthrough false on `express.static`. Bearer auth bypassed for the
556
+ // Same guard as `/artifacts/images` (extension allowlist + `artifactMount`).
557
+ // Bearer auth bypassed for the
607
558
  // same reason as `/artifacts/images` / `/artifacts/html`: an
608
559
  // `<img src>` request can't carry an Authorization header. Loopback-
609
560
  // only listener + `requireSameOrigin` remain the trust boundary.
610
561
  const SVG_EXT_RE = /\.svg$/i;
611
- const getSvgsDirReal = makeCachedRealpath(WORKSPACE_PATHS.svgs);
612
562
  app.use(
613
563
  "/artifacts/svg",
614
- async (req, res, next) => {
615
- if (!SVG_EXT_RE.test(req.path)) {
616
- res.status(404).end();
617
- return;
618
- }
619
- const root = await getSvgsDirReal();
620
- if (!root) {
621
- res.status(404).end();
622
- return;
623
- }
624
- // denyDotfiles for the same reason as the html mount: this handler
625
- // sets headers and serves via a bearer-bypassed <img> path, so the
626
- // dotfile guard can't be left solely to `express.static`.
627
- if (resolveArtifactRequestPath(root, req.path, true) === null) {
628
- res.status(404).end();
629
- return;
630
- }
564
+ artifactMount(workspacePath, WORKSPACE_DIRS.svgs, SVG_EXT_RE, async (req, res, scope, relPath) => {
631
565
  res.setHeader("X-Content-Type-Options", "nosniff");
632
566
  res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
633
567
  res.setHeader("Content-Security-Policy", SVG_RESPONSE_CSP);
634
- next();
635
- },
636
- express.static(WORKSPACE_PATHS.svgs, { dotfiles: "deny", fallthrough: false }),
568
+ await sendArtifactFile(req, res, scope, relPath);
569
+ }),
637
570
  );
638
571
 
639
572
  app.get(API_ROUTES.health, (_req: Request, res: Response) => {
@@ -22,8 +22,8 @@ import { ensureInsideBase } from "./runtime-loader.js";
22
22
  //
23
23
  // 1. Replace `\` with `/` (Windows path.join leak repair).
24
24
  // 2. `path.posix.normalize` (folds `..`, `.`, `//`).
25
- // 3. Reject if the normalised form starts with `..` or `/`
26
- // (would escape the scope root or be absolute).
25
+ // 3. Reject if the normalised form starts with `..`, `/` or a
26
+ // drive letter (would escape the scope root or be absolute).
27
27
  // 4. `path.join(scopeRoot, ...segments)` — platform-aware join so
28
28
  // the returned absolute path uses the host OS separator
29
29
  // (backslashes on Windows, forward slashes on POSIX). Critical
@@ -36,11 +36,13 @@ import { ensureInsideBase } from "./runtime-loader.js";
36
36
  /** Resolve a plugin-supplied path to an absolute path inside the
37
37
  * plugin's scope root. Throws on traversal. Lexical only — symlinks
38
38
  * are checked by the FileOps below. Exported for tests. */
39
+ const WINDOWS_DRIVE_RE = /^[a-zA-Z]:/;
40
+
39
41
  export function normalizePluginPath(scopeRoot: string, rel: string): string {
40
42
  const slashed = rel.replace(/\\/g, "/");
41
43
  const normalised = path.posix.normalize(slashed);
42
44
  // `path.posix.normalize` produces `..` alone if the input is `..` itself, hence the equality branch.
43
- if (normalised === ".." || normalised.startsWith("../") || normalised.startsWith("/")) {
45
+ if (normalised === ".." || normalised.startsWith("../") || normalised.startsWith("/") || WINDOWS_DRIVE_RE.test(normalised)) {
44
46
  throw new Error(`path escapes plugin scope: ${rel}`);
45
47
  }
46
48
  const segments = normalised === "." ? [] : normalised.split("/");
@@ -55,7 +55,7 @@ export interface AppSettings {
55
55
 
56
56
  // Photo-EXIF auto-capture (#1222 PR-A). When `autoCapture` is true
57
57
  // (the default), every saved attachment with an image MIME runs
58
- // through `readPhotoExif` and a sidecar JSON lands at
58
+ // through `parsePhotoExif` and a sidecar JSON lands at
59
59
  // `data/locations/<YYYY>/<MM>/<id>.json`. Users opt out via
60
60
  // Settings → Privacy when they don't want GPS metadata captured
61
61
  // automatically (the future `manageMap` `extractExif` tool can
@@ -15,7 +15,6 @@
15
15
  // extracted coords straight to the Google Map plugin without a
16
16
  // reshape (#1227).
17
17
 
18
- import { readFile } from "fs/promises";
19
18
  import exifr from "exifr";
20
19
  import { isRecord } from "./types.js";
21
20
 
@@ -159,17 +158,15 @@ export type ExifParser = (buf: Buffer) => Promise<unknown>;
159
158
 
160
159
  const defaultParser: ExifParser = (buf) => exifr.parse(buf, PARSE_OPTIONS);
161
160
 
162
- /** Parse a photo file and project the fields we care about. Returns
163
- * `null` when the file has no parseable EXIF (screenshots, scrubbed
161
+ /** Parse a photo's bytes and project the fields we care about. Returns
162
+ * `null` when they hold no parseable EXIF (screenshots, scrubbed
164
163
  * uploads, non-image MIME types, malformed JPEG). Never throws. */
165
- export async function readPhotoExif(absPath: string, parser: ExifParser = defaultParser): Promise<PhotoExif | null> {
164
+ export async function parsePhotoExif(buf: Buffer, parser: ExifParser = defaultParser): Promise<PhotoExif | null> {
166
165
  let raw: unknown;
167
166
  try {
168
- const buf = await readFile(absPath);
169
167
  raw = await parser(buf);
170
168
  } catch {
171
- // Includes both fs errors (handler should have ensured the file
172
- // exists) and exifr "couldn't find any EXIF data" rejections.
169
+ // exifr rejects with "couldn't find any EXIF data" on photos without it.
173
170
  return null;
174
171
  }
175
172
  if (!isRecord(raw)) return null;
@@ -0,0 +1,89 @@
1
+ // The `/artifacts/*` mounts serve a file only while it sits inside the workspace: the mount folder is
2
+ // re-resolved under the workspace on every request, so a folder replaced by a link out is a 404
3
+ // rather than the new root, and the file is served from a handle proven to be the in-scope file
4
+ // (plans/fix-3573-artifact-mounts.md).
5
+
6
+ import path from "node:path";
7
+ import type { FileHandle } from "node:fs/promises";
8
+ import type { Request, RequestHandler, Response } from "express";
9
+ import { openVerifiedWithinScope, realpathIfPresent, scopeRealpath, type VerifiedScope } from "@mulmoclaude/core/files";
10
+ import { log } from "../../system/logger/index.js";
11
+ import { resolveArtifactRequestPath } from "./safe.js";
12
+ import { sendFileHandle } from "./sendFileHandle.js";
13
+
14
+ /** `mountDir` is the mount folder relative to the workspace, e.g. `artifacts/images`; `""` is the workspace itself. */
15
+ export async function artifactScope(workspaceRoot: string, mountDir: string): Promise<VerifiedScope | null> {
16
+ const trustedReal = await realpathIfPresent(workspaceRoot);
17
+ return trustedReal === null ? null : { trustedReal, scopeWithinTrusted: mountDir };
18
+ }
19
+
20
+ /** The decoded in-scope path of a request, or null when the mount folder is absent or leads out of the
21
+ * workspace, or the URL is malformed, escapes, or touches a dotfile. */
22
+ export async function artifactRequestPath(scope: VerifiedScope, reqPath: string): Promise<string | null> {
23
+ const scopeReal = await scopeRealpath(scope);
24
+ const decoded = scopeReal === null ? null : resolveArtifactRequestPath(scopeReal, reqPath, true);
25
+ return scopeReal === null || decoded === null ? null : lexicallyInside(scopeReal, decoded);
26
+ }
27
+
28
+ // The `path.relative` form is the containment check CodeQL recognises before the path reaches realpath.
29
+ export function lexicallyInside(scopeReal: string, relPath: string): string | null {
30
+ const inside = path.relative(scopeReal, path.resolve(scopeReal, relPath));
31
+ return inside === "" || inside.startsWith("..") || path.isAbsolute(inside) ? null : inside;
32
+ }
33
+
34
+ /** A verified handle on a regular file of the scope; null for anything else. The caller closes it. */
35
+ export async function openArtifactFile(scope: VerifiedScope, relPath: string): Promise<FileHandle | null> {
36
+ const handle = await openVerifiedWithinScope(scope, relPath);
37
+ if (handle === null) return null;
38
+ const isFile = await handle.stat().then(
39
+ (stats) => stats.isFile(),
40
+ () => false,
41
+ );
42
+ if (isFile) return handle;
43
+ await handle.close();
44
+ return null;
45
+ }
46
+
47
+ /** Bytes of a regular file of the scope, read through the verified handle; null for anything else or on any error. */
48
+ export async function readArtifactFile(scope: VerifiedScope, relPath: string): Promise<Buffer | null> {
49
+ try {
50
+ const handle = await openArtifactFile(scope, relPath);
51
+ if (handle === null) return null;
52
+ try {
53
+ return await handle.readFile();
54
+ } finally {
55
+ await handle.close();
56
+ }
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export type ArtifactResponder = (req: Request, res: Response, scope: VerifiedScope, relPath: string) => Promise<void>;
63
+
64
+ export const sendArtifactFile: ArtifactResponder = async (req, res, scope, relPath) => {
65
+ const handle = await openArtifactFile(scope, relPath);
66
+ if (handle === null) {
67
+ res.status(404).end();
68
+ return;
69
+ }
70
+ await sendFileHandle(req, res, handle, relPath);
71
+ };
72
+
73
+ /** A mount over `workspaceRoot/mountDir` that answers requests matching `extension`; everything else is a 404. */
74
+ export function artifactMount(workspaceRoot: string, mountDir: string, extension: RegExp, respond: ArtifactResponder = sendArtifactFile): RequestHandler {
75
+ return async (req, res) => {
76
+ try {
77
+ const scope = extension.test(req.path) ? await artifactScope(workspaceRoot, mountDir) : null;
78
+ const relPath = scope === null ? null : await artifactRequestPath(scope, req.path);
79
+ if (scope === null || relPath === null) {
80
+ res.status(404).end();
81
+ return;
82
+ }
83
+ await respond(req, res, scope, relPath);
84
+ } catch (err) {
85
+ log.warn("artifacts", "could not serve an artifact", { mountDir, path: req.path, error: String(err) });
86
+ if (!res.headersSent) res.status(404).end();
87
+ }
88
+ };
89
+ }
@@ -10,17 +10,15 @@
10
10
  // data/attachments/YYYY/MM/<id>.<ext> (original, always)
11
11
  // data/attachments/YYYY/MM/<id>.pdf (companion, PPTX only — same <id>)
12
12
 
13
- import { readFile, stat } from "fs/promises";
14
13
  import path from "path";
15
14
  import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../../workspace/paths.js";
16
15
  import { shortId } from "../id.js";
17
- import { writeFileAtomic } from "./atomic.js";
18
16
  import { yearMonthUtc } from "@mulmoclaude/core/artifacts";
19
17
  import { makePathValidator } from "./path-validator.js";
20
- import { makeStoreResolvers } from "./store-resolvers.js";
18
+ import { makeStoreFiles } from "./store-files.js";
21
19
  import { storedExtensionFor } from "./attachment-mime.js";
22
20
 
23
- const resolvers = makeStoreResolvers(() => WORKSPACE_PATHS.attachments, WORKSPACE_DIRS.attachments);
21
+ const store = makeStoreFiles(() => WORKSPACE_PATHS.attachments, WORKSPACE_DIRS.attachments);
24
22
 
25
23
  export interface SavedAttachment {
26
24
  /** Workspace-relative path of the file written to disk. */
@@ -31,14 +29,15 @@ export interface SavedAttachment {
31
29
  }
32
30
 
33
31
  /** Post-save hook fired after every successful `saveAttachment`.
34
- * Receives the absolute on-disk path, the workspace-relative path
35
- * (the same one returned to the caller), and the MIME type the
36
- * bytes were saved with. Hooks must NEVER throw — the upload is
37
- * already on disk by the time the hook runs, so a failure should
32
+ * Receives the workspace-relative path (the same one returned to the
33
+ * caller) and the MIME type the bytes were saved with — no absolute
34
+ * path, so a hook reads the bytes back through the store. Hooks must
35
+ * NEVER throw — the upload is already on disk by the time the hook
36
+ * runs, so a failure should
38
37
  * log and return, not propagate. Multiple hooks fan out via
39
38
  * `Promise.allSettled` so one slow / failing hook can't block or
40
39
  * break the others. (#1222 PR-A.) */
41
- export type SaveAttachmentHook = (absPath: string, relativePath: string, mimeType: string) => Promise<void>;
40
+ export type SaveAttachmentHook = (relativePath: string, mimeType: string) => Promise<void>;
42
41
 
43
42
  const saveAttachmentHooks: SaveAttachmentHook[] = [];
44
43
 
@@ -54,14 +53,14 @@ export function registerSaveAttachmentHook(hook: SaveAttachmentHook): () => void
54
53
  };
55
54
  }
56
55
 
57
- async function runSaveAttachmentHooks(absPath: string, relativePath: string, mimeType: string): Promise<void> {
56
+ async function runSaveAttachmentHooks(relativePath: string, mimeType: string): Promise<void> {
58
57
  if (saveAttachmentHooks.length === 0) return;
59
58
  // Snapshot the array so a hook that calls `registerSaveAttachmentHook`
60
59
  // (or its unregister fn) during this loop doesn't mutate the
61
60
  // iteration order. `allSettled` so one failing hook doesn't stop
62
61
  // the others — failures are the hook's responsibility to log.
63
62
  const snapshot = [...saveAttachmentHooks];
64
- await Promise.allSettled(snapshot.map((hook) => hook(absPath, relativePath, mimeType)));
63
+ await Promise.allSettled(snapshot.map((hook) => hook(relativePath, mimeType)));
65
64
  }
66
65
 
67
66
  /** Save a single attachment under data/attachments/YYYY/MM/. The
@@ -70,15 +69,13 @@ async function runSaveAttachmentHooks(absPath: string, relativePath: string, mim
70
69
  export async function saveAttachment(base64Data: string, mimeType: string, originalFilename?: string): Promise<SavedAttachment> {
71
70
  const partition = yearMonthUtc();
72
71
  const ext = storedExtensionFor(mimeType, originalFilename);
73
- const filename = `${shortId()}${ext}`;
74
- const absPath = path.join(WORKSPACE_PATHS.attachments, partition, filename);
75
- await writeFileAtomic(absPath, Buffer.from(base64Data, "base64"));
76
- const relativePath = path.posix.join(WORKSPACE_DIRS.attachments, partition, filename);
72
+ const relativePath = path.posix.join(WORKSPACE_DIRS.attachments, partition, `${shortId()}${ext}`);
73
+ await store.write(relativePath, Buffer.from(base64Data, "base64"));
77
74
  // Hooks (e.g. photo-EXIF capture) are awaited so callers can rely
78
75
  // on the sidecar existing by the time `saveAttachment` resolves —
79
76
  // simplifies test assertions and removes a class of race conditions
80
77
  // for downstream tools that read sidecars right after upload.
81
- await runSaveAttachmentHooks(absPath, relativePath, mimeType);
78
+ await runSaveAttachmentHooks(relativePath, mimeType);
82
79
  return { relativePath, mimeType };
83
80
  }
84
81
 
@@ -92,30 +89,21 @@ export async function saveCompanion(originalRelativePath: string, buf: Buffer, e
92
89
  const dir = path.posix.dirname(originalRelativePath);
93
90
  const base = path.posix.basename(originalRelativePath, path.posix.extname(originalRelativePath));
94
91
  const relativePath = path.posix.join(dir, `${base}${ext}`);
95
- const absPath = await resolvers.forWrite(relativePath);
96
- await writeFileAtomic(absPath, buf);
92
+ await store.write(relativePath, buf);
97
93
  return relativePath;
98
94
  }
99
95
 
100
96
  export async function loadAttachmentBase64(relativePath: string): Promise<string> {
101
- const absPath = await resolvers.forRead(relativePath);
102
- const buf = await readFile(absPath);
103
- return buf.toString("base64");
97
+ return (await store.read(relativePath)).toString("base64");
104
98
  }
105
99
 
106
100
  export async function loadAttachmentBytes(relativePath: string): Promise<Buffer> {
107
- const absPath = await resolvers.forRead(relativePath);
108
- return readFile(absPath);
101
+ return store.read(relativePath);
109
102
  }
110
103
 
111
104
  /** True when `relativePath` names a regular file inside the attachment store. */
112
105
  export async function attachmentExists(relativePath: string): Promise<boolean> {
113
- try {
114
- const absPath = await resolvers.forRead(relativePath);
115
- return (await stat(absPath)).isFile();
116
- } catch {
117
- return false;
118
- }
106
+ return store.isFile(relativePath);
119
107
  }
120
108
 
121
109
  export const isAttachmentPath = makePathValidator({ prefix: WORKSPACE_DIRS.attachments });
@@ -1,32 +1,26 @@
1
- import { readFile } from "fs/promises";
2
1
  import path from "path";
3
2
  import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../../workspace/paths.js";
4
3
  import { shortId } from "../id.js";
5
- import { writeFileAtomic } from "./atomic.js";
6
4
  import { yearMonthUtc } from "@mulmoclaude/core/artifacts";
7
5
  import { makePathValidator } from "./path-validator.js";
8
- import { makeStoreResolvers } from "./store-resolvers.js";
6
+ import { makeStoreFiles } from "./store-files.js";
9
7
 
10
- const resolvers = makeStoreResolvers(() => WORKSPACE_PATHS.images, WORKSPACE_DIRS.images);
8
+ const store = makeStoreFiles(() => WORKSPACE_PATHS.images, WORKSPACE_DIRS.images);
11
9
 
12
10
  // #764 sharded under images/YYYY/MM/ (UTC). Buffer pass-through avoids re-encoding the PNG bytes.
13
11
  export async function saveImage(base64Data: string): Promise<string> {
14
12
  const partition = yearMonthUtc();
15
- const filename = `${shortId()}.png`;
16
- const absPath = path.join(WORKSPACE_PATHS.images, partition, filename);
17
- await writeFileAtomic(absPath, Buffer.from(base64Data, "base64"));
18
- return path.posix.join(WORKSPACE_DIRS.images, partition, filename);
13
+ const relativePath = path.posix.join(WORKSPACE_DIRS.images, partition, `${shortId()}.png`);
14
+ await store.write(relativePath, Buffer.from(base64Data, "base64"));
15
+ return relativePath;
19
16
  }
20
17
 
21
18
  export async function overwriteImage(relativePath: string, base64Data: string): Promise<void> {
22
- const absPath = await resolvers.forWrite(relativePath);
23
- await writeFileAtomic(absPath, Buffer.from(base64Data, "base64"));
19
+ await store.write(relativePath, Buffer.from(base64Data, "base64"));
24
20
  }
25
21
 
26
22
  export async function loadImageBase64(relativePath: string): Promise<string> {
27
- const absPath = await resolvers.forRead(relativePath);
28
- const buf = await readFile(absPath);
29
- return buf.toString("base64");
23
+ return (await store.read(relativePath)).toString("base64");
30
24
  }
31
25
 
32
26
  export function stripDataUri(dataUri: string): string {
@@ -15,8 +15,9 @@
15
15
  // written ledger never bricks server boot. Writes go through the
16
16
  // atomic helper, so a crashed install can't leave a corrupt file.
17
17
 
18
- import { readFile } from "node:fs/promises";
19
18
  import path from "node:path";
19
+ import type { VerifiedScope } from "@mulmoclaude/core/files";
20
+ import { openArtifactFile } from "./artifactMount.js";
20
21
  import { loadJsonFile } from "./json.js";
21
22
  import { hasStringProp } from "../types.js";
22
23
  import { WORKSPACE_PATHS } from "../../workspace/paths.js";
@@ -57,10 +58,17 @@ export interface PluginAsset {
57
58
  contentType: string;
58
59
  }
59
60
 
60
- export async function readPluginAsset(absPath: string): Promise<PluginAsset> {
61
- const data = await readFile(absPath);
62
- const ext = path.extname(absPath).toLowerCase();
63
- return { data, contentType: pluginAssetContentType(ext) };
61
+ /** Null for a path that is missing, not a regular file, or outside the scope (also when swapped mid-read); other
62
+ * read errors propagate. The bytes come from the verified handle (#3573). */
63
+ export async function readPluginAsset(scope: VerifiedScope, relPath: string): Promise<PluginAsset | null> {
64
+ const handle = await openArtifactFile(scope, relPath);
65
+ if (handle === null) return null;
66
+ try {
67
+ const data = await handle.readFile();
68
+ return { data, contentType: pluginAssetContentType(path.extname(relPath).toLowerCase()) };
69
+ } finally {
70
+ await handle.close();
71
+ }
64
72
  }
65
73
 
66
74
  // Lookup table over a switch — flat data structure stays under
@@ -14,14 +14,6 @@ export function isEnoent(err: unknown): boolean {
14
14
  return isErrorWithCode(err) && err.code === "ENOENT";
15
15
  }
16
16
 
17
- export function readBinarySafeSync(absPath: string): Buffer | null {
18
- try {
19
- return readFileSync(absPath);
20
- } catch {
21
- return null;
22
- }
23
- }
24
-
25
17
  export async function readTextSafe(absPath: string): Promise<string | null> {
26
18
  try {
27
19
  return await promises.readFile(absPath, "utf-8");