From 85d5df625652906937d5ac330205b1042dc9354f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:29:22 -0700 Subject: [PATCH] Unify browser asset preview routing - Replace attachment and favicon routes with signed asset URLs - Harden workspace and attachment asset resolution - Update browser preview components and shared contracts --- apps/server/src/assets/AssetAccess.test.ts | 150 +++++ apps/server/src/assets/AssetAccess.ts | 287 +++++++++ apps/server/src/attachmentPaths.ts | 2 - apps/server/src/http.ts | 105 +--- .../Layers/ProjectFaviconResolver.test.ts | 3 +- .../project/Layers/ProjectFaviconResolver.ts | 52 +- apps/server/src/server.test.ts | 169 +---- apps/server/src/server.ts | 12 +- apps/server/src/ws.ts | 49 ++ apps/web/src/assets/assetUrls.ts | 89 +++ apps/web/src/browser/openFileInPreview.ts | 36 ++ .../src/components/ChatMarkdown.browser.tsx | 97 ++- apps/web/src/components/ChatMarkdown.tsx | 95 ++- apps/web/src/components/ChatView.browser.tsx | 16 +- apps/web/src/components/ChatView.tsx | 72 ++- .../components/KeybindingsToast.browser.tsx | 3 +- apps/web/src/components/PlanSidebar.tsx | 5 +- apps/web/src/components/ProjectFavicon.tsx | 22 +- .../src/components/chat/MessagesTimeline.tsx | 11 + .../src/components/chat/ProposedPlanCard.tsx | 18 +- .../preview/PreviewChromeRow.browser.tsx | 34 + .../components/preview/PreviewChromeRow.tsx | 32 +- .../src/components/preview/PreviewView.tsx | 12 + .../preview/previewUrlPresentation.test.ts | 45 ++ .../preview/previewUrlPresentation.ts | 27 + apps/web/src/environmentApi.ts | 3 + .../service.threadSubscriptions.test.ts | 1 + apps/web/src/store.ts | 9 - .../t3-code-connect-auth-flow-3-page.html | 592 ++++++++++++++++++ docs/cloud/t3-code-connect-auth-flow.pdf | Bin 0 -> 113502 bytes packages/client-runtime/src/wsRpcClient.ts | 7 + packages/contracts/src/assets.ts | 38 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/rpc.ts | 9 + 35 files changed, 1779 insertions(+), 328 deletions(-) create mode 100644 apps/server/src/assets/AssetAccess.test.ts create mode 100644 apps/server/src/assets/AssetAccess.ts create mode 100644 apps/web/src/assets/assetUrls.ts create mode 100644 apps/web/src/browser/openFileInPreview.ts create mode 100644 apps/web/src/components/preview/previewUrlPresentation.test.ts create mode 100644 apps/web/src/components/preview/previewUrlPresentation.ts create mode 100644 docs/cloud/t3-code-connect-auth-flow-3-page.html create mode 100644 docs/cloud/t3-code-connect-auth-flow.pdf create mode 100644 packages/contracts/src/assets.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6abd8f48e611aba8701f62be9defe01039253f64 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -0,0 +1,150 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../config.ts"; +import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts"; +import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; +import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; + +const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-asset-access-test-", +}); +const testLayer = Layer.mergeAll( + configLayer, + WorkspacePathsLive, + ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), +).pipe(Layer.provideMerge(NodeServices.layer)); + +describe("AssetAccess", () => { + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-workspace-", + }); + const htmlPath = path.join(root, "report.html"); + const cssPath = path.join(root, "report.css"); + yield* fileSystem.writeFileString(htmlPath, ''); + yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); + yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "report.html")).toEqual({ + kind: "file", + path: htmlPath, + }); + expect(yield* resolveAsset(token, "report.css")).toEqual({ + kind: "file", + path: cssPath, + }); + expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); + expect(yield* resolveAsset(token, ".env")).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects workspace files outside the authorized root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-outside-", + }); + const htmlPath = path.join(outside, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

outside

"); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error.message).toContain("relative to the project root"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues exact attachment capabilities by attachment id", () => + Effect.gen(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "ignored.png")).toEqual({ + kind: "file", + path: attachmentPath, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues project favicon capabilities with a signed fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-", + }); + const faviconPath = path.join(root, "favicon.svg"); + yield* fileSystem.writeFileString(faviconPath, ""); + + const faviconResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const faviconSeparatorIndex = faviconSuffix.indexOf("/"); + expect( + yield* resolveAsset( + faviconSuffix.slice(0, faviconSeparatorIndex), + faviconSuffix.slice(faviconSeparatorIndex + 1), + ), + ).toEqual({ kind: "file", path: faviconPath }); + + yield* fileSystem.remove(faviconPath); + const fallbackResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); + expect( + yield* resolveAsset( + fallbackSuffix.slice(0, fallbackSeparatorIndex), + fallbackSuffix.slice(fallbackSeparatorIndex + 1), + ), + ).toEqual({ kind: "project-favicon-fallback" }); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts new file mode 100644 index 0000000000000000000000000000000000000000..659413f47481dcdf261ea01564ee32af3b0ebd53 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.ts @@ -0,0 +1,287 @@ +import type { AssetResource } from "@t3tools/contracts"; +import { AssetAccessError } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; + +export const ASSET_ROUTE_PREFIX = "/api/assets"; +export const FALLBACK_PROJECT_FAVICON_SVG = ``; + +const SIGNING_SECRET_NAME = "asset-access-signing-key"; +const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; +const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); +const PREVIEW_ASSET_EXTENSIONS = new Set([ + ...PREVIEWABLE_EXTENSIONS, + ".avif", + ".css", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".js", + ".mjs", + ".otf", + ".png", + ".svg", + ".ttf", + ".webp", + ".woff", + ".woff2", +]); + +const AssetClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file"), + workspaceRoot: Schema.String, + baseRelativePath: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment"), + attachmentId: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon"), + workspaceRoot: Schema.String, + relativePath: Schema.NullOr(Schema.String), + expiresAt: Schema.Number, + }), +]); +type AssetClaims = typeof AssetClaimsSchema.Type; + +const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); +const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); +const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); + +export type ResolvedAsset = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "project-favicon-fallback" }; + +function decodeClaims(encodedPayload: string): AssetClaims | null { + try { + return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +function decodeRelativePath(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +const failAccess = (message: string, cause?: unknown) => + new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) }); + +const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( + function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot(input) + .pipe(Effect.orElseSucceed(() => null)); + if (!resolved) return null; + + const [canonicalRoot, canonicalFile] = yield* Effect.all([ + fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)), + fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)), + ]); + if (!canonicalRoot || !canonicalFile) return null; + + const path = yield* Path.Path; + const relative = path.relative(canonicalRoot, canonicalFile); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null; + + const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null)); + return info?.type === "File" ? canonicalFile : null; + }, +); + +export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { + readonly resource: AssetResource; + readonly workspaceRoot?: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; + const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + let claims: AssetClaims; + let fileName: string; + + switch (input.resource._tag) { + case "workspace-file": { + if (!input.workspaceRoot) { + return yield* failAccess("Workspace context was not found."); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const relativePath = path.isAbsolute(input.resource.path) + ? path.relative(workspaceRoot, input.resource.path) + : input.resource.path; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { + return yield* failAccess("Only HTML and PDF files can open in the browser."); + } + const canonicalFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot, + relativePath: resolved.relativePath, + }); + if (!canonicalFile) { + return yield* failAccess("Workspace asset was not found."); + } + claims = { + version: 1, + kind: "workspace-file", + workspaceRoot: yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt, + }; + fileName = path.basename(resolved.relativePath); + break; + } + case "attachment": { + const config = yield* ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: input.resource.attachmentId, + }); + if (!attachmentPath) { + return yield* failAccess("Attachment was not found."); + } + claims = { + version: 1, + kind: "attachment", + attachmentId: input.resource.attachmentId, + expiresAt, + }; + fileName = path.basename(attachmentPath); + break; + } + case "project-favicon": { + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.resource.cwd) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const faviconResolver = yield* ProjectFaviconResolver; + const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); + const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + if ( + relativePath && + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath })) + ) { + return yield* failAccess("Project favicon was not found."); + } + claims = { + version: 1, + kind: "project-favicon", + workspaceRoot: yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), + relativePath, + expiresAt, + }; + fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; + break; + } + } + + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore + .getOrCreateRandom(SIGNING_SECRET_NAME, 32) + .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); + const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); + const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; + return { + relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, + expiresAt, + }; +}); + +export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( + token: string, + relativePath: string, +) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) return null; + + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore + .getOrCreateRandom(SIGNING_SECRET_NAME, 32) + .pipe(Effect.orElseSucceed(() => null)); + if (!signingSecret) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + + if (claims.kind === "attachment") { + const config = yield* ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: claims.attachmentId, + }); + if (!attachmentPath) return null; + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null)); + return info?.type === "File" + ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) + : null; + } + + if (claims.kind === "project-favicon") { + if (claims.relativePath === null) { + return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; + } + const faviconPath = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: claims.workspaceRoot, + relativePath: claims.relativePath, + }); + return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; + } + + const decodedPath = decodeRelativePath(relativePath); + if (decodedPath === null) return null; + const path = yield* Path.Path; + const segments = decodedPath.split(/[\\/]/); + if ( + decodedPath.length === 0 || + decodedPath.includes("\0") || + segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) || + !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase()) + ) { + return null; + } + const joinedRelativePath = + claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); + const workspaceFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: claims.workspaceRoot, + relativePath: joinedRelativePath, + }); + return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null; +}); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index 8c6999a7341590118695f3ff4568ae88b9b30f64..dc7db4354262cb1edc1882c6f0677cea2099afe7 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -1,8 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import NodePath from "node:path"; -export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; - export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 517d57168c39c0e830aa425344da09d6699eff2e..5197ad342964351ea523afb0ed5d2457fd2172cc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -24,15 +24,13 @@ import { import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; -import { - ATTACHMENTS_ROUTE_PREFIX, - normalizeAttachmentRelativePath, - resolveAttachmentRelativePath, -} from "./attachmentPaths.ts"; -import { resolveAttachmentPathById } from "./attachmentStore.ts"; import { resolveStaticDir, ServerConfig } from "./config.ts"; +import { + ASSET_ROUTE_PREFIX, + FALLBACK_PROJECT_FAVICON_SVG, + resolveAsset, +} from "./assets/AssetAccess.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { annotateEnvironmentRequest, @@ -43,8 +41,6 @@ import { import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; -const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; -const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); @@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( ), ); -export const attachmentsRouteLayer = HttpRouter.add( +export const assetRouteLayer = HttpRouter.add( "GET", - `${ATTACHMENTS_ROUTE_PREFIX}/*`, + `${ASSET_ROUTE_PREFIX}/*`, Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } - const config = yield* ServerConfig; - const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); - if (!normalizedRelativePath) { - return HttpServerResponse.text("Invalid attachment path", { status: 400 }); - } - - const isIdLookup = - !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); - const filePath = isIdLookup - ? resolveAttachmentPathById({ - attachmentsDir: config.attachmentsDir, - attachmentId: normalizedRelativePath, - }) - : resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: normalizedRelativePath, - }); - if (!filePath) { - return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", { - status: isIdLookup ? 404 : 400, - }); - } - - const fileSystem = yield* FileSystem.FileSystem; - const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); - if (!fileInfo || fileInfo.type !== "File") { + const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(filePath, { - status: 200, - headers: { - "Cache-Control": "public, max-age=31536000, immutable", - }, - }).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), -); - -export const projectFaviconRouteLayer = HttpRouter.add( - "GET", - "/api/project-favicon", - Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Bad Request", { status: 400 }); + if (!asset) { + return HttpServerResponse.text("Not Found", { status: 404 }); } - - const projectCwd = url.value.searchParams.get("cwd"); - if (!projectCwd) { - return HttpServerResponse.text("Missing cwd parameter", { status: 400 }); - } - - const faviconResolver = yield* ProjectFaviconResolver; - const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd); - if (!faviconFilePath) { + if (asset.kind === "project-favicon-fallback") { return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { status: 200, contentType: "image/svg+xml", headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }); } - return yield* HttpServerResponse.file(faviconFilePath, { + return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), + }), ); export const staticAndDevRouteLayer = HttpRouter.add( diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index c983aca4ba7521597d4142c7e4cb6fa85ca0138a..5c0e5d957425e93317b04a86fda15963806de153 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -7,9 +7,10 @@ import * as Path from "effect/Path"; import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; +import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index cdfddd5438a05bd9c6226eaf73ac6e7ff1f68065..a994d1a7e8c32d0d8bfb1db35b96f6b56699b636 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -7,6 +7,7 @@ import { ProjectFaviconResolver, type ProjectFaviconResolverShape, } from "../Services/ProjectFaviconResolver.ts"; +import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null { export const makeProjectFaviconResolver = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; - const resolveIconHref = (projectCwd: string, href: string): string[] => { + const resolveIconHref = (href: string): string[] => { const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; - }; - - const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return [path.join("public", clean), clean]; }; const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, - candidates: ReadonlyArray, + relativeCandidates: ReadonlyArray, ): Effect.fn.Return { - for (const candidate of candidates) { - if (!isPathWithinProject(projectCwd, candidate)) { + for (const relativePath of relativeCandidates) { + const candidate = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!candidate) { continue; } - const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null)); + const stats = yield* fileSystem + .stat(candidate.absolutePath) + .pipe(Effect.orElseSucceed(() => null)); if (stats?.type === "File") { - return candidate; + return candidate.absolutePath; } } return null; @@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd: string): Effect.fn.Return { + const projectCwd = yield* workspacePaths + .normalizeWorkspaceRoot(cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (!projectCwd) { + return null; + } for (const candidate of FAVICON_CANDIDATES) { - const resolved = path.join(cwd, candidate); - const existing = yield* findExistingFile(cwd, [resolved]); + const existing = yield* findExistingFile(projectCwd, [candidate]); if (existing) { return existing; } } for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = path.join(cwd, sourceFile); + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath: sourceFile, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!sourcePath) { + continue; + } const source = yield* fileSystem - .readFileString(sourcePath) + .readFileString(sourcePath.absolutePath) .pipe(Effect.orElseSucceed(() => null)); if (!source) { continue; @@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { if (!href) { continue; } - const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); if (existing) { return existing; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a1cd119a7a6d54770068b8d29bcbda011fdbefeb..40c9c7cd9a81abc374a725b41e9bda9463570239 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { CheckpointDiffQuery, type CheckpointDiffQueryShape, @@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolverLive, + ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves project favicon requests before the dev URL redirect", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-", - }); - yield* fileSystem.writeFileString( - path.join(projectDir, "favicon.svg"), - "router-project-favicon", - ); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.equal(yield* response.text, "router-project-favicon"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves the fallback project favicon when no icon exists", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-fallback-", - }); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.include(yield* response.text, 'data-fallback="project-favicon"'); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("serves the public environment descriptor without requiring auth", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; - const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - }); - const faviconBody = (yield* faviconResponse.json) as { - readonly _tag: string; - readonly code: string; - readonly requiredScope: string; - readonly traceId: string; - }; - assert.equal(overbroadPairingResponse.status, 403); assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); assert.equal(pairingResponse.status, 200); assert.equal(wsTicketResponse.status, 200); - assert.equal(faviconResponse.status, 403); - assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); - assert.equal(faviconBody.code, "insufficient_scope"); - assert.equal(faviconBody.requiredScope, "orchestration:read"); - assert.equal(typeof faviconBody.traceId, "string"); - const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; const rpcError = yield* Effect.flip( Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), @@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "does not accept session tokens via query parameters on authenticated HTTP routes", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-query-token-", - }); - - yield* buildAppUnderTest(); - - const { cookie } = yield* bootstrapBrowserSession(); - assert.isDefined(cookie); - const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, - ); - - assert.equal(response.status, 401); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves attachment files from state dir", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: `${attachmentId}.bin`, - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - - const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves attachment files for URL-encoded paths", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: "thread%20folder/message%20folder/file%20name.png", - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); - - const response = yield* HttpClient.get( - "/attachments/thread%20folder/message%20folder/file%20name.png", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-encoded-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("proxies browser OTLP trace exports through the server", () => Effect.gen(function* () { const upstreamRequests: Array<{ @@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("returns 404 for missing attachment id lookups", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const response = yield* HttpClient.get( - "/attachments/missing-11111111-1111-4111-8111-111111111111", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 404); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c565c91f1a49ceb749434bc6c0f6c5ca3bfd3e51..3a95f906866f7adf29d2d6e9115552e05c84745c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { ServerConfig } from "./config.ts"; import { - attachmentsRouteLayer, otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, + assetRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll( WorkspaceFileSystemLayerLive, ); +const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe( + Layer.provide(WorkspacePathsLive), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), Layer.provide(ServerSecretStore.layer), @@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), - Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), @@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), - attachmentsRouteLayer, otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, + assetRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d35ab88fee6b366ed8458c027680544a67ba465a..2823923e0335dc2378cd1e4b3fb44ac441ed62ca 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -40,6 +40,7 @@ import { type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, FilesystemBrowseError, + AssetAccessError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe import { TerminalManager } from "./terminal/Services/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; +import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], + [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.assetsCreateUrl]: (input) => + observeRpcEffect( + WS_METHODS.assetsCreateUrl, + Effect.gen(function* () { + if (input.resource._tag !== "workspace-file") { + return yield* issueAssetUrl({ resource: input.resource }); + } + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.resource.threadId) + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace context.", + cause, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new AssetAccessError({ + message: "Workspace context was not found.", + }); + } + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.value.projectId) + .pipe( + Effect.mapError( + (cause) => + new AssetAccessError({ + message: "Failed to resolve workspace context.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetAccessError({ + message: "Workspace context was not found.", + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + }); + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4fba2c5b99c36b1c43c3a964da02e4bad4e5ff6 --- /dev/null +++ b/apps/web/src/assets/assetUrls.ts @@ -0,0 +1,89 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { readEnvironmentApi } from "~/environmentApi"; +import { readEnvironmentConnection } from "~/environments/runtime"; + +const REFRESH_MARGIN_MS = 30_000; + +interface CachedAssetUrl { + readonly url: string; + readonly expiresAt: number; +} + +const assetUrlCache = new Map(); +const assetUrlRequests = new Map>(); + +function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { + return `${environmentId}:${JSON.stringify(resource)}`; +} + +export async function resolveAssetUrl( + environmentId: EnvironmentId, + resource: AssetResource, +): Promise { + const key = assetCacheKey(environmentId, resource); + const cached = assetUrlCache.get(key); + if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) { + return cached; + } + + const inFlight = assetUrlRequests.get(key); + if (inFlight) { + return inFlight; + } + + const request = (async () => { + const api = readEnvironmentApi(environmentId); + const connection = readEnvironmentConnection(environmentId); + if (!api || !connection) { + throw new Error("Environment is not connected."); + } + const result = await api.assets.createUrl({ resource }); + const cachedResult = { + url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(), + expiresAt: result.expiresAt, + }; + assetUrlCache.set(key, cachedResult); + return cachedResult; + })().finally(() => { + assetUrlRequests.delete(key); + }); + assetUrlRequests.set(key, request); + return request; +} + +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const resourceJson = JSON.stringify(resource); + const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]); + const key = assetCacheKey(environmentId, stableResource); + const [url, setUrl] = useState(() => assetUrlCache.get(key)?.url ?? null); + + useEffect(() => { + let cancelled = false; + let refreshTimer: ReturnType | undefined; + + const load = () => { + void resolveAssetUrl(environmentId, stableResource) + .then((result) => { + if (cancelled) return; + setUrl(result.url); + refreshTimer = setTimeout( + load, + Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS), + ); + }) + .catch(() => { + if (!cancelled) setUrl(null); + }); + }; + load(); + + return () => { + cancelled = true; + if (refreshTimer) clearTimeout(refreshTimer); + }; + }, [environmentId, key, stableResource]); + + return url; +} diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fcc8ec9954e2ab7a4afcc6c9ef532887c4e4ed9 --- /dev/null +++ b/apps/web/src/browser/openFileInPreview.ts @@ -0,0 +1,36 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { readEnvironmentApi } from "~/environmentApi"; +import { resolveAssetUrl } from "~/assets/assetUrls"; +import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +export const isBrowserPreviewFile = (path: string): boolean => + /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); + +export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + throw new Error("Environment is not connected."); + } + + const snapshot = await api.preview.open({ threadId: threadRef.threadId, url }); + usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); + usePreviewStateStore.getState().rememberUrl(threadRef, url); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); +} + +export async function openFileInPreview( + threadRef: ScopedThreadRef, + filePath: string, +): Promise { + if (!isPreviewSupportedInRuntime()) { + throw new Error("The integrated browser is unavailable in this runtime."); + } + const asset = await resolveAssetUrl(threadRef.environmentId, { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: filePath, + }); + await openUrlInPreview(threadRef, asset.url); +} diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index e047392c12d7041727b15d73eaacf30e029e291d..a93a6d231fd868b147a81737355aea366475a2dc 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; -const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ +const { + contextMenuShowMock, + openFileInPreviewMock, + openInPreferredEditorMock, + openUrlInPreviewMock, + readLocalApiMock, +} = vi.hoisted(() => ({ + contextMenuShowMock: vi.fn(), + openFileInPreviewMock: vi.fn(async () => undefined), openInPreferredEditorMock: vi.fn(async () => "vscode"), + openUrlInPreviewMock: vi.fn(async () => undefined), readLocalApiMock: vi.fn(() => ({ + contextMenu: { show: contextMenuShowMock }, server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, - shell: { openInEditor: vi.fn(async () => undefined) }, + shell: { + openExternal: vi.fn(async () => undefined), + openInEditor: vi.fn(async () => undefined), + }, })), })); @@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({ readLocalApi: readLocalApiMock, })); +vi.mock("../previewStateStore", async (importOriginal) => ({ + ...(await importOriginal()), + isPreviewSupportedInRuntime: () => true, +})); + +vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ + ...(await importOriginal()), + openFileInPreview: openFileInPreviewMock, + openUrlInPreview: openUrlInPreviewMock, +})); + import ChatMarkdown from "./ChatMarkdown"; import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +const threadRef = { + environmentId: EnvironmentId.make("environment-test"), + threadId: ThreadId.make("thread-test"), +}; describe("ChatMarkdown", () => { afterEach(() => { openInPreferredEditorMock.mockClear(); + openFileInPreviewMock.mockClear(); + openUrlInPreviewMock.mockClear(); + contextMenuShowMock.mockReset(); readLocalApiMock.mockClear(); localStorage.clear(); document.body.innerHTML = ""; @@ -155,6 +188,66 @@ describe("ChatMarkdown", () => { } }); + it("opens web links in the integrated browser from the context menu", async () => { + contextMenuShowMock.mockResolvedValue("open-in-browser"); + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }).element(); + link.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 12, + clientY: 24, + }), + ); + + await vi.waitFor(() => { + expect(contextMenuShowMock).toHaveBeenCalled(); + expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs"); + }); + } finally { + await screen.unmount(); + } + }); + + it("offers integrated browser opening for HTML file links", async () => { + contextMenuShowMock.mockResolvedValue("open-in-browser"); + const filePath = "/repo/project/report.html"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "report.html" }).element(); + link.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }), + ); + + await vi.waitFor(() => { + expect(contextMenuShowMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: "open-in-browser", + label: "Open in integrated browser", + }), + ]), + { x: 4, y: 8 }, + ); + expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath); + }); + } finally { + await screen.unmount(); + } + }); + it("keeps a favicon with the leading segment of a wrapping URL", async () => { const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; const screen = await render( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a9b4ae5372b2b75496fe43a3399edbbda9f3819b..3aba45249fa316298bbe6d8de47ae5e5edabfd97 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -8,7 +8,7 @@ import { Minimize2Icon, WrapTextIcon, } from "lucide-react"; -import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import React, { Children, Suspense, @@ -62,6 +62,12 @@ import { } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { + isBrowserPreviewFile, + openFileInPreview, + openUrlInPreview, +} from "../browser/openFileInPreview"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component< interface ChatMarkdownProps { text: string; cwd: string | undefined; + threadRef?: ScopedThreadRef | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -670,6 +677,7 @@ interface MarkdownFileLinkProps { label: string; copyMarkdown: string; theme: "light" | "dark"; + threadRef?: ScopedThreadRef | undefined; className?: string | undefined; } @@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({ ); } +function MarkdownExternalLink({ + href, + threadRef, + children, + ...props +}: React.ComponentProps<"a"> & { + href: string; + threadRef?: ScopedThreadRef | undefined; +}) { + const handleContextMenu = useCallback( + async (event: ReactMouseEvent) => { + if (!threadRef || !isPreviewSupportedInRuntime()) return; + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + if (clicked === "open-in-browser") { + void openUrlInPreview(threadRef, href).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + } else if (clicked === "open-external") { + void api.shell.openExternal(href); + } + }, + [href, threadRef], + ); + + return ( + + {children} + + ); +} + const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, @@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ label, copyMarkdown, theme, + threadRef, className, }: MarkdownFileLinkProps) { const handleOpen = useCallback(() => { @@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }); }, [targetPath]); + const handleOpenInBrowser = useCallback(() => { + if (!threadRef) return; + void openFileInPreview(threadRef, iconPath).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, [iconPath, threadRef]); + const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; + const canOpenInBrowser = + Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, + ...(canOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, @@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpen(); return; } + if (clicked === "open-in-browser") { + handleOpenInBrowser(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleCopy(targetPath, "Full path"); } }, - [displayPath, handleCopy, handleOpen, targetPath], + [displayPath, handleCopy, handleOpen, handleOpenInBrowser, iconPath, targetPath, threadRef], ); return ( @@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual( previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && + previous.threadRef?.environmentId === next.threadRef?.environmentId && + previous.threadRef?.threadId === next.threadRef?.threadId && previous.className === next.className ); } @@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual( function ChatMarkdown({ text, cwd, + threadRef, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, @@ -1137,9 +1219,10 @@ function ChatMarkdown({ const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const link = ( - { @@ -1156,7 +1239,7 @@ function ChatMarkdown({ ) : ( children )} - + ); if (!faviconHost || !href) { return link; @@ -1194,6 +1277,7 @@ function ChatMarkdown({ label={labelParts.join(" ยท ")} copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} + threadRef={threadRef} className={props.className} /> ); @@ -1238,6 +1322,7 @@ function ChatMarkdown({ fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, + threadRef, resolvedTheme, skills, ], diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 249434a60d78b40d25556150bbf809f53bb48b50..9e939e838aeed8088ec91fa6d91d7fbfe52f95bb 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: { filesystem: { browse: input.browse, }, + assets: { + createUrl: vi.fn(async ({ resource }) => ({ + relativeUrl: `/api/assets/test/${encodeURIComponent( + resource._tag === "attachment" + ? resource.attachmentId + : resource._tag === "project-favicon" + ? "favicon.svg" + : (resource.path.split(/[\\/]/).at(-1) ?? "asset"), + )}`, + expiresAt: Date.now() + 60_000, + })), + }, sourceControl: {} as EnvironmentApi["sourceControl"], vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], @@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, - previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -1153,14 +1164,13 @@ const worker = setupWorker( }); }), ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/attachments/:attachmentId", () => + http.get("*/api/assets/test/:assetName", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { "Content-Type": "image/svg+xml", }, }), ), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); async function nextFrame(): Promise { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b2686f59c70e92f7b5a593170e7f2e32beb9eda6..9b99cc8272f8406d10a6b700a586c271e472663f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow"; import { useVcsStatus } from "~/lib/vcsStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; +import { resolveAssetUrl } from "../assets/assetUrls"; import { isElectron } from "../env"; import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { readLocalApi } from "../localApi"; @@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; + const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState>({}); useEffect(() => { - if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { + if (!serverMessages) return; + const attachmentIds = [ + ...new Set( + serverMessages.flatMap( + (message) => + message.attachments?.flatMap((attachment) => + attachment.type === "image" && !attachment.previewUrl ? [attachment.id] : [], + ) ?? [], + ), + ), + ].filter((attachmentId) => !attachmentAssetUrlById[attachmentId]); + if (attachmentIds.length === 0) return; + + let cancelled = false; + void Promise.all( + attachmentIds.map(async (attachmentId) => { + const asset = await resolveAssetUrl(environmentId, { + _tag: "attachment", + attachmentId, + }); + return [attachmentId, asset.url] as const; + }), + ) + .then((entries) => { + if (!cancelled) { + setAttachmentAssetUrlById((current) => ({ ...current, ...Object.fromEntries(entries) })); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [attachmentAssetUrlById, environmentId, serverMessages]); + const serverMessagesWithAssetUrls = useMemo(() => { + if (!serverMessages || Object.keys(attachmentAssetUrlById).length === 0) { + return serverMessages; + } + return serverMessages.map((message) => { + if (!message.attachments) return message; + let changed = false; + const attachments = message.attachments.map((attachment) => { + const previewUrl = attachmentAssetUrlById[attachment.id]; + if (!previewUrl || attachment.previewUrl === previewUrl) return attachment; + changed = true; + return { ...attachment, previewUrl }; + }); + return changed ? { ...message, attachments } : message; + }); + }, [attachmentAssetUrlById, serverMessages]); + useEffect(() => { + if ( + typeof Image === "undefined" || + !serverMessagesWithAssetUrls || + serverMessagesWithAssetUrls.length === 0 + ) { return; } @@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) { continue; } - const serverMessage = serverMessages.find( + const serverMessage = serverMessagesWithAssetUrls.find( (message) => message.id === messageId && message.role === "user", ); if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { @@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) { cleanup(); } }; - }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); + }, [ + attachmentPreviewHandoffByMessageId, + clearAttachmentPreviewHandoff, + serverMessagesWithAssetUrls, + ]); const timelineMessages = useMemo(() => { - const messages = serverMessages ?? []; + const messages = serverMessagesWithAssetUrls ?? []; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 ? messages @@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), @@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) { activeProposedPlan={sidebarProposedPlan} label={planSidebarLabel} environmentId={environmentId} + threadRef={activeThreadRef} markdownCwd={gitCwd ?? undefined} workspaceRoot={activeWorkspaceRoot} timestampFormat={timestampFormat} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b7aa6d7a645b11e48b9dc552908b3e20312233d4..01f501b0a1b62a603f8a28e6830b1dd2f0596246 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -322,8 +322,7 @@ const worker = setupWorker( }); }), ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), + http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })), ); function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index e87361c92d83c45127a3bb9fc4de577a29f4d94a..498f1912c71f16240365e1efa4a326f2d4ea4f7c 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,5 +1,5 @@ import { memo, useState, useCallback } from "react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -56,6 +56,7 @@ interface PlanSidebarProps { activeProposedPlan: LatestProposedPlanState | null; label?: string; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | undefined; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; @@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({ activeProposedPlan, label = "Plan", environmentId, + threadRef, markdownCwd, workspaceRoot, timestampFormat, @@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({ diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index ad47e01bb11163fe3282958aee9c49939b5aab91..0b849bc563ec1dc12003792b4f6751fa0b8d75cd 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { FolderIcon } from "lucide-react"; -import { useState } from "react"; -import { resolveEnvironmentHttpUrl } from "../environments/runtime"; +import { useEffect, useState } from "react"; +import { useAssetUrl } from "../assets/assetUrls"; const loadedProjectFaviconSrcs = new Set(); @@ -10,20 +10,16 @@ export function ProjectFavicon(input: { cwd: string; className?: string; }) { - const src = (() => { - try { - return resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd }, - }); - } catch { - return null; - } - })(); + const src = useAssetUrl(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + }); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", ); + useEffect(() => { + setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading"); + }, [src]); if (!src) { return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a56330c592360a726a6d05bfd420dda5a3a8a0a2..216c7ca2a4dd86e207c7c3aab106bb704529366d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,9 +1,11 @@ import { type EnvironmentId, type MessageId, + type ScopedThreadRef, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; +import { parseScopedThreadKey } from "@t3tools/client-runtime"; import { createContext, Fragment, @@ -109,6 +111,7 @@ import { interface TimelineRowSharedState { timestampFormat: TimestampFormat; routeThreadKey: string; + threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; @@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ timestampFormat, routeThreadKey, + threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, @@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract @@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({ @@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills: ReadonlyArray>; markdownCwd: string | undefined; }) { + const ctx = use(TimelineRowCtx); const renderInlineMarkdownSegment = (text: string, key: string) => { const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); @@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
{canCollapse && !expanded ? ( - + ) : ( - + )} {canCollapse && !expanded ? (
diff --git a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx index f9a0e16e378587aeb540429a3a1e45c24c526662..d92839b55fa4d756274e0ec71fce8954042d0b92 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx @@ -38,4 +38,38 @@ describe("PreviewChromeRow", () => { previouslyFocused.remove(); }); + + it("shows a friendly asset label until the URL input receives focus", async () => { + const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf"; + await render( + , + ); + const input = page.getByRole("textbox"); + + await expect.element(input).toHaveValue("Local environment ยท report.pdf"); + + await input.click(); + + await expect.element(input).toHaveValue(fullUrl); + + input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + await expect.element(input).toHaveValue("Local environment ยท report.pdf"); + }); + + it("shows only the host for regular URLs until the input receives focus", async () => { + const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79"; + await render(); + const input = page.getByRole("textbox"); + + await expect.element(input).toHaveValue("t3.chat"); + + await input.click(); + + await expect.element(input).toHaveValue(fullUrl); + }); }); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 9064dc71d2ae44391e12ac89087a92c59ea2a7bd..1452e6c758854707ebb5d13c09281cb06626d5cf 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -3,7 +3,6 @@ import { ArrowRight, Camera, ExternalLink, - Globe, MousePointerClick, RotateCw, } from "lucide-react"; @@ -23,6 +22,7 @@ import { cn } from "~/lib/utils"; interface Props { url: string; + displayUrl?: string | undefined; loading: boolean; loadProgress: number; canGoBack: boolean; @@ -61,6 +61,7 @@ const NOOP = () => {}; export function PreviewChromeRow({ url, + displayUrl, loading, loadProgress, canGoBack, @@ -84,6 +85,7 @@ export function PreviewChromeRow({ }: Props) { const inputRef = useRef(null); const [draft, setDraft] = useState(url); + const [inputFocused, setInputFocused] = useState(false); // Sync the input with external URL changes, but only when the user isn't // actively typing (preserves in-progress edits during navigation events). @@ -96,7 +98,6 @@ export function PreviewChromeRow({ const node = inputRef.current; if (!node) return; node.focus(); - node.select(); }, [focusUrlNonce]); const submit = (event?: FormEvent | KeyboardEvent) => { @@ -167,14 +168,23 @@ export function PreviewChromeRow({
- - - - + setDraft(event.target.value)} + onFocus={() => { + setDraft(url); + setInputFocused(true); + queueMicrotask(() => inputRef.current?.select()); + }} + onBlur={() => { + setDraft(url); + setInputFocused(false); + }} onKeyDown={(event) => { if (event.key === "Enter") submit(event); if (event.key === "Escape") { @@ -187,10 +197,14 @@ export function PreviewChromeRow({ spellCheck={false} disabled={inputDisabled} data-preview-url-input + title={!inputFocused && displayUrl ? url : undefined} size="sm" /> - {onOpenInBrowser ? ( - + {onOpenInBrowser && !inputFocused ? ( + { @@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, > { + it("formats signed asset URLs with the environment label and decoded filename", () => { + expect( + formatPreviewUrl({ + url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("Local environment ยท architecture brief.pdf"); + }); + + it("does not alias assets from another origin", () => { + expect( + formatPreviewUrl({ + url: "https://example.com/api/assets/token/report.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("example.com"); + }); + + it("formats regular preview URLs as their exact host", () => { + expect( + formatPreviewUrl({ + url: "http://127.0.0.1:5173/dashboard", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("127.0.0.1:5173"); + }); + + it("does not compact non-http URLs", () => { + expect( + formatPreviewUrl({ + url: "file:///tmp/report.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/preview/previewUrlPresentation.ts b/apps/web/src/components/preview/previewUrlPresentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ae3c1900aacd6fee6181770c14d4f4da0a89649 --- /dev/null +++ b/apps/web/src/components/preview/previewUrlPresentation.ts @@ -0,0 +1,27 @@ +interface PreviewUrlPresentationInput { + readonly url: string; + readonly environmentLabel: string; + readonly environmentHttpBaseUrl: string; +} + +export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null { + try { + const url = new URL(input.url); + const environmentUrl = new URL(input.environmentHttpBaseUrl); + if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) { + const encodedFileName = url.pathname.split("/").at(-1); + if (!encodedFileName) { + return null; + } + const fileName = decodeURIComponent(encodedFileName); + if (!fileName || fileName === "." || fileName === "..") { + return null; + } + return `${input.environmentLabel} ยท ${fileName}`; + } + + return url.protocol === "http:" || url.protocol === "https:" ? url.host : null; + } catch { + return null; + } +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 4bb3c3654cb639b2dd60a27ba481da89c00c788e..49de33091a2ee29bb0010bcc79fd129e8bf3d550 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -25,6 +25,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { filesystem: { browse: rpcClient.filesystem.browse, }, + assets: { + createUrl: rpcClient.assets.createUrl, + }, sourceControl: { lookupRepository: rpcClient.sourceControl.lookupRepository, cloneRepository: rpcClient.sourceControl.cloneRepository, diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 94292da1d96904dda5bc79deefefde51f2708f55..2f223c4b1b4c771987d1b01d1bac9e47ce837f8c 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -127,6 +127,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { filesystem: { browse: vi.fn(), }, + assets: { createUrl: vi.fn() }, sourceControl: { lookupRepository: vi.fn(), cloneRepository: vi.fn(), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 12b621e3900fffa66e39b789fd5815a174a914aa..9a9b05f92f2a794536e88aeb72ee1e7accd5ee7a 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -34,7 +34,6 @@ import { type ThreadTurnState, type TurnDiffSummary, } from "./types"; -import { resolveEnvironmentHttpUrl } from "./environments/runtime"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; import { getThreadFromEnvironmentState } from "./threadDerivation"; const isProviderDriverKindValue = Schema.is(ProviderDriverKind); @@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage) name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: resolveEnvironmentHttpUrl({ - environmentId, - pathname: attachmentPreviewRoutePath(attachment.id), - }), })); return { @@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind { return ProviderDriverKind.make("codex"); } -function attachmentPreviewRoutePath(attachmentId: string): string { - return `/attachments/${encodeURIComponent(attachmentId)}`; -} - function updateThreadState( state: EnvironmentState, threadId: ThreadId, diff --git a/docs/cloud/t3-code-connect-auth-flow-3-page.html b/docs/cloud/t3-code-connect-auth-flow-3-page.html new file mode 100644 index 0000000000000000000000000000000000000000..9fe7bd59081374c542990a8436cb0869c7168d7c --- /dev/null +++ b/docs/cloud/t3-code-connect-auth-flow-3-page.html @@ -0,0 +1,592 @@ + + + + + + T3 Connect Architecture Brief + + + +
+
Architecture brief ยท page 1 of 3
+

T3 Connect Control Plane and Managed Endpoint Flow

+

+ T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions a + managed HTTPS/WSS endpoint, and brokers proof-bound remote connections. The environment + remains the access authority; the relay coordinates identity, endpoint lifecycle, and + notifications. +

+
+ +
+
+

Security Invariant

+

Cloud identity alone is not an environment login. Remote access requires:

+
    +
  • a Clerk-authenticated T3 Connect user;
  • +
  • an active user/environment relay link;
  • +
  • a DPoP private key held by the remote client;
  • +
  • a relay-signed mint request accepted locally; and
  • +
  • an environment token bound to the client's DPoP key.
  • +
+
+ The relay cannot mint an environment access token by itself. The local environment + issues the bootstrap credential and final access token. +
+
+
+

Trust Boundaries

+
    +
  • Clerk: user identity and OAuth/PKCE.
  • +
  • Relay Worker: links, endpoint allocations, proofs, and notifications.
  • +
  • Environment: local sessions, signing keys, and access tokens.
  • +
  • Relay client: exposes only the validated loopback HTTP server.
  • +
  • Remote client: retains the DPoP private key and environment token.
  • +
+
+ The relay remains a trusted mint broker because it holds the cloud-mint signing key. + DPoP limits credential reuse but does not neutralize compromise of that authority. +
+
+ +
+

Current Topology

+
+
+

Clients

+ Desktop web UI
Mobile app
Headless CLI

+ Clerk bearer / relay DPoP +
+
โ†’
+
+

T3 Code Relay

+ Cloudflare Worker
Hyperdrive + Postgres
Tunnel/DNS APIs
APNs queue +
+
โ†’
+
+

Linked Environment

+ Managed HTTPS/WSS endpoint
Relay client
Loopback server
Local secrets +
+
+
+ Steady-state HTTP and WebSocket traffic goes directly from the remote client through the + managed endpoint. The relay handles sparse health and bootstrap requests, not the active + session data path. +
+
+ +
+

Authentication Boundaries

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BoundaryAuthenticationPurpose
Client โ†’ relay link managementClerk bearer / CLI OAuthCreate, list, or remove links.
Client โ†’ protected relay APIsRelay DPoP token + proofStatus, connect, and mobile registration.
Relay โ†’ environmentShort-lived relay-signed JWTSigned health and credential-mint requests.
Client โ†’ environmentEnvironment token + DPoPNormal HTTPS APIs and WSS ticket issuance.
Environment โ†’ relayEnvironment bearer + signed proofPublish redacted agent activity.
+
+
+ +
+ +
+
Core workflows ยท page 2 of 3
+

Link, Bootstrap, and Operate

+

+ Every workflow preserves local authority while making cloud coordination retry-safe and + suitable for desktop, mobile, and headless use. +

+
+ +
+
+

1. Link a Local Environment

+
+
+ AuthenticateDesktop/mobile uses a Clerk bearer. CLI uses browser PKCE and + persists desired link state. +
+
+ Prove locallyRelay issues a short-lived challenge. The local environment + validates loopback origin and signs it. +
+
+ ReconcileRelay verifies proof and capabilities, then creates or reuses the + tunnel, ingress, and CNAME. +
+
+ ActivateRelay returns endpoint config and connector token. Environment persists + config and starts the relay client. +
+
+
+ The local proof endpoint requires relay:write, rejects forwarded authority + headers, and atomically persists the environment key pair. +
+
+ +
+

2. Remote Connection Bootstrap

+
+
+ Proof keyClient creates a DPoP key and exchanges its Clerk JWT for a relay DPoP + access token. +
+
+ Mint requestClient calls connect. Relay sends an audience-bound, short-lived + signed proof to the managed endpoint. +
+
+ Local issueEnvironment validates user, scope, lifetime, nonce, and DPoP + thumbprint, then returns a signed bootstrap credential. +
+
+ Direct sessionClient redeems locally for an environment token, gets a one-time + WSS ticket, and opens the WebSocket. +
+
+
+ The relay validates the environment response signature, nonce, endpoint, and DPoP + binding before returning bootstrap data to the client. +
+
+ +
+

Managed Endpoint Lifecycle

+
    +
  1. + Reserve deterministic hostname and tunnel name for + (userId, environmentId). +
  2. +
  3. + Reuse or create the named tunnel; rewrite ingress to the validated loopback origin. +
  4. +
  5. + Reconcile CNAME records and remove duplicates; recover safely from create conflicts. +
  6. +
  7. Mark ready only after resource IDs and namespace checks are persisted.
  8. +
+
+
+

Unlink Cleanup

+
    +
  • Delete managed DNS and tunnel resources.
  • +
  • Remove the allocation and revoke the user link.
  • +
  • Revoke publication credentials after the final active link disappears.
  • +
  • Stop the local relay client and clear persisted relay configuration.
  • +
  • Retain checkpoints when teardown fails so cleanup can be retried.
  • +
+
+ +
+

3. Agent Activity Notifications

+
+
+ ProjectEnvironment creates a narrow publishable thread state and redacts/caps + failure details. +
+
+ SignEnvironment posts with its bearer credential and a per-state signed proof. +
+
+ PersistRelay checks credential, signature, scope, expiry, and nonce, then + stores current state. +
+
+ DeliverQueue workers send push notifications or Live Activity updates through + APNs. +
+
+
+
+ +
+ +
+
Controls and operations ยท page 3 of 3
+

Hardening and Deployment Contract

+

+ The design constrains tunnel origins, proof audiences, egress destinations, redirects, + credential exposure, and teardown behavior. +

+
+ +
+
+

SSRF and Tunnel Controls

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RiskImplemented control
Arbitrary tunnel upstreamBoth sides accept managed origins only for loopback hosts and valid ports.
Forwarded-origin confusion + Link proof requires the exact loopback URL and rejects forwarded host/protocol + headers. +
Stored endpoint replacement + Status/connect resolve ready allocations from Postgres and require the managed DNS + namespace. +
Redirected relay egressRelay health and mint requests use manual redirect handling.
Impersonated tunnel backendRelay verifies signed environment responses, nonce, and request binding.
Stolen access token replayRelay and environment tokens require DPoP proofs and replay guards.
Connector token exposure + Relay client is spawned without a shell and receives the token in + TUNNEL_TOKEN. +
+
+ +
+

Credential Ownership

+
    +
  • Environment Ed25519 private key stays local.
  • +
  • + Cloud-mint private key stays in hosted relay config; only its public key is installed + locally. +
  • +
  • + Relay environment bearer is hashed in Postgres and stored plaintext only locally. +
  • +
  • Remote DPoP private keys and environment access tokens remain client-side.
  • +
  • WebSocket tickets are short-lived and one-time.
  • +
+
+
+

Operational Profile

+
    +
  • One named tunnel and DNS record per active user/environment allocation.
  • +
  • HTTPS request/response and WSS only; no arbitrary TCP exposure.
  • +
  • Relay traffic is limited to health and bootstrap control calls.
  • +
  • + Active sessions are interactive and WebSocket-heavy; inactive links are nearly idle. +
  • +
  • + Dedicated registrable tunnel domain and Public Suffix List entry are required before + broad untrusted use. +
  • +
+
+ +
+

Deployment and Runtime

+ + + + + + + + + + + + + + + + + + + + + +
LayerContract
Relay + Cloudflare Worker, tunnel/DNS bindings, Hyperdrive, PlanetScale Postgres, APNs + queue + DLQ, replay-row pruning cron, and Axiom OTLP traces. +
Environment + Loopback server, atomic secret store, relay-client lifecycle, signed health/mint + responses, OAuth exchange, and WebSocket tickets. +
Clients + Secure relay URL, Clerk configuration, DPoP key management, environment token + storage, and direct managed-endpoint traffic. +
+
+ +
+

Release Gate

+
+
+

Identity

+ Keep private signing keys in their owning trust domain. Validate issuer, audience, + scope, expiry, nonce, and proof-key binding. +
+
+

Network

+ Permit loopback tunnel origins only. Resolve relay egress from ready allocations and + disable redirects. +
+
+

Lifecycle

+ Make provisioning idempotent, preserve failed-cleanup checkpoints, and revoke shared + credentials only after the final link disappears. +
+
+
+ Standards basis: RFC 8252 for native-app OAuth, RFC 8693 for token exchange, and RFC + 9449 for DPoP proof of possession. +
+
+
+ +
+ + diff --git a/docs/cloud/t3-code-connect-auth-flow.pdf b/docs/cloud/t3-code-connect-auth-flow.pdf new file mode 100644 index 0000000000000000000000000000000000000000..42c604f29ce1564cdee6b1bdef626e0517d6bf44 GIT binary patch literal 113502 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 literal 0 HcmV?d00001 diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index ca01302cbe3759b1bc0e35a122bb09c542a90e5e..3e4e9f343ceb80c500ed685a8af3b9ab24f66c33 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -102,6 +102,9 @@ export interface WsRpcClient { readonly filesystem: { readonly browse: RpcUnaryMethod; }; + readonly assets: { + readonly createUrl: RpcUnaryMethod; + }; readonly sourceControl: { readonly lookupRepository: RpcUnaryMethod; readonly cloneRepository: RpcUnaryMethod; @@ -272,6 +275,10 @@ export function createWsRpcClient( filesystem: { browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), }, + assets: { + createUrl: (input) => + transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)), + }, sourceControl: { lookupRepository: (input) => transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd1ac0a53ec6d805d2226ca8882c0b84d8a17ba3 --- /dev/null +++ b/packages/contracts/src/assets.ts @@ -0,0 +1,38 @@ +import * as Schema from "effect/Schema"; + +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const ASSET_PATH_MAX_LENGTH = 1024; + +export const AssetResource = Schema.Union([ + Schema.TaggedStruct("workspace-file", { + threadId: ThreadId, + path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + }), + Schema.TaggedStruct("attachment", { + attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), + Schema.TaggedStruct("project-favicon", { + cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + }), +]); +export type AssetResource = typeof AssetResource.Type; + +export const AssetCreateUrlInput = Schema.Struct({ + resource: AssetResource, +}); +export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; + +export const AssetCreateUrlResult = Schema.Struct({ + relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + expiresAt: Schema.Number, +}); +export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; + +export class AssetAccessError extends Schema.TaggedErrorClass()( + "AssetAccessError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4ffb7642d17401ce36a54d2865148952c8e6f95e..43270efdec786152b221a0a1343b9b1815e2edee 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -21,6 +21,7 @@ export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./assets.ts"; export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 463879e9a5e19f48943fc3418d15289cb4370bd6..92f669c44f043471456c9de94002aa1f8bd19ed1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -20,6 +20,7 @@ import type { } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; +import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -1115,6 +1116,9 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise; }; + assets: { + createUrl: (input: AssetCreateUrlInput) => Promise; + }; sourceControl: { lookupRepository: ( input: SourceControlRepositoryLookupInput, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 189b1bdb6796e35fb2c37fd8f25cfc8d2f7e4f0a..b1dc36651e3ffaaf46070ce52ceb9bd90b5e7585 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,6 +13,7 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import { GitActionProgressEvent, VcsSwitchRefInput, @@ -148,6 +149,7 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + assetsCreateUrl: "assets.createUrl", // VCS methods vcsPull: "vcs.pull", @@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { + payload: AssetCreateUrlInput, + success: AssetCreateUrlResult, + error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, -- 2.54.0