diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index c1680f6c082f360371fb4cdd35ce93574511c6d3..be404076bd6f7c0c8ec9096b2cd820afe918fc05 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -2,6 +2,14 @@ import path from "node:path"; export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; +export function stripAttachmentRoutePrefix(pathname: string): string | null { + if (!pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { + return null; + } + const remainder = pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); + return remainder.startsWith("/") ? remainder.slice(1) : remainder; +} + export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 48be2df8a6cfcc053656de8de6835053241587a4..5fcebd64570fc9ce0ba391807e068c1fc602b39e 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -14,6 +14,7 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +// Keep this regex strict so attachment IDs cannot be used as path traversal vectors. const ATTACHMENT_ID_PATTERN = new RegExp( `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, "i", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469d6feaf6d63876f91175f9ab6d9b6874..ce226dc7b01e4408975bdfc8473079c4238f8863 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -9,9 +9,12 @@ import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; export const DEFAULT_PORT = 3773; +export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const; export type RuntimeMode = "web" | "desktop"; +export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop"; + /** * ServerConfigShape - Process/runtime configuration required by the server. */ @@ -50,7 +53,7 @@ export class ServerConfig extends ServiceMap.Service()( /** * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. + * + * Keep this union centralized so callers can pattern-match once at API boundaries. */ export type GitManagerServiceError = | GitManagerError diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index e3f90202dd92a047e9b466fb0a32c7b48900e8e8..d917d4c1e91855b303a7855d521b0c94bcc39a0b 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -38,6 +38,8 @@ export interface GitManagerShape { /** * GitManager - Service tag for stacked Git workflow orchestration. + * + * Single tag keeps orchestration wiring swappable in Layer-based tests. */ export class GitManager extends ServiceMap.Service()( "t3/git/Services/GitManager", diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe66d4e7b313c72a741a39ea2dbc14d3de..877d1904ece2c1510034972169ee7d0c6b2f5dac 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -62,6 +62,8 @@ export interface TextGenerationService { /** * TextGenerationShape - Service API for commit/PR text generation. + * + * Interface isolates provider-specific model behavior behind deterministic method contracts. */ export interface TextGenerationShape { /** diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 814abbb32c1c483b595a2a0c8e82e3d03718f0db..8131dcec3a2953db66e7b699569b604c4a9b2022 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,6 +29,28 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +const normalizeImageExtension = (extension: string): string | null => { + const trimmed = extension.trim(); + if (trimmed.length === 0) { + return null; + } + + const lower = trimmed.toLowerCase(); + return lower.startsWith(".") ? lower : `.${lower}`; +}; + +const coerceSafeImageExtension = ( + extension: string | null | undefined, +): string | null => { + if (!extension) return null; + const normalized = normalizeImageExtension(extension); + if (!normalized) return null; + return SAFE_IMAGE_FILE_EXTENSIONS.has(normalized) ? normalized : null; +}; + +export const isSafeImageExtension = (extension: string): boolean => + coerceSafeImageExtension(extension) !== null; + export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { @@ -63,15 +85,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string return fromMime; } - const fromMimeExtension = Mime.getExtension(input.mimeType); - if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { + const fromMimeExtension = coerceSafeImageExtension(Mime.getExtension(input.mimeType)); + if (fromMimeExtension) { return fromMimeExtension; } const fileName = input.fileName?.trim() ?? ""; const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); - const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; - if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { + const fileNameExtension = extensionMatch ? coerceSafeImageExtension(extensionMatch[1]) : null; + if (fileNameExtension) { return fileNameExtension; } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6eb26602cab91bb1954d6f8b933ef4342877c0ca..8cdd090ec94e5683976847a294730f4813645449 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,6 +10,7 @@ import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; import { NetService } from "@t3tools/shared/Net"; +// Compose all required services once so every CLI invocation shares the same runtime. const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(CliConfig.layer), Layer.provideMerge(ServerLive), diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4f36e7ea1291bb7ca890a0ba507eb6b35a023fe3..2ea89cb609b1d64045007f723896a085ea62fc15 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -12,8 +12,10 @@ import { NetService } from "@t3tools/shared/Net"; import { DEFAULT_PORT, + KEYBINDINGS_CONFIG_FILENAME, resolveStaticDir, ServerConfig, + isDesktopMode, type RuntimeMode, type ServerConfigShape, } from "./config"; @@ -133,6 +135,8 @@ const ServerConfigLive = (input: CliInput) => ); const mode = Option.getOrElse(input.mode, () => env.mode); + const isDesktop = isDesktopMode(mode); + const isWeb = !isDesktop; const port = yield* Option.match(input.port, { onSome: (value) => Effect.succeed(value), @@ -140,7 +144,7 @@ const ServerConfigLive = (input: CliInput) => if (env.port) { return Effect.succeed(env.port); } - if (mode === "desktop") { + if (isDesktop) { return Effect.succeed(DEFAULT_PORT); } return findAvailablePort(DEFAULT_PORT); @@ -153,15 +157,15 @@ const ServerConfigLive = (input: CliInput) => const noBrowser = Option.match(input.noBrowser, { // effect/cli boolean flags parse to `false` when absent; in that case // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), - onNone: () => env.noBrowser ?? mode === "desktop", + onSome: (value) => (value ? true : (env.noBrowser ?? isDesktop)), + onNone: () => env.noBrowser ?? isDesktop, }); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { // effect/cli boolean flags parse to `false` when absent; in that case // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), - onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", + onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? isWeb)), + onNone: () => env.autoBootstrapProjectFromCwd ?? isWeb, }); const logWebSocketEvents = Option.match(input.logWebSocketEvents, { // effect/cli boolean flags parse to `false` when absent; in that case @@ -171,11 +175,11 @@ const ServerConfigLive = (input: CliInput) => }); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; const { join } = yield* Path.Path; - const keybindingsConfigPath = join(stateDir, "keybindings.json"); + const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME); const host = Option.getOrUndefined(input.host) ?? env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); + (isDesktop ? "127.0.0.1" : undefined); return { mode, diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 3f5e2129da06fae068f70415a18331c03dbc8312..978b2414e6ada7dce6313e0eecca8090d3c1c4e8 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -3,6 +3,7 @@ import { Effect, Path } from "effect"; import { execFileSync } from "node:child_process"; export function fixPath(): void { + // Only macOS shells need PATH hydration from interactive startup files. if (process.platform !== "darwin") return; try { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 50af3b8b10ef5fd0b168047e61bd45269e7e4581..310bf756288df8fa34c254a33e788c9a6dcb8ae6 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -60,9 +60,9 @@ import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; import { - ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, resolveAttachmentRelativePath, + stripAttachmentRoutePrefix, } from "./attachmentPaths"; import { createAttachmentId, @@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } - if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { - const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); + const routeRelativePath = stripAttachmentRoutePrefix(url.pathname); + if (routeRelativePath !== null) { + const normalizedRelativePath = normalizeAttachmentRelativePath(routeRelativePath); if (!normalizedRelativePath) { respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b3601bc3f0221b09d5f2684d905273fde15e761..1998ad84aed9798583fb61f13b7899c4187dfb6e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -340,6 +340,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; theme: "light" | "dark"; + className?: string; }) { const [failedIconUrl, setFailedIconUrl] = useState(null); const iconUrl = useMemo( @@ -350,9 +351,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { if (failed) { return props.kind === "directory" ? ( - + ) : ( - + ); } @@ -361,7 +362,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { src={iconUrl} alt="" aria-hidden="true" - className="size-4 shrink-0" + className={cn("size-4 shrink-0", props.className)} loading="lazy" onError={() => setFailedIconUrl(iconUrl)} /> @@ -2554,6 +2555,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} /> @@ -3154,14 +3156,23 @@ const DiffStatLabel = memo(function DiffStatLabel(props: { ); }); -function buildInitiallyExpandedDirectoryState( - nodes: ReadonlyArray, +function collectDirectoryPaths(nodes: ReadonlyArray): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.kind !== "directory") continue; + paths.push(node.path); + paths.push(...collectDirectoryPaths(node.children)); + } + return paths; +} + +function buildDirectoryExpansionState( + directoryPaths: ReadonlyArray, + expanded: boolean, ): Record { const expandedState: Record = {}; - for (const node of nodes) { - if (node.kind === "directory") { - expandedState[node.path] = true; - } + for (const directoryPath of directoryPaths) { + expandedState[directoryPath] = expanded; } return expandedState; } @@ -3169,13 +3180,27 @@ function buildInitiallyExpandedDirectoryState( const ChangedFilesTree = memo(function ChangedFilesTree(props: { turnId: TurnId; files: ReadonlyArray; + allDirectoriesExpanded: boolean; + resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const { files, onOpenTurnDiff, turnId } = props; + const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); + const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]); + const allDirectoryExpansionState = useMemo( + () => + buildDirectoryExpansionState( + directoryPathsKey ? directoryPathsKey.split("\u0000") : [], + allDirectoriesExpanded, + ), + [allDirectoriesExpanded, directoryPathsKey], + ); const [expandedDirectories, setExpandedDirectories] = useState>( - () => buildInitiallyExpandedDirectoryState(treeNodes), + () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true), ); + useEffect(() => { + setExpandedDirectories(allDirectoryExpansionState); + }, [allDirectoryExpansionState]); const toggleDirectory = useCallback((pathValue: string) => { setExpandedDirectories((current) => ({ @@ -3233,7 +3258,12 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { onClick={() => onOpenTurnDiff(turnId, node.path)} >