diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index a5d0a2f6616b4..0a86f3fd249db 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -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); @@ -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(); @@ -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(); } diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c25dce2494f26..e0cb140d2cf6b 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -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; @@ -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") { @@ -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(/[\\/]/); @@ -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; }); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 48c75a1240351..186a63380db2e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -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; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8871a5df34517..3a95a5114f7e0 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -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, @@ -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"] ?? ""); diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 457e960e9fb62..f240c39f6c728 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -302,6 +302,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? resolveViewedImageAsset(viewedImagePath, { threadId: threadRef.threadId, workspaceRoot, + entryId: workEntry.id, }) : null; const canExpand = diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index e156688ea584f..1f662b71a4fe0 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -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", () => { @@ -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"))); + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 22052e6ade824..b5b1dfc730818 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -165,7 +165,17 @@ export function createAssetEnvironmentAtoms( }); 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, diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f16b0722abe3c..7d3f532a2b73d 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -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 ?? "."); @@ -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( diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index e21a71dd7dd25..4b2ea3fd1dc18 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -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)), diff --git a/scripts/evidence/image-freshness/compare.py b/scripts/evidence/image-freshness/compare.py new file mode 100755 index 0000000000000..579ed9e3718bf --- /dev/null +++ b/scripts/evidence/image-freshness/compare.py @@ -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() diff --git a/scripts/evidence/image-freshness/fixture.py b/scripts/evidence/image-freshness/fixture.py new file mode 100755 index 0000000000000..908c9499832e0 --- /dev/null +++ b/scripts/evidence/image-freshness/fixture.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Public image freshness fixture. Run COMMAND ROOT; ROOT is a disposable project.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import struct +import tempfile +import zlib + + +def png(rgba): + def chunk(kind, data): + return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) + return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 100, 100, 8, 6, 0, 0, 0)) + chunk(b'IDAT', zlib.compress((b'\0' + bytes(rgba) * 100) * 100, 9)) + chunk(b'IEND', b'') + +COLORS = {'red': (255, 0, 0, 255), 'blue': (0, 0, 255, 255)} +CANONICAL = {name: png(rgba) for name, rgba in COLORS.items()} + + +def validate(data): + assert data[:8] == b'\x89PNG\r\n\x1a\n' + offset, raw = 8, b'' + while offset < len(data): + length = struct.unpack('>I', data[offset:offset + 4])[0] + kind, payload = data[offset + 4:offset + 8], data[offset + 8:offset + 8 + length] + assert zlib.crc32(kind + payload) == struct.unpack('>I', data[offset + 8 + length:offset + 12 + length])[0] + if kind == b'IHDR': + assert struct.unpack('>IIBBBBB', payload) == (100, 100, 8, 6, 0, 0, 0) + if kind == b'IDAT': + raw += payload + offset += 12 + length + decoded = zlib.decompress(raw) + color = next(name for name, rgba in COLORS.items() if decoded == (b'\0' + bytes(rgba) * 100) * 100) + assert data == CANONICAL[color] + return {'dimensions': [100, 100], 'rgba': COLORS[color], 'color': color} + + +def replace(path, data, atomic): + before = path.stat() if path.exists() else None + if atomic: + fd, temporary = tempfile.mkstemp(dir=path.parent) + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + else: + with path.open('r+b' if before else 'wb') as stream: + stream.seek(0) + stream.write(data) + stream.truncate() + stream.flush() + os.fsync(stream.fileno()) + after = path.stat() + if before: + assert ((before.st_dev, before.st_ino) != (after.st_dev, after.st_ino)) == atomic + return {'before': [before.st_dev, before.st_ino] if before else None, 'after': [after.st_dev, after.st_ino]} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', choices=['init', 'red-inplace', 'blue-inplace', 'red-atomic', 'blue-atomic', 'manifest']) + parser.add_argument('root', type=Path) + args = parser.parse_args() + directory = args.root / 'evidence-public' + directory.mkdir(parents=True, exist_ok=True) + state_path = directory / '.fixture-state.json' + state = json.loads(state_path.read_text()) if state_path.exists() else {'sequence': 0, 'phase': 'uninitialized'} + changes = {} + if args.command != 'manifest': + color = 'red' if args.command == 'init' else args.command.split('-')[0] + atomic = args.command.endswith('-atomic') + changes['repro.png'] = replace(directory / 'repro.png', CANONICAL[color], atomic) + svg = f'\n'.encode() + changes['swatch.svg'] = replace(directory / 'swatch.svg', svg, atomic) + if args.command == 'init': + (directory / 'immutable-upload.png').write_bytes(CANONICAL['red']) + (directory / 'style.css').write_text('body { font: 16px sans-serif; margin: 24px; } img { width: 100px; height: 100px; margin-right: 24px; }\n') + (directory / 'index.html').write_text('Public image freshness fixture

Public image freshness fixture

PNG and SVG live siblings

Live PNGLive SVG\n') + state = {'sequence': state['sequence'] + 1, 'phase': args.command} + state_path.write_text(json.dumps(state)) + files = [] + for path in sorted(directory.iterdir()): + if path.name.startswith('.'): + continue + data, info = path.read_bytes(), path.stat() + record = {'path': str(path.relative_to(args.root)), 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data), 'device': info.st_dev, 'inode': info.st_ino} + if path.suffix == '.png': + record.update(validate(data)) + files.append(record) + print(json.dumps({**state, 'changes': changes, 'files': files, 'canonical': {name: {'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data)} for name, data in CANONICAL.items()}}, indent=2)) + +if __name__ == '__main__': + main() diff --git a/scripts/evidence/image-freshness/public/immutable-upload.png b/scripts/evidence/image-freshness/public/immutable-upload.png new file mode 100644 index 0000000000000..1431e450a9679 Binary files /dev/null and b/scripts/evidence/image-freshness/public/immutable-upload.png differ diff --git a/scripts/evidence/image-freshness/public/index.html b/scripts/evidence/image-freshness/public/index.html new file mode 100644 index 0000000000000..fe1725298a367 --- /dev/null +++ b/scripts/evidence/image-freshness/public/index.html @@ -0,0 +1,13 @@ + + + + + Public image freshness fixture + + + +

Public image freshness fixture

+

PNG and SVG live siblings

+ Live PNGLive SVG + + diff --git a/scripts/evidence/image-freshness/public/repro.png b/scripts/evidence/image-freshness/public/repro.png new file mode 100644 index 0000000000000..1431e450a9679 Binary files /dev/null and b/scripts/evidence/image-freshness/public/repro.png differ diff --git a/scripts/evidence/image-freshness/public/style.css b/scripts/evidence/image-freshness/public/style.css new file mode 100644 index 0000000000000..fbfdd049f119a --- /dev/null +++ b/scripts/evidence/image-freshness/public/style.css @@ -0,0 +1,9 @@ +body { + font: 16px sans-serif; + margin: 24px; +} +img { + width: 100px; + height: 100px; + margin-right: 24px; +} diff --git a/scripts/evidence/image-freshness/public/swatch.svg b/scripts/evidence/image-freshness/public/swatch.svg new file mode 100644 index 0000000000000..607cbac37cc76 --- /dev/null +++ b/scripts/evidence/image-freshness/public/swatch.svg @@ -0,0 +1 @@ + diff --git a/scripts/evidence/image-freshness/seed.sh b/scripts/evidence/image-freshness/seed.sh new file mode 100755 index 0000000000000..6f583e55ecf22 --- /dev/null +++ b/scripts/evidence/image-freshness/seed.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +fixture_root=${1:?Usage: seed.sh DISPOSABLE_PROJECT_ROOT} +script_root=$(cd -- "$(dirname -- "$0")" && pwd) +python3 "$script_root/fixture.py" init "$fixture_root" +git -C "$fixture_root" rev-parse --git-dir >/dev/null 2>&1 || git init "$fixture_root" +# Add the printed root through the app's Add project > Local folder command. +# Choose Claude; ask Read of ROOT/evidence-public/repro.png. Wait for completion. +# Expand that Read, overwrite with blue-inplace, then ask a second Read in that thread. +printf 'Public fixture project: %s\n' "$fixture_root"