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
4 changes: 4 additions & 0 deletions changelog.d/default-paste-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- Pasted and attached files no longer show up in the project's Git status: the default
`.ovis` folder now ignores itself, and its files still open by the path the prompt
names. An ignore file already in that folder, or an `OVIS_PASTE_DIR` folder, is left as is.
117 changes: 116 additions & 1 deletion server/api.contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeAll, afterAll, spyOn } from "bun:test";
import { Database } from "bun:sqlite";
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, copyFileSync, mkdirSync, mkdtempSync, existsSync, readFileSync, readdirSync, realpathSync, openSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, watch, writeFileSync } from "node:fs";
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, copyFileSync, mkdirSync, mkdtempSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, openSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, watch, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
Expand Down Expand Up @@ -2108,8 +2108,28 @@ describe("POST /api/pane/image", () => {
afterAll(async () => {
if (qaWorkspaceId) await herdrRpc("workspace.close", { workspace_id: qaWorkspaceId });
if (qaCwd) rmSync(qaCwd, { recursive: true, force: true });
for (const project of gitProjects.splice(0)) {
await herdrRpc("workspace.close", { workspace_id: project.workspaceId });
rmSync(project.cwd, { recursive: true, force: true });
}
});

/** A pane of its own whose cwd is a fresh Git project, so `git status` sees what an upload leaves. */
const gitProjects: { cwd: string; workspaceId: string }[] = [];
const gitProjectPane = async (label: string) => {
const cwd = mkdtempSync(join(tmpdir(), "ovis-image-git-"));
expect(Bun.spawnSync(["git", "init", "-q", cwd]).exitCode).toBe(0);
const created = await herdrRpc<{ workspace: { workspace_id: string }; root_pane: { pane_id: string } }>(
"workspace.create",
{ label, cwd, focus: false },
);
gitProjects.push({ cwd, workspaceId: created.workspace.workspace_id });
return { cwd, paneId: created.root_pane.pane_id };
};
const git = (cwd: string, ...args: string[]) => Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" });
const gitStatus = (cwd: string) => git(cwd, "status", "--porcelain", "--untracked-files=all").stdout.toString().split("\n").filter(Boolean);
const pathOf = async (res: Response) => ((await res.json()) as { path: string }).path;

it("stores a pasted image under the pane cwd and returns its absolute path", async () => {
const res = await post({ pane_id: qaPaneId, content_type: "image/png", data_base64: TINY_PNG_BASE64 });
expect(res.status).toBe(200);
Expand Down Expand Up @@ -2211,6 +2231,101 @@ describe("POST /api/pane/image", () => {
expect(basename(bare.path)).toMatch(/^file-.*\.bin$/);
});

it("keeps a pasted image and a named file out of Git status, each readable by its returned path", async () => {
const { cwd, paneId } = await gitProjectPane("ovis-test-image-git");
const png = Buffer.from(TINY_PNG_BASE64, "base64");
const json = Buffer.from('{"tool":"herdr","ok":true}\n');
// both at once: whichever creates the folder's ignore file first, the other finds it there
const answers = await Promise.all([
post({ pane_id: paneId, content_type: "image/png", data_base64: png.toString("base64") }),
post({ pane_id: paneId, content_type: "application/json", data_base64: json.toString("base64"), name: "report.json" }),
]);
expect(answers.map((res) => res.status)).toEqual([200, 200]);
const paths = await Promise.all(answers.map(pathOf));
expect(readFileSync(join(cwd, ".ovis", ".gitignore"), "utf8")).toBe("*\n");
expect(gitStatus(cwd)).toEqual([]);
for (const [index, path] of paths.entries()) {
const ignored = git(cwd, "check-ignore", path);
expect(ignored.exitCode).toBe(0);
expect(ignored.stdout.toString().trim()).toBe(path);
// ignored is not unreachable: the explicit path still opens, on disk and through the viewer's route
const bytes = index === 0 ? png : json;
expect(readFileSync(path).equals(bytes)).toBe(true);
const opened = await fetch(`${base()}/api/fs/file?pane_id=${encodeURIComponent(paneId)}&path=${encodeURIComponent(path)}`);
expect(opened.status).toBe(200);
expect(Buffer.from(await opened.arrayBuffer()).equals(bytes)).toBe(true);
}
});

it("preserves an existing ignore file during concurrent uploads", async () => {
const upload = (paneId: string) => Promise.all(Array.from({ length: 6 }, (_, index) => post({
pane_id: paneId,
content_type: "application/json",
data_base64: Buffer.from(`{"n":${index}}`).toString("base64"),
name: `n${index}.json`,
})));
const own = await gitProjectPane("ovis-test-image-own-ignore");
mkdirSync(join(own.cwd, ".ovis"));
const rules = "# kept by hand\n*.png\n!keep.txt";
writeFileSync(join(own.cwd, ".ovis", ".gitignore"), rules);
expect((await upload(own.paneId)).map((res) => res.status)).toEqual(Array(6).fill(200));
expect(readFileSync(join(own.cwd, ".ovis", ".gitignore"), "utf8")).toBe(rules);
// the user's own rules still decide: they leave .json files, and the ignore file itself, visible
expect(gitStatus(own.cwd)).toHaveLength(7);
if (process.platform === "win32") return; // creating a symlink needs developer mode there
const outside = mkdtempSync(join(tmpdir(), "ovis-image-rules-"));
try {
writeFileSync(join(outside, "rules"), "shared\n");
for (const target of [join(outside, "rules"), join(outside, "missing")]) {
const linked = await gitProjectPane("ovis-test-image-linked-ignore");
mkdirSync(join(linked.cwd, ".ovis"));
symlinkSync(target, join(linked.cwd, ".ovis", ".gitignore"));
expect((await upload(linked.paneId)).map((res) => res.status)).toEqual(Array(6).fill(200));
expect(lstatSync(join(linked.cwd, ".ovis", ".gitignore")).isSymbolicLink()).toBe(true);
expect(readlinkSync(join(linked.cwd, ".ovis", ".gitignore"))).toBe(target);
}
// nothing was written through either link
expect(readFileSync(join(outside, "rules"), "utf8")).toBe("shared\n");
expect(existsSync(join(outside, "missing"))).toBe(false);
} finally {
rmSync(outside, { recursive: true, force: true });
}
});

it("writes no ignore file into a OVIS_PASTE_DIR override, even one naming the default folder", async () => {
const { cwd, paneId } = await gitProjectPane("ovis-test-image-override");
const previous = process.env["OVIS_PASTE_DIR"];
const elsewhere = mkdtempSync(join(tmpdir(), "ovis-image-override-"));
try {
for (const override of [` ${elsewhere} `, join(cwd, ".ovis")]) {
process.env["OVIS_PASTE_DIR"] = override;
const answers = await Promise.all([0, 1].map(() => post({ pane_id: paneId, content_type: "image/png", data_base64: TINY_PNG_BASE64 })));
expect(answers.map((res) => res.status)).toEqual([200, 200]);
for (const path of await Promise.all(answers.map(pathOf))) expect(path.startsWith(join(override.trim(), "paste-"))).toBe(true);
expect(existsSync(join(override.trim(), ".gitignore"))).toBe(false);
}
// a folder the user picked shows in Git status like any other
expect(gitStatus(cwd)).toHaveLength(2);
} finally {
if (previous === undefined) delete process.env["OVIS_PASTE_DIR"]; else process.env["OVIS_PASTE_DIR"] = previous;
rmSync(elsewhere, { recursive: true, force: true });
}
});

it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("answers an error, not a path, when the ignore file cannot be created", async () => {
const { cwd, paneId } = await gitProjectPane("ovis-test-image-unwritable");
mkdirSync(join(cwd, ".ovis"));
chmodSync(join(cwd, ".ovis"), 0o500);
try {
const res = await post({ pane_id: paneId, content_type: "image/png", data_base64: TINY_PNG_BASE64 });
expect(res.status).toBe(500);
expect(((await res.json()) as { path?: string }).path).toBeUndefined();
expect(existsSync(join(cwd, ".ovis", ".gitignore"))).toBe(false);
} finally {
chmodSync(join(cwd, ".ovis"), 0o700);
}
});

it("stores a 20 MiB file whole (regression: 413 while the ceiling was 8 MiB)", async () => {
const bytes = Buffer.alloc(20 * 1024 * 1024, 7);
const res = await post({ pane_id: qaPaneId, content_type: "application/zip", data_base64: bytes.toString("base64"), name: "big.zip" });
Expand Down
15 changes: 15 additions & 0 deletions server/file-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,21 @@ describe("locateFile", () => {
expect(locateFile("demo.mp4", root)).toMatchObject({ info: { path: join(root, "recordings/demo.mp4") } });
});

it("opens an explicitly named ignored paste", () => {
const root = temp();
expect(Bun.spawnSync(["git", "init", "-q", root]).exitCode).toBe(0);
mkdirSync(join(root, ".ovis"));
writeFileSync(join(root, ".ovis/.gitignore"), "*\n");
const path = join(root, ".ovis/notes-20261021-120000-0123abcd.json");
writeFileSync(path, '{"a":1}');
clearFileCache();
// ignored keeps it out of Git status, not out of reach: the absolute path the upload answered
// and the project-relative one both open it
expect(Bun.spawnSync(["git", "-C", root, "check-ignore", "-q", path]).exitCode).toBe(0);
expect(locateFile(path, root)).toMatchObject({ info: { path } });
expect(locateFile(".ovis/notes-20261021-120000-0123abcd.json", root)).toMatchObject({ info: { path } });
});

it("walks the subfolders of a folder holding thousands of files outside a repository", () => {
const root = temp();
for (let index = 0; index < 1_500; index += 1) writeFileSync(join(root, `page-${index}.txt`), "");
Expand Down
109 changes: 107 additions & 2 deletions server/paste.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";

import type { PaneInfo } from "../shared/protocol.ts";
import * as client from "./herdr/client.ts";
import { MAX_IMAGE_BYTES, PasteImageError, pasteDirectory, savePaneImage } from "./paste.ts";
import { MAX_IMAGE_BYTES, PasteImageError, pasteDirectory, preparePasteDirectory, savePaneImage } from "./paste.ts";

describe("pasteDirectory", () => {
const previous = process.env["OVIS_PASTE_DIR"];
Expand Down Expand Up @@ -158,3 +158,108 @@ describe("savePaneImage", () => {
expect(paneGet).toHaveBeenCalledTimes(5);
});
});

describe("preparePasteDirectory", () => {
const saved = { dir: process.env["OVIS_PASTE_DIR"], tmp: process.env["TMPDIR"] };
const roots: string[] = [];
const temp = () => { const root = mkdtempSync(join(tmpdir(), "herdr-paste-dir-")); roots.push(root); return root; };
afterEach(() => {
if (saved.dir === undefined) delete process.env["OVIS_PASTE_DIR"]; else process.env["OVIS_PASTE_DIR"] = saved.dir;
if (saved.tmp === undefined) delete process.env["TMPDIR"]; else process.env["TMPDIR"] = saved.tmp;
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

it("creates a self-ignoring default paste directory", () => {
for (const value of [undefined, "", " "]) {
if (value === undefined) delete process.env["OVIS_PASTE_DIR"]; else process.env["OVIS_PASTE_DIR"] = value;
const cwd = temp();
const directory = preparePasteDirectory(cwd);
expect(directory).toBe(join(cwd, ".ovis"));
expect(readFileSync(join(directory, ".gitignore"), "utf8")).toBe("*\n");
if (process.platform !== "win32") expect(statSync(join(directory, ".gitignore")).mode & 0o777).toBe(0o600);
// the next paste finds it there and leaves it
expect(preparePasteDirectory(cwd)).toBe(directory);
expect(readFileSync(join(directory, ".gitignore"), "utf8")).toBe("*\n");
}
});

it.skipIf(process.platform === "win32")("ignores the temp-dir fallback of a pane without a cwd too", () => {
delete process.env["OVIS_PASTE_DIR"];
const tmp = temp();
process.env["TMPDIR"] = tmp;
const directory = preparePasteDirectory(null);
expect(directory).toBe(join(tmp, "ovis", ".ovis"));
expect(readFileSync(join(directory, ".gitignore"), "utf8")).toBe("*\n");
});

it("keeps an ignore file that is already there, a link included, byte for byte", () => {
delete process.env["OVIS_PASTE_DIR"];
const custom = temp();
mkdirSync(join(custom, ".ovis"));
writeFileSync(join(custom, ".ovis", ".gitignore"), "# mine\n*.png\n!keep.txt");
preparePasteDirectory(custom);
expect(readFileSync(join(custom, ".ovis", ".gitignore"), "utf8")).toBe("# mine\n*.png\n!keep.txt");
if (process.platform === "win32") return; // creating a symlink needs developer mode there
const outside = temp();
writeFileSync(join(outside, "rules"), "shared\n");
for (const target of [join(outside, "rules"), join(outside, "missing")]) {
const cwd = temp();
mkdirSync(join(cwd, ".ovis"));
symlinkSync(target, join(cwd, ".ovis", ".gitignore"));
preparePasteDirectory(cwd);
expect(lstatSync(join(cwd, ".ovis", ".gitignore")).isSymbolicLink()).toBe(true);
expect(readlinkSync(join(cwd, ".ovis", ".gitignore"))).toBe(target);
}
// neither the link's file nor a dangling link's target is written through it
expect(readFileSync(join(outside, "rules"), "utf8")).toBe("shared\n");
expect(existsSync(join(outside, "missing"))).toBe(false);
});

it.skipIf(process.platform === "win32")("writes no ignore file through a .ovis that is a link to another folder", () => {
delete process.env["OVIS_PASTE_DIR"];
const outside = temp();
writeFileSync(join(outside, "user-file"), "theirs\n");
const cwd = temp();
symlinkSync(outside, join(cwd, ".ovis"));
expect(preparePasteDirectory(cwd)).toBe(join(cwd, ".ovis"));
// the link's target, another repository's root for example, is left as it was
expect(readdirSync(outside)).toEqual(["user-file"]);
});

it("never creates an ignore in a trimmed nonempty override", () => {
const cwd = temp();
const override = join(temp(), "pastes");
process.env["OVIS_PASTE_DIR"] = ` ${override} `;
expect(preparePasteDirectory(cwd)).toBe(override);
expect(statSync(override).isDirectory()).toBe(true);
expect(existsSync(join(override, ".gitignore"))).toBe(false);
// an override naming the default path is still the user's own directory
process.env["OVIS_PASTE_DIR"] = join(cwd, ".ovis");
expect(preparePasteDirectory(cwd)).toBe(join(cwd, ".ovis"));
expect(existsSync(join(cwd, ".ovis", ".gitignore"))).toBe(false);
// and so is one under ~, expanded against a home of the test's own (a child reads HOME at start)
const home = temp();
const child = Bun.spawnSync([process.execPath, "-e", `const { preparePasteDirectory } = await import(${JSON.stringify(join(import.meta.dir, "paste.ts"))}); console.log(preparePasteDirectory(${JSON.stringify(cwd)}));`], {
env: { ...process.env, HOME: home, USERPROFILE: home, OVIS_PASTE_DIR: " ~/pastes " },
stdout: "pipe",
stderr: "pipe",
});
expect(child.stderr.toString()).toBe("");
expect(child.stdout.toString().trim()).toBe(join(home, "pastes"));
expect(statSync(join(home, "pastes")).isDirectory()).toBe(true);
expect(existsSync(join(home, "pastes", ".gitignore"))).toBe(false);
});

it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("reports an ignore file it could not create", () => {
delete process.env["OVIS_PASTE_DIR"];
const cwd = temp();
mkdirSync(join(cwd, ".ovis"));
chmodSync(join(cwd, ".ovis"), 0o500);
try {
expect(() => preparePasteDirectory(cwd)).toThrow(expect.objectContaining({ code: "EACCES" }));
expect(existsSync(join(cwd, ".ovis", ".gitignore"))).toBe(false);
} finally {
chmodSync(join(cwd, ".ovis"), 0o700);
}
});
});
33 changes: 28 additions & 5 deletions server/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
*
* Files land in `<pane cwd>/.ovis/`, not /tmp, on purpose: agents ask before
* reading outside their project directory, and a path inside the project keeps the
* attachment visible (and git-ignorable) next to the conversation. A pane without a
* attachment visible next to the conversation. That folder ignores itself (a `.gitignore`
* of `*`), so attachments stay out of Git status while their paths still open. A pane without a
* known cwd falls back to the OS temp dir - the path still works, it just may cost
* the agent a read-permission prompt. `OVIS_PASTE_DIR` sends every attachment to
* one directory instead, for those who would rather keep them out of the working tree.
*/

import { mkdirSync, writeFileSync } from "node:fs";
import { lstatSync, mkdirSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { imageType } from "./codex-images.ts";
Expand Down Expand Up @@ -79,7 +80,7 @@ function storedName(name: string | undefined): { base: string; extension: string

/** Where a pane's attachments go: `OVIS_PASTE_DIR` when set, else `<cwd>/.ovis`. */
export function pasteDirectory(cwd: string | null | undefined): string {
const override = process.env["OVIS_PASTE_DIR"]?.trim();
const override = pasteDirectoryOverride();
if (override) {
// the plugin's env file is not a shell: expand a leading ~ here, before either separator.
// The separators are dropped with it: a leading one would make the rest an absolute path
Expand All @@ -89,6 +90,29 @@ export function pasteDirectory(cwd: string | null | undefined): string {
return join(cwd ?? join(tmpdir(), "ovis"), ".ovis");
}

function pasteDirectoryOverride(): string {
return process.env["OVIS_PASTE_DIR"]?.trim() ?? "";
}

/**
* Creates the attachment directory and returns it. The default one also gets a `.gitignore`
* of `*`, created exclusively so an ignore file (or link) already there is never followed or
* replaced; a directory the user named in `OVIS_PASTE_DIR` is theirs and gets none. Nor does a `.ovis` that is a
* link: the ignore file would land in the link's target, another folder or repository, and hide files it never wrote.
*/
export function preparePasteDirectory(cwd: string | null | undefined): string {
const directory = pasteDirectory(cwd);
mkdirSync(directory, { recursive: true });
if (pasteDirectoryOverride() === "" && !lstatSync(directory).isSymbolicLink()) {
try {
writeFileSync(join(directory, ".gitignore"), "*\n", { flag: "wx", mode: 0o600 });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
return directory;
}

const EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
Expand Down Expand Up @@ -143,8 +167,7 @@ export async function savePaneImage(options: {

const pane = await paneGet(options.paneId);

const directory = pasteDirectory(pane.cwd);
mkdirSync(directory, { recursive: true });
const directory = preparePasteDirectory(pane.cwd);
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
const unique = `${stamp}-${crypto.randomUUID().slice(0, 8)}`;
const name = named === null ? `paste-${unique}.${imageExtension}` : `${named.base}-${unique}.${named.extension}`;
Expand Down
Loading