Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 105 additions & 44 deletions coworker/server/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]

Expand All @@ -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)
Expand Down Expand Up @@ -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"}:
Expand Down
2 changes: 1 addition & 1 deletion surfaces/gui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions surfaces/gui/src/artifactPaths.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
36 changes: 36 additions & 0 deletions surfaces/gui/src/artifactPaths.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ArtifactPath>(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;
});
}
12 changes: 12 additions & 0 deletions surfaces/gui/src/components/Markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,16 @@ describe("Markdown artifact links", () => {
render(<Markdown text="[](artifact:out/report.pdf)" />);
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(<Markdown text="[News](artifact:%E6%96%B0%E9%97%BB%E6%91%98%E8%A6%81.md)" />);
fireEvent.click(screen.getByTestId("artifact-chip"));
expect(seen).toEqual(["新闻摘要.md"]);

window.removeEventListener(OPEN_ARTIFACT_EVENT, listener);
});
});
10 changes: 7 additions & 3 deletions surfaces/gui/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -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 —
Expand All @@ -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 (
<button
className="art-chip"
data-testid="artifact-chip"
title={path}
title={decodedPath}
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_ARTIFACT_EVENT, { detail: { path } }))
window.dispatchEvent(
new CustomEvent(OPEN_ARTIFACT_EVENT, { detail: { path: decodedPath } }),
)
}
>
<span className="art-chip-ico">
Expand Down
9 changes: 4 additions & 5 deletions surfaces/gui/src/components/RightRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ArtifactContent,
type ArtifactInfo,
} from "../api";
import { artifactBaseName, findArtifact } from "../artifactPaths";
import type { TodoItem } from "../types";
import { AccessSection } from "./AccessSection";
import { Icon } from "./Icon";
Expand Down Expand Up @@ -122,25 +123,23 @@ export function RightRail({
if (!active) return;
const minimal = (path: string): ArtifactInfo => ({
path,
name: path.split("/").pop() || path,
name: artifactBaseName(path),
kind: kindFromPath(path),
size: 0,
modified_at: 0,
});
const match = (list: ArtifactInfo[], path: string) =>
list.find((a) => a.path === path || a.path.endsWith("/" + path) || a.name === path);
const onOpen = (e: Event) => {
const path = String((e as CustomEvent).detail?.path || "");
if (!path) return;
const found = match(artifacts, path);
const found = findArtifact(artifacts, path);
if (found) {
setSelected(found);
return;
}
getArtifacts(sessionId)
.then((list) => {
setArtifacts(list);
setSelected(match(list, path) ?? minimal(path));
setSelected(findArtifact(list, path) ?? minimal(path));
})
.catch(() => setSelected(minimal(path)));
};
Expand Down
19 changes: 19 additions & 0 deletions tests/test_multiroot.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,25 @@ def test_add_and_remove_roots_live_and_persisted(tmp_path):
assert str(ro.resolve()) not in persisted


def test_artifacts_follow_live_root_grants(tmp_path):
mgr = _cowork_manager(tmp_path)
shared = tmp_path / "shared_artifacts"
shared.mkdir()
artifact = shared / "实时摘要.md"
artifact.write_text("live", encoding="utf-8")
sid = "live-artifacts"
mgr.get_engine(sid, agent="cowork")

assert not mgr.list_artifacts(sid)

mgr.add_root(sid, str(shared), writable=True)
listed = mgr.list_artifacts(sid)
assert [item["path"] for item in listed] == [str(artifact.resolve())]

mgr.remove_root(sid, str(shared))
assert not mgr.list_artifacts(sid)


def test_add_root_before_first_turn_persists(tmp_path):
"""Adding a folder on a brand-new conversation (no record, no engine yet) must survive:
the manager creates a minimal cowork record so the grant isn't lost (GUI start panel).
Expand Down
Loading