Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 9bf04b7351 fix(server): retain restart-safe images with async processing
Integrate current main while preserving stable attachment storage, async materialization, bounded decoding and atomic publication. Adapt the restart regression and private-storage tests to the asynchronous API and isolate all materialization tests from the real home.
2026-10-27 09:30:35 +00:00
paseo-bot[bot]andChristoph Leiter 89d1e0cd2e fix(server): reuse provider image files across daemon restarts
Integrate persistent provider-image storage with current main while retaining image paragraph boundaries. Replaying identical image bytes must reuse the existing content-addressed file instead of leaving another temporary copy after each restart.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-10-26 04:18:01 +00:00
Christoph Leiter a6f975f49a fix(server): stop sweeping materialized images by age
The 30-day sweep reintroduced the bug this PR fixes, on a longer fuse.
mtime cannot distinguish an image a transcript still references from an
abandoned one, and reading a transcript never touches the file, so an
image you still look at ages out and disappears.

It was also a regression on hosts whose temp dir is not reaped on a
schedule. There these images survived indefinitely; adding our own
deleter made them mortal.

Nothing deletes them now. Content addressing bounds growth to distinct
images rather than renders. Bounding the directory properly needs to be
reference-aware, against the agent timelines that point into it.
2026-07-30 06:05:50 +02:00
Christoph Leiter ba42900d1a test(server): keep POSIX mode assertions out of the cross-platform suite
Windows ignores mkdir/writeFile modes and reports 0o666, so the directory
and file mode assertions failed on the windows-latest runner. The .posix
suite already covers both and skips on win32.
2026-07-30 06:05:50 +02:00
Christoph LeiterandClaude Opus 4.8 14ec5a8ae9 fix(server): materialize provider images under the paseo home
Provider images were materialized into a per-process directory created
with mkdtemp(os.tmpdir(), "paseo-attachments-"). The OS reaps that
location -- macOS clears /var/folders after roughly three days -- so
every image in a transcript older than the reap window was silently
orphaned: the stored markdown still pointed at a path whose bytes no
longer existed, and nothing could recreate them.

Materialize into resolvePaseoHome()/paseo-attachments/ instead. The
directory keeps its 0700 mode and the files their 0600 mode, and the
basename is unchanged so PROVIDER_IMAGE_MARKDOWN still matches history
written by older builds (the optional "-<suffix>" group covers the old
mkdtemp shape).

A stable directory is no longer self-limiting, so add a best-effort
sweep of entries older than 30 days, run once per process on the first
image materialized. Re-materializing an image rewrites it and refreshes
its mtime, so images still being rendered keep themselves alive and only
genuinely cold ones age out.

This also makes the existing content-hash filename deduplication work
across daemon restarts; previously it only held within a single process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 06:05:50 +02:00
24 changed files with 161 additions and 250 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-zgyG4roTTTOnNkqOu10gnwwG8GXcjrKH6XlqOvvqKQo=
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
+1 -4
View File
@@ -90,7 +90,6 @@ import {
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import {
createWorkspaceFileTabTarget,
type WorkspaceFileLocation,
@@ -460,7 +459,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files",
});
});
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -468,9 +466,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
[workspaceRoot, client, openInlineFile, openInlineDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
@@ -71,9 +71,6 @@ it("ignores an older probe after a newer file link opens", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
@@ -98,9 +95,6 @@ it("ignores a pending probe when its view is left", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
@@ -108,25 +102,3 @@ it("ignores a pending probe when its view is left", async () => {
await pending;
expect(opened).toEqual([]);
});
it("sends folders outside the workspace away from the workspace Files panel", async () => {
const { createInlinePathOpener } = await import("./path-open");
const workspaceFolders: string[] = [];
const outsideFolders: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/Users/me/repo",
client: null,
openFile: () => {},
openDirectory: (directory) => {
workspaceFolders.push(directory);
},
openOutsideDirectory: (directory) => {
outsideFolders.push(directory);
},
});
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
await opener.open({ raw, path: raw }, "preferred");
}
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
@@ -14,23 +14,12 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget =
| { kind: "file" }
| { kind: "directory"; directoryPath: string }
| { kind: "outsideDirectory"; directoryPath: string };
// Workspace paths arrive relative to the root; anything still absolute or
// home-relative lives outside it, where the Files panel cannot list it.
function isOutsideWorkspacePath(pathValue: string): boolean {
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return isOutsideWorkspacePath(input.directory)
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
return { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
@@ -53,7 +42,6 @@ export function createInlinePathOpener(input: {
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
}) {
let generation = 0;
return {
@@ -70,8 +58,6 @@ export function createInlinePathOpener(input: {
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
-1
View File
@@ -67,7 +67,6 @@ export const ar: TranslationResources = {
daemonClientUnavailable: "عميل Daemon غير متوفر",
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
},
connectionStatus: {
-1
View File
@@ -63,7 +63,6 @@ export const en = {
daemonClientUnavailable: "Daemon client unavailable",
daemonClientDisconnected: "Daemon client is disconnected",
noFileFound: "No file found for {{token}}",
folderOutsideWorkspace: "{{path}} is outside this workspace",
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const es: TranslationResources = {
daemonClientUnavailable: "ClienteDaemonno disponible",
daemonClientDisconnected: "El clienteDaemonestá desconectado",
noFileFound: "No se encontró ningún archivo para{{token}}",
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const fr: TranslationResources = {
daemonClientUnavailable: "Client du daemon indisponible",
daemonClientDisconnected: "Le client du daemon est déconnecté",
noFileFound: "Aucun fichier trouvé pour {{token}}",
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ja: TranslationResources = {
daemonClientUnavailable: "デーモンクライアントが利用できません",
daemonClientDisconnected: "デーモンクライアントが切断されています",
noFileFound: "{{token}}のファイルが見つかりません",
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
},
connectionStatus: {
-1
View File
@@ -67,7 +67,6 @@ export const ko: TranslationResources = {
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ptBR: TranslationResources = {
daemonClientUnavailable: "Cliente do daemon indisponível",
daemonClientDisconnected: "O cliente do daemon está desconectado",
noFileFound: "Nenhum arquivo encontrado para {{token}}",
folderOutsideWorkspace: "{{path}} está fora deste workspace",
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ru: TranslationResources = {
daemonClientUnavailable: "Daemon клиента недоступен",
daemonClientDisconnected: "Daemon клиента отключен",
noFileFound: "Файл для {{token}} не найден",
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
},
connectionStatus: {
-1
View File
@@ -67,7 +67,6 @@ export const zhCN: TranslationResources = {
daemonClientUnavailable: "Daemon client 不可用",
daemonClientDisconnected: "Daemon client 已断开连接",
noFileFound: "未找到 {{token}} 对应的文件",
folderOutsideWorkspace: "{{path}} 不在此工作区内",
unexpectedDictationError: "处理听写时发生意外错误。",
},
connectionStatus: {
@@ -64,7 +64,6 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
import { useToast } from "@/contexts/toast-context";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -3683,7 +3682,6 @@ function WorkspaceScreenContent({
view: "files",
});
});
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -3691,15 +3689,8 @@ function WorkspaceScreenContent({
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
openOutsideDirectory: openMarkdownOutsideDirectory,
}),
[
workspaceDirectory,
client,
openMarkdownFile,
openMarkdownDirectory,
openMarkdownOutsideDirectory,
],
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
@@ -1,36 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/contexts/toast-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
/**
* Opens a folder outside the workspace in the system file manager. The Files
* panel only lists the workspace, so hosts without a local file manager get a
* toast instead.
*/
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
const { t } = useTranslation();
const toast = useToast();
const isLocalExecution = useIsLocalDaemon(serverId);
const { targets } = useDesktopOpenTargets({ isLocalExecution });
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
return useCallback(
(folderPath: string) => {
const showOutsideWorkspace = () =>
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
if (!fileManagerTarget) {
showOutsideWorkspace();
return;
}
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
(error: unknown) => {
console.warn("[open-outside-workspace-folder] open failed", error);
showOutsideWorkspace();
},
);
},
[fileManagerTarget, t, toast],
);
}
@@ -86,10 +86,6 @@ class FakeEditorTargets implements EditorTargetRuntime {
return this.paths.has(targetPath);
}
expandHomePath(targetPath: string): string {
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
}
isAbsolutePath(targetPath: string): boolean {
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
}
@@ -517,19 +513,6 @@ describe("editor target registry", () => {
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
});
it("opens a home-relative folder in the file manager", async () => {
const runtime = new FakeEditorTargets();
runtime.addPath("/home/me/Downloads/contracts");
await openEditorTarget(
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
runtime,
[fileManagerTarget],
);
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
});
it("keeps the platform file-manager ids used by stored preferences", async () => {
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
finderTarget,
@@ -97,16 +97,10 @@ export function getEditorTarget(
}
export async function openEditorTarget(
request: EditorTargetLaunchInput & { editorId: string },
input: EditorTargetLaunchInput & { editorId: string },
runtime: EditorTargetRuntime,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): Promise<void> {
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
const input = {
...request,
workspacePath: runtime.expandHomePath(request.workspacePath),
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
};
if (!runtime.isAbsolutePath(input.workspacePath)) {
throw new Error("Editor target workspace path must be an absolute local path");
}
@@ -61,14 +61,6 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function expandHomePath(value: string, homeDirectory: string): string {
if (value === "~") return homeDirectory;
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(homeDirectory, value.slice(2));
}
return value;
}
function resolveExecutable(
commands: readonly string[],
input: {
@@ -174,7 +166,6 @@ export function createEditorTargetRuntime(
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
listDirectory,
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
@@ -30,8 +30,6 @@ export interface EditorTargetRuntime {
pathExists(path: string): boolean;
isAbsolutePath(path: string): boolean;
listDirectory(path: string): EditorTargetDirectoryEntry[];
/** Resolves a leading `~` to the user's home directory. */
expandHomePath(path: string): string;
resolveCommand(commands: readonly string[]): string | null;
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
openPath(path: string): Promise<void>;
@@ -997,9 +997,7 @@ class AcpRuntime {
): string {
if (messageId) {
this.fallbackChunkIds.delete(kind);
// Native IDs identify a message within its chunk kind. Timeline IDs must
// also distinguish reasoning from replies and from generated fallback IDs.
return JSON.stringify([kind, messageId]);
return messageId;
}
let id = this.fallbackChunkIds.get(kind);
if (!id) {
+4 -77
View File
@@ -1307,15 +1307,7 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
const items = events.flatMap((event) =>
event.type === "timeline.item" &&
(event.item.type === "assistant_message" || event.item.type === "reasoning")
? [
{
id: event.item.id,
text: event.item.text,
...(event.item.type === "assistant_message" && event.item.messageId
? { messageId: event.item.messageId }
: {}),
},
]
? [{ id: event.item.id, text: event.item.text }]
: [],
);
expect(items).toEqual([
@@ -1323,11 +1315,7 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
{ id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" },
{ id: "agent_message_chunk:2", text: "next turn" },
{ id: "agent_thought_chunk:3", text: "think A" },
{
id: JSON.stringify(["agent_message_chunk", "m-explicit"]),
messageId: "m-explicit",
text: "say",
},
{ id: "m-explicit", text: "say" },
{ id: "agent_thought_chunk:4", text: "think B" },
]);
await connection.close();
@@ -1414,21 +1402,6 @@ async function streamChunkTurns(
}
describe("ACP streamed message boundaries", () => {
it("keeps reasoning separate from a reply sharing its native message ID", async () => {
expect(
await streamChunkTurns([
textChunk("private ", "agent_thought_chunk", "shared-id"),
textChunk("reasoning", "agent_thought_chunk", "shared-id"),
textChunk("public ", "agent_message_chunk", "shared-id"),
textChunk("reply", "agent_message_chunk", "shared-id"),
]),
).toEqual([
{ type: "reasoning", text: "private reasoning" },
{ type: "assistant_message", text: "public reply" },
{ type: "assistant_message", text: "Next turn" },
]);
});
it.each([
{
name: "tool call",
@@ -1611,12 +1584,8 @@ describe("ACP session history replay", () => {
);
try {
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " },
{
type: "user_message",
id: '["user_message_chunk","user-1"]',
text: "What is the temperature?",
},
{ type: "user_message", id: "user-1", text: "What is the " },
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
{ type: "tool_call", id: "tool-1", status: "running" },
{ type: "tool_call", id: "tool-1", status: "completed" },
@@ -1630,48 +1599,6 @@ describe("ACP session history replay", () => {
}
});
it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => {
const { connection, events } = await openReplaySession(
[
userChunk("What is the ", "shared-id"),
userChunk("temperature?", "shared-id"),
textChunk("Checking ", "agent_thought_chunk", "shared-id"),
textChunk("the sensor", "agent_thought_chunk", "shared-id"),
textChunk("It is ", "agent_message_chunk", "shared-id"),
textChunk("25°C", "agent_message_chunk", "shared-id"),
],
"replay",
);
try {
const items = new Map(
events.flatMap((event) =>
event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [],
),
);
expect([...items.values()]).toEqual([
{
type: "user_message",
id: '["user_message_chunk","shared-id"]',
messageId: "shared-id",
text: "What is the temperature?",
},
{
type: "reasoning",
id: '["agent_thought_chunk","shared-id"]',
text: "Checking the sensor",
},
{
type: "assistant_message",
id: '["agent_message_chunk","shared-id"]',
messageId: "shared-id",
text: "It is 25°C",
},
]);
} finally {
await connection.close();
}
});
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
const { connection, events } = await openReplaySession(
[
@@ -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", async () => {
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", async () => {
const materialized = await 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,18 +1,45 @@
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
utimesSync,
statSync,
readFileSync,
} 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 {
MAX_PROVIDER_IMAGE_BYTES,
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");
}
async function renderImageMarkdown(imagePath: string): Promise<string> {
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") {
@@ -94,28 +121,95 @@ describe("isProviderImageMarkdown", () => {
});
});
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;
}
});
describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", async () => {
test("recreates the private directory if the cached directory is removed", async () => {
const first = await 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 = await 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", async () => {
const result = await 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 = await 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 = await 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", async () => {
const item = await 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 = await 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");
await restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
expect(existsSync(image.path)).toBe(true);
});
});
@@ -1,10 +1,10 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
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;
@@ -18,11 +18,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;
// Serialize materializations: one decoder and one file operation at a time, including
// concurrent history replay and live turns. A rejected operation must not poison the queue.
@@ -37,14 +39,14 @@ async function getMaterializedImageAttachmentDir(): Promise<string> {
return materializedImageAttachmentDir;
}
} catch {
/* Temp storage may have been removed between turns. */
/* Attachment storage may have been removed between turns. */
}
}
materializedImageAttachmentDir = await fs.mkdtemp(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
);
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR);
await fs.mkdir(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
await fs.chmod(dir, PRIVATE_ATTACHMENT_DIR_MODE);
materializedImageAttachmentDir = dir;
return dir;
}
function getImageExtension(mimeType: string): string {
@@ -71,6 +73,7 @@ function getImageExtension(mimeType: string): string {
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
// Content-addressed filenames deduplicate repeated image bytes across restarts.
export function materializeProviderImage(image: {
data: string;
mimeType: string | null;