Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- [Preview generated images](./docs/user/generated-images.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
- Linux: [run T3 Code as a background service](./docs/user/background-service.md)

Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
import { AssetPreviewTypeValidationError, EventId, ThreadId } from "@t3tools/contracts";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import { describe, expect, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -208,6 +208,66 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);

it.effect("issues exact capabilities for provider-generated images", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-generated-image-",
});
const imagePath = path.join(root, "generated.png");
const siblingPath = path.join(root, "other.png");
yield* fileSystem.writeFile(imagePath, new Uint8Array([137, 80, 78, 71]));
yield* fileSystem.writeFile(siblingPath, new Uint8Array([137, 80, 78, 71]));
const canonicalImagePath = yield* fileSystem.realPath(imagePath);
const resource = {
_tag: "generated-image" as const,
threadId: ThreadId.make("thread-1"),
activityId: EventId.make("activity-1"),
};

const result = yield* issueAssetUrl({ resource, generatedImagePath: imagePath });
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");
const token = suffix.slice(0, separatorIndex);

expect(yield* resolveAsset(token, "generated.png")).toEqual({
kind: "file",
path: canonicalImagePath,
});
expect(yield* resolveAsset(token, "other.png")).toBeNull();
expect(yield* resolveAsset(token, "../generated.png")).toBeNull();

yield* fileSystem.remove(imagePath);
yield* fileSystem.symlink(siblingPath, imagePath);
expect(yield* resolveAsset(token, "generated.png")).toBeNull();
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects generated-image resources without a renderable image", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-generated-image-invalid-",
});
const resource = {
_tag: "generated-image" as const,
threadId: ThreadId.make("thread-1"),
activityId: EventId.make("activity-1"),
};

const missingPathError = yield* issueAssetUrl({ resource }).pipe(Effect.flip);
expect(missingPathError._tag).toBe("AssetGeneratedImageNotFoundError");

const invalidTypeError = yield* issueAssetUrl({
resource,
generatedImagePath: path.join(root, "generated.txt"),
}).pipe(Effect.flip);
expect(invalidTypeError._tag).toBe("AssetPreviewTypeValidationError");
}).pipe(Effect.provide(testLayer)),
);

it.effect("issues project favicon capabilities with a signed fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
81 changes: 81 additions & 0 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { AssetResource } from "@t3tools/contracts";
import {
AssetAttachmentNotFoundError,
AssetGeneratedImageInspectionError,
AssetGeneratedImageNotFoundError,
AssetPreviewTypeValidationError,
AssetProjectFaviconInspectionError,
AssetProjectFaviconNotFoundError,
Expand Down Expand Up @@ -81,6 +83,12 @@ const AssetClaimsSchema = Schema.Union([
attachmentId: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("generated-image"),
absolutePath: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("project-favicon"),
Expand Down Expand Up @@ -166,9 +174,38 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {
Effect.orElseSucceed(() => null),
);

const resolveCanonicalGeneratedImage = Effect.fn("AssetAccess.resolveCanonicalGeneratedImage")(
function* (absolutePath: string) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
if (!path.isAbsolute(absolutePath) || !isWorkspaceImagePreviewPath(absolutePath)) {
return null;
}
const canonicalPath = yield* optionOnNotFound(fileSystem.realPath(absolutePath));
if (Option.isNone(canonicalPath) || !isWorkspaceImagePreviewPath(canonicalPath.value)) {
return null;
}
const info = yield* optionOnNotFound(fileSystem.stat(canonicalPath.value));
return Option.isSome(info) && info.value.type === "File" ? canonicalPath.value : null;
},
);

const resolveCanonicalGeneratedImageForRequest = (absolutePath: string) =>
resolveCanonicalGeneratedImage(absolutePath).pipe(
Effect.map((canonicalPath) => (canonicalPath === absolutePath ? canonicalPath : null)),
Effect.tapError((cause) =>
Effect.logError("Failed to resolve generated image asset.", {
absolutePath,
cause,
}),
),
Effect.orElseSucceed(() => null),
);

export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
readonly resource: AssetResource;
readonly workspaceRoot?: string;
readonly generatedImagePath?: string;
readonly projectFaviconPath?: string;
}) {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -278,6 +315,43 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
fileName = path.basename(attachmentPath);
break;
}
case "generated-image": {
if (!input.generatedImagePath) {
return yield* new AssetGeneratedImageNotFoundError({
resource: input.resource,
});
}
if (
!path.isAbsolute(input.generatedImagePath) ||
!isWorkspaceImagePreviewPath(input.generatedImagePath)
) {
return yield* new AssetPreviewTypeValidationError({
resource: input.resource,
});
}
const canonicalPath = yield* resolveCanonicalGeneratedImage(input.generatedImagePath).pipe(
Effect.mapError(
(cause) =>
new AssetGeneratedImageInspectionError({
resource: input.resource,
cause,
}),
),
);
if (!canonicalPath) {
return yield* new AssetGeneratedImageNotFoundError({
resource: input.resource,
});
}
claims = {
version: 1,
kind: "generated-image",
absolutePath: canonicalPath,
expiresAt,
};
fileName = path.basename(canonicalPath);
break;
}
case "project-favicon": {
const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe(
Effect.mapError(
Expand Down Expand Up @@ -444,6 +518,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
const decodedPath = decodeRelativePath(relativePath);
if (decodedPath === null) return null;
const path = yield* Path.Path;
if (claims.kind === "generated-image") {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (decodedPath !== path.basename(claims.absolutePath)) return null;
const generatedImagePath = yield* resolveCanonicalGeneratedImageForRequest(claims.absolutePath);
return generatedImagePath
? ({ kind: "file", path: generatedImagePath } satisfies ResolvedAsset)
: null;
}
if (claims.kind === "workspace-file-exact") {
if (decodedPath !== path.basename(claims.relativePath)) return null;
const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({
Expand Down
98 changes: 98 additions & 0 deletions apps/server/src/assets/GeneratedImageResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { EventId, type OrchestrationThreadActivity } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { findGeneratedImagePath } from "./GeneratedImageResolver.ts";

const ACTIVITY_ID = EventId.make("activity-generated-image");

function activity(
item: Record<string, unknown>,
overrides: Partial<OrchestrationThreadActivity> = {},
): OrchestrationThreadActivity {
return {
id: ACTIVITY_ID,
tone: "tool",
kind: "tool.completed",
summary: "Image view",
payload: { data: { item } },
turnId: null,
createdAt: "2026-08-10T00:00:00.000Z",
...overrides,
};
}

describe("generated image resolution", () => {
it("reads the saved path from a completed image generation activity", () => {
const path = "/provider/session/generated.png";
expect(
findGeneratedImagePath(
[activity({ type: "imageGeneration", status: "completed", savedPath: path })],
ACTIVITY_ID,
),
).toBe(path);
});

it("preserves filesystem-significant whitespace in the saved path", () => {
const path = "/provider/session/ generated.png ";
expect(
findGeneratedImagePath(
[activity({ type: "imageGeneration", status: "completed", savedPath: path })],
ACTIVITY_ID,
),
).toBe(path);
expect(
findGeneratedImagePath(
[activity({ type: "imageGeneration", status: "completed", savedPath: " " })],
ACTIVITY_ID,
),
).toBeNull();
});

it("rejects other image activities, incomplete generations, and mismatched ids", () => {
expect(
findGeneratedImagePath(
[activity({ type: "imageView", status: "completed", path: "/tmp/viewed.png" })],
ACTIVITY_ID,
),
).toBeNull();
expect(
findGeneratedImagePath(
[
activity({
type: "imageGeneration",
status: "inProgress",
savedPath: "/tmp/generated.png",
}),
],
ACTIVITY_ID,
),
).toBeNull();
expect(
findGeneratedImagePath(
[
activity(
{
type: "imageGeneration",
status: "completed",
savedPath: "/tmp/generated.png",
},
{ kind: "tool.updated" },
),
],
ACTIVITY_ID,
),
).toBeNull();
expect(
findGeneratedImagePath(
[
activity({
type: "imageGeneration",
status: "completed",
savedPath: "/tmp/generated.png",
}),
],
EventId.make("activity-other"),
),
).toBeNull();
});
});
36 changes: 36 additions & 0 deletions apps/server/src/assets/GeneratedImageResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { EventId, OrchestrationThreadActivity } from "@t3tools/contracts";
import * as Predicate from "effect/Predicate";

export function findGeneratedImagePath(
activities: ReadonlyArray<OrchestrationThreadActivity>,
activityId: EventId,
): string | null {
const activity = activities.find((candidate) => candidate.id === activityId);
if (!activity || activity.kind !== "tool.completed") {
return null;
}

const payload = activity.payload;
if (!Predicate.isObject(payload) || !Predicate.hasProperty(payload, "data")) {
return null;
}
const data = payload.data;
if (!Predicate.isObject(data) || !Predicate.hasProperty(data, "item")) {
return null;
}
const item = data.item;
if (
!Predicate.isObject(item) ||
!Predicate.hasProperty(item, "type") ||
item.type !== "imageGeneration" ||
!Predicate.hasProperty(item, "status") ||
item.status !== "completed" ||
!Predicate.hasProperty(item, "savedPath") ||
!Predicate.isString(item.savedPath)
) {
return null;
}

const savedPath = item.savedPath;
return savedPath.trim().length > 0 ? savedPath : null;
}
32 changes: 32 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,28 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;
}

function projectGeneratedImageData(data: Record<string, unknown>): Record<string, unknown> {
const item = asRecord(data.item);
const savedPath = asTrimmedString(item?.savedPath);
const fileName = savedPath?.replaceAll("\\", "/").split("/").at(-1)?.trim();
const projectedData: Record<string, unknown> = {};

if (item?.type === "imageGeneration" && item.status === "completed" && fileName) {
projectedData.item = {
type: "imageGeneration",
status: "completed",
fileName,
};
}
if ("toolCallId" in data) {
projectedData.toolCallId = data.toolCallId;
}
if ("kind" in data) {
projectedData.kind = data.kind;
}
return projectedData;
}

function summarizeToolTextOutput(value: string): string | null {
const lines: string[] = [];
for (const rawLine of value.split(/\r?\n/u)) {
Expand Down Expand Up @@ -282,6 +304,16 @@ export function projectActivityPayload(
};
}

if (payload.itemType === "image_view" && asRecord(data.item)?.type === "imageGeneration") {
return {
...activity,
payload: {
...payload,
data: projectGeneratedImageData(data),
},
};
}

const projectedData: Record<string, unknown> = {};
const item = projectCommandData(data);
if (item) {
Expand Down
Loading
Loading