Skip to content
Merged
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
112 changes: 112 additions & 0 deletions apps/server/src/provider/CodexInlineVisualization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @effect-diagnostics nodeBuiltinImport:off
import { constants as NodeFsConstants } from "node:fs";
import * as NodeFs from "node:fs/promises";
import NodePath from "node:path";

import {
CodexInlineVisualizationReadError,
type CodexInlineVisualizationReadResult,
} from "@threadlines/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

export const MAX_CODEX_INLINE_VISUALIZATION_BYTES = 5_000_000;

const CODEX_INLINE_VISUALIZATION_FILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*\.html$/;
const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const isReadError = Schema.is(CodexInlineVisualizationReadError);
const textDecoder = new TextDecoder("utf-8", { fatal: true });

/** Codex shards visualizations using the UUIDv7 timestamp in local time. */
export function codexVisualizationDateShard(providerThreadId: string): string | null {
if (!UUID_V7.test(providerThreadId)) {
return null;
}

const timestampHex = `${providerThreadId.slice(0, 8)}${providerThreadId.slice(9, 13)}`;
const timestampMs = Number.parseInt(timestampHex, 16);
const timestamp = new Date(timestampMs);
if (!Number.isFinite(timestampMs) || Number.isNaN(timestamp.getTime())) {
return null;
}

const year = String(timestamp.getFullYear()).padStart(4, "0");
const month = String(timestamp.getMonth() + 1).padStart(2, "0");
const day = String(timestamp.getDate()).padStart(2, "0");
return NodePath.join(year, month, day);
}

function readError(message: string, cause?: unknown): CodexInlineVisualizationReadError {
return new CodexInlineVisualizationReadError({
message,
...(cause === undefined ? {} : { cause }),
});
}

export const readCodexInlineVisualization = Effect.fn("CodexInlineVisualization.read")(
function* (input: {
readonly codexHomePath: string;
readonly providerThreadId: string;
readonly file: string;
}): Effect.fn.Return<CodexInlineVisualizationReadResult, CodexInlineVisualizationReadError> {
if (!CODEX_INLINE_VISUALIZATION_FILE_NAME.test(input.file)) {
return yield* readError("The visualization filename is invalid.");
}

const dateShard = codexVisualizationDateShard(input.providerThreadId);
if (!dateShard) {
return yield* readError("This Codex thread cannot be mapped to a visualization directory.");
}

const filePath = NodePath.join(
input.codexHomePath,
"visualizations",
dateShard,
input.providerThreadId,
input.file,
);

return yield* Effect.tryPromise({
try: async () => {
const linkInfo = await NodeFs.lstat(filePath);
if (linkInfo.isSymbolicLink() || !linkInfo.isFile()) {
throw readError("The visualization is not a regular file.");
}
if (linkInfo.size > MAX_CODEX_INLINE_VISUALIZATION_BYTES) {
throw readError("The visualization is too large to display.");
}

const noFollow = NodeFsConstants.O_NOFOLLOW ?? 0;
const handle = await NodeFs.open(filePath, NodeFsConstants.O_RDONLY | noFollow);
try {
const openedInfo = await handle.stat();
if (
!openedInfo.isFile() ||
openedInfo.dev !== linkInfo.dev ||
openedInfo.ino !== linkInfo.ino
) {
throw readError("The visualization changed while it was being opened.");
}
if (openedInfo.size > MAX_CODEX_INLINE_VISUALIZATION_BYTES) {
throw readError("The visualization is too large to display.");
}
const bytes = await handle.readFile();
if (bytes.byteLength > MAX_CODEX_INLINE_VISUALIZATION_BYTES) {
throw readError("The visualization is too large to display.");
}
return {
file: input.file,
contents: textDecoder.decode(bytes),
sizeBytes: bytes.byteLength,
};
} finally {
await handle.close();
}
},
catch: (cause) =>
isReadError(cause)
? cause
: readError("The visualization could not be read for this Codex thread.", cause),
});
},
);
84 changes: 84 additions & 0 deletions apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
materializeCodexShadowHome,
resolveCodexHomeLayout,
} from "./CodexHomeLayout.ts";
import {
codexVisualizationDateShard,
MAX_CODEX_INLINE_VISUALIZATION_BYTES,
readCodexInlineVisualization,
} from "../CodexInlineVisualization.ts";
const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings);

const decodeCodexSettings = (input: {
Expand Down Expand Up @@ -81,6 +86,85 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
);
});

describe("Codex inline visualizations", () => {
const providerThreadId = "019f8ca0-8000-7992-a481-9125813cb125";

it("derives the local date shard from a native UUIDv7 thread id", () => {
const timestamp = new Date(Number.parseInt("019f8ca08000", 16));
const expected = [
String(timestamp.getFullYear()).padStart(4, "0"),
String(timestamp.getMonth() + 1).padStart(2, "0"),
String(timestamp.getDate()).padStart(2, "0"),
].join("/");

expect(codexVisualizationDateShard(providerThreadId)).toBe(expected);
expect(codexVisualizationDateShard("not-a-native-thread-id")).toBeNull();
});

it.effect("reads only the expected visualization fragment", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const codexHomePath = yield* makeTempDir("threadlines-codex-visualization-");
const shard = codexVisualizationDateShard(providerThreadId);
expect(shard).not.toBeNull();
const filePath = path.join(
codexHomePath,
"visualizations",
shard ?? "",
providerThreadId,
"connection-map.html",
);
yield* writeTextFile(filePath, "<div>Connection map</div>");

const result = yield* readCodexInlineVisualization({
codexHomePath,
providerThreadId,
file: "connection-map.html",
});

expect(result).toEqual({
file: "connection-map.html",
contents: "<div>Connection map</div>",
sizeBytes: 25,
});
}),
);

it.effect("rejects symlinks and oversized visualization files", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const codexHomePath = yield* makeTempDir("threadlines-codex-visualization-");
const shard = codexVisualizationDateShard(providerThreadId) ?? "";
const directory = path.join(codexHomePath, "visualizations", shard, providerThreadId);
const targetPath = path.join(directory, "target.html");
const symlinkPath = path.join(directory, "linked-file.html");
const oversizedPath = path.join(directory, "oversized-file.html");
yield* writeTextFile(targetPath, "<div>target</div>");
if (process.platform !== "win32") {
yield* fileSystem.symlink(targetPath, symlinkPath);
const symlinkError = yield* readCodexInlineVisualization({
codexHomePath,
providerThreadId,
file: "linked-file.html",
}).pipe(Effect.flip);
expect(symlinkError.message).toContain("not a regular file");
}

yield* fileSystem.writeFile(
oversizedPath,
new Uint8Array(MAX_CODEX_INLINE_VISUALIZATION_BYTES + 1),
);
const oversizedError = yield* readCodexInlineVisualization({
codexHomePath,
providerThreadId,
file: "oversized-file.html",
}).pipe(Effect.flip);
expect(oversizedError.message).toContain("too large");
}),
);
});

describe("materializeCodexShadowHome", () => {
it.effect("materializes a shadow home with shared state links and private auth", () =>
Effect.gen(function* () {
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
OrchestrationThreadSearchError,
ORCHESTRATION_WS_METHODS,
ChatAttachmentReadError,
CodexInlineVisualizationReadError,
CodexSettings,
ProviderDriverKind,
ProjectFaviconError,
ProjectListEntriesError,
ProjectReadFileError,
Expand Down Expand Up @@ -72,6 +75,9 @@ import {
import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
import { ProviderService } from "./provider/Services/ProviderService.ts";
import { readCodexInlineVisualization } from "./provider/CodexInlineVisualization.ts";
import { resolveCodexHomeLayout } from "./provider/Drivers/CodexHomeLayout.ts";
import { deriveProviderInstanceConfigMap } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
import { startProviderReviewForThread } from "./provider/ProviderReviewCoordinator.ts";
import { importExternalProviderThread } from "./provider/ExternalThreadImport.ts";
import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
Expand Down Expand Up @@ -131,6 +137,8 @@ import {
type SessionCredentialChange,
} from "./auth/Services/SessionCredentialService.ts";
import { respondToAuthError } from "./auth/http.ts";

const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings);
const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);

Expand Down Expand Up @@ -1476,6 +1484,71 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
}),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.visualizationsRead]: (input) =>
observeRpcEffect(
WS_METHODS.visualizationsRead,
Effect.gen(function* () {
const threadOption = yield* projectionSnapshotQuery
.getThreadShellById(input.threadId)
.pipe(
Effect.mapError(
(cause) =>
new CodexInlineVisualizationReadError({
message: "The visualization thread could not be loaded.",
cause,
}),
),
);
if (Option.isNone(threadOption)) {
return yield* new CodexInlineVisualizationReadError({
message: "The visualization thread was not found.",
});
}

const thread = threadOption.value;
const providerThreadId = thread.session?.providerThreadId;
if (!providerThreadId) {
return yield* new CodexInlineVisualizationReadError({
message: "This thread does not have a native Codex session.",
});
}

const providerInstanceId =
thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
const settings = yield* serverSettings.getSettings.pipe(
Effect.mapError(
(cause) =>
new CodexInlineVisualizationReadError({
message: "The Codex provider settings could not be loaded.",
cause,
}),
),
);
const providerConfig = deriveProviderInstanceConfigMap(settings)[providerInstanceId];
if (!providerConfig || providerConfig.driver !== ProviderDriverKind.make("codex")) {
return yield* new CodexInlineVisualizationReadError({
message: "This thread is not backed by a configured Codex provider.",
});
}

const codexSettings = yield* decodeCodexSettings(providerConfig.config ?? {}).pipe(
Effect.mapError(
(cause) =>
new CodexInlineVisualizationReadError({
message: "The Codex provider settings are invalid.",
cause,
}),
),
);
const homeLayout = yield* resolveCodexHomeLayout(codexSettings);
return yield* readCodexInlineVisualization({
codexHomePath: homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath,
providerThreadId,
file: input.file,
});
}),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.shellOpenInEditor]: (input) =>
observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), {
"rpc.aggregate": "workspace",
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,45 @@ describe("ChatMarkdown", () => {
}
});

it("renders Codex inline visualization directives in a script-only sandbox", async () => {
const readVisualization = vi.fn(async () => ({
file: "connection-map.html",
contents: '<div id="connection-map"><button type="button">Explore</button></div>',
sizeBytes: 72,
}));
__setEnvironmentApiOverrideForTests(CHAT_MARKDOWN_ENVIRONMENT_ID, {
visualizations: { read: readVisualization },
} as unknown as EnvironmentApi);
const screen = await render(
<ChatMarkdown
text={'Overview\n\n::codex-inline-vis{file="connection-map.html"}'}
cwd="/repo/project"
environmentId={CHAT_MARKDOWN_ENVIRONMENT_ID}
threadId={CHAT_MARKDOWN_THREAD_ID}
/>,
);

try {
await vi.waitFor(() => {
expect(readVisualization).toHaveBeenCalledWith({
threadId: CHAT_MARKDOWN_THREAD_ID,
file: "connection-map.html",
});
});
const iframe = document.querySelector<HTMLIFrameElement>(
'iframe[title="Interactive visualization: connection map"]',
);
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
expect(iframe?.getAttribute("sandbox")).not.toContain("allow-same-origin");
expect(iframe?.srcdoc).toContain("Content-Security-Policy");
expect(iframe?.srcdoc).toContain('id="connection-map"');
expect(document.body.textContent).not.toContain("::codex-inline-vis");
} finally {
await screen.unmount();
}
});

it("wraps long fenced text and shows copy feedback", async () => {
const code = `Please run ${"a-very-long-unbroken-value".repeat(20)} when ready.`;
const writeText = vi.fn(async () => undefined);
Expand Down
Loading
Loading