diff --git a/coworker/server/manager.py b/coworker/server/manager.py index b2d9be0c..187df5ae 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1184,15 +1184,51 @@ def browser_screenshot(self) -> dict[str, Any]: def browser_close(self) -> dict[str, Any]: return browser_close_session() + def _artifact_roots(self, session_id: str) -> list[tuple[Path, bool]]: + """Return the session's granted directories in resolution order. + + The live engine is authoritative because ``request_directory`` mutates its shared + roots list immediately. Without a live engine, reconstruct the same list from the + persisted conversation. Unknown sessions retain the historical standalone-server + behaviour of using ``default_workspace``. + """ + engine = self._engines.get(session_id) + if engine is not None and getattr(engine, "roots", None): + raw = [(Path(r.path), bool(r.writable)) for r in engine.roots] + else: + record = self.session_store.load(session_id) + if record and record.workspace: + raw = [(Path(record.workspace), True)] + raw.extend( + (Path(str(r.get("path", ""))), bool(r.get("writable", False))) + for r in (record.extra_roots or []) + if str(r.get("path", "")).strip() + ) + elif self.default_workspace: + raw = [(Path(self.default_workspace), True)] + else: + raw = [] + + out: list[tuple[Path, bool]] = [] + seen: set[Path] = set() + for path, writable in raw: + root = path.expanduser().resolve() + if root in seen: + continue + seen.add(root) + out.append((root, writable)) + return out + def list_artifacts(self, session_id: str) -> list[dict[str, Any]]: - record = self.session_store.load(session_id) - workspace = record.workspace if record else self.default_workspace - if not workspace: - return [] - root = Path(workspace).expanduser().resolve() - if not root.is_dir(): + roots = [ + root + for root, writable in self._artifact_roots(session_id) + if writable and root.is_dir() + ] + if not roots: return [] out: list[dict[str, Any]] = [] + seen_files: set[Path] = set() suffixes = { ".md", ".markdown", @@ -1222,32 +1258,40 @@ def list_artifacts(self, session_id: str) -> list[dict[str, Any]]: ".doc", ".docm", } - for path in root.rglob("*"): - try: - rel = path.relative_to(root) - if any( - part.startswith(".") - or part in {"node_modules", "target", "dist", "__pycache__"} - for part in rel.parts - ): - continue - if not path.is_file() or path.suffix.lower() not in suffixes: + for index, root in enumerate(roots): + for path in root.rglob("*"): + try: + rel = path.relative_to(root) + if any( + part.startswith(".") + or part in {"node_modules", "target", "dist", "__pycache__"} + for part in rel.parts + ): + continue + if not path.is_file() or path.suffix.lower() not in suffixes: + continue + resolved = path.resolve() + # Do not list a symlink that escapes this granted directory. + resolved.relative_to(root) + if resolved in seen_files: + continue + seen_files.add(resolved) + st = resolved.stat() + out.append( + { + # Preserve workspace-relative identifiers for the primary root. + # Extra-root artifacts use their absolute path so duplicate relative + # names remain unambiguous when the viewer calls read/reveal. + "path": str(rel) if index == 0 else str(resolved), + "abs_path": str(resolved), + "name": resolved.name, + "kind": _artifact_kind(resolved), + "size": st.st_size, + "modified_at": st.st_mtime, + } + ) + except (OSError, ValueError): continue - st = path.stat() - out.append( - { - "path": str(rel), - # Absolute path for "Copy path" — the relative one is useless outside - # the app (tester catch 2026-07-12: it copied just the filename). - "abs_path": str(path), - "name": path.name, - "kind": _artifact_kind(path), - "size": st.st_size, - "modified_at": st.st_mtime, - } - ) - except OSError: - continue out.sort(key=lambda a: a["modified_at"], reverse=True) return out[:80] @@ -1256,20 +1300,29 @@ def list_artifacts(self, session_id: str) -> list[dict[str, Any]]: def _artifact_target( self, session_id: str, path: str ) -> tuple[Optional[Path], Optional[str]]: - """Resolve an artifact path under the session's workspace, or (None, error).""" - record = self.session_store.load(session_id) - workspace = record.workspace if record else self.default_workspace - if not workspace: + """Resolve an artifact inside any directory granted to this session.""" + roots = [root for root, _writable in self._artifact_roots(session_id)] + if not roots: return None, "no workspace" - root = Path(workspace).expanduser().resolve() - target = (root / path).expanduser().resolve() - try: - target.relative_to(root) - except ValueError: - return None, "path escapes workspace" - if not target.is_file(): - return None, "not found" - return target, None + + requested = Path(path).expanduser() + candidates = ( + [requested.resolve()] + if requested.is_absolute() + else [(root / requested).resolve() for root in roots] + ) + confined = False + for target in candidates: + if not any(_path_is_under(target, root) for root in roots): + continue + confined = True + if target.is_file(): + return target, None + return ( + (None, "not found") + if confined + else (None, "path escapes session directories") + ) def read_artifact(self, session_id: str, path: str) -> dict[str, Any]: target, err = self._artifact_target(session_id, path) @@ -3717,6 +3770,14 @@ def _recent_files(workspace: str, *, since: float, limit: int = 20) -> list[str] return out +def _path_is_under(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + def _artifact_kind(path: Path) -> str: suffix = path.suffix.lower() if suffix in {".md", ".markdown"}: diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index c700cdd0..b9e94041 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -176,7 +176,7 @@ export async function deleteSession(sessionId: string): Promise<{ ok: boolean; e } export interface ArtifactInfo { - path: string; // workspace-relative (the display/API identifier) + path: string; // primary-root relative or absolute for an additional granted root abs_path?: string; // absolute — what "Copy path" copies name: string; kind: "markdown" | "html" | "image" | "code" | "text" | string; diff --git a/surfaces/gui/src/artifactPaths.test.ts b/surfaces/gui/src/artifactPaths.test.ts new file mode 100644 index 00000000..a6b908e3 --- /dev/null +++ b/surfaces/gui/src/artifactPaths.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { artifactBaseName, decodeArtifactPath, findArtifact } from "./artifactPaths"; + +const external = { + path: "/Users/example/Documents/新闻摘要_2026-07-27.md", + abs_path: "/Users/example/Documents/新闻摘要_2026-07-27.md", + name: "新闻摘要_2026-07-27.md", +}; + +describe("artifact paths", () => { + it("decodes URL-encoded Unicode paths but preserves malformed literal percent names", () => { + expect(decodeArtifactPath("%E6%96%B0%E9%97%BB%E6%91%98%E8%A6%81.md")).toBe("新闻摘要.md"); + expect(decodeArtifactPath("progress-100%.md")).toBe("progress-100%.md"); + }); + + it("extracts names with POSIX or Windows separators", () => { + expect(artifactBaseName("reports/summary.md")).toBe("summary.md"); + expect(artifactBaseName("C:\\Reports\\summary.md")).toBe("summary.md"); + }); + + it("matches encoded bare names and relative paths against external artifacts", () => { + expect( + findArtifact([external], "%E6%96%B0%E9%97%BB%E6%91%98%E8%A6%81_2026-07-27.md"), + ).toBe(external); + expect(findArtifact([external], "Documents/新闻摘要_2026-07-27.md")).toBe(external); + expect(findArtifact([external], external.path)).toBe(external); + }); +}); diff --git a/surfaces/gui/src/artifactPaths.ts b/surfaces/gui/src/artifactPaths.ts new file mode 100644 index 00000000..19310bed --- /dev/null +++ b/surfaces/gui/src/artifactPaths.ts @@ -0,0 +1,36 @@ +type ArtifactPath = { path: string; abs_path?: string; name: string }; + +export function decodeArtifactPath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + // A literal percent in a filename is valid even when it is not URL encoding. + return path; + } +} + +export function normalizeArtifactPath(path: string): string { + return decodeArtifactPath(path).replace(/\\/g, "/").replace(/^\.\/+/, ""); +} + +export function artifactBaseName(path: string): string { + const normalized = normalizeArtifactPath(path); + return normalized.split("/").pop() || normalized; +} + +export function findArtifact(list: T[], path: string): T | undefined { + const requested = normalizeArtifactPath(path); + const bareName = !requested.includes("/"); + return list.find((artifact) => { + const candidates = [artifact.path, artifact.abs_path].filter(Boolean) as string[]; + if ( + candidates.some((candidate) => { + const normalized = normalizeArtifactPath(candidate); + return normalized === requested || normalized.endsWith("/" + requested); + }) + ) { + return true; + } + return bareName && normalizeArtifactPath(artifact.name) === requested; + }); +} diff --git a/surfaces/gui/src/components/Markdown.test.tsx b/surfaces/gui/src/components/Markdown.test.tsx index b2d4fee0..171c7417 100644 --- a/surfaces/gui/src/components/Markdown.test.tsx +++ b/surfaces/gui/src/components/Markdown.test.tsx @@ -35,4 +35,16 @@ describe("Markdown artifact links", () => { render(); expect(screen.getByTestId("artifact-chip").textContent).toContain("report.pdf"); }); + + it("dispatches a decoded Unicode filename", () => { + const seen: string[] = []; + const listener = (e: Event) => seen.push((e as CustomEvent).detail.path); + window.addEventListener(OPEN_ARTIFACT_EVENT, listener); + + render(); + fireEvent.click(screen.getByTestId("artifact-chip")); + expect(seen).toEqual(["新闻摘要.md"]); + + window.removeEventListener(OPEN_ARTIFACT_EVENT, listener); + }); }); diff --git a/surfaces/gui/src/components/Markdown.tsx b/surfaces/gui/src/components/Markdown.tsx index 33670427..a8bb22a9 100644 --- a/surfaces/gui/src/components/Markdown.tsx +++ b/surfaces/gui/src/components/Markdown.tsx @@ -1,5 +1,6 @@ import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { artifactBaseName, decodeArtifactPath } from "../artifactPaths"; import { Icon } from "./Icon"; // §34 (UX-016): the agent ends a deliverable turn with plain markdown — @@ -10,14 +11,17 @@ import { Icon } from "./Icon"; export const OPEN_ARTIFACT_EVENT = "ocw-open-artifact"; function ArtifactChip({ path, title }: { path: string; title: string }) { - const file = path.split("/").pop() || path; + const decodedPath = decodeArtifactPath(path); + const file = artifactBaseName(decodedPath); return (