diff --git a/tests/sanitize-path.test.ts b/tests/sanitize-path.test.ts new file mode 100644 index 0000000..75e5a80 --- /dev/null +++ b/tests/sanitize-path.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { sanitizePath, sanitizeValue } from "../web/src/lib/sanitize-path.ts"; + +describe("sanitizePath", () => { + it("re-roots the production /app/data path at /", () => { + // Regression: PI_PROJECTS_ROOT=/app/data/projects (see server/.ocd-deploy.json) + // used to leave the `/app` segment dangling -> `/app./.pi`. + expect(sanitizePath("/app/data/projects/3fjAOHPDTXR8rg4aYn3dU/.pi")).toBe("/.pi"); + expect(sanitizePath("/app/data/projects/abc/foo/bar.ts")).toBe("/foo/bar.ts"); + }); + + it("re-roots the /var/zero code-default path at /", () => { + expect(sanitizePath("/var/zero/projects/abc/.pi")).toBe("/.pi"); + }); + + it("re-roots the local-dev /Users path at /", () => { + expect(sanitizePath("/Users/anton/Dev/zero-agent/data/projects/abc/x.ts")).toBe("/x.ts"); + }); + + it("re-roots a project path embedded in surrounding text", () => { + expect(sanitizePath('read "/app/data/projects/abc/.pi/SYSTEM.md" ok')).toBe( + 'read "/.pi/SYSTEM.md" ok', + ); + }); + + it("collapses the project root itself to /", () => { + expect(sanitizePath("/app/data/projects/abc")).toBe("/"); + }); + + it("collapses the container deploy root /app to ~ (non-project internals)", () => { + expect(sanitizePath("zero-sdk -> /app/zero/src/sdk")).toBe("zero-sdk -> ~/zero/src/sdk"); + expect(sanitizePath("/app/node_modules/x")).toBe("~/node_modules/x"); + expect(sanitizePath("/app")).toBe("~"); + }); + + it("does NOT collapse an app/ directory that lives inside a project", () => { + // The project's own `app/` dir re-roots to `/app/...` and must be left + // alone — only the container root `/app` at a path boundary collapses. + expect(sanitizePath("/app/data/projects/abc/app/main.ts")).toBe("/app/main.ts"); + expect(sanitizePath("/app/data/projects/abc/sub/app/x")).toBe("/sub/app/x"); + }); + + it("handles project and container-root paths in the same string", () => { + expect(sanitizePath("see /app/data/projects/abc/.pi and /app/zero/y")).toBe( + "see /.pi and ~/zero/y", + ); + }); + + it("leaves unrelated absolute paths untouched (no project segment)", () => { + expect(sanitizePath("no project path here /etc/hosts")).toBe("no project path here /etc/hosts"); + }); + + it("collapses leftover /Users home paths to ~", () => { + expect(sanitizePath("/Users/anton/.config/thing")).toBe("~/.config/thing"); + }); + + it("is a no-op on empty input", () => { + expect(sanitizePath("")).toBe(""); + }); +}); + +describe("sanitizeValue", () => { + it("recurses through objects and arrays", () => { + const input = { + path: "/app/data/projects/abc/.pi", + nested: { files: ["/var/zero/projects/abc/a.ts", "plain"] }, + count: 3, + }; + expect(sanitizeValue(input)).toEqual({ + path: "/.pi", + nested: { files: ["/a.ts", "plain"] }, + count: 3, + }); + }); +}); diff --git a/web/src/lib/sanitize-path.ts b/web/src/lib/sanitize-path.ts index 46db448..c9dd01f 100644 --- a/web/src/lib/sanitize-path.ts +++ b/web/src/lib/sanitize-path.ts @@ -1,25 +1,40 @@ /** * Strip server-internal absolute path prefixes from strings shown in the UI. * - * The server stores project workspaces under paths like - * /Users//Dev/zero-agent/data/projects//... - * /var/zero/projects//... - * Pi tools echo absolute paths back in their args/results. Showing them in - * the chat reveals server internals — rewrite them to a project-relative - * form. Any other absolute path under a Unix home dir is collapsed to `~/…`. + * The server stores project workspaces under a configurable root + * (PI_PROJECTS_ROOT) — e.g. `/app/data/projects/` in production, + * `/var/zero/projects/` on the code default, or + * `/Users//Dev/zero-agent/data/projects/` in local dev. Pi tools + * echo absolute paths back in their args/results. Showing them in the chat + * reveals server internals, so we rewrite them: + * - the project workspace reads as the filesystem root (`/foo` rather than + * the real `/app/data/projects//foo`); + * - the container deploy root (`/app/...`) and any leftover Unix home dir + * (`/Users//...`) collapse to `~/…` — i.e. "outside the project". */ -// Matches a "projects//" segment anywhere in an absolute path -// preceded by something that looks like a workspace root marker. -const PROJECTS_RE = - /(?:\/Users\/[^/]+\/[^/]+\/[^/]+\/data|\/var\/zero|\/data|)\/projects\/[A-Za-z0-9_-]+(\/|$)/g; +// One pass, two alternatives (project prefix is tried first so it wins on a +// path that lives under the workspace): +// +// 1. The absolute prefix up to and including `/projects/` (plus +// any trailing slash), regardless of which root it lives under. We +// anchor on `/projects/` and greedily consume every leading +// `/segment` so the whole prefix is replaced -> `/`. Earlier versions +// only matched a known `/var/zero` or `/data` marker and left stray +// leading segments behind (e.g. `/app/data/projects//.pi` -> +// `/app./.pi`). +// 2. The container deploy root `/app`, but only at a path boundary (start +// of string or after whitespace/quote/`=`) so we never touch an `app/` +// directory *inside* a project (e.g. a re-rooted `/app/main.ts`) -> `~`. +const SANITIZE_RE = + /(?:\/[^/\s"'`]+)*\/projects\/[A-Za-z0-9_-]+(?:\/|$)|(?<=^|[\s"'`(=])\/app(?=\/|$)/g; -// Catch-all for remaining `/Users//...` paths. +// Catch-all for remaining `/Users//...` paths (local dev). const HOME_RE = /\/Users\/[^/\s"'`]+/g; export function sanitizePath(input: string): string { if (!input) return input; - let out = input.replace(PROJECTS_RE, (_match, tail) => "./" + (tail === "/" ? "" : "")); + let out = input.replace(SANITIZE_RE, (m) => (m === "/app" ? "~" : "/")); // Collapse any leftover `/Users/` references. out = out.replace(HOME_RE, "~"); return out;