Skip to content
Merged
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
75 changes: 75 additions & 0 deletions tests/sanitize-path.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
39 changes: 27 additions & 12 deletions web/src/lib/sanitize-path.ts
Original file line number Diff line number Diff line change
@@ -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/<name>/Dev/zero-agent/data/projects/<projectId>/...
* /var/zero/projects/<projectId>/...
* 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/<id>` in production,
* `/var/zero/projects/<id>` on the code default, or
* `/Users/<name>/Dev/zero-agent/data/projects/<id>` 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/<id>/foo`);
* - the container deploy root (`/app/...`) and any leftover Unix home dir
* (`/Users/<name>/...`) collapse to `~/…` — i.e. "outside the project".
*/

// Matches a "projects/<projectId>/" 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/<projectId>` (plus
// any trailing slash), regardless of which root it lives under. We
// anchor on `/projects/<id>` 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/<id>/.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/<name>/...` paths.
// Catch-all for remaining `/Users/<name>/...` 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/<name>` references.
out = out.replace(HOME_RE, "~");
return out;
Expand Down
Loading