@mulmoclaude/core 1.7.0 → 1.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.
@@ -623,3 +623,49 @@ queue of paths, with one short-delay retry on 429. See the throttled-resolver
623
623
  example in `custom-view.md` ("Displaying images"). Do NOT widen the server
624
624
  cap, switch to base64-embedding images in the HTML, or treat the 429'd paths
625
625
  as bad values.
626
+
627
+ ## An API key in `.env` has no effect — the shell is shadowing it
628
+
629
+ ### Symptoms
630
+
631
+ - The user says they put a key (`GEMINI_API_KEY`, `OPENAI_API_KEY`, …) in
632
+ `.env` and restarted, but generation still fails with an auth / 401 /
633
+ "API key not valid" error from the provider.
634
+ - They may have edited `.env` several times, each time with no change.
635
+ - The bell may show **"Shell env is overriding .env"**, and the server log
636
+ a `[shadowed-env]` warning naming the keys.
637
+
638
+ ### Cause
639
+
640
+ An exported shell variable beats the file. `.env` is loaded with
641
+ no-override semantics, so if `~/.zshrc` (or the current shell) still holds
642
+ `export GEMINI_API_KEY=<old value>`, the file's value is read and
643
+ discarded. Editing `.env` cannot fix it, which is why the loop repeats.
644
+
645
+ An **empty** export shadows just as hard: `export GEMINI_API_KEY=` counts
646
+ as set, so the provider receives an empty key while a perfectly good one
647
+ sits in `.env`.
648
+
649
+ Two files can be shadowed this way — the directory the user launched from
650
+ (`npx mulmoclaude`) and the server's own working directory (`yarn dev`).
651
+
652
+ ### Fix
653
+
654
+ Have the user check the shell, not the file. Test whether the variable
655
+ is **set**, not whether it prints something — `export GEMINI_API_KEY=`
656
+ prints nothing and still shadows, which is the case `echo` cannot see:
657
+
658
+ ```bash
659
+ [ -n "${GEMINI_API_KEY+x}" ] && echo "set in the shell — this is what the app uses" \
660
+ || echo "not set — the shell is not the problem"
661
+ ```
662
+
663
+ If it reports "set", that shell value is what the app is using, whatever
664
+ `.env` says. Either correct the export, or remove it — from the current
665
+ shell AND from `~/.zshrc` / `~/.bashrc`, or the next terminal brings it
666
+ straight back — so the `.env` value takes effect. Restart the app
667
+ afterwards; the load happens once at boot.
668
+
669
+ Do NOT tell the user to re-check the spelling in `.env`, add the key
670
+ again, or move it elsewhere; the file is already correct, and it is being
671
+ read. The conflict is the whole problem.
@@ -57,10 +57,53 @@ function toWorkspaceArtifactPath(relPath) {
57
57
  function hasUnsafePathSegment(value) {
58
58
  return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
59
59
  }
60
+ var WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
61
+ /** True when `value` names a location that does not depend on a base directory.
62
+ * Exported so a URL builder and a path resolver cannot disagree about which
63
+ * values are rooted. */
64
+ function isAbsoluteFilePathValue(value) {
65
+ return value.startsWith("/") || value.startsWith("\\") || WINDOWS_DRIVE_RE.test(value);
66
+ }
67
+ /**
68
+ * Classify a caller-supplied path to a file the host may read and overwrite.
69
+ *
70
+ * Accepts one of `extensions` (compared case-insensitively) and rejects NUL
71
+ * bytes and any `.` / `..` / empty segment — a relative path must be canonical
72
+ * so it can be joined onto a root, and an absolute one must not climb, so
73
+ * neither form can be re-pointed by traversal after the host has vetted it.
74
+ * Returns `"absolute"` / `"relative"` so the host knows whether to resolve
75
+ * against its workspace root, or `null` when the value is unusable.
76
+ *
77
+ * This is a LEXICAL check only. Existence, file-vs-directory, symlink
78
+ * containment and any host policy about which roots are reachable stay with
79
+ * the host, which is the only layer that can consult the filesystem.
80
+ */
81
+ function classifyFilePath(value, extensions) {
82
+ if (!value || value.includes("\0")) return null;
83
+ const lower = value.toLowerCase();
84
+ if (!extensions.some((ext) => lower.endsWith(ext))) return null;
85
+ const absolute = isAbsoluteFilePathValue(value);
86
+ const segments = value.split(/[/\\]/);
87
+ const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;
88
+ if (body.length === 0) return null;
89
+ if (body.some((segment) => segment === "" || segment === "." || segment === "..")) return null;
90
+ return absolute ? "absolute" : "relative";
91
+ }
92
+ /** True when any `/` or `\`-separated segment starts with a dot. The file
93
+ * servers that hand these pages to a browser refuse dotfile segments (the
94
+ * artifact mounts' `dotfiles: "deny"` policy), so a `path` argument bearing
95
+ * one can be accepted by a tool and then never render — the gate and the
96
+ * server have to agree on this, hence one definition. */
97
+ function hasDotfileSegment(value) {
98
+ return value.split(/[/\\]/).some((segment) => segment.startsWith("."));
99
+ }
60
100
  //#endregion
61
101
  exports.ARTIFACTS_ROOT = ARTIFACTS_ROOT;
62
102
  exports.buildArtifactRelPath = buildArtifactRelPath;
103
+ exports.classifyFilePath = classifyFilePath;
104
+ exports.hasDotfileSegment = hasDotfileSegment;
63
105
  exports.hasUnsafePathSegment = hasUnsafePathSegment;
106
+ exports.isAbsoluteFilePathValue = isAbsoluteFilePathValue;
64
107
  exports.slugifyArtifact = slugifyArtifact;
65
108
  exports.toWorkspaceArtifactPath = toWorkspaceArtifactPath;
66
109
  exports.yearMonthUtc = yearMonthUtc;
@@ -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"],"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"}
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"}
@@ -43,3 +43,30 @@ export declare function toWorkspaceArtifactPath(relPath: string): string;
43
43
  * — so a workspace-escape judgement can't drift between plugins.
44
44
  */
45
45
  export declare function hasUnsafePathSegment(value: string): boolean;
46
+ /** How a caller-supplied file path must be resolved. `null` = not a usable path. */
47
+ export type FilePathKind = "absolute" | "relative";
48
+ /** True when `value` names a location that does not depend on a base directory.
49
+ * Exported so a URL builder and a path resolver cannot disagree about which
50
+ * values are rooted. */
51
+ export declare function isAbsoluteFilePathValue(value: string): boolean;
52
+ /**
53
+ * Classify a caller-supplied path to a file the host may read and overwrite.
54
+ *
55
+ * Accepts one of `extensions` (compared case-insensitively) and rejects NUL
56
+ * bytes and any `.` / `..` / empty segment — a relative path must be canonical
57
+ * so it can be joined onto a root, and an absolute one must not climb, so
58
+ * neither form can be re-pointed by traversal after the host has vetted it.
59
+ * Returns `"absolute"` / `"relative"` so the host knows whether to resolve
60
+ * against its workspace root, or `null` when the value is unusable.
61
+ *
62
+ * This is a LEXICAL check only. Existence, file-vs-directory, symlink
63
+ * containment and any host policy about which roots are reachable stay with
64
+ * the host, which is the only layer that can consult the filesystem.
65
+ */
66
+ export declare function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null;
67
+ /** True when any `/` or `\`-separated segment starts with a dot. The file
68
+ * servers that hand these pages to a browser refuse dotfile segments (the
69
+ * artifact mounts' `dotfiles: "deny"` policy), so a `path` argument bearing
70
+ * one can be accepted by a tool and then never render — the gate and the
71
+ * server have to agree on this, hence one definition. */
72
+ export declare function hasDotfileSegment(value: string): boolean;
@@ -56,7 +56,47 @@ function toWorkspaceArtifactPath(relPath) {
56
56
  function hasUnsafePathSegment(value) {
57
57
  return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
58
58
  }
59
+ var WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
60
+ /** True when `value` names a location that does not depend on a base directory.
61
+ * Exported so a URL builder and a path resolver cannot disagree about which
62
+ * values are rooted. */
63
+ function isAbsoluteFilePathValue(value) {
64
+ return value.startsWith("/") || value.startsWith("\\") || WINDOWS_DRIVE_RE.test(value);
65
+ }
66
+ /**
67
+ * Classify a caller-supplied path to a file the host may read and overwrite.
68
+ *
69
+ * Accepts one of `extensions` (compared case-insensitively) and rejects NUL
70
+ * bytes and any `.` / `..` / empty segment — a relative path must be canonical
71
+ * so it can be joined onto a root, and an absolute one must not climb, so
72
+ * neither form can be re-pointed by traversal after the host has vetted it.
73
+ * Returns `"absolute"` / `"relative"` so the host knows whether to resolve
74
+ * against its workspace root, or `null` when the value is unusable.
75
+ *
76
+ * This is a LEXICAL check only. Existence, file-vs-directory, symlink
77
+ * containment and any host policy about which roots are reachable stay with
78
+ * the host, which is the only layer that can consult the filesystem.
79
+ */
80
+ function classifyFilePath(value, extensions) {
81
+ if (!value || value.includes("\0")) return null;
82
+ const lower = value.toLowerCase();
83
+ if (!extensions.some((ext) => lower.endsWith(ext))) return null;
84
+ const absolute = isAbsoluteFilePathValue(value);
85
+ const segments = value.split(/[/\\]/);
86
+ const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;
87
+ if (body.length === 0) return null;
88
+ if (body.some((segment) => segment === "" || segment === "." || segment === "..")) return null;
89
+ return absolute ? "absolute" : "relative";
90
+ }
91
+ /** True when any `/` or `\`-separated segment starts with a dot. The file
92
+ * servers that hand these pages to a browser refuse dotfile segments (the
93
+ * artifact mounts' `dotfiles: "deny"` policy), so a `path` argument bearing
94
+ * one can be accepted by a tool and then never render — the gate and the
95
+ * server have to agree on this, hence one definition. */
96
+ function hasDotfileSegment(value) {
97
+ return value.split(/[/\\]/).some((segment) => segment.startsWith("."));
98
+ }
59
99
  //#endregion
60
- export { ARTIFACTS_ROOT, buildArtifactRelPath, hasUnsafePathSegment, slugifyArtifact, toWorkspaceArtifactPath, yearMonthUtc };
100
+ export { ARTIFACTS_ROOT, buildArtifactRelPath, classifyFilePath, hasDotfileSegment, hasUnsafePathSegment, isAbsoluteFilePathValue, slugifyArtifact, toWorkspaceArtifactPath, yearMonthUtc };
61
101
 
62
102
  //# sourceMappingURL=paths.js.map
@@ -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"],"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"}
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"}
@@ -0,0 +1,32 @@
1
+ export declare const MARKDOWN_EXTENSIONS: readonly [".md"];
2
+ export declare const HTML_EXTENSIONS: readonly [".html", ".htm"];
3
+ /** Minimal structural echo of gui-chat-protocol's `FileOps`. Declared here
4
+ * rather than imported so core keeps no dependency on the protocol package;
5
+ * a host assigns the result straight to a `FileOps`-typed capability. */
6
+ export interface ByPathFileOps {
7
+ read: (rel: string) => Promise<string>;
8
+ readBytes: (rel: string) => Promise<Uint8Array>;
9
+ write: (rel: string, content: string | Uint8Array) => Promise<void>;
10
+ readDir: (rel: string) => Promise<string[]>;
11
+ stat: (rel: string) => Promise<{
12
+ mtimeMs: number;
13
+ size: number;
14
+ }>;
15
+ exists: (rel: string) => Promise<boolean>;
16
+ unlink: (rel: string) => Promise<void>;
17
+ }
18
+ /** Absolute on-disk path for a caller-supplied path with one of `extensions`,
19
+ * or null when the value is not usable on this platform. */
20
+ export declare function resolveByPath(root: string, value: string, extensions: readonly string[]): string | null;
21
+ /** True when the path names an existing regular file. */
22
+ export declare function existsAsFile(root: string, value: string, extensions: readonly string[]): Promise<boolean>;
23
+ export interface ByPathOptions {
24
+ /** The root relative values resolve against, read PER CALL — hosts inject the
25
+ * workspace after these ops are already bound into plugin closures. */
26
+ rootFor: () => string;
27
+ extensions: readonly string[];
28
+ }
29
+ /** FileOps over caller-supplied paths — what a host injects as `files.byPath`
30
+ * for plugins whose `path` argument may leave their artifact directory. Every
31
+ * method takes the same value the tool call carried, not a scope-relative one. */
32
+ export declare function createByPathFileOps(options: ByPathOptions): ByPathFileOps;
@@ -0,0 +1,4 @@
1
+ /** Absolute path for a `/htmlfile/<scope>/<segments…>` request, or null when
2
+ * the URL is malformed, uses an unknown scope, or touches a `.` / `..` /
3
+ * dotfile segment. `workspaceRoot` should already be a realpath. */
4
+ export declare function resolveHtmlFileRequestPath(workspaceRoot: string, reqPath: string): string | null;
@@ -1,10 +1,12 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
3
+ const require_artifacts_paths = require("../artifacts/paths.cjs");
3
4
  const require_atomic = require("../atomic-C_7YpMiM.cjs");
4
5
  const require_relPath = require("../relPath-CgGi4-Nv.cjs");
5
6
  let node_fs = require("node:fs");
6
7
  let node_path = require("node:path");
7
8
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
9
+ let node_fs_promises = require("node:fs/promises");
8
10
  //#region src/files/json.ts
9
11
  var JSON_INDENT = 2;
10
12
  /** Atomic JSON write (2-space indent) — serialize then write through the atomic
@@ -39,8 +41,114 @@ function realpathOrNull(absPath) {
39
41
  }
40
42
  }
41
43
  //#endregion
44
+ //#region src/files/byPath.ts
45
+ var MARKDOWN_EXTENSIONS = [".md"];
46
+ var HTML_EXTENSIONS = [".html", ".htm"];
47
+ /** Absolute on-disk path for a caller-supplied path with one of `extensions`,
48
+ * or null when the value is not usable on this platform. */
49
+ function resolveByPath(root, value, extensions) {
50
+ const kind = require_artifacts_paths.classifyFilePath(value, extensions);
51
+ if (kind === null) return null;
52
+ if (kind === "relative") return node_path.default.resolve(root, value);
53
+ return node_path.default.isAbsolute(value) ? node_path.default.resolve(value) : null;
54
+ }
55
+ /** True when the path names an existing regular file. */
56
+ async function existsAsFile(root, value, extensions) {
57
+ const absPath = resolveByPath(root, value, extensions);
58
+ return absPath === null ? false : await regularFileTarget(absPath) !== null;
59
+ }
60
+ function resolveOrThrow({ rootFor, extensions }, value) {
61
+ const absPath = resolveByPath(rootFor(), value, extensions);
62
+ if (absPath === null) throw new Error(`invalid path: ${value}`);
63
+ return absPath;
64
+ }
65
+ /** The canonical REGULAR FILE a path names, or null when it is missing, is a
66
+ * directory, or is anything else (a FIFO would block a read forever).
67
+ * Resolved through `realpath` so a symlink is judged — and later written — by
68
+ * what it points at: `writeFileAtomic` renames a temp file into place, which
69
+ * through a link would replace the link itself and leave the real document
70
+ * untouched. */
71
+ async function regularFileTarget(absPath) {
72
+ try {
73
+ const target = await (0, node_fs_promises.realpath)(absPath);
74
+ return (await (0, node_fs_promises.stat)(target)).isFile() ? target : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+ /** The file to read or overwrite. Throws rather than creating: neither tool
80
+ * ever writes to a path that does not already hold a document. */
81
+ async function existingFileFor(options, rel) {
82
+ const target = await regularFileTarget(resolveOrThrow(options, rel));
83
+ if (target === null) throw new Error(`no file exists at ${rel}`);
84
+ return target;
85
+ }
86
+ /** Browsing and deleting are NOT part of this capability: it exists so a view
87
+ * can read and re-save the ONE document the tool call named, not to enumerate
88
+ * a directory it was never pointed at or remove a file. */
89
+ function unsupported(operation) {
90
+ return () => Promise.reject(/* @__PURE__ */ new Error(`byPath FileOps does not support ${operation}`));
91
+ }
92
+ /** FileOps over caller-supplied paths — what a host injects as `files.byPath`
93
+ * for plugins whose `path` argument may leave their artifact directory. Every
94
+ * method takes the same value the tool call carried, not a scope-relative one. */
95
+ function createByPathFileOps(options) {
96
+ return {
97
+ read: async (rel) => (0, node_fs_promises.readFile)(await existingFileFor(options, rel), "utf-8"),
98
+ readBytes: async (rel) => {
99
+ const buf = await (0, node_fs_promises.readFile)(await existingFileFor(options, rel));
100
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
101
+ },
102
+ write: async (rel, content) => require_atomic.writeFileAtomic(await existingFileFor(options, rel), content),
103
+ readDir: unsupported("readDir"),
104
+ stat: async (rel) => {
105
+ const { mtimeMs, size } = await (0, node_fs_promises.stat)(resolveOrThrow(options, rel));
106
+ return {
107
+ mtimeMs,
108
+ size
109
+ };
110
+ },
111
+ exists: (rel) => existsAsFile(options.rootFor(), rel, options.extensions),
112
+ unlink: unsupported("unlink")
113
+ };
114
+ }
115
+ //#endregion
116
+ //#region src/files/htmlFileRequest.ts
117
+ var HTML_FILE_SCOPE_WORKSPACE = "ws";
118
+ var HTML_FILE_SCOPE_ABSOLUTE = "abs";
119
+ var WINDOWS_DRIVE_ONLY_RE = /^[a-zA-Z]:$/;
120
+ function decodeSegments(reqPath) {
121
+ let decoded;
122
+ try {
123
+ decoded = reqPath.replace(/^\//, "").split("/").map((segment) => decodeURIComponent(segment));
124
+ } catch {
125
+ return null;
126
+ }
127
+ return decoded.some((segment) => segment.includes("/") || segment.includes("\\")) ? null : decoded;
128
+ }
129
+ /** Absolute path for a `/htmlfile/<scope>/<segments…>` request, or null when
130
+ * the URL is malformed, uses an unknown scope, or touches a `.` / `..` /
131
+ * dotfile segment. `workspaceRoot` should already be a realpath. */
132
+ function resolveHtmlFileRequestPath(workspaceRoot, reqPath) {
133
+ const decoded = decodeSegments(reqPath);
134
+ if (decoded === null || decoded.length < 2) return null;
135
+ const [scope, ...rest] = decoded;
136
+ if (scope !== HTML_FILE_SCOPE_WORKSPACE && scope !== HTML_FILE_SCOPE_ABSOLUTE) return null;
137
+ if (rest.length === 0) return null;
138
+ if (rest.some((segment) => segment === "" || segment === "." || segment === ".." || segment.startsWith(".") || segment.includes("\0"))) return null;
139
+ if (scope === HTML_FILE_SCOPE_WORKSPACE) return node_path.default.resolve(workspaceRoot, ...rest);
140
+ const candidate = WINDOWS_DRIVE_ONLY_RE.test(rest[0]) ? rest.join("/") : `/${rest.join("/")}`;
141
+ return node_path.default.isAbsolute(candidate) ? node_path.default.resolve(candidate) : null;
142
+ }
143
+ //#endregion
144
+ exports.HTML_EXTENSIONS = HTML_EXTENSIONS;
145
+ exports.MARKDOWN_EXTENSIONS = MARKDOWN_EXTENSIONS;
146
+ exports.createByPathFileOps = createByPathFileOps;
147
+ exports.existsAsFile = existsAsFile;
42
148
  exports.isEnoent = isEnoent;
43
149
  exports.joinPosixRelPath = require_relPath.joinPosixRelPath;
150
+ exports.resolveByPath = resolveByPath;
151
+ exports.resolveHtmlFileRequestPath = resolveHtmlFileRequestPath;
44
152
  exports.resolveWithinRoot = resolveWithinRoot;
45
153
  exports.toPosixRelPath = require_relPath.toPosixRelPath;
46
154
  exports.writeFileAtomic = require_atomic.writeFileAtomic;
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../src/files/json.ts","../../src/files/safe.ts"],"sourcesContent":["import { writeFileAtomic, type WriteAtomicOptions } from \"./atomic.js\";\n\nconst JSON_INDENT = 2;\n\n/** Atomic JSON write (2-space indent) — serialize then write through the atomic\n * tmp-file + rename seam so a reader never sees a half-written document. */\nexport async function writeJsonAtomic(filePath: string, data: unknown, opts: WriteAtomicOptions = {}): Promise<void> {\n await writeFileAtomic(filePath, JSON.stringify(data, null, JSON_INDENT), opts);\n}\n","import { realpathSync } from \"node:fs\";\nimport path from \"node:path\";\n\n/** True for a `not found` filesystem error (ENOENT) — lets callers treat a\n * missing file as an empty/default result instead of a thrown error. */\nexport function isEnoent(err: unknown): boolean {\n return typeof err === \"object\" && err !== null && \"code\" in err && err.code === \"ENOENT\";\n}\n\n/** Realpath-based read-time containment: resolve `relPath` against the root's\n * realpath and require the target's realpath to stay inside it. Returns null\n * on ENOENT or traversal (symlink escapes included). `rootReal` MUST already\n * be a realpath. The one implementation for host and plugins (#2461) — this\n * security-critical primitive must not drift per consumer. */\nexport function resolveWithinRoot(rootReal: string, relPath: string): string | null {\n const normalized = path.normalize(relPath || \"\");\n const resolved = path.resolve(rootReal, normalized);\n const resolvedReal = realpathOrNull(resolved);\n if (resolvedReal === null) return null;\n if (resolvedReal !== rootReal && !resolvedReal.startsWith(rootReal + path.sep)) {\n return null;\n }\n return resolvedReal;\n}\n\nfunction realpathOrNull(absPath: string): string | null {\n try {\n return realpathSync(absPath);\n } catch {\n return null;\n }\n}\n"],"mappings":";;;;;;;;AAEA,IAAM,cAAc;;;AAIpB,eAAsB,gBAAgB,UAAkB,MAAe,OAA2B,CAAC,GAAkB;CACnH,MAAM,eAAA,gBAAgB,UAAU,KAAK,UAAU,MAAM,MAAM,WAAW,GAAG,IAAI;AAC/E;;;;;ACHA,SAAgB,SAAS,KAAuB;CAC9C,OAAO,OAAO,QAAQ,YAAY,QAAQ,QAAQ,UAAU,OAAO,IAAI,SAAS;AAClF;;;;;;AAOA,SAAgB,kBAAkB,UAAkB,SAAgC;CAClF,MAAM,aAAa,UAAA,QAAK,UAAU,WAAW,EAAE;CAE/C,MAAM,eAAe,eADJ,UAAA,QAAK,QAAQ,UAAU,UACJ,CAAQ;CAC5C,IAAI,iBAAiB,MAAM,OAAO;CAClC,IAAI,iBAAiB,YAAY,CAAC,aAAa,WAAW,WAAW,UAAA,QAAK,GAAG,GAC3E,OAAO;CAET,OAAO;AACT;AAEA,SAAS,eAAe,SAAgC;CACtD,IAAI;EACF,QAAA,GAAA,QAAA,aAAA,CAAoB,OAAO;CAC7B,QAAQ;EACN,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/files/json.ts","../../src/files/safe.ts","../../src/files/byPath.ts","../../src/files/htmlFileRequest.ts"],"sourcesContent":["import { writeFileAtomic, type WriteAtomicOptions } from \"./atomic.js\";\n\nconst JSON_INDENT = 2;\n\n/** Atomic JSON write (2-space indent) — serialize then write through the atomic\n * tmp-file + rename seam so a reader never sees a half-written document. */\nexport async function writeJsonAtomic(filePath: string, data: unknown, opts: WriteAtomicOptions = {}): Promise<void> {\n await writeFileAtomic(filePath, JSON.stringify(data, null, JSON_INDENT), opts);\n}\n","import { realpathSync } from \"node:fs\";\nimport path from \"node:path\";\n\n/** True for a `not found` filesystem error (ENOENT) — lets callers treat a\n * missing file as an empty/default result instead of a thrown error. */\nexport function isEnoent(err: unknown): boolean {\n return typeof err === \"object\" && err !== null && \"code\" in err && err.code === \"ENOENT\";\n}\n\n/** Realpath-based read-time containment: resolve `relPath` against the root's\n * realpath and require the target's realpath to stay inside it. Returns null\n * on ENOENT or traversal (symlink escapes included). `rootReal` MUST already\n * be a realpath. The one implementation for host and plugins (#2461) — this\n * security-critical primitive must not drift per consumer. */\nexport function resolveWithinRoot(rootReal: string, relPath: string): string | null {\n const normalized = path.normalize(relPath || \"\");\n const resolved = path.resolve(rootReal, normalized);\n const resolvedReal = realpathOrNull(resolved);\n if (resolvedReal === null) return null;\n if (resolvedReal !== rootReal && !resolvedReal.startsWith(rootReal + path.sep)) {\n return null;\n }\n return resolvedReal;\n}\n\nfunction realpathOrNull(absPath: string): string | null {\n try {\n return realpathSync(absPath);\n } catch {\n return null;\n }\n}\n","// Read / overwrite files the TOOL CALL named, rather than files the app minted.\n// `presentDocument(path)` / `presentHtml(path)` open a document that already\n// exists — a repo's `README.md`, `docs/report.html`, an absolute path — and the\n// user's edits in the view overwrite it in place.\n//\n// Shared by both hosts (MulmoClaude's `server/utils/files/by-path.ts` and\n// MulmoTerminal's backend bind their own workspace root to it), because the\n// judgement of what a `path` argument may reach is exactly the thing that must\n// not drift between them: a host that accepts what the other refuses turns one\n// tool call into two different behaviours.\n//\n// The rules:\n// - `classifyFilePath` (also what the plugins' `path` gate calls) decides the\n// shape: right extension, no NUL, no `.` / `..` / empty segment. That\n// lexical guard is what stops a vetted path from being re-pointed later.\n// - relative paths resolve against the injected root; absolute paths are taken\n// as given. There is deliberately NO containment check — opening a file\n// outside the workspace is the documented purpose of the `path` form. The\n// agent can already read and write those files directly; what is new is that\n// the view can too.\n// - a value that is absolute only under ANOTHER platform's rules is refused:\n// `classifyFilePath` recognises `C:\\proj\\x.md` everywhere (the value may\n// come from a remote host), but on POSIX `path.resolve(\"C:/proj/x.md\")`\n// lands under the process cwd — a file nobody named.\n// - reads and writes require a REGULAR FILE, judged through `realpath` so a\n// symlink is assessed by what it points at and a directory named `x.md`\n// cannot masquerade as a document.\n// - `write` overwrites only. Neither tool creates a file at a caller-supplied\n// path, so a write to a path that does not exist means the view is stale or\n// the path was wrong — refusing keeps a typo from scattering files.\n// - `readDir` and `unlink` are refused outright: this capability exists to\n// read and re-save ONE named document, not to browse or delete.\n\nimport { readFile, realpath, stat as fsStat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { classifyFilePath } from \"../artifacts/paths.js\";\nimport { writeFileAtomic } from \"./atomic.js\";\n\nexport const MARKDOWN_EXTENSIONS = [\".md\"] as const;\nexport const HTML_EXTENSIONS = [\".html\", \".htm\"] as const;\n\n/** Minimal structural echo of gui-chat-protocol's `FileOps`. Declared here\n * rather than imported so core keeps no dependency on the protocol package;\n * a host assigns the result straight to a `FileOps`-typed capability. */\nexport interface ByPathFileOps {\n read: (rel: string) => Promise<string>;\n readBytes: (rel: string) => Promise<Uint8Array>;\n write: (rel: string, content: string | Uint8Array) => Promise<void>;\n readDir: (rel: string) => Promise<string[]>;\n stat: (rel: string) => Promise<{ mtimeMs: number; size: number }>;\n exists: (rel: string) => Promise<boolean>;\n unlink: (rel: string) => Promise<void>;\n}\n\n/** Absolute on-disk path for a caller-supplied path with one of `extensions`,\n * or null when the value is not usable on this platform. */\nexport function resolveByPath(root: string, value: string, extensions: readonly string[]): string | null {\n const kind = classifyFilePath(value, extensions);\n if (kind === null) return null;\n if (kind === \"relative\") return path.resolve(root, value);\n return path.isAbsolute(value) ? path.resolve(value) : null;\n}\n\n/** True when the path names an existing regular file. */\nexport async function existsAsFile(root: string, value: string, extensions: readonly string[]): Promise<boolean> {\n const absPath = resolveByPath(root, value, extensions);\n return absPath === null ? false : (await regularFileTarget(absPath)) !== null;\n}\n\nexport interface ByPathOptions {\n /** The root relative values resolve against, read PER CALL — hosts inject the\n * workspace after these ops are already bound into plugin closures. */\n rootFor: () => string;\n extensions: readonly string[];\n}\n\nfunction resolveOrThrow({ rootFor, extensions }: ByPathOptions, value: string): string {\n const absPath = resolveByPath(rootFor(), value, extensions);\n if (absPath === null) throw new Error(`invalid path: ${value}`);\n return absPath;\n}\n\n/** The canonical REGULAR FILE a path names, or null when it is missing, is a\n * directory, or is anything else (a FIFO would block a read forever).\n * Resolved through `realpath` so a symlink is judged — and later written — by\n * what it points at: `writeFileAtomic` renames a temp file into place, which\n * through a link would replace the link itself and leave the real document\n * untouched. */\nasync function regularFileTarget(absPath: string): Promise<string | null> {\n try {\n const target = await realpath(absPath);\n return (await fsStat(target)).isFile() ? target : null;\n } catch {\n return null;\n }\n}\n\n/** The file to read or overwrite. Throws rather than creating: neither tool\n * ever writes to a path that does not already hold a document. */\nasync function existingFileFor(options: ByPathOptions, rel: string): Promise<string> {\n const target = await regularFileTarget(resolveOrThrow(options, rel));\n if (target === null) throw new Error(`no file exists at ${rel}`);\n return target;\n}\n\n/** Browsing and deleting are NOT part of this capability: it exists so a view\n * can read and re-save the ONE document the tool call named, not to enumerate\n * a directory it was never pointed at or remove a file. */\nfunction unsupported(operation: string): () => Promise<never> {\n return () => Promise.reject(new Error(`byPath FileOps does not support ${operation}`));\n}\n\n/** FileOps over caller-supplied paths — what a host injects as `files.byPath`\n * for plugins whose `path` argument may leave their artifact directory. Every\n * method takes the same value the tool call carried, not a scope-relative one. */\nexport function createByPathFileOps(options: ByPathOptions): ByPathFileOps {\n return {\n read: async (rel) => readFile(await existingFileFor(options, rel), \"utf-8\"),\n readBytes: async (rel) => {\n const buf = await readFile(await existingFileFor(options, rel));\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n },\n write: async (rel, content) => writeFileAtomic(await existingFileFor(options, rel), content),\n readDir: unsupported(\"readDir\"),\n stat: async (rel) => {\n const { mtimeMs, size } = await fsStat(resolveOrThrow(options, rel));\n return { mtimeMs, size };\n },\n exists: (rel) => existsAsFile(options.rootFor(), rel, options.extensions),\n unlink: unsupported(\"unlink\"),\n };\n}\n","// Server half of the `/htmlfile` scheme (client half: `src/config/htmlFileUrl.ts`).\n// Turns a request path into the absolute file to serve, or null.\n//\n// Shared by both hosts so the URL the View asks for and the file a host resolves\n// cannot drift apart.\n//\n// This mount is deliberately NOT contained to a root: presentHtml's `path` form\n// may name any HTML page on disk, and serving it is the point. What still holds:\n//\n// - only `ws` (workspace-relative) and `abs` (absolute) scopes exist; anything\n// else 404s, so a bare `/htmlfile/etc/passwd` is not a path at all.\n// - no `.` / `..` / empty segment survives, so a vetted URL cannot be\n// re-pointed by traversal, and no dotfile segment is reachable (same policy\n// as `resolveArtifactRequestPath`, which the artifact mounts share).\n// - extension allowlist, realpath and regular-file checks stay with the host's\n// mount, which is also where the CSP header and rate limit are applied.\n//\n// The trust boundary is whatever the host applies to its `/artifacts/html`\n// mount — in MulmoClaude, the loopback-only listener plus `requireSameOrigin`\n// (bearer auth does not apply outside `/api`, and an iframe `src` request cannot\n// carry an Authorization header anyway).\n\nimport path from \"node:path\";\n// The scope constants are re-declared rather than imported: core must not\n// depend on a plugin (dependencies flow the other way). `html-plugin`'s\n// `htmlFileUrl` builds the URLs these parse, and the round-trip test in the host\n// is what keeps the two halves honest.\nconst HTML_FILE_SCOPE_WORKSPACE = \"ws\";\nconst HTML_FILE_SCOPE_ABSOLUTE = \"abs\";\n\nconst WINDOWS_DRIVE_ONLY_RE = /^[a-zA-Z]:$/;\n\nfunction decodeSegments(reqPath: string): string[] | null {\n let decoded: string[];\n try {\n decoded = reqPath\n .replace(/^\\//, \"\")\n .split(\"/\")\n .map((segment) => decodeURIComponent(segment));\n } catch {\n // Malformed escape (`%ZZ`) — fail closed rather than bubbling a URIError.\n return null;\n }\n // Segmentation happens on the ENCODED path, so a `%2F` (or `%5C`) becomes a\n // separator only after this decode — `a%2F..%2F..%2Ftmp%2Fx.html` would arrive\n // as one segment that the `..` check below never sees, and then split apart\n // inside `path.resolve`. A decoded segment that still contains a separator is\n // never legitimate here, so refuse it.\n return decoded.some((segment) => segment.includes(\"/\") || segment.includes(\"\\\\\")) ? null : decoded;\n}\n\n/** Absolute path for a `/htmlfile/<scope>/<segments…>` request, or null when\n * the URL is malformed, uses an unknown scope, or touches a `.` / `..` /\n * dotfile segment. `workspaceRoot` should already be a realpath. */\nexport function resolveHtmlFileRequestPath(workspaceRoot: string, reqPath: string): string | null {\n const decoded = decodeSegments(reqPath);\n if (decoded === null || decoded.length < 2) return null;\n const [scope, ...rest] = decoded;\n if (scope !== HTML_FILE_SCOPE_WORKSPACE && scope !== HTML_FILE_SCOPE_ABSOLUTE) return null;\n if (rest.length === 0) return null;\n if (rest.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\" || segment.startsWith(\".\") || segment.includes(\"\\0\"))) {\n return null;\n }\n if (scope === HTML_FILE_SCOPE_WORKSPACE) return path.resolve(workspaceRoot, ...rest);\n // `abs/C:/proj/page.html` — the drive letter arrives as its own segment, so\n // rejoin it before resolving; everything else is rooted at `/`.\n const candidate = WINDOWS_DRIVE_ONLY_RE.test(rest[0]) ? rest.join(\"/\") : `/${rest.join(\"/\")}`;\n // Only THIS platform's `path` can say whether that is really absolute: on\n // POSIX, `path.resolve(\"C:/proj/page.html\")` would land under the process cwd\n // rather than at any drive, silently serving the wrong file. Refuse instead.\n return path.isAbsolute(candidate) ? path.resolve(candidate) : null;\n}\n"],"mappings":";;;;;;;;;;AAEA,IAAM,cAAc;;;AAIpB,eAAsB,gBAAgB,UAAkB,MAAe,OAA2B,CAAC,GAAkB;CACnH,MAAM,eAAA,gBAAgB,UAAU,KAAK,UAAU,MAAM,MAAM,WAAW,GAAG,IAAI;AAC/E;;;;;ACHA,SAAgB,SAAS,KAAuB;CAC9C,OAAO,OAAO,QAAQ,YAAY,QAAQ,QAAQ,UAAU,OAAO,IAAI,SAAS;AAClF;;;;;;AAOA,SAAgB,kBAAkB,UAAkB,SAAgC;CAClF,MAAM,aAAa,UAAA,QAAK,UAAU,WAAW,EAAE;CAE/C,MAAM,eAAe,eADJ,UAAA,QAAK,QAAQ,UAAU,UACJ,CAAQ;CAC5C,IAAI,iBAAiB,MAAM,OAAO;CAClC,IAAI,iBAAiB,YAAY,CAAC,aAAa,WAAW,WAAW,UAAA,QAAK,GAAG,GAC3E,OAAO;CAET,OAAO;AACT;AAEA,SAAS,eAAe,SAAgC;CACtD,IAAI;EACF,QAAA,GAAA,QAAA,aAAA,CAAoB,OAAO;CAC7B,QAAQ;EACN,OAAO;CACT;AACF;;;ACOA,IAAa,sBAAsB,CAAC,KAAK;AACzC,IAAa,kBAAkB,CAAC,SAAS,MAAM;;;AAiB/C,SAAgB,cAAc,MAAc,OAAe,YAA8C;CACvG,MAAM,OAAO,wBAAA,iBAAiB,OAAO,UAAU;CAC/C,IAAI,SAAS,MAAM,OAAO;CAC1B,IAAI,SAAS,YAAY,OAAO,UAAA,QAAK,QAAQ,MAAM,KAAK;CACxD,OAAO,UAAA,QAAK,WAAW,KAAK,IAAI,UAAA,QAAK,QAAQ,KAAK,IAAI;AACxD;;AAGA,eAAsB,aAAa,MAAc,OAAe,YAAiD;CAC/G,MAAM,UAAU,cAAc,MAAM,OAAO,UAAU;CACrD,OAAO,YAAY,OAAO,QAAS,MAAM,kBAAkB,OAAO,MAAO;AAC3E;AASA,SAAS,eAAe,EAAE,SAAS,cAA6B,OAAuB;CACrF,MAAM,UAAU,cAAc,QAAQ,GAAG,OAAO,UAAU;CAC1D,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,iBAAiB,OAAO;CAC9D,OAAO;AACT;;;;;;;AAQA,eAAe,kBAAkB,SAAyC;CACxE,IAAI;EACF,MAAM,SAAS,OAAA,GAAA,iBAAA,SAAA,CAAe,OAAO;EACrC,QAAQ,OAAA,GAAA,iBAAA,KAAA,CAAa,MAAM,EAAA,CAAG,OAAO,IAAI,SAAS;CACpD,QAAQ;EACN,OAAO;CACT;AACF;;;AAIA,eAAe,gBAAgB,SAAwB,KAA8B;CACnF,MAAM,SAAS,MAAM,kBAAkB,eAAe,SAAS,GAAG,CAAC;CACnE,IAAI,WAAW,MAAM,MAAM,IAAI,MAAM,qBAAqB,KAAK;CAC/D,OAAO;AACT;;;;AAKA,SAAS,YAAY,WAAyC;CAC5D,aAAa,QAAQ,uBAAO,IAAI,MAAM,mCAAmC,WAAW,CAAC;AACvF;;;;AAKA,SAAgB,oBAAoB,SAAuC;CACzE,OAAO;EACL,MAAM,OAAO,SAAA,GAAA,iBAAA,SAAA,CAAiB,MAAM,gBAAgB,SAAS,GAAG,GAAG,OAAO;EAC1E,WAAW,OAAO,QAAQ;GACxB,MAAM,MAAM,OAAA,GAAA,iBAAA,SAAA,CAAe,MAAM,gBAAgB,SAAS,GAAG,CAAC;GAC9D,OAAO,IAAI,WAAW,IAAI,QAAQ,IAAI,YAAY,IAAI,UAAU;EAClE;EACA,OAAO,OAAO,KAAK,YAAY,eAAA,gBAAgB,MAAM,gBAAgB,SAAS,GAAG,GAAG,OAAO;EAC3F,SAAS,YAAY,SAAS;EAC9B,MAAM,OAAO,QAAQ;GACnB,MAAM,EAAE,SAAS,SAAS,OAAA,GAAA,iBAAA,KAAA,CAAa,eAAe,SAAS,GAAG,CAAC;GACnE,OAAO;IAAE;IAAS;GAAK;EACzB;EACA,SAAS,QAAQ,aAAa,QAAQ,QAAQ,GAAG,KAAK,QAAQ,UAAU;EACxE,QAAQ,YAAY,QAAQ;CAC9B;AACF;;;ACxGA,IAAM,4BAA4B;AAClC,IAAM,2BAA2B;AAEjC,IAAM,wBAAwB;AAE9B,SAAS,eAAe,SAAkC;CACxD,IAAI;CACJ,IAAI;EACF,UAAU,QACP,QAAQ,OAAO,EAAE,CAAC,CAClB,MAAM,GAAG,CAAC,CACV,KAAK,YAAY,mBAAmB,OAAO,CAAC;CACjD,QAAQ;EAEN,OAAO;CACT;CAMA,OAAO,QAAQ,MAAM,YAAY,QAAQ,SAAS,GAAG,KAAK,QAAQ,SAAS,IAAI,CAAC,IAAI,OAAO;AAC7F;;;;AAKA,SAAgB,2BAA2B,eAAuB,SAAgC;CAChG,MAAM,UAAU,eAAe,OAAO;CACtC,IAAI,YAAY,QAAQ,QAAQ,SAAS,GAAG,OAAO;CACnD,MAAM,CAAC,OAAO,GAAG,QAAQ;CACzB,IAAI,UAAU,6BAA6B,UAAU,0BAA0B,OAAO;CACtF,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,QAAQ,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,IAAI,CAAC,GACnI,OAAO;CAET,IAAI,UAAU,2BAA2B,OAAO,UAAA,QAAK,QAAQ,eAAe,GAAG,IAAI;CAGnF,MAAM,YAAY,sBAAsB,KAAK,KAAK,EAAE,IAAI,KAAK,KAAK,GAAG,IAAI,IAAI,KAAK,KAAK,GAAG;CAI1F,OAAO,UAAA,QAAK,WAAW,SAAS,IAAI,UAAA,QAAK,QAAQ,SAAS,IAAI;AAChE"}
@@ -2,3 +2,5 @@ export { writeFileAtomic, writeFileAtomicSync, type WriteAtomicOptions } from '.
2
2
  export { writeJsonAtomic } from './json.js';
3
3
  export { isEnoent, resolveWithinRoot } from './safe.js';
4
4
  export { toPosixRelPath, joinPosixRelPath } from './relPath.js';
5
+ export { resolveByPath, existsAsFile, createByPathFileOps, MARKDOWN_EXTENSIONS, HTML_EXTENSIONS, type ByPathFileOps, type ByPathOptions } from './byPath.js';
6
+ export { resolveHtmlFileRequestPath } from './htmlFileRequest.js';
@@ -1,7 +1,9 @@
1
+ import { classifyFilePath } from "../artifacts/paths.js";
1
2
  import { n as writeFileAtomicSync, t as writeFileAtomic } from "../atomic-DPpdrJzO.js";
2
3
  import { n as toPosixRelPath, t as joinPosixRelPath } from "../relPath-DW8MC8VO.js";
3
4
  import { realpathSync } from "node:fs";
4
5
  import path from "node:path";
6
+ import { readFile, realpath, stat } from "node:fs/promises";
5
7
  //#region src/files/json.ts
6
8
  var JSON_INDENT = 2;
7
9
  /** Atomic JSON write (2-space indent) — serialize then write through the atomic
@@ -36,6 +38,106 @@ function realpathOrNull(absPath) {
36
38
  }
37
39
  }
38
40
  //#endregion
39
- export { isEnoent, joinPosixRelPath, resolveWithinRoot, toPosixRelPath, writeFileAtomic, writeFileAtomicSync, writeJsonAtomic };
41
+ //#region src/files/byPath.ts
42
+ var MARKDOWN_EXTENSIONS = [".md"];
43
+ var HTML_EXTENSIONS = [".html", ".htm"];
44
+ /** Absolute on-disk path for a caller-supplied path with one of `extensions`,
45
+ * or null when the value is not usable on this platform. */
46
+ function resolveByPath(root, value, extensions) {
47
+ const kind = classifyFilePath(value, extensions);
48
+ if (kind === null) return null;
49
+ if (kind === "relative") return path.resolve(root, value);
50
+ return path.isAbsolute(value) ? path.resolve(value) : null;
51
+ }
52
+ /** True when the path names an existing regular file. */
53
+ async function existsAsFile(root, value, extensions) {
54
+ const absPath = resolveByPath(root, value, extensions);
55
+ return absPath === null ? false : await regularFileTarget(absPath) !== null;
56
+ }
57
+ function resolveOrThrow({ rootFor, extensions }, value) {
58
+ const absPath = resolveByPath(rootFor(), value, extensions);
59
+ if (absPath === null) throw new Error(`invalid path: ${value}`);
60
+ return absPath;
61
+ }
62
+ /** The canonical REGULAR FILE a path names, or null when it is missing, is a
63
+ * directory, or is anything else (a FIFO would block a read forever).
64
+ * Resolved through `realpath` so a symlink is judged — and later written — by
65
+ * what it points at: `writeFileAtomic` renames a temp file into place, which
66
+ * through a link would replace the link itself and leave the real document
67
+ * untouched. */
68
+ async function regularFileTarget(absPath) {
69
+ try {
70
+ const target = await realpath(absPath);
71
+ return (await stat(target)).isFile() ? target : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+ /** The file to read or overwrite. Throws rather than creating: neither tool
77
+ * ever writes to a path that does not already hold a document. */
78
+ async function existingFileFor(options, rel) {
79
+ const target = await regularFileTarget(resolveOrThrow(options, rel));
80
+ if (target === null) throw new Error(`no file exists at ${rel}`);
81
+ return target;
82
+ }
83
+ /** Browsing and deleting are NOT part of this capability: it exists so a view
84
+ * can read and re-save the ONE document the tool call named, not to enumerate
85
+ * a directory it was never pointed at or remove a file. */
86
+ function unsupported(operation) {
87
+ return () => Promise.reject(/* @__PURE__ */ new Error(`byPath FileOps does not support ${operation}`));
88
+ }
89
+ /** FileOps over caller-supplied paths — what a host injects as `files.byPath`
90
+ * for plugins whose `path` argument may leave their artifact directory. Every
91
+ * method takes the same value the tool call carried, not a scope-relative one. */
92
+ function createByPathFileOps(options) {
93
+ return {
94
+ read: async (rel) => readFile(await existingFileFor(options, rel), "utf-8"),
95
+ readBytes: async (rel) => {
96
+ const buf = await readFile(await existingFileFor(options, rel));
97
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
98
+ },
99
+ write: async (rel, content) => writeFileAtomic(await existingFileFor(options, rel), content),
100
+ readDir: unsupported("readDir"),
101
+ stat: async (rel) => {
102
+ const { mtimeMs, size } = await stat(resolveOrThrow(options, rel));
103
+ return {
104
+ mtimeMs,
105
+ size
106
+ };
107
+ },
108
+ exists: (rel) => existsAsFile(options.rootFor(), rel, options.extensions),
109
+ unlink: unsupported("unlink")
110
+ };
111
+ }
112
+ //#endregion
113
+ //#region src/files/htmlFileRequest.ts
114
+ var HTML_FILE_SCOPE_WORKSPACE = "ws";
115
+ var HTML_FILE_SCOPE_ABSOLUTE = "abs";
116
+ var WINDOWS_DRIVE_ONLY_RE = /^[a-zA-Z]:$/;
117
+ function decodeSegments(reqPath) {
118
+ let decoded;
119
+ try {
120
+ decoded = reqPath.replace(/^\//, "").split("/").map((segment) => decodeURIComponent(segment));
121
+ } catch {
122
+ return null;
123
+ }
124
+ return decoded.some((segment) => segment.includes("/") || segment.includes("\\")) ? null : decoded;
125
+ }
126
+ /** Absolute path for a `/htmlfile/<scope>/<segments…>` request, or null when
127
+ * the URL is malformed, uses an unknown scope, or touches a `.` / `..` /
128
+ * dotfile segment. `workspaceRoot` should already be a realpath. */
129
+ function resolveHtmlFileRequestPath(workspaceRoot, reqPath) {
130
+ const decoded = decodeSegments(reqPath);
131
+ if (decoded === null || decoded.length < 2) return null;
132
+ const [scope, ...rest] = decoded;
133
+ if (scope !== HTML_FILE_SCOPE_WORKSPACE && scope !== HTML_FILE_SCOPE_ABSOLUTE) return null;
134
+ if (rest.length === 0) return null;
135
+ if (rest.some((segment) => segment === "" || segment === "." || segment === ".." || segment.startsWith(".") || segment.includes("\0"))) return null;
136
+ if (scope === HTML_FILE_SCOPE_WORKSPACE) return path.resolve(workspaceRoot, ...rest);
137
+ const candidate = WINDOWS_DRIVE_ONLY_RE.test(rest[0]) ? rest.join("/") : `/${rest.join("/")}`;
138
+ return path.isAbsolute(candidate) ? path.resolve(candidate) : null;
139
+ }
140
+ //#endregion
141
+ export { HTML_EXTENSIONS, MARKDOWN_EXTENSIONS, createByPathFileOps, existsAsFile, isEnoent, joinPosixRelPath, resolveByPath, resolveHtmlFileRequestPath, resolveWithinRoot, toPosixRelPath, writeFileAtomic, writeFileAtomicSync, writeJsonAtomic };
40
142
 
41
143
  //# sourceMappingURL=index.js.map