@mulmoclaude/core 4.6.0 → 4.8.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.
@@ -1296,3 +1296,33 @@ Deleting a shared collection is refused outright: the delete can neither archive
1296
1296
  nor remove documents that other members also read, and removing its records
1297
1297
  first does not unlock it. Retiring the whole app is a Firestore project
1298
1298
  administrator's recursive delete, not something this app does.
1299
+
1300
+ ## `renderShapeScript` says Chromium is not installed
1301
+
1302
+ `renderShapeScript` rasterises a ShapeScript model by driving Puppeteer's
1303
+ headless Chromium — the same browser the PDF export uses. Puppeteer downloads
1304
+ it at install time, so this normally just works; a host that set
1305
+ `PUPPETEER_SKIP_DOWNLOAD`, or a sandbox that cannot spawn a browser, has none.
1306
+ The tool then returns, instead of an image path:
1307
+
1308
+ ```
1309
+ renderShapeScript needs Puppeteer's headless Chromium, which this host does
1310
+ not have. Run `npx puppeteer browsers install chrome`, then retry.
1311
+ ```
1312
+
1313
+ What to do:
1314
+
1315
+ 1. Tell the user the one command above.
1316
+ 2. Do NOT retry the call — nothing about the model changed, so the
1317
+ second attempt fails identically.
1318
+ 3. Carry on with `presentShapeScript`, which needs no browser: it
1319
+ validates the geometry headlessly and shows the model to the user
1320
+ in the chat canvas. The only thing you lose is your own ability to
1321
+ LOOK at the render before presenting it, so be more conservative
1322
+ about complex geometry and describe what you built rather than
1323
+ claiming you verified its appearance.
1324
+
1325
+ The same message with `(launch failed: …)` appended means the browser is
1326
+ installed but would not start — usually a sandbox with no permission to
1327
+ spawn it. Report the parenthesised reason to the user rather than the
1328
+ install hint alone.
@@ -30,14 +30,20 @@ function yearMonthUtc(now = /* @__PURE__ */ new Date()) {
30
30
  return `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
31
31
  }
32
32
  /**
33
- * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
34
- * `<epochMs>` suffix keeps freshly-built filenames collision-free without a
35
- * random component. This is what `files.artifacts.write` takes; prefix it with
33
+ * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.
34
+ * This is what `files.artifacts.write` takes; prefix it with
36
35
  * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
36
+ *
37
+ * `<epochMs>` alone separates paths built at different times, but NOT two calls
38
+ * with the same title inside one millisecond — those produce the same name, and
39
+ * the second write silently replaces the first artifact. Pass `suffix` (a short
40
+ * random token, as `shapeArtifactPath` does) wherever concurrent callers can
41
+ * share a title.
37
42
  */
38
43
  function buildArtifactRelPath(params) {
39
- const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true } = params;
40
- const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;
44
+ const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true, suffix } = params;
45
+ const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;
46
+ const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;
41
47
  return (partitioned ? [
42
48
  dir,
43
49
  yearMonthUtc(now),
@@ -49,13 +55,19 @@ function toWorkspaceArtifactPath(relPath) {
49
55
  return `${ARTIFACTS_ROOT}/${relPath}`;
50
56
  }
51
57
  /**
52
- * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
53
- * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
54
- * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
55
- * — so a workspace-escape judgement can't drift between plugins.
58
+ * True when any `/` or `\`-separated segment of `value` is empty (`//`,
59
+ * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical
60
+ * guard every artifact path check shares — so a workspace-escape judgement
61
+ * can't drift between plugins.
62
+ *
63
+ * Both separators, matching `classifyFilePath`: an artifact path is minted from
64
+ * a slug and so never contains a backslash, while `node:path` on Windows treats
65
+ * one as a separator. Splitting on `/` alone accepted
66
+ * `artifacts/shapes/..\..\secrets.shape` — canonical to this check, traversal
67
+ * to `path.join` (codex on #3056).
56
68
  */
57
69
  function hasUnsafePathSegment(value) {
58
- return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
70
+ return value.split(/[/\\]/).some((seg) => seg === "" || seg === "." || seg === "..");
59
71
  }
60
72
  var WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
61
73
  /** True when `value` names a location that does not depend on a base directory.
@@ -1 +1 @@
1
- {"version":3,"file":"paths.cjs","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The\n * `<epochMs>` suffix keeps freshly-built filenames collision-free without a\n * random component. This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true } = params;\n const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),\n * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path\n * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes(\"..\")`\n * — so a workspace-escape judgement can't drift between plugins.\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(\"/\").some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";;AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;AAsBA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,SAAS;CAC5E,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,IAAI,QAAQ,IAAI;CAExE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;AAQA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACjF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
1
+ {"version":3,"file":"paths.cjs","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n /** Extra token appended after the timestamp, for callers that cannot accept a\n * same-millisecond collision (see the note on `buildArtifactRelPath`). */\n suffix?: string;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.\n * This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n *\n * `<epochMs>` alone separates paths built at different times, but NOT two calls\n * with the same title inside one millisecond — those produce the same name, and\n * the second write silently replaces the first artifact. Pass `suffix` (a short\n * random token, as `shapeArtifactPath` does) wherever concurrent callers can\n * share a title.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true, suffix } = params;\n const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;\n const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/` or `\\`-separated segment of `value` is empty (`//`,\n * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical\n * guard every artifact path check shares — so a workspace-escape judgement\n * can't drift between plugins.\n *\n * Both separators, matching `classifyFilePath`: an artifact path is minted from\n * a slug and so never contains a backslash, while `node:path` on Windows treats\n * one as a separator. Splitting on `/` alone accepted\n * `artifacts/shapes/..\\..\\secrets.shape` — canonical to this check, traversal\n * to `path.join` (codex on #3056).\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";;AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;;;;;;AA8BA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,MAAM,WAAW;CACpF,MAAM,QAAQ,SAAS,GAAG,IAAI,QAAQ,EAAE,GAAG,WAAW,GAAG,IAAI,QAAQ;CACrE,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,QAAQ;CAEhE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;;;;;;;AAcA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACrF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
@@ -26,21 +26,35 @@ export interface ArtifactRelPathParams {
26
26
  now?: Date;
27
27
  /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */
28
28
  partitioned?: boolean;
29
+ /** Extra token appended after the timestamp, for callers that cannot accept a
30
+ * same-millisecond collision (see the note on `buildArtifactRelPath`). */
31
+ suffix?: string;
29
32
  }
30
33
  /**
31
- * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
32
- * `<epochMs>` suffix keeps freshly-built filenames collision-free without a
33
- * random component. This is what `files.artifacts.write` takes; prefix it with
34
+ * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.
35
+ * This is what `files.artifacts.write` takes; prefix it with
34
36
  * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
37
+ *
38
+ * `<epochMs>` alone separates paths built at different times, but NOT two calls
39
+ * with the same title inside one millisecond — those produce the same name, and
40
+ * the second write silently replaces the first artifact. Pass `suffix` (a short
41
+ * random token, as `shapeArtifactPath` does) wherever concurrent callers can
42
+ * share a title.
35
43
  */
36
44
  export declare function buildArtifactRelPath(params: ArtifactRelPathParams): string;
37
45
  /** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */
38
46
  export declare function toWorkspaceArtifactPath(relPath: string): string;
39
47
  /**
40
- * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
41
- * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
42
- * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
43
- * — so a workspace-escape judgement can't drift between plugins.
48
+ * True when any `/` or `\`-separated segment of `value` is empty (`//`,
49
+ * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical
50
+ * guard every artifact path check shares — so a workspace-escape judgement
51
+ * can't drift between plugins.
52
+ *
53
+ * Both separators, matching `classifyFilePath`: an artifact path is minted from
54
+ * a slug and so never contains a backslash, while `node:path` on Windows treats
55
+ * one as a separator. Splitting on `/` alone accepted
56
+ * `artifacts/shapes/..\..\secrets.shape` — canonical to this check, traversal
57
+ * to `path.join` (codex on #3056).
44
58
  */
45
59
  export declare function hasUnsafePathSegment(value: string): boolean;
46
60
  /** How a caller-supplied file path must be resolved. `null` = not a usable path. */
@@ -29,14 +29,20 @@ function yearMonthUtc(now = /* @__PURE__ */ new Date()) {
29
29
  return `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
30
30
  }
31
31
  /**
32
- * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
33
- * `<epochMs>` suffix keeps freshly-built filenames collision-free without a
34
- * random component. This is what `files.artifacts.write` takes; prefix it with
32
+ * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.
33
+ * This is what `files.artifacts.write` takes; prefix it with
35
34
  * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
35
+ *
36
+ * `<epochMs>` alone separates paths built at different times, but NOT two calls
37
+ * with the same title inside one millisecond — those produce the same name, and
38
+ * the second write silently replaces the first artifact. Pass `suffix` (a short
39
+ * random token, as `shapeArtifactPath` does) wherever concurrent callers can
40
+ * share a title.
36
41
  */
37
42
  function buildArtifactRelPath(params) {
38
- const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true } = params;
39
- const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;
43
+ const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true, suffix } = params;
44
+ const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;
45
+ const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;
40
46
  return (partitioned ? [
41
47
  dir,
42
48
  yearMonthUtc(now),
@@ -48,13 +54,19 @@ function toWorkspaceArtifactPath(relPath) {
48
54
  return `${ARTIFACTS_ROOT}/${relPath}`;
49
55
  }
50
56
  /**
51
- * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
52
- * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
53
- * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
54
- * — so a workspace-escape judgement can't drift between plugins.
57
+ * True when any `/` or `\`-separated segment of `value` is empty (`//`,
58
+ * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical
59
+ * guard every artifact path check shares — so a workspace-escape judgement
60
+ * can't drift between plugins.
61
+ *
62
+ * Both separators, matching `classifyFilePath`: an artifact path is minted from
63
+ * a slug and so never contains a backslash, while `node:path` on Windows treats
64
+ * one as a separator. Splitting on `/` alone accepted
65
+ * `artifacts/shapes/..\..\secrets.shape` — canonical to this check, traversal
66
+ * to `path.join` (codex on #3056).
55
67
  */
56
68
  function hasUnsafePathSegment(value) {
57
- return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
69
+ return value.split(/[/\\]/).some((seg) => seg === "" || seg === "." || seg === "..");
58
70
  }
59
71
  var WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
60
72
  /** True when `value` names a location that does not depend on a base directory.
@@ -1 +1 @@
1
- {"version":3,"file":"paths.js","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The\n * `<epochMs>` suffix keeps freshly-built filenames collision-free without a\n * random component. This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true } = params;\n const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),\n * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path\n * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes(\"..\")`\n * — so a workspace-escape judgement can't drift between plugins.\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(\"/\").some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;AAsBA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,SAAS;CAC5E,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,IAAI,QAAQ,IAAI;CAExE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;AAQA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACjF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
1
+ {"version":3,"file":"paths.js","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n /** Extra token appended after the timestamp, for callers that cannot accept a\n * same-millisecond collision (see the note on `buildArtifactRelPath`). */\n suffix?: string;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.\n * This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n *\n * `<epochMs>` alone separates paths built at different times, but NOT two calls\n * with the same title inside one millisecond — those produce the same name, and\n * the second write silently replaces the first artifact. Pass `suffix` (a short\n * random token, as `shapeArtifactPath` does) wherever concurrent callers can\n * share a title.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true, suffix } = params;\n const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;\n const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/` or `\\`-separated segment of `value` is empty (`//`,\n * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical\n * guard every artifact path check shares — so a workspace-escape judgement\n * can't drift between plugins.\n *\n * Both separators, matching `classifyFilePath`: an artifact path is minted from\n * a slug and so never contains a backslash, while `node:path` on Windows treats\n * one as a separator. Splitting on `/` alone accepted\n * `artifacts/shapes/..\\..\\secrets.shape` — canonical to this check, traversal\n * to `path.join` (codex on #3056).\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;;;;;;AA8BA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,MAAM,WAAW;CACpF,MAAM,QAAQ,SAAS,GAAG,IAAI,QAAQ,EAAE,GAAG,WAAW,GAAG,IAAI,QAAQ;CACrE,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,QAAQ;CAEhE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;;;;;;;AAcA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACrF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/core",
3
- "version": "4.6.0",
3
+ "version": "4.8.0",
4
4
  "description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./artifacts, ./whisper/client, ./workspace-setup/slug, ./translation/client, ./remote-view, ./remote-host and ./plugin-vue entries. All host specifics are injected.",
5
5
  "repository": {
6
6
  "type": "git",