Skip to content
Closed
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
15 changes: 13 additions & 2 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,12 @@ describe("AssetAccess", () => {
const separator = suffix.indexOf("/");
const token = suffix.slice(0, separator);
const name = suffix.slice(separator + 1);
const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" };
const expected = {
kind: "file",
path: canonicalFile,
mimeType: "image/svg+xml",
cacheControl: "private, no-store",
};
expect(yield* resolveAsset(token, name)).toMatchObject(expected);
yield* fs.remove(aliasPath);
yield* fs.symlink(replacementPath, aliasPath);
Expand Down Expand Up @@ -640,10 +645,12 @@ describe("AssetAccess", () => {
expect(yield* resolveAsset(token, "report.html")).toEqual({
kind: "file",
path: canonicalHtmlPath,
cacheControl: "private, no-store",
});
expect(yield* resolveAsset(token, "report.css")).toEqual({
kind: "file",
path: canonicalCssPath,
cacheControl: "private, no-store",
});
expect(yield* resolveAsset(token, "../secret.txt")).toBeNull();
expect(yield* resolveAsset(token, ".env")).toBeNull();
Expand Down Expand Up @@ -708,7 +715,11 @@ describe("AssetAccess", () => {
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const token = suffix.slice(0, suffix.indexOf("/"));

expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: canonicalFile });
expect(yield* resolveAsset(token, name)).toEqual({
kind: "file",
path: canonicalFile,
cacheControl: "private, no-store",
});
if (name.endsWith(".png")) {
expect(yield* resolveAsset(token, "other.png")).toBeNull();
}
Expand Down
23 changes: 20 additions & 3 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export type ResolvedAsset =
| {
readonly kind: "file";
readonly path: string;
readonly cacheControl?: string;
readonly download?: boolean;
readonly fileName?: string;
readonly mimeType?: string;
Expand Down Expand Up @@ -953,7 +954,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
Effect.orElseSucceed(() => null),
);
return file
? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset)
? ({
kind: "file",
path: canonicalFile,
mimeType,
file,
cacheControl: "private, no-store",
} satisfies ResolvedAsset)
: null;
}
if (claims.kind === "workspace-file-exact") {
Expand All @@ -963,7 +970,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
relativePath: claims.relativePath,
});
return exactWorkspaceFile
? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset)
? ({
kind: "file",
path: exactWorkspaceFile,
cacheControl: "private, no-store",
} satisfies ResolvedAsset)
: null;
}
const segments = decodedPath.split(/[\\/]/);
Expand All @@ -981,5 +992,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
workspaceRoot: claims.workspaceRoot,
relativePath: joinedRelativePath,
});
return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;
return workspaceFile
? ({
kind: "file",
path: workspaceFile,
cacheControl: "private, no-store",
} satisfies ResolvedAsset)
: null;
});
35 changes: 35 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,41 @@ describe("video asset byte ranges", () => {
}).pipe(Effect.provide(layerFileResponse)),
);

it.effect("serves overwritten image bytes without allowing the browser to cache them", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-image-cache-" });
const filePath = path.join(directory, "image.png");
for (const bytes of ["red", "blue"]) {
yield* fs.writeFileString(filePath, bytes);
const canonicalPath = yield* fs.realPath(filePath);
const file = yield* openMediaFile(canonicalPath);
if (!file) throw new Error("Expected an opened image file");
for (const asset of [{ path: canonicalPath, file }, { path: canonicalPath }]) {
const response = HttpServerResponse.toWeb(
yield* assetFileResponse({ ...asset, cacheControl: "private, no-store" }),
);
expect(response.headers.get("cache-control")).toBe("private, no-store");
expect(yield* Effect.promise(() => response.text())).toBe(bytes);
}
}
}).pipe(Effect.provide(layerFileResponse)),
);

it.effect("keeps immutable image files cacheable", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-immutable-image-" });
const filePath = path.join(directory, "image.png");
yield* fs.writeFileString(filePath, "image bytes");
const response = HttpServerResponse.toWeb(yield* assetFileResponse({ path: filePath }));
expect(response.headers.get("cache-control")).toBe("private, max-age=3600");
expect(yield* Effect.promise(() => response.text())).toBe("image bytes");
}).pipe(Effect.provide(layerFileResponse)),
);

it.effect("keeps attachment media out of the cache once its signed URL expires", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
readonly fileName?: string;
readonly mimeType?: string;
readonly file?: OpenMediaFile;
readonly cacheControl?: string;
readonly renderIntent?: "mcp-app";
},
rangeHeader?: string,
Expand All @@ -192,6 +193,15 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
? HttpServerResponse.empty({ status: 200, headers })
: HttpServerResponse.uint8Array(body, { headers });
}
// Host images can be overwritten while a signed URL is still valid.
if (
asset.cacheControl !== undefined &&
(headers["Content-Type"] ?? Option.getOrElse(Mime.getType(asset.path), () => "")).startsWith(
"image/",
)
) {
headers["Cache-Control"] = asset.cacheControl;
}
const mediaFile = asset.file;
const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;
const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? "");
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/TimelineWorkEntryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
? resolveViewedImageAsset(viewedImagePath, {
threadId: threadRef.threadId,
workspaceRoot,
entryId: workEntry.id,
})
: null;
const canExpand =
Expand Down
41 changes: 39 additions & 2 deletions packages/client-runtime/src/state/assets.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { describe, expect, it } from "@effect/vitest";
import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
import { type AssetCreateUrlResult, EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";

import { createProjectFaviconUrlAtomFamily } from "./assets.ts";
import type * as EnvironmentRegistry from "../connection/registry.ts";
import {
resolveViewedImageAsset,
workEntryViewedImagePath,
type WorkLogPresentationEntry,
} from "../work-log/presentation.ts";
import { createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily } from "./assets.ts";

describe("project favicon URL cache", () => {
it("does not reuse another environment, workspace, or selected icon's cached URL", () => {
Expand Down Expand Up @@ -38,3 +45,33 @@ describe("project favicon URL cache", () => {
}
});
});

describe("image Read preview URL cache", () => {
it("does not reuse an earlier Read's cached URL when the same path is read again", () => {
const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
EnvironmentRegistry.EnvironmentRegistry,
never
>;
const { createUrl } = createAssetEnvironmentAtoms(runtime);
const environmentId = EnvironmentId.make("local");
const threadId = ThreadId.make("thread");
const read = (id: string): WorkLogPresentationEntry => ({
id,
createdAt: "2026-10-21T00:00:00.000Z",
label: "Read",
tone: "tool",
viewedImagePath: "repro.png",
});
const previewUrl = (entry: WorkLogPresentationEntry) => {
const asset = resolveViewedImageAsset(workEntryViewedImagePath(entry)!, {
threadId,
workspaceRoot: "/repo",
entryId: entry.id,
});
return createUrl({ environmentId, input: { resource: asset!.resource } });
};

expect(previewUrl(read("read-red"))).toBe(previewUrl(read("read-red")));
expect(previewUrl(read("read-blue"))).not.toBe(previewUrl(read("read-red")));
});
});
12 changes: 11 additions & 1 deletion packages/client-runtime/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,17 @@ export function createAssetEnvironmentAtoms<R, E>(
});
const createUrl = createEnvironmentQueryAtomFamily(runtime, {
label: "environment-data:assets:create-url",
execute,
execute: (input: AssetCreateUrlInput) =>
execute(input).pipe(
Effect.map((result) => {
if (input.resource._tag !== "media-file" || input.resource.revision === undefined)
return result;
return {
...result,
relativeUrl: `${result.relativeUrl}${result.relativeUrl.includes("?") ? "&" : "?"}revision=${encodeURIComponent(input.resource.revision)}`,
};
}),
),
staleTimeMs: ASSET_URL_STALE_TIME_MS,
idleTtlMs: ASSET_URL_IDLE_TTL_MS,
refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,
Expand Down
10 changes: 9 additions & 1 deletion packages/client-runtime/src/work-log/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ export function resolveViewedImageAsset(
input: {
readonly threadId: ThreadId;
readonly workspaceRoot?: string | null | undefined;
readonly entryId?: string | undefined;
},
): ViewedImageAsset | null {
const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? ".");
Expand All @@ -526,7 +527,14 @@ export function resolveViewedImageAsset(
resolvedFilePath,
});
if (media === null || media.access !== "environment") return null;
return { resource: media.resource, alt: media.name, srcFragment: media.srcFragment };
return {
resource: {
...media.resource,
...(input.entryId !== undefined ? { revision: input.entryId } : {}),
},
alt: media.name,
srcFragment: media.srcFragment,
};
}

function toolGroupActionCount(
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const AssetResource = Schema.Union([
Schema.TaggedStruct("media-file", {
threadId: ThreadId,
path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
// Client cache identity only; it does not change the file being requested.
revision: Schema.optionalKey(Schema.String),
}),
Schema.TaggedStruct("draft-workspace-file", {
cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
Expand Down
45 changes: 45 additions & 0 deletions scripts/evidence/image-freshness/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Compare unscaled full-frame RGBA captures. Requires Pillow 12.3.0.

Usage: compare.py BEFORE AFTER OUTPUT_PREFIX [--repeat FIXED_REPEAT]
Diff0 is always BEFORE versus AFTER with zero threshold; repeat noise is separate.
"""
import argparse
import json
from pathlib import Path
from PIL import Image, ImageChops, __version__


def compare(first, second, prefix):
a, b = Image.open(first).convert('RGBA'), Image.open(second).convert('RGBA')
assert a.size == b.size, 'No scaling permitted'
delta = ImageChops.difference(a, b)
values = [max(pixel) for pixel in delta.getdata()]
mask0 = Image.new('L', a.size)
mask0.putdata([255 if value else 0 for value in values])
mask16 = Image.new('L', a.size)
mask16.putdata([255 if value > 16 else 0 for value in values])
amplified = delta.point(lambda value: min(255, value * 8)).convert('RGB')
amplified.save(str(prefix) + '-diff0-amplified.png')
overlay = b.convert('RGB')
overlay.paste((255, 0, 255), mask=mask16)
overlay.save(str(prefix) + '-diffT16-overlay.png')
return {'size': a.size, 'totalPixels': len(values), 'maxChannelDelta': max(values), 'diff0': {'changedPixels': sum(value > 0 for value in values), 'bbox': mask0.getbbox()}, 'diffT16': {'changedPixels': sum(value > 16 for value in values), 'percentage': 100 * sum(value > 16 for value in values) / len(values), 'bbox': mask16.getbbox()}}


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('before')
parser.add_argument('after')
parser.add_argument('prefix', type=Path)
parser.add_argument('--repeat')
args = parser.parse_args()
args.prefix.parent.mkdir(parents=True, exist_ok=True)
report = {'pillow': __version__, 'threshold': 16, 'amplification': 8, 'masks': [], 'transforms': [], 'comparison': compare(args.before, args.after, args.prefix)}
if args.repeat:
report['repeatNoise'] = compare(args.after, args.repeat, str(args.prefix) + '-repeat')
Path(str(args.prefix) + '.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report))

if __name__ == '__main__':
main()
Loading
Loading