mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d1e0cd2e | ||
|
|
a6f975f49a | ||
|
|
ba42900d1a | ||
|
|
14ec5a8ae9 |
@@ -1,24 +1,46 @@
|
||||
import { existsSync, rmSync, statSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { materializeProviderImage } from "./provider-image-output.js";
|
||||
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
|
||||
|
||||
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
|
||||
test("writes image attachments under a private temp directory", () => {
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
let testHome: string | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-posix-"));
|
||||
process.env.PASEO_HOME = testHome;
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("writes image attachments under a private directory in the paseo home", () => {
|
||||
const materialized = materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const attachmentDir = path.dirname(materialized.path);
|
||||
|
||||
try {
|
||||
expect(path.basename(attachmentDir)).toMatch(/^paseo-attachments-/);
|
||||
expect(existsSync(materialized.path)).toBe(true);
|
||||
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
rmSync(attachmentDir, { recursive: true, force: true });
|
||||
}
|
||||
// Exactly "paseo-attachments" — no mkdtemp suffix. The old per-process temp
|
||||
// directory is what let the OS reap these images out from under the app.
|
||||
expect(path.basename(attachmentDir)).toBe("paseo-attachments");
|
||||
expect(attachmentDir).toBe(path.join(testHome as string, "paseo-attachments"));
|
||||
expect(existsSync(materialized.path)).toBe(true);
|
||||
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "./provider-image-output.js";
|
||||
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
// Materialized images live under the paseo home now, so without this every test
|
||||
// run would read and delete the developer's real provider images.
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
let testHome: string | null = null;
|
||||
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
|
||||
|
||||
function useTempPaseoHome(): string {
|
||||
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-"));
|
||||
process.env.PASEO_HOME = testHome;
|
||||
return testHome;
|
||||
}
|
||||
|
||||
function attachmentsDir(): string {
|
||||
if (!testHome) {
|
||||
throw new Error("useTempPaseoHome() must run before reading the attachments dir.");
|
||||
}
|
||||
return path.join(testHome, "paseo-attachments");
|
||||
}
|
||||
|
||||
function renderImageMarkdown(imagePath: string): string {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
@@ -92,27 +111,91 @@ describe("isProviderImageMarkdown", () => {
|
||||
});
|
||||
|
||||
describe("materializeProviderImage", () => {
|
||||
test("recreates the private temp directory if the cached directory is removed", () => {
|
||||
beforeEach(async () => {
|
||||
useTempPaseoHome();
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("recreates the private directory if the cached directory is removed", () => {
|
||||
const first = materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const firstDir = path.dirname(first.path);
|
||||
expect(existsSync(first.path)).toBe(true);
|
||||
|
||||
rmSync(firstDir, { recursive: true, force: true });
|
||||
rmSync(path.dirname(first.path), { recursive: true, force: true });
|
||||
|
||||
const second = materializeProviderImage({
|
||||
data: "ZGVmNDU2",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
const secondDir = path.dirname(second.path);
|
||||
|
||||
try {
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
} finally {
|
||||
rmSync(secondDir, { recursive: true, force: true });
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
});
|
||||
|
||||
test("stores images in a stable directory under the paseo home", () => {
|
||||
const result = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// A fixed directory, not a per-process mkdtemp one — the old shape is what
|
||||
// let the OS reap these images out from under the app after ~3 days.
|
||||
expect(path.dirname(result.path)).toBe(attachmentsDir());
|
||||
expect(path.basename(path.dirname(result.path))).toBe("paseo-attachments");
|
||||
// Mode assertions live in the .posix suite: Windows ignores mkdir/writeFile
|
||||
// modes and reports 0o666, so asserting them here fails on that runner.
|
||||
});
|
||||
|
||||
test("is idempotent for the same bytes across daemon restarts", async () => {
|
||||
const first = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// Reload the module while retaining the files from the previous instance.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
const second = restarted.materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
expect(second.path).toBe(first.path);
|
||||
expect(readdirSync(attachmentsDir())).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("emits markdown that isProviderImageMarkdown still recognizes", () => {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: "YWJjMTIz", mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
throw new Error("Expected provider image output to render as assistant markdown.");
|
||||
}
|
||||
expect(isProviderImageMarkdown(item.text)).toBe(true);
|
||||
});
|
||||
|
||||
// The whole point of moving out of the temp dir is that nothing deletes these
|
||||
// behind the user's back. An age-based sweep would reintroduce that on a
|
||||
// longer fuse, because mtime cannot tell a referenced image from an abandoned
|
||||
// one and reading a transcript never touches the file.
|
||||
test("keeps images no matter how old they are", async () => {
|
||||
const image = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
const longAgoSeconds = Date.now() / 1000 - 365 * 24 * 60 * 60;
|
||||
utimesSync(image.path, longAgoSeconds, longAgoSeconds);
|
||||
|
||||
// Reloading the module must not sweep images referenced by older transcripts.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
|
||||
|
||||
expect(existsSync(image.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import { resolvePaseoHome } from "../../paseo-home.js";
|
||||
|
||||
export interface ProviderImageOutput {
|
||||
path?: string | null;
|
||||
@@ -17,11 +17,13 @@ export interface MaterializedProviderImage {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Keep this basename compatible with provider-image Markdown in existing timelines.
|
||||
const PROVIDER_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||
const PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX = `${PROVIDER_IMAGE_ATTACHMENT_DIR}-`;
|
||||
const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
|
||||
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
|
||||
|
||||
// Persist images across process lifetimes. Age-based cleanup would delete files
|
||||
// still referenced by transcripts; any future cleanup must account for references.
|
||||
let materializedImageAttachmentDir: string | null = null;
|
||||
|
||||
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
|
||||
@@ -45,11 +47,11 @@ function getMaterializedImageAttachmentDir(): string {
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
|
||||
materializedImageAttachmentDir = fsSync.mkdtempSync(
|
||||
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
|
||||
);
|
||||
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return materializedImageAttachmentDir;
|
||||
const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
|
||||
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
materializedImageAttachmentDir = dir;
|
||||
return dir;
|
||||
}
|
||||
|
||||
function getImageExtension(mimeType: string): string {
|
||||
@@ -81,9 +83,7 @@ function normalizeImageData(mimeType: string, data: string): { mimeType: string;
|
||||
return { mimeType, data };
|
||||
}
|
||||
|
||||
// Filenames are a content hash of the bytes so re-materializing the same image
|
||||
// within a process reuses the existing temp file instead of leaking a fresh one
|
||||
// for repeated image blocks or history replay.
|
||||
// Content-addressed filenames deduplicate repeated image bytes across restarts.
|
||||
export function materializeProviderImage(image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
|
||||
Reference in New Issue
Block a user